Search code examples
javascripthtml5-audioweb-mediarecorder

JavaScript - how to immediately rewind through recorded audio


I've built a very simple POC for recording an audio file using a microphone and appending the recorded blob into the audio tag element in the browser. The problem is that after recording is done, I can not rewind back and forth until the recording is fully loaded. Looks like there is a problem with duration time. What I want to achieve is something like this on:

https://online-voice-recorder.com/beta/

Right there, after you finish recording, you can immediately rewind to the end of the recording, even if it is 30min long. It works like magic. How can this be achieved?

This is the code I have written (mostly copied from MDN). You can copy-paste to any index.html:

<body>
    <button class="record">RECORD</button>
    <button class="stop">STOP</button>
    <div class="clips"></div>
    <script>
    if (navigator.mediaDevices) {
        const record = document.querySelector('.record')
        const stop = document.querySelector('.stop')
        const soundClips = document.querySelector('.clips')

        const constraints = { audio: true };
        let chunks = [];

        navigator.mediaDevices.getUserMedia(constraints)
            .then(function (stream) {

                const mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });

                record.onclick = function () {
                    mediaRecorder.start();
                    record.style.background = "red";
                    record.style.color = "black";
                }

                stop.onclick = function () {
                    mediaRecorder.stop();
                    record.style.background = "";
                    record.style.color = "";
                }

                mediaRecorder.onstop = function (e) {
                    const clipName = prompt('Enter a name for your sound clip');

                    const clipContainer = document.createElement('article');
                    const clipLabel = document.createElement('p');
                    const audio = document.createElement('audio');
                    const deleteButton = document.createElement('button');

                    clipContainer.classList.add('clip');
                    audio.setAttribute('controls', '');
                    audio.setAttribute('preload', 'metadata');
                    deleteButton.innerHTML = "Delete";
                    clipLabel.innerHTML = clipName;

                    clipContainer.appendChild(audio);
                    clipContainer.appendChild(clipLabel);
                    clipContainer.appendChild(deleteButton);
                    soundClips.appendChild(clipContainer);

                    audio.controls = true;
                    const blob = new Blob(chunks);
                    chunks = [];
                    const audioURL = URL.createObjectURL(blob);
                    audio.src = audioURL;

                    deleteButton.onclick = function (e) {
                        evtTgt = e.target;
                        evtTgt.parentNode.parentNode.removeChild(evtTgt.parentNode);
                    }
                }

                mediaRecorder.ondataavailable = function (e) {
                    chunks.push(e.data);
                }
            })
            .catch(function (err) {
                console.log('The following error occurred: ' + err);
            })
    }
    </script>
</body>

Solution

  • Why seeking doesn't work right away

    When using a Blob URL, your Audio player does not get any information about the media's duration. I've also found that you cannot set it manually. That will prevent you from seeking on the progress bar of native browser Audio controls. So, unfortunately, I believe you cannot use native controls.

    What would be a possible workaround?

    What you could do is measure how much time the recording session lasts, and pass that duration to a player controller. That player controller can be an existing one (such as HowlerJS), or a custom one. The problem with an existing one is that most (all?) of them do not support manually setting a duration. There might be another workaround for that if you dig into their code, but for the time being, I thought it would be fun to create a custom player.

    A custom player

    I created a SoundClip function which creates the DOM elements of a working player, and allows you to set a URL for the audio, along with its duration, in seconds. Here is how you can use it:

    // Declare a new SoundClip instance
    const audio = new SoundClip();
    
    // Get its DOM player element and append it to some container
    someContainer.appendChild(audio.getElement());
    
    // Set the audio Url and duration
    audio.setSource(audioURL, duration);
    

    How can you adapt your code to use it

    First, you'll have to measure the time it takes for the recording to happen:

    // At the top of your code, create an Object that will hold that data
    const recordingTimes = {};
    
    record.onclick = function() {
        // Record the start time
        recordingTimes.start = +new Date();
        /* ... */
    }
    
    stop.onclick = function() {
        // Record the end time
        recordingTimes.end = +new Date();
        // Calculate the duration in seconds
        recordingTimes.duration = (recordingTimes.end - recordingTimes.start) / 1000;
        /* ... */
    }
    

    Then, instead of using an audio DOM element, use a SoundClip instance:

      mediaRecorder.onstop = function(e) {
        /* ... */
        const deleteButton = document.createElement('button');
        // Declare a new SoundClip instance
        const audio = new SoundClip();
    
        /* ... */
    
        // Append the SoundClip element to the DOM
        clipContainer.appendChild(audio.getElement());
        clipContainer.appendChild(clipLabel);
    
        /* ... */
        const audioURL = URL.createObjectURL(blob);
    
        // Set the audio Url and duration
        audio.setSource(audioURL, recordingTimes.duration);
    
        /* ... */
      }
    

    And then?

    Then, you should be able to do what you want. I provided the full code for the SoundClip function and CSS below, but it's pretty basic and not very stylish. You can decide either to customize it to suit your needs, or to go with an existing player on the market, keeping in mind you'll have to hack it to make it work.

    Live demo

    https://shrt.tf/so_49886426/

    Full code

    This won't work on StackOverflow because it won't allow using the Microphone, but here is the full code:

    function SoundClip() {
        const self = {
            dom: {},
            player: {},
            class: 'sound-clip',
    
            ////////////////////////////////
            // SoundClip basic functions
            ////////////////////////////////
    
            // ======================
            // Setup the DOM of the player and the player instance
            // [Automatically called on instantiation]
            // ======================
            init: function() {
                //  == Create the DOM elements ==
                // Wrapper
                self.dom.wrapper = self.createElement('div', {
                    className: `${self.class} ${self.class}-disabled`
                });
                // Play button
                self.dom.playBtn = self.createElement('div', {
                    className: `${self.class}-play-btn`,
                    onclick: self.toggle
                }, self.dom.wrapper);
                // Range slider
                self.dom.progress = self.createElement('input', {
                    className: `${self.class}-progress`,
                    min: 0,
                    max: 100,
                    value: 0,
                    type: 'range',
                    onchange: self.onChange
                }, self.dom.wrapper);
                // Time and duration
                self.dom.time = self.createElement('div', {
                    className: `${self.class}-time`,
                    innerHTML: '00:00 / 00:00'
                }, self.dom.wrapper);
    
                self.player.disabled = true;
                //  == Create the Audio player ==
                self.player.instance = new Audio();
                self.player.instance.ontimeupdate = self.onTimeUpdate;
                self.player.instance.onended = self.stop;
    
                return self;
            },
            // ======================
            // Sets the URL and duration of the audio clip
            // ======================
            setSource: function(url, duration) {
                self.player.url = url;
                self.player.duration = duration;
                self.player.instance.src = self.player.url;
                // Enable the interface
                self.player.disabled = false;
                self.dom.wrapper.classList.remove(`${self.class}-disabled`);
                // Update the duration
                self.onTimeUpdate();
            },
            // ======================
            // Returns the wrapper DOM element
            // ======================
            getElement: function() {
                return self.dom.wrapper;
            },
    
            ////////////////////////////////
            // Player functions
            ////////////////////////////////
    
            // ======================
            // Plays or pauses the player
            // ======================
            toggle: function() {
                if (!self.player.disabled) {
                    self[self.player.playing ? 'pause' : 'play']();
                }
            },
            // ======================
            // Starts the player
            // ======================
            play: function() {
                if (!self.player.disabled) {
                    self.player.playing = true;
                    self.dom.playBtn.classList.add(`${self.class}-playing`);
                    self.player.instance.play();
                }
            },
            // ======================
            // Pauses the player
            // ======================
            pause: function() {
                if (!self.player.disabled) {
                    self.player.playing = false;
                    self.dom.playBtn.classList.remove(`${self.class}-playing`);
                    self.player.instance.pause();
                }
            },
            // ======================
            // Pauses the player and resets its currentTime
            // ======================
            stop: function() {
                if (!self.player.disabled) {
                    self.pause();
                    self.seekTo(0);
                }
            },
            // ======================
            // Sets the player's current time
            // ======================
            seekTo: function(sec) {
                if (!self.player.disabled) {
                    self.player.instance.currentTime = sec;
                }
            },
    
            ////////////////////////////////
            // Event handlers
            ////////////////////////////////
    
            // ======================
            // Called every time the player instance's time gets updated
            // ======================
            onTimeUpdate: function() {
                self.player.currentTime = self.player.instance.currentTime;
                self.dom.progress.value = Math.floor(
                    self.player.currentTime / self.player.duration * 100
                );
                self.dom.time.innerHTML = `
                    ${self.formatTime(self.player.currentTime)}
                    /
                    ${self.formatTime(self.player.duration)}
                `;
            },
            // ======================
            // Called every time the user changes the progress bar value
            // ======================
            onChange: function() {
                const sec = self.dom.progress.value / 100 * self.player.duration;
                self.seekTo(sec);
            },
    
            ////////////////////////////////
            // Utility functions
            ////////////////////////////////
    
            // ======================
            // Create DOM elements,
            // assign them attributes and append them to a parent
            // ======================
            createElement: function(type, attributes, parent) {
                const el = document.createElement(type);
                if (attributes) {
                    Object.assign(el, attributes);
                }
                if (parent) {
                    parent.appendChild(el);
                }
                return el;
            },
            // ======================
            // Formats seconds into [hours], minutes and seconds
            // ======================
            formatTime: function(sec) {
                const secInt = parseInt(sec, 10);
                const hours = Math.floor(secInt / 3600);
                const minutes = Math.floor((secInt - (hours * 3600)) / 60);
                const seconds = secInt - (hours * 3600) - (minutes * 60);
    
                return (hours ? (`0${hours}:`).slice(-3) : '') +
                       (`0${minutes}:`).slice(-3) +
                       (`0${seconds}`).slice(-2);
            }
        };
    
        return self.init();
    }
    
    if (navigator.mediaDevices) {
      const record = document.querySelector('.record');
      const stop = document.querySelector('.stop');
      const soundClips = document.querySelector('.clips');
      // Will hold the start time, end time and duration of recording
      const recordingTimes = {};
    
      const constraints = {
        audio: true
      };
      let chunks = [];
    
      navigator.mediaDevices.getUserMedia(constraints)
        .then(function(stream) {
    
          const mediaRecorder = new MediaRecorder(stream, {
            mimeType: 'audio/webm'
          });
    
          record.onclick = function() {
            // Record the start time
            recordingTimes.start = +new Date();
            mediaRecorder.start();
            record.style.background = "red";
            record.style.color = "black";
          }
    
          stop.onclick = function() {
            // Record the end time
            recordingTimes.end = +new Date();
            // Calculate the duration in seconds
            recordingTimes.duration = (recordingTimes.end - recordingTimes.start) / 1000;
            mediaRecorder.stop();
            record.style.background = "";
            record.style.color = "";
          }
    
          mediaRecorder.onstop = function(e) {
            const clipName = prompt('Enter a name for your sound clip');
    
            const clipContainer = document.createElement('article');
            const clipLabel = document.createElement('p');
            const deleteButton = document.createElement('button');
            // Declare a new SoundClip
            const audio = new SoundClip();
    
            clipContainer.classList.add('clip');
            deleteButton.innerHTML = "Delete";
            clipLabel.innerHTML = clipName;
    
            // Append the SoundClip element to the DOM
            clipContainer.appendChild(audio.getElement());
            clipContainer.appendChild(clipLabel);
            clipContainer.appendChild(deleteButton);
            soundClips.appendChild(clipContainer);
    
            const blob = new Blob(chunks);
            chunks = [];
            const audioURL = URL.createObjectURL(blob);
    
            // Set the audio Url and duration
            audio.setSource(audioURL, recordingTimes.duration);
    
            deleteButton.onclick = function(e) {
              evtTgt = e.target;
              evtTgt.parentNode.parentNode.removeChild(evtTgt.parentNode);
            }
          }
    
          mediaRecorder.ondataavailable = function(e) {
            chunks.push(e.data);
          }
        })
        .catch(function(err) {
          console.log('The following error occurred: ' + err);
        })
    }
    .sound-clip, .sound-clip * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
    }
    .sound-clip {
        border: 1px solid #9ee0ff;
        padding: .5em;
        font-family: Arial, Helvetica, sans-serif;
    }
    .sound-clip.sound-clip-disabled {
        opacity: .5;
    }
    .sound-clip-play-btn {
        display: inline-block;
        text-align: center;
        width: 2em;
        height: 2em;
        border: 1px solid #12b2ff;
        color: #12b2ff;
        cursor: pointer;
        vertical-align: middle;
        margin-right: .5em;
        transition: all .2s ease;
    }
    .sound-clip-play-btn:before {
        content: "►";
        line-height: 2em;
    }
    .sound-clip-play-btn.sound-clip-playing:before {
        content: "❚❚";
        line-height: 2em;
    }
    .sound-clip-play-btn:not(.sound-clip-disabled):hover {
        background: #12b2ff;
        color: #fff;
    }
    .sound-clip-progress {
        line-height: 2em;
        vertical-align: middle;
        width: calc(100% - 3em);
    }
    .sound-clip-time {
        text-align: right;
    }
    <button class="record">RECORD</button>
    <button class="stop">STOP</button>
    <div class="clips"></div>