javascripthtmlhtml5-audioweb-audio-api

Why: my javascript functions do not trigger errors with audio elements loaded from HTML but do with audio elements added programmatically


Background

Stuff that's always worked fine

I've got a series of automatically generated HTML pages that can have anywhere between 24 and 180 (or more) audio elements loading separate audio files. Each audio element has its own controls (play/pause button, replay button).

I've implemented a series of JavaScript functions and event handlers that take care of playing the audio files only once they're ready and the UI of the buttons. I've used this script in production for months with no issues at all (see Code section).

New Feature

I've been building a feature so users can record audio as a response to about half of the audio elements already on the page. I've added to the original script, and the addition to the script

Problem

The programmatically added audio elements use all the same JS functions as the audio elements that are loaded in the initial HTML page, but now I'm getting errors when playing/pausing a puzzling variety of audio elements on the page (happens seemingly randomly, both with original audio elements and programmatically added audio elements).

Here is the error message I'm getting:

Uncaught (in promise) DOMException: The play() request was interrupted by a call to pause().

Why am I getting the error message now but not before?

Speculations

Code

HTML structure

The page mostly consists of a bunch of these <li> elements. Each <audio> element is within a container of class audioContainer, and my event handlers search within the parent audioContainer to find which audio element to play.

simplified

Biggest thing to note here is that there are two audioContainer elements. The first one has the class playAudioCtrlzContainer and contains an audio element upon page load. The second one has a class of recordingPlayback and is where an audio element (with a src pointing to user's uploaded recording) will be programmatically inserted.

<li>
  <div>
    <h4>Dr</h4>
    <div>
      <div class="playAudioCtrlzContainer audioContainer">
        <audio></audio>
        <button name="playPauseBtn" class="playPauseBtn"></button>
        <button name="replayBtn" class="replayBtn"></button>
      </div>
      <div class="recordAudioCtrlzContainer">
        <form action="" method="post" class="recordForm">
          <input type="hidden" name="csrfmiddlewaretoken" value="BLAHBLAHBLAHBLAHBLAH">
          <input type="hidden" class="line_id" value="713" />
          <button type="submit" name="recordBtn"></button>
          <button type="button" name="stopRecordBtn"></button>
        </form>
        <div class="recordingIndicator">
          <div class="poof simpleCheckmarkContainer">
            <img alt="checkmark icon" />
          </div>
        </div>
      </div>
    </div>
  </div>
  <p>¿Sr. López?</p>
  <div class="recordingPlayback audioContainer poof">
    <!-- here is where <audio> element from recording will get prepended -->
    <button name="playPauseBtn" class="playPauseBtn"></button>
    <button name="replayBtn" class="replayBtn"></button>
  </div>
</li>

more verbose

<li class="line int int-norm TR nonPowerLang" lang="es-US">
  <div class="lineLabelCtrlzWrapper">
    <h4 class="metaInfo">Dr</h4>
    <div>
      <div class="playAudioCtrlzContainer audioContainer">
        <audio
          id="TR-11"
          class="dialogueAudio"
          src="/static/audio/en/es/hear/tr-11.mp3"
          preload="auto"
        >
        </audio>
        <button type="button" name="playPauseBtn" class="svgBtn playPauseBtn playDialogueBtn" title="play audio" disabled>
          <img class="playIcon" src="play.svg" alt="play icon" />
        </button>
        <button type="button" name="replayBtn" class="svgBtn replayBtn poof" title="replay audio">
          <img class="replayIcon" src="replay_forward.svg" alt="replay icon" />
        </button>
      </div>
      <div class="recordAudioCtrlzContainer">
        <form action="" method="post" class="recordForm">
          <input type="hidden" name="csrfmiddlewaretoken" value="BLAHBLAHBLAHBLAHBLAH">
          <input type="hidden" class="line_id" value="713" />
          <button type="submit" name="recordBtn" class="svgBtn recordBtn poof" >
            <img class="recordIcon" src="mic.svg" alt="mic icon" />
          </button>
          <button type="button" name="stopRecordBtn" class="svgBtn stopRecordBtn recordingPulse poof" >
            <img class="stopIcon" src="stop.svg" alt="stop icon" />
          </button>
        </form>
        <div class="recordingIndicator">
          <div class="poof simpleCheckmarkContainer">
            <img class="simpleCheckIcon" src="simple_check.svg" alt="checkmark icon" />
          </div>
        </div>
      </div>
    </div>
  </div>
  <p class="poof dialogueText">¿Sr. López?</p>
  <div class="recordingPlayback audioContainer poof">
    <!-- here is where <audio> element from recording will get prepended -->
    <button type="button" name="playPauseBtn" id="playRecording_713" class="svgBtn playPauseBtn playRecordingBtn poof" title="play audio" >
      <img class="playIcon" src="play.svg" alt="play icon" />
    </button>
    <button type="button" name="replayBtn" class="svgBtn replayBtn poof" title="replay audio">
      <img class="replayIcon" src="replay_forward.svg" alt="replay icon" />
    </button>
  </div>
</li>

Javascript

Original JavaScript (functions to play audio and handle UI)

const allPlayButtons = document.querySelectorAll(".playPauseBtn");
const allReplayButtons = document.querySelectorAll(".replayBtn");

/* --Function to update UI of audio control btns 
    -> Inputs =
      -> btn (element; audio control button element)
      -> btnTitle (string; text for button element title attr)
      -> imgSVG (string; path for icon img within btn element)
      -> imgAlt (string; alt text for icon img element)
      -> buffer (bool; whether or not btn should be disabled and whether or not img should have spinny 
    ** no return value, just behavior
*/
const changeAudioControlBtn = function(btn, btnTitle, imgSVG, imgAlt, buffer=false) {
  // get other elements
  let btnImg = btn.querySelector("img");
  // change UI of btn
  btn.title = btnTitle;
  btn.disabled = buffer;
  // change UI of img
  btnImg.src = imgSVG;
  btnImg.alt = imgAlt;
  if (buffer === true) {
    btnImg.classList.add("spinny");
    btn.disabled = true;
  } else {
    btnImg.classList.remove("spinny");
    btn.disabled = false;
  }
}

/* Function to pause all audios */
const pauseAll = async function() {
  let allYeAudios = document.querySelectorAll("audio");
  allYeAudios.forEach((item, i) => {
    item.pause()
  });
};

/* Function for playing or pausing audio associated with play/pause button */
const playOrPause = async function(btn) {
  let soundByte = btn.closest('.audioContainer').querySelector('audio');
  
  if (!soundByte.loaded) {
    soundByte.load();
    soundByte.manuallyloaded = true;
  } else {
    if (soundByte.paused) {
      // pause other audios before playing
      await pauseAll();
      // play the audio
      soundByte.play();
    } else {
      soundByte.pause();
    }
  }
};

/* Function for playing audio from start when replay button is hit */
const playFromStart = async function(btn) {
  let soundByte = btn.closest('.audioContainer').querySelector('audio');

  if (!soundByte.loaded) {
    soundByte.load();
    soundByte.manuallyloaded = true;
  } else {
    // pause other audios before playing
    await pauseAll();
    soundByte.currentTime = 0;
    soundByte.play();
  }
};

/* Function for any click on an audio control btn */
const audioContainerClickHandler = function() {
  switch (this.name) {
    case 'playPauseBtn':
      playOrPause(this);
      break;
    case 'replayBtn':
      playFromStart(this);
      break;
  }
};

/* Function to:
--1) change play/pause button icon to play or pause symbol
--2) cause replay button to display while audio is playing
----------------*/
const changeAudioControlGraphics = async function(e) {
  let playPauseBtn = e.target.closest('.audioContainer').querySelector('.playPauseBtn');
  let replayBtn = e.target.closest('.audioContainer').querySelector('.replayBtn');
  switch (e.type) {
    case 'playing':
      changeAudioControlBtn(playPauseBtn, paTitle, paSVG, paAlt);
      replayBtn.classList.remove('poof');
      break;
    case 'pause':
    case 'canplay':
      if (e.target.manuallyloaded) {
        delete e.target.manuallyloaded;
        // pause other audios before playing
        await pauseAll();
        e.target.play();
        changeAudioControlBtn(playPauseBtn, paTitle, paSVG, paAlt);
        replayBtn.classList.remove('poof');
        e.target.loaded = true;
      } else if(e.type == 'canplay'){
        e.target.loaded = true;
      }
      changeAudioControlBtn(playPauseBtn, plTitle, plSVG, plAlt);
      break;
    case 'ended':
      if (e.target.manuallyloaded) {
        delete e.target.manuallyloaded;
        // pause other audios before playing
        await pauseAll();
        e.target.play();
        e.target.loaded = true;
      } else if(e.type == 'canplay'){
        e.target.loaded = true;
      }
      changeAudioControlBtn(playPauseBtn, reTitle, reSVG, reAlt);
      replayBtn.classList.add('poof');
      break;
    default:
      changeAudioControlBtn(playPauseBtn, buTitle, buSVG, buAlt, true);
      break;
  }
};

// look for clicks on audio control btns
allPlayButtons.forEach((item, i) => {
  item.addEventListener("click", audioContainerClickHandler);
});
allReplayButtons.forEach((item, i) => {
  item.addEventListener("click", audioContainerClickHandler);
});

// change visuals upon play and pause
dialogueSection.addEventListener("playing", changeAudioControlGraphics, true);
dialogueSection.addEventListener("pause", changeAudioControlGraphics, true);
// and these other 3 events
dialogueSection.addEventListener("canplay", changeAudioControlGraphics, true);
dialogueSection.addEventListener("emptied", changeAudioControlGraphics, true);
dialogueSection.addEventListener("ended", changeAudioControlGraphics, true);

Added JavaScript (record audio and create audio elements from the recorded audio)

Mostly pay attention to the if (response.ok) {} block within the uploadInterpretation() function because it is the code creating the audio element. I eliminated most code in all the other functions whose code isn't really relevant.

const allRecordForms = document.querySelectorAll(".recordForm");

// User media constraints
const constraints = {
  audio: true,
  video: false,
};

// function to change UI for record button
const changeRecordBtnUI = async function(container) {
  // bunch of stuff for logic surrounding recording UI
};

// declare mediaRecorder
var mediaRecorder;
// options for recorder
const recorderOptions = {
  audioBitsPerSecond: 44100,
};

const recordInterpretation = async function(stream) {
  return new Promise((resolve, reject) => {
    // bunch of stuff for MediaRecorder
  });
};

const stopRecording = function() {
  // returns a promise and handles the stop logic for the MediaRecorder
};

/* this function is the focus, since it creates a new audio element after uploading the recording */
const uploadInterpretation = async function(lineForm, blob) {
  const line_id = lineForm.querySelector("input.line_id").value;
  const intUploadEndpointFull = `bunchOfStuff/for/endpoint`;
  console.log(`Interpretation Creation endpoint is: ${intUploadEndpointFull}`);
  // append data
  const formData = new FormData();
  formData.append('audio_file', blob, 'interpretation.webm');
  for (let [key, value] of formData.entries()) {
    console.log(key, value);
  }

  // fetch request
  try {
    const response = await fetch(intUploadEndpointFull, {
      method: 'POST',
      headers: {
        'X-CSRFToken': lineForm.querySelector('[name=csrfmiddlewaretoken]').value,
      },
      body: formData
    });

    if (response.ok) {
      const data = await response.json();
      console.log('Interpretation uploaded successfully:', data);
      // attach returned audio url to a new audio element and place inside correct .audioContainer element
      const playback_audio_container = lineForm.closest("li.line").querySelector(".recordingPlayback");
      const playback_audio_element = document.createElement("audio");
      playback_audio_element.src = data.audio_file;
      playback_audio_element.classList.add("intPlaybackAudio");
      playback_audio_container.prepend(playback_audio_element);
      const playback_playBtn = playback_audio_container.querySelector(".playPauseBtn");
      const playback_replayBtn = playback_audio_container.querySelector(".replayBtn");
      playback_playBtn.addEventListener("click", audioContainerClickHandler);
      playback_replayBtn.addEventListener("click", audioContainerClickHandler);
      if (isItCurrentlyReviewMode === true) { // if in review mode, appear audio container
        playback_audio_container.classList.remove("poof");
      }
      return data;
    } else {
      throw new Error('Failed to upload interpretation');
    }
  } catch (error) {
    console.error('Error uploading interpretation:', error);
    throw error;
  }
};

// sundry global variables for recording logic
var currentlyRecording = false;
var doneRecording = false;

const handleRecordingProcess = async function(event) {
  event.preventDefault();
  const lineForm = this;
  const lineWrapper = lineForm.closest("li.line");
  const recordInterpretationContainer = lineForm.closest(".recordAudioCtrlzContainer");
  const lineStopBtn = lineForm.querySelector(".stopRecordBtn");
  doneRecording = false;
  
  if (!currentlyRecording) {
    try {
      // this should change recordBtn to buffer icon
      await changeRecordBtnUI(recordInterpretationContainer);
      // then we start up the stream
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      // then we mark we are recording to change UI (disappear recordBtn, appear stopBtn)
      currentlyRecording = true;
      await changeRecordBtnUI(recordInterpretationContainer);
      // start the recording up
      const recordingPromise = recordInterpretation(stream);
      lineStopBtn.onclick = async () => {
        changeAudioControlBtn(lineStopBtn, buTitle, buSVG, buAlt, true); // buffer icon for stopBtn
        try {
          // stop the recording and get the blob
          await stopRecording();
          lineStopBtn.classList.remove("recordingPulse"); // get rid of pulsing for stopBtn (what indicates that it is recording)
          // get the blob
          const blob = await recordingPromise;
          
          // upload blob
          await uploadInterpretation(lineForm, blob);
          
          // change global variables (and UI accordingly to show file was uploaded successfully)
          currentlyRecording = false;
          doneRecording = true;
          await changeRecordBtnUI(recordInterpretationContainer);
          
        } catch (error) {
          console.error('Error stopping recording:', error);
        }
      };
    } catch (error) {
      console.error('Error in stopping recording or getting blob:', error);
    }
  } else {
    console.log('Already recording');
  }
};

allRecordForms.forEach((form, i) => {
  form.addEventListener("submit", handleRecordingProcess)
});

Solution

  • Problem = doubling up on event listeners

    Kaiido's comment was correct. The problem was that in my uploadInterpretation() function, I was assigning event listeners to the audio control buttons:

    const playback_playBtn = playback_audio_container.querySelector(".playPauseBtn");
    const playback_replayBtn = playback_audio_container.querySelector(".replayBtn");
    playback_playBtn.addEventListener("click", audioContainerClickHandler);
    playback_replayBtn.addEventListener("click", audioContainerClickHandler);
    

    but I had already assigned identical event listeners to these same button elements in the first chunk of script that was from before the new feature (because the added audio control buttons had the same class):

    const allPlayButtons = document.querySelectorAll(".playPauseBtn");
    const allReplayButtons = document.querySelectorAll(".replayBtn");
    
    //...
    
    allPlayButtons.forEach((item, i) => {
      item.addEventListener("click", audioContainerClickHandler);
    });
    allReplayButtons.forEach((item, i) => {
      item.addEventListener("click", audioContainerClickHandler);
    });
    

    Solution

    I deleted these lines of code from my uploadInterpretation() function and now everything works as expected:

    const playback_playBtn = playback_audio_container.querySelector(".playPauseBtn");
    const playback_replayBtn = playback_audio_container.querySelector(".replayBtn");
    playback_playBtn.addEventListener("click", audioContainerClickHandler);
    playback_replayBtn.addEventListener("click", audioContainerClickHandler);