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
- handles all the recording logic,
- uploads the recorded blob to my server,
- (which sends it to a cloud storage service, and the server returns a signed URL to the resource),
- and creates another audio element to be added to the same container as the audio element the recording is in response to.
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
- Am I now getting this error message because the programmatically added audio elements don’t have
preload="auto" set like the ones from the server have?
- I can see that I’m not handling the
.play() promise the same as is recommended in this Chrome Developer article, but also 1) I don’t see how I would integrate it and 2) don’t understand why it is an issue now but wasn’t before
- Was my code never handling the interaction between the
.play() promises and the .pause actions correctly but now that I’m increasing the number of audio elements by 50% the issue is coming cropping up somehow?
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.
<li class="line int int-norm TR nonPowerLang" lang="es-US">
<div class="lineLabelCtrlzWrapper">
<h4 class="metaInfo">
<span class="speakerTag">Int</span>
<span class="trunkOrBranchTag">TR-</span><span class="dialogueID">11</span>
</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="buffering..." disabled>
<img height="22px" width="22px" class="playIcon spinny" src="/static/svg/buffer.svg" alt="buffering icon" />
</button>
<button type="button" name="replayBtn" class="svgBtn replayBtn poof" title="replay audio">
<img height="22px" width="22px" class="replayIcon" src="/static/svg/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" id="record_713" class="svgBtn recordBtn poof" title="start recording" >
<img height="28px" width="28px" class="recordIcon" src="/static/svg/mic.svg" alt="microphone icon" />
</button>
<button type="button" name="stopRecordBtn" id="stopRecord_713" class="svgBtn stopRecordBtn recordingPulse poof" title="stop recording" >
<img height="20px" width="20px" class="stopIcon" src="/static/svg/stop.svg" alt="stop icon" />
</button>
</form>
<div class="recordingIndicator">
<div class="poof simpleCheckmarkContainer">
<img height="30px" width="30px" class="simpleCheckIcon" src="/static/svg/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 -->
<img height="28px" width="28px" class="recordingIcon" src="/static/svg/recording.svg" alt="tape recorder icon" />
<button type="button" name="playPauseBtn" id="playRecording_713" class="svgBtn playPauseBtn playRecordingBtn poof" title="play audio" >
<img height="22px" width="22px" class="playIcon" src="/static/svg/play.svg" alt="play icon" />
</button>
<button type="button" name="replayBtn" class="svgBtn replayBtn poof" title="replay audio">
<img height="22px" width="22px" class="replayIcon" src="/static/svg/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 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.
const allRecordForms = document.querySelectorAll(".recordForm");
// User media constraints
const constraints = {
audio: true,
video: false,
};
// sundry global variables for recording logic
var currentlyRecording = false;
var doneRecording = false;
// function to change UI for record button
const changeRecordBtnUI = async function(container) {
const recordBtn = container.querySelector(".recordBtn");
const stopBtn = container.querySelector(".stopRecordBtn");
const checkMark = container.querySelector(".simpleCheckmarkContainer");
if (currentlyRecording === false && doneRecording === false) { // recording is actually happening..
changeAudioControlBtn(recordBtn, buTitle, buSVG, buAlt, true); // buffer icon for recordBtn
allRecordButtons.forEach((btn, i) => { // disable all record btns
if (btn !== recordBtn) {
btn.disabled = true;
}
});
} else if (currentlyRecording === false && doneRecording === true) { // if we done recording..
changeAudioControlBtn(stopBtn, stopTitle, stopSVG, stopAlt); // take off buffer UI for stopBtn (i.e., reset)
stopBtn.disabled = true;
stopBtn.classList.add("poof", "done"); // disappear stopBtn (and give "done" class)
checkMark.classList.remove("poof"); // appear checkmark
allRecordButtons.forEach((btn, i) => { // un-disable all other record btns
if (btn !== recordBtn && !btn.classList.contains("done")) {
btn.disabled = false;
}
});
} else if (currentlyRecording === true) { // once recording is actually happening..
changeAudioControlBtn(recordBtn, recTitle, recSVG, recAlt); // take off buffer UI for recordBtn (i.e., reset)
allRecordButtons.forEach((btn, i) => { // disable all record btns (including present company)
if (btn !== recordBtn) {
btn.disabled = true;
}
});
recordBtn.classList.add("poof", "done"); // disappear record button and give it "done" class
stopBtn.classList.remove("poof"); // appear stopBtn
}
};
// declare mediaRecorder
var mediaRecorder;
// options for recorder
const recorderOptions = {
audioBitsPerSecond: 44100,
};
const recordInterpretation = async function(stream) {
return new Promise((resolve, reject) => {
const chunks = [];
mediaRecorder = new MediaRecorder(stream, recorderOptions);
console.log("MediaRecorder initialized with state:", mediaRecorder.state);
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) chunks.push(e.data);
};
mediaRecorder.addEventListener('stop', () => {
console.log("MediaRecorder stop event detected");
const blob = new Blob(chunks, {type: "audio/webm",});
stream.getTracks().forEach(track => track.stop());
resolve(blob);
});
mediaRecorder.onerror = (err) => {
console.error("MediaRecorder error:", err);
reject(err);
};
mediaRecorder.start();
console.log("MediaRecorder started with state:", mediaRecorder.state);
});
};
const stopRecording = function() {
console.log("stopRecording function has been called")
return new Promise((resolve, reject) => {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
console.log("MediaRecorder state before stopping:", mediaRecorder.state);
mediaRecorder.onstop = () => {
console.log("MediaRecorder onstop event triggered");
resolve();
};
mediaRecorder.stop();
} else {
console.log("MediaRecorder is either not initialized or already inactive");
reject(new Error('No active recording to stop'));
}
});
};
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_element.id = `intPlaybackAudio_${line_id}`;
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;
}
};
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);
analyzeAudio(stream, lineWrapper);
// start the recording up
const recordingPromise = recordInterpretation(stream);
lineStopBtn.onclick = async () => {
changeAudioControlBtn(lineStopBtn, buTitle, buSVG, buAlt, true); // buffer icon for stopBtn
try {
console.log("now trying to stop recording");
// stop the recording and get the blob
await stopRecording();
// stop the UI jambly
await stopAudioAnalysis();
lineWrapper.classList.remove("recordingUnderway");
if (lineWrapper.classList.contains("simul")) {
lineWrapper.style.outline = "7px solid #feb01d";
} else {
lineWrapper.style.outline = "unset";
}
lineStopBtn.classList.remove("recordingPulse"); // get rid of pulsing for stopBtn (what indicates that it is recording)
console.log("Recording stopped, now waiting for blob");
// get the blob
const blob = await recordingPromise;
console.log("recording has now stopped. now trying to upload")
// 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)
});