/** An AudioPane handles audio playback, playback controls, and playback visuals. */
class AudioPane {
/**
* Create an audio pane.
* @param {Object} audioContext - An AudioContext.
* @param {Object} options - Configuration options.
* @author Lawrence Fyfe
*/
constructor(audioContext, options) {
this.paneType = "audio";
this.audioContext = audioContext;
this.options = options;
this.showPlayLine = options?.showPlayLine ?? true;
this.showPanelTitle = options?.showPanelTitle ?? false;
this.showPlayButton = options?.showPlayButton ?? true;
this.showStopButton = options?.showStopButton ?? true;
this.showSkipBackwardButton = options?.showSkipBackwardButton ?? false;
this.showSkipForwardButton = options?.showSkipForwardButton ?? false;
this.showScheduledAudioButton = options?.showScheduledAudioButton ?? false;
this.buttonWidth = options?.buttonWidth ?? "3ch";
this.buttonHeight = options?.buttonHeight ?? "3ch";
this.buttonFontSize = options?.buttonFontSize ?? "x-large";
this.buttonBorderRadius = options?.buttonBorderRadius ?? "8px";
this.showButtonDescription = options?.showButtonDescription ?? true;
this.parentElement;
this.paneWidth;
this.paneHeight;
this.timeScale;
this.initialized = false;
this.audioPaneGroup;
this.playerSet = false;
this.player;
this.onReady;
this.onPlay;
this.onPause;
this.onSkip;
this.onSkipToNextBuffer;
this.onSkipToPreviousBuffer;
this.onStop;
this.onFinished;
this.onMovePlayIndicator;
this.onKeyboardInput;
this.clockTime = 0;
this.resetClockTime = true;
this.playLine;
this.playLineX = 0;
this.playerTime = 0;
this.animatePlayIndicators = this.drawPlayIndicator.bind(this);
this.animationID;
this.scheduledBufferPlayerArray = [];
this.playScheduledBuffers = true;
this.panelElement;
this.panelInitialized = false;
this.panelOn = false;
this.audioPanelDiv;
this.audioControlsDiv;
this.playButtonDiv;
this.playButton;
this.stopButtonDiv;
this.stopButton;
this.buttonDescriptionSpan;
this.scheduledAudioButtonDiv;
this.scheduledAudioButton;
}
setPlayer(player) {
this.player = player;
this.playerSet = true;
this.player.onEnded = this.onPlayerEnded.bind(this);
this.playLine = new ProgressIndicator();
}
/**
* Determine whether this AudioPane has a player set.
* @return {boolean} Whether a player has been added.
*/
hasPlayer() {
return this.playerSet;
}
/**
* Run this method when the player for this pane has ended.
*/
onPlayerEnded() {
this.cancelPlayIndicators();
this.playerTime = this.player.offsetStartTime;
this.resetClockTime = true;
this.playLineX = 0;
this.playLine.erase();
this.resetAudioButtons();
if (this.onFinished !== undefined) {
this.onFinished();
}
}
/**
* Determines whether a buffer has been decoded and is ready to play.
*/
async isReady() {
return this.player === undefined ? Promise.resolve(false) : await this.player.isReady();
}
/**
* Get the duration of the player for this pane
*/
getDuration() {
return this.player === undefined ? 0 : this.player.duration;
}
/**
* Set the time range for playback.
* @param {number} start - The start time.
* @param {number} stop - The end time.
*/
setTimeRange(start, stop) {
if (this.player !== undefined) {
this.playLineX = 0;
this.playerTime = start;
this.player.setTimeRange(start, stop);
this.stop();
}
}
/**
* Resets the time range to start at zero and end at the duration of the player.
*/
resetTimeRange() {
if (this.player !== undefined) {
this.player.offsetStartTime = 0;
this.player.offsetStopTime = null;
this.playerTime = 0;
this.resetClockTime = true;
}
}
/**
* Get the elapsed time in seconds since the last call to this method.
*/
getElapsedTime() {
var now = this.audioContext.currentTime;
if (this.resetClockTime) {
this.clockTime = now;
this.resetClockTime = false;
}
var elapsedTime = now-this.clockTime;
this.clockTime = now;
return elapsedTime;
}
/**
* Get the total playback time of the player for this pane.
*/
getPlayerTime() {
return this.playerTime;
}
/**
* Set the built-in control buttons to a playing state.
*/
setAudioButtonsToPlaying() {
if (this.panelOn) {
if (this.player !== undefined && this.player.paused) {
this.setPlayButtonToPaused();
}
else {
this.playButton.setOption("text", "\u23f8");
this.playButton.setOption("description", "Pause audio");
this.playButton.draw();
}
this.stopButton.setOption("opacity", "1.0");
this.stopButton.draw();
if (this.scheduledBufferPlayerArray.length > 0) {
this.skipBackwardButton.setOption("opacity", "1.0");
this.skipBackwardButton.draw();
this.skipForwardButton.setOption("opacity", "1.0");
this.skipForwardButton.draw();
}
}
}
/**
* Set the built-in control buttons to a paused state.
*/
setPlayButtonToPaused() {
if (this.panelOn) {
this.playButton.setOption("text", "\u23f5");
this.playButton.setOption("description", "Play audio");
this.playButton.draw();
}
}
/**
* Reset the built-in control buttons to their starting state.
*/
resetAudioButtons() {
if (this.panelOn) {
this.stopButton.setOption("opacity", "0.5");
this.stopButton.draw();
this.playButton.setOption("text", "\u23ef");
this.playButton.setOption("description", "Play audio");
this.playButton.draw();
if (this.scheduledBufferPlayerArray.length > 0) {
this.skipBackwardButton.setOption("opacity", "0.5");
this.skipBackwardButton.draw();
this.skipForwardButton.setOption("opacity", "0.5");
this.skipForwardButton.draw();
}
}
}
/**
* Switch playback of scheduled buffers on and off.
*/
switchScheduledAudio() {
if (this.playScheduledBuffers) {
this.playScheduledBuffers = false;
this.scheduledAudioButton.setOption("opacity", "0.5");
this.scheduledAudioButton.draw();
}
else {
this.playScheduledBuffers = true;
this.scheduledAudioButton.setOption("opacity", "1.0");
this.scheduledAudioButton.draw();
}
this.stopScheduledBufferPlayers();
this.stop();
}
/**
* Initialize the panel for this pane, setting its parent element to the specified element.
* @param {element} panelElement - The parent element.
*/
initializePanel(panelElement) {
this.panelElement = panelElement instanceof d3.selection ? panelElement : d3.select(panelElement);
this.panelInitialized = true;
}
/**
* Draw the panel for this pane.
*/
drawPanel() {
if (this.panelInitialized) {
this.erasePanel();
this.panelOn = true; // Set this AFTER calling erasePanel()
this.audioPanelDiv = this.panelElement.append("div")
.attr("class", "audioPanelDiv")
.style("display", "flex")
.style("flex-direction", "column")
.style("font-family", "sans-serif");
if (this.showPanelTitle) {
var audioTitleDiv = this.audioPanelDiv.append("div")
.attr("class", "audioTitleDiv")
.style("font-weight", "bold")
.style("font-size", "x-large")
.style("padding-bottom", "2ex")
.text("Audio");
}
this.audioControlsDiv = this.audioPanelDiv.append("div")
.attr("class", "audioControlsDiv")
.style("display", "flex")
.style("flex-direction", "row");
if (this.showPlayButton) {
this.playButton = new TextButton({
text: "\u23ef",
description: "Play audio",
opacity: "1.0",
fontSize: this.buttonFontSize,
borderRadius: this.buttonBorderRadius,
showDescription: this.showButtonDescription
});
this.playButton.onClick = this.playOrPause.bind(this);
this.playButton.initialize(this.audioControlsDiv, this.buttonWidth, this.buttonHeight);
this.playButton.draw();
}
if (this.showStopButton) {
this.stopButton = new TextButton({
text: "\u23f9",
description: "Stop audio",
opacity: "0.5",
fontSize: this.buttonFontSize,
borderRadius: this.buttonBorderRadius,
showDescription: this.showButtonDescription
});
this.stopButton.onClick = this.stop.bind(this);
this.stopButton.initialize(this.audioControlsDiv, this.buttonWidth, this.buttonHeight);
this.stopButton.draw();
}
if (this.showSkipBackwardButton) {
this.skipBackwardButton = new TextButton({
text: "\u23ee",
description: "Skip to previous boundary",
opacity: "0.5",
fontSize: this.buttonFontSize,
borderRadius: this.buttonBorderRadius,
showDescription: this.showButtonDescription
});
this.skipBackwardButton.onClick = this.skipToPreviousScheduledBufferPlayer.bind(this);
this.skipBackwardButton.initialize(this.audioControlsDiv, this.buttonWidth, this.buttonHeight);
this.skipBackwardButton.draw();
}
if (this.showSkipForwardButton) {
this.skipForwardButton = new TextButton({
text: "\u23ed",
description: "Skip to next boundary",
opacity: "0.5",
fontSize: this.buttonFontSize,
borderRadius: this.buttonBorderRadius,
showDescription: this.showButtonDescription
});
this.skipForwardButton.onClick = this.skipToNextScheduledBufferPlayer.bind(this);
this.skipForwardButton.initialize(this.audioControlsDiv, this.buttonWidth, this.buttonHeight);
this.skipForwardButton.draw();
}
if (this.showScheduledAudioButton) {
let scheduledAudioButtonOptions = {
text: "\u2346",
description: "Play boundary sounds",
fontSize: this.buttonFontSize,
borderRadius: this.buttonBorderRadius,
showDescription: this.showButtonDescription
};
if (this.playScheduledBuffers) {
scheduledAudioButtonOptions.opacity = "1.0";
}
else {
scheduledAudioButtonOptions.opacity = "0.5";
}
this.scheduledAudioButton = new TextButton(scheduledAudioButtonOptions);
this.scheduledAudioButton.onClick = this.switchScheduledAudio.bind(this);
this.scheduledAudioButton.initialize(this.audioControlsDiv, this.buttonWidth, this.buttonHeight);
this.scheduledAudioButton.draw();
}
}
}
/**
* Erase the panel for this pane.
*/
erasePanel() {
if (this.audioPanelDiv !== undefined) {
this.audioPanelDiv.selectAll("*").remove();
this.panelOn = false;
}
}
/**
* Initialize this AudioPane.
* @param {Element} parentElement - The parent element.
* @param {number} width - The parent width.
* @param {number} height - The parent height.
* @param {d3.scaleLinear} timeScale - The time scale.
*/
initialize(parentElement, width, height, timeScale) {
this.parentElement = parentElement;
this.paneWidth = width;
this.paneHeight = height;
this.timeScale = timeScale;
this.audioPaneGroup = this.parentElement.append("g")
.attr("class", "audioPaneGroup");
this.audioPaneGroup.append("rect")
.attr("class", "audioEventRect")
.attr("x", 0)
.attr("y", 0)
.attr("width", this.paneWidth)
.attr("height", this.paneHeight)
.style("fill", "none")
.style("pointer-events", "visible")
.style("cursor", "crosshair")
.on("click", (event) => {
// Skip to the time selected by the pointer event's x value
this.skip(this.timeScale.invert(d3.pointer(event)[0]));
});
this.initialized = true;
}
/**
* Activate this pane, allowing it to accept pointer events.
*/
activate() {
if (this.initialized) {
this.audioPaneGroup.style("pointer-events", "visible");
}
}
/**
* Deactivate this pane, preventing it from accepting pointer events.
*/
deactivate() {
if (this.initialized) {
this.audioPaneGroup.style("pointer-events", "none");
}
}
/**
* Raise this pane to the top of the list of panes in its containing TimeFrame.
*/
raise() {
if (this.audioPaneGroup !== undefined) {
this.audioPaneGroup.raise();
}
}
/**
* Lower this pane to the bottom of the list of panes in its containing TimeFrame.
*/
lower() {
if (this.audioPaneGroup !== undefined) {
this.audioPaneGroup.lower();
}
}
/**
* Draw this AudioPane. Note that this pane has no visual component other than the audio playback progress indicator
* which only appears when the player is playing or paused.
*/
draw() {
if (this.initialized) {
this.activateKeyboardInput();
}
}
erase() {
if (this.audioPaneGroup !== undefined) {
this.audioPaneGroup.remove();
}
}
/**
* Activate keyboard controls for player playing, pausing, stopping, and skipping.
*/
activateKeyboardInput() {
d3.select("body").on("keydown.audio", (event) => {
if (event.key === " " && event.target == document.body) {
event.preventDefault(); // Prevent spacebar scrolling first
if (this.player !== undefined) {
if (this.player.playing && !this.player.paused) {
this.pause();
}
else {
this.play();
}
}
}
else if (event.key === "Escape") {
if (this.player !== undefined && this.player.playing) {
this.stop();
}
}
else if (event.key === "ArrowLeft") {
if (this.player !== undefined && this.player.playing) {
this.skipToPreviousScheduledBufferPlayer();
}
}
else if (event.key === "ArrowRight") {
if (this.player !== undefined && this.player.playing) {
this.skipToNextScheduledBufferPlayer();
}
}
if (this.onKeyboardInput !== undefined) {
this.onKeyboardInput(event.key);
}
});
}
/**
* Deactivate keyboard controls.
*/
deactivateKeyboardInput() {
d3.select("body").on("keydown.audio", null);
}
/**
* Draw the animated play indicator.
*/
drawPlayIndicator() {
if (this.player !== undefined && this.player.playing && !this.player.paused) {
this.playerTime += this.getElapsedTime();
this.playLineX = this.timeScale(this.playerTime);
// Only show the play line if it is within the bounds of the visual pane
if (this.playLineX < this.paneWidth) {
this.playLine.move(this.playLineX);
if (this.onMovePlayIndicator !== undefined) {
this.onMovePlayIndicator(this.playerTime);
}
}
else {
this.playerTime = 0;
this.playLine.erase();
}
}
// requestAnimationFrame() uses the bound version of drawPlayIndicator() as a callback
this.animationID = window.requestAnimationFrame(this.animatePlayIndicators);
}
/**
* Cancel the animation of the play indicator.
*/
cancelPlayIndicators() {
window.cancelAnimationFrame(this.animationID);
}
/**
* Play the player for this AudioPane.
*/
play() {
if (this.player !== undefined && (!this.player.playing || this.player.paused)) {
this.player.play();
if (this.playScheduledBuffers && this.scheduledBufferPlayerArray.length > 0) {
this.playScheduledBufferPlayers();
}
if (this.showPlayLine) {
this.playLine.initialize(this.audioPaneGroup, this.paneWidth, this.paneHeight, this.timeScale);
this.playLine.draw(this.playLineX);
this.animatePlayIndicators();
}
this.setAudioButtonsToPlaying();
if (this.onPlay !== undefined) {
this.onPlay();
}
}
}
/**
* Determines whether the player is playing or paused.
* @return {boolean} True if the player is playing or paused, false otherwise
*/
isPlaying() {
return this.player === undefined ? false : this.player.playing;
}
/**
* Pause the player for this AudioPane.
*/
pause() {
if (this.player !== undefined && this.player.playing && !this.player.paused) {
this.player.pause();
this.resetClockTime = true;
if (this.playScheduledBuffers && this.scheduledBufferPlayerArray.length > 0) {
this.stopScheduledBufferPlayers();
}
if (this.showPlayLine) {
this.cancelPlayIndicators();
this.playLine.erase();
this.playLine.initialize(this.audioPaneGroup, this.paneWidth, this.paneHeight, this.timeScale);
this.playLine.draw(this.playLineX);
}
this.setPlayButtonToPaused();
if (this.onPause !== undefined) {
this.onPause();
}
}
}
/**
* Play the player if it's stopped or paused; pause otherwise.
*/
playOrPause() {
if (this.player !== undefined) {
if (!this.player.playing || this.player.paused) {
this.play();
}
else {
this.pause();
}
}
}
/**
* Skip player playback to the specified time. If the player is playing, skip to that time and keep playing.
*/
skip(time) {
if (this.player !== undefined && this.player.playing) {
this.player.skip(time);
this.playerTime = time;
if (this.playScheduledBuffers && this.scheduledBufferPlayerArray.length > 0) {
this.stopScheduledBufferPlayers();
if (!this.player.paused) {
this.playScheduledBufferPlayers();
}
}
if (this.showPlayLine) {
this.playLineX = this.timeScale(time);
this.playLine.move(this.playLineX);
}
if (this.onSkip !== undefined) {
this.onSkip(time);
}
}
}
/**
* Stop player. This will cancel the play indicator, stop any buffers scheduled after the stopping time,
* and reset the control buttons.
*/
stop() {
if (this.player !== undefined && this.player.playing) {
this.player.stop();
this.playerTime = this.player.offsetStartTime;
this.resetClockTime = true;
if (this.playScheduledBuffers && this.scheduledBufferPlayerArray.length > 0) {
this.stopScheduledBufferPlayers();
}
if (this.showPlayLine) {
this.playLineX = 0;
this.cancelPlayIndicators();
this.playLine.erase();
}
this.resetAudioButtons();
if (this.onStop !== undefined) {
this.onStop();
}
}
}
/**
* Add the specified scheduled player.
*/
addScheduledBufferPlayer(player) {
this.scheduledBufferPlayerArray.push(player);
}
/**
* Delete the scheduled player specified by its time vale.
*/
deleteScheduledBufferPlayer(time) {
for (let i = this.scheduledBufferPlayerArray.length-1; i >= 0; --i) {
if (this.scheduledBufferPlayerArray[i].time === time) {
this.scheduledBufferPlayerArray.splice(i, 1);
}
}
}
/**
* Delete all scheduled buffer players.
*/
deleteAllScheduledBufferPlayers() {
this.scheduledBufferPlayerArray.length = 0;
}
/**
* Play scheduled buffer players. Note that only buffer players scheduled after the current playback time will play.
*/
playScheduledBufferPlayers() {
if (this.player !== undefined) {
let totalOffsetTime = this.player.offsetStartTime+this.player.playOffsetTime;
if (this.player.offsetStopTime !== null) {
for (let i = 0; i < this.scheduledBufferPlayerArray.length; i++) {
if (this.scheduledBufferPlayerArray[i].time > totalOffsetTime && this.scheduledBufferPlayerArray[i].time < this.player.offsetStopTime) {
this.scheduledBufferPlayerArray[i].play(totalOffsetTime);
}
}
}
else {
for (let i = 0; i < this.scheduledBufferPlayerArray.length; i++) {
if (this.scheduledBufferPlayerArray[i].time > totalOffsetTime) {
this.scheduledBufferPlayerArray[i].play(totalOffsetTime);
}
}
}
}
}
/**
* Skip to the previously scheduled player.
*/
skipToPreviousScheduledBufferPlayer() {
if (this.player !== undefined) {
var bufferArray = [];
var currentTime = 0;
if (this.scheduledBufferPlayerArray.length > 0) {
if (this.player.offsetStopTime !== null) {
bufferArray = this.scheduledBufferPlayerArray.filter(
player => player.time > this.player.offsetStartTime && player.time < this.player.offsetStopTime
);
}
else {
bufferArray = this.scheduledBufferPlayerArray;
}
bufferArray.sort((a,b) => b.time-a.time);
if (this.player.paused) {
currentTime = this.player.offsetStartTime+this.player.playOffsetTime;
}
else {
currentTime = this.audioContext.currentTime-this.player.bufferPlayer.playStartedClockTime+this.player.offsetStartTime+this.player.playOffsetTime;
}
for (let i = 0; i < bufferArray.length; i++) {
if (bufferArray[i].time < currentTime) {
this.skip(bufferArray[i].time);
if (this.playScheduledBuffers) {
bufferArray[i].play(this.player.offsetStartTime+this.player.playOffsetTime);
}
if (this.onSkipToPreviousBuffer !== undefined) {
this.onSkipToPreviousBuffer();
}
break;
}
}
}
}
}
/**
* Skip to the next scheduled player.
*/
skipToNextScheduledBufferPlayer() {
if (this.player !== undefined) {
var bufferArray = [];
var currentTime = 0;
if (this.scheduledBufferPlayerArray.length > 0) {
if (this.player.offsetStopTime !== null) {
bufferArray = this.scheduledBufferPlayerArray.filter(
player => player.time > this.player.offsetStartTime && player.time < this.player.offsetStopTime
);
}
else {
bufferArray = this.scheduledBufferPlayerArray;
}
bufferArray.sort((a,b) => a.time-b.time);
if (this.player.paused) {
currentTime = this.player.offsetStartTime+this.player.playOffsetTime;
}
else {
currentTime = this.audioContext.currentTime-this.player.bufferPlayer.playStartedClockTime+this.player.offsetStartTime+this.player.playOffsetTime;
}
for (let i = 0; i < bufferArray.length; i++) {
if (bufferArray[i].time > currentTime) {
this.skip(bufferArray[i].time);
if (this.playScheduledBuffers) {
bufferArray[i].play(this.player.offsetStartTime+this.player.playOffsetTime);
}
if (this.onSkipToNextBuffer !== undefined) {
this.onSkipToNextBuffer();
}
break;
}
}
}
}
}
/**
* Stop all scheduled buffer players.
*/
stopScheduledBufferPlayers() {
for (let i = 0; i < this.scheduledBufferPlayerArray.length; i++) {
this.scheduledBufferPlayerArray[i].stop();
}
}
}
/** An audio buffer player. */
class BufferPlayer {
/**
* Create an audio buffer player.
* @param {Object} audioContext - An AudioContext.
* @param {Object} buffer - The audio buffer to play.
* @author Lawrence Fyfe
*/
constructor(audioContext, buffer) {
this.audioContext = audioContext;
this.buffer = buffer;
this.bufferAndGainArray = [];
this.currentBuffer;
this.currentGain;
this.rampDuration = 0.006;
this.playStartedClockTime = 0;
this.onEnded;
}
/**
* Slice audio channel data in the specified time range and return it.
* @param {number} channel - The BufferPlayer channel.
* @param {number} startTime - The start time for the slice.
* @param {number} stopTime - The stop time for the slice.
* @return {Array} The audio data in the specified time range.
*/
sliceAudioData(channel, startTime, stopTime) {
var startSample = Math.floor(startTime * this.buffer.sampleRate);
var stopSample = Math.ceil(stopTime * this.buffer.sampleRate);
if (startSample < 0) {
startSample = 0;
}
// If stopSample (via stopTime) is > this.buffer.getChannelData(0).length,
// then the size of the audio data will = this.buffer.getChannelData(0).length
return this.buffer.getChannelData(channel).slice(startSample, stopSample);
}
/**
* Reduce the amount of audio channel data to the specified target array size. The original channel audio data is broken into a number of segments
* specified by the target array size. Then the maxmum absolute value in that segment is put into the target array.
* @param {number} channel - The BufferPlayer channel.
* @param {number} targetSize - The target size of the reduced data array.
* @param {number} offsetStartTime - The time offset to start reducing.
* @param {number} offsetStopTime - The time offset to stop reducing.
* @return {Float32Array} The reduced audio data array.
*/
reduceAudioData(channel, targetSize, offsetStartTime, offsetStopTime) {
var audioData = this.sliceAudioData(channel, offsetStartTime, offsetStopTime);
var reducedAudioData = new Float32Array(targetSize);
var segmentSize = Math.floor(audioData.length/targetSize);
for (let i = 0; i < targetSize; i++) {
let segment = audioData.slice(i*segmentSize, (i+1)*segmentSize);
if (segment.length > 0) {
let max = d3.max(segment);
let min = d3.min(segment);
reducedAudioData[i] = Math.abs(min) > max ? min : max;
}
}
return reducedAudioData;
}
/**
* Play the buffer with the specified parameters.
* @param {number} offsetTime - The offset time within the buffer.
* @param {number} duration - The amount of time to play.
*/
play(offsetTime, duration) {
var now = this.audioContext.currentTime;
var totalTime = now+duration;
this.playStartedClockTime = now;
// Push buffer and gain onto an array to avoid clicks
this.bufferAndGainArray.push([new AudioBufferSourceNode(this.audioContext), new GainNode(this.audioContext)]);
// Set the current buffer and gain to the last ones in the array
this.currentBuffer = this.bufferAndGainArray.at(-1)[0];
this.currentGain = this.bufferAndGainArray.at(-1)[1];
this.currentBuffer.buffer = this.buffer;
// Connect the current buffer to the current gain
this.currentBuffer.connect(this.currentGain);
this.currentGain.connect(this.audioContext.destination);
this.currentGain.gain.setValueAtTime(0.0, now);
this.currentGain.gain.linearRampToValueAtTime(1.0, now+this.rampDuration);
this.currentGain.gain.setValueAtTime(1.0, totalTime-this.rampDuration);
this.currentGain.gain.linearRampToValueAtTime(0.0, totalTime);
this.currentBuffer.start(0, offsetTime, duration);
this.currentBuffer.onended = () => {
if (this.onEnded !== undefined) {
this.onEnded();
}
};
}
/**
* Stop playing the buffer.
*/
stop() {
var now = this.audioContext.currentTime;
this.currentGain.gain.setValueAtTime(1.0, now);
this.currentGain.gain.linearRampToValueAtTime(0.0, now+this.rampDuration);
this.currentBuffer.stop(now+this.rampDuration);
// Don't let the buffer and gain array grow indefinitely
if (this.bufferAndGainArray.length > 2) {
this.bufferAndGainArray.splice(0, 1);
}
}
}
/** An audio file player. */
class AudioFilePlayer {
/**
* Create an audio file player.
* @param {Object} audioContext - An AudioContext.
* @param {Object} audioFile - The audio file to play.
* @author Lawrence Fyfe
*/
constructor(audioContext, audioFile) {
this.type = "buffer";
this.audioContext = audioContext;
this.bufferPlayer;
this.playOffsetTime = 0;
this.offsetStartTime = 0;
this.offsetStopTime = null;
this.playing = false;
this.paused = false;
this.skipped = false;
this.duration = 0;
this.onEnded;
this.readyPromise = new Promise((resolve) => {
audioFile.arrayBuffer().then((audioArrayBuffer) => {
this.audioContext.decodeAudioData(audioArrayBuffer).then((decodedBuffer) => {
this.duration = decodedBuffer.duration;
this.bufferPlayer = new BufferPlayer(audioContext, decodedBuffer);
this.bufferPlayer.onEnded = this.endPlayer.bind(this);
resolve(true);
})
.catch(() => resolve(false));
})
.catch(() => resolve(false));
});
}
/**
* Is this player ready to play?
* @return {Promise} A promise that resolves when this player has decoded its audio file.
*/
async isReady() {
return await this.readyPromise;
}
/**
* Sets the specified time range for the BufferPlayer.
* @param {number} start - The start time.
* @param {number} stop - The end time.
*/
setTimeRange(start, stop) {
this.offsetStartTime = start;
this.offsetStopTime = stop;
}
/**
* Play.
*/
play() {
this.paused = false;
this.readyPromise.then(() => {
let duration = this.offsetStopTime === null ?
this.duration-this.offsetStartTime-this.playOffsetTime : this.offsetStopTime-this.offsetStartTime-this.playOffsetTime;
this.bufferPlayer.play(this.offsetStartTime+this.playOffsetTime, duration);
this.playing = true;
});
}
/**
* Pause.
*/
pause() {
this.paused = true;
this.readyPromise.then(() => {
this.playOffsetTime += (this.audioContext.currentTime-this.bufferPlayer.playStartedClockTime);
this.bufferPlayer.stop();
});
}
/**
* Skip to the specified time.
* @param {number} time - The time to skip to.
*/
skip(time) {
this.playOffsetTime = time-this.offsetStartTime;
if (!this.paused) {
this.skipped = true;
this.readyPromise.then(() => {
let duration = this.offsetStopTime === null ?
this.duration-this.offsetStartTime-this.playOffsetTime : this.offsetStopTime-this.offsetStartTime-this.playOffsetTime;
this.bufferPlayer.stop();
this.bufferPlayer.play(this.offsetStartTime+this.playOffsetTime, duration);
});
}
}
/**
* Stop.
*/
stop() {
this.paused = false;
this.playOffsetTime = 0;
this.readyPromise.then(() => {
this.bufferPlayer.stop();
this.playing = false;
});
}
/**
* This is called when buffer playback has eneded.
*/
endPlayer() {
if (this.skipped) {
this.skipped = false;
}
else {
if (!this.paused) {
this.playOffsetTime = 0;
this.playing = false;
if (this.onEnded !== undefined) {
this.onEnded();
}
}
}
}
}
/** A scheduled buffer player plays its buffer at a scheduled time. */
class ScheduledBufferPlayer {
/**
* Create a scheduled buffer player.
* @param {Object} audioContext - An AudioContext.
* @param {Object} audioArrayBuffer - An ArrayBuffer containing audio data.
* @param {Object} time - The scheduled time in seconds to start playing.
* @param {Object} gainValue - The value for the gain.
* @author Lawrence Fyfe
*/
constructor(audioContext, audioArrayBuffer, time, gainValue) {
this.type = "scheduledBuffer";
this.audioContext = audioContext;
this.buffer = audioArrayBuffer;
this.time = time;
this.gainValue = gainValue;
this.bufferSource;
this.bufferGain;
}
/**
* Set scheduled buffer to play.
* @param {number} offsetTime - The offset time.
*/
play(offsetTime) {
let now = this.audioContext.currentTime;
this.bufferSource = this.audioContext.createBufferSource();
this.bufferGain = this.audioContext.createGain();
this.bufferGain.gain.setValueAtTime(this.gainValue, now);
this.bufferSource.buffer = this.buffer;
this.bufferSource.connect(this.bufferGain);
this.bufferGain.connect(this.audioContext.destination);
this.bufferSource.start(now+this.time-offsetTime);
}
/**
* Stop scheduled buffer from playing.
*/
stop() {
if (this.bufferSource !== undefined) {
this.bufferSource.stop();
}
}
}
class Synthesizer {
/**
* A basic synthesizer that can also serve as a parent class for other types of synthesizers.
* @param {Object} audioContext - An AudioContext.
* @author Lawrence Fyfe
*/
constructor(audioContext) {
this.audioContext = audioContext;
this.oscillatorType = "sine";
this.rampTime = 0.006;
this.maxVelocity = 127;
this.activeNotes = [];
this.scheduledNotes = [];
this.outputGainNode = new GainNode(this.audioContext);
this.outputGainNode.connect(this.audioContext.destination);
this.limiter = new DynamicsCompressorNode(this.audioContext);
this.limiter.threshold.value = -3;
this.limiter.knee.value = 0;
this.limiter.ratio.value = 20;
this.limiter.attack.value = 0;
this.limiter.release.value = 0.8;
this.limiter.connect(this.outputGainNode);
this.lowpassFilter = new BiquadFilterNode(this.audioContext);
this.lowpassFilter.type = "lowpass";
this.lowpassFilter.frequency.value = 8000;
this.lowpassFilter.connect(this.limiter);
this.highpassFilter = new BiquadFilterNode(this.audioContext);
this.highpassFilter.type = "highpass";
this.highpassFilter.frequency.value = 20;
this.highpassFilter.connect(this.lowpassFilter);
this.inputGainNode = new GainNode(this.audioContext);
this.inputGainNode.connect(this.highpassFilter);
}
/**
* Converts the specified note value to a frequency value.
* @param {number} note - The note value.
* @returns {number} The frequency of the note.
*/
noteToFrequency(note) {
return (2**((note-69)/12))*440;
}
/**
* Converts the specified velocity to a gain value by dividing the velocity by the maxmimum velocity (127 by default).
* @param {number} velocity - The velocity.
* @returns {number} A gain value from 0-1.
*/
velocityToGain(velocity) {
return velocity/this.maxVelocity;
}
/**
* Gets the output gain value.
* @returns {number} The gain value.
*/
get outputGain() {
return this.outputGainNode.gain.value;
}
/**
* Sets the output gain to the specified value.
* @param {Object} value - The gain value.
*/
set outputGain(value) {
var now = this.audioContext.currentTime;
this.outputGainNode.gain.setValueAtTime(this.outputGainNode.gain.value, now);
this.outputGainNode.gain.linearRampToValueAtTime(value, now + this.rampTime);
}
/**
* Gets the input gain value.
* @returns {number} The gain value.
*/
get inputGain() {
return this.inputGainNode.gain.value;
}
/**
* Sets the input gain to the specified value.
* @param {Object} value - The gain value.
*/
set inputGain(value) {
var now = this.audioContext.currentTime;
this.inputGainNode.gain.setValueAtTime(this.outputGainNode.gain.value, now);
this.inputGainNode.gain.linearRampToValueAtTime(value, now + this.rampTime);
}
/**
* Schedules the specified note to be played according to its parameters.
* @param {Object} note - The note to schedule.
*/
scheduleNote(note) {
var scheduledNote = {
lfo: new OscillatorNode(this.audioContext),
lfoGain: new GainNode(this.audioContext),
osc: new OscillatorNode(this.audioContext),
oscGain: new GainNode(this.audioContext)
};
scheduledNote.lfo.type = "sine";
scheduledNote.lfo.frequency.value = 30;
scheduledNote.lfoGain.gain.value = 3;
scheduledNote.lfo.connect(scheduledNote.lfoGain);
scheduledNote.osc.type = this.oscillatorType;
scheduledNote.osc.frequency.value = this.noteToFrequency(note.number);
scheduledNote.oscGain.gain.value = 0.0;
scheduledNote.lfoGain.connect(scheduledNote.osc.frequency);
scheduledNote.osc.connect(scheduledNote.oscGain);
scheduledNote.oscGain.connect(this.inputGainNode);
var targetGain = this.velocityToGain(note.velocity);
var now = this.audioContext.currentTime;
scheduledNote.oscGain.gain.setValueAtTime(0.0, now + note.startTime);
scheduledNote.oscGain.gain.linearRampToValueAtTime(targetGain, now + note.startTime + this.rampTime);
scheduledNote.oscGain.gain.setValueAtTime(targetGain, now + note.stopTime - this.rampTime);
scheduledNote.oscGain.gain.linearRampToValueAtTime(0.0, now + note.stopTime);
scheduledNote.osc.start(now + note.startTime);
scheduledNote.osc.stop(now + note.stopTime);
scheduledNote.lfo.start(now + note.startTime);
scheduledNote.lfo.stop(now + note.stopTime);
this.scheduledNotes.push(scheduledNote);
}
/**
* Stop all scheduled notes. This is mainly to stop scheduled notes that are currently playing.
*/
stopScheduledNotes() {
var now = this.audioContext.currentTime;
for (let i = this.scheduledNotes.length-1; i >= 0; --i) {
let noteToStop = this.scheduledNotes.splice(i, 1)[0];
noteToStop.oscGain.gain.setValueAtTime(noteToStop.oscGain.gain.value, now);
noteToStop.oscGain.gain.linearRampToValueAtTime(0.0, now + this.rampTime);
noteToStop.osc.stop(now + this.rampTime);
}
}
/**
* Starts playing the specified note immediately.
* @param {Object} note - The note to play.
*/
startNote(note) {
var noteToStart = {
number: note.number,
osc: new OscillatorNode(this.audioContext),
oscGain: new GainNode(this.audioContext)
};
noteToStart.osc.type = this.oscillatorType;
noteToStart.osc.frequency.value = this.noteToFrequency(note.number);
noteToStart.oscGain.gain.value = 0.0;
noteToStart.osc.connect(noteToStart.oscGain);
noteToStart.oscGain.connect(this.inputGainNode);
var now = this.audioContext.currentTime;
noteToStart.oscGain.gain.setValueAtTime(0.0, now);
noteToStart.osc.start(now);
noteToStart.oscGain.gain.linearRampToValueAtTime(this.velocityToGain(note.velocity), now + this.rampTime);
this.activeNotes.push(noteToStart);
}
/**
* Stops playing the specified note immediately.
* @param {Object} note - The note to stop.
*/
stopNote(note) {
var noteIndex = this.activeNotes.findIndex(activeNote => activeNote.number === note.number);
if (noteIndex > -1) {
let noteToStop = this.activeNotes.splice(noteIndex, 1)[0];
let now = this.audioContext.currentTime;
noteToStop.oscGain.gain.setValueAtTime(noteToStop.oscGain.gain.value, now);
noteToStop.oscGain.gain.linearRampToValueAtTime(0.0, now + this.rampTime);
noteToStop.osc.stop(now + this.rampTime);
}
}
}
class WavetableSynthesizer extends Synthesizer {
/**
* Create an WavetableSynthesizer.
* @param {Object} audioContext - An AudioContext.
* @param {Object} url - A URL for fetching wavetable data.
* @author Lawrence Fyfe
*/
constructor(audioContext, url) {
super(audioContext);
this.outputGainNode.gain.value = 0.5;
this.inputGainNode.gain.value = 0.1;
this.onLoadWavetable;
this.readyPromise = new Promise((resolve) => {
fetch(url).then((response) => {
response.json().then((json) => {
this.wave = this.audioContext.createPeriodicWave(json.real, json.imag);
if (this.onLoadWavetable !== undefined) {
this.onLoadWavetable();
}
resolve(true);
})
.catch(() => resolve(false));
})
.catch(() => resolve(false));
});
}
/**
* Determines whether this synthesizer is ready.
* @returns {Promise} A promise that resolves when this synthesizer is ready.
*/
async isReady() {
return await this.readyPromise;
}
/**
* Schedules the specified note to be played according to its parameters.
* @param {Object} note - The note to schedule.
*/
scheduleNote(note) {
this.readyPromise.then(() => {
let scheduledNote = {
osc: new OscillatorNode(this.audioContext),
oscGain: new GainNode(this.audioContext)
};
scheduledNote.osc.setPeriodicWave(this.wave);
scheduledNote.osc.frequency.value = this.noteToFrequency(note.number);
scheduledNote.oscGain.gain.value = 0.0;
scheduledNote.osc.connect(scheduledNote.oscGain);
scheduledNote.oscGain.connect(this.inputGainNode);
let targetGain = this.velocityToGain(note.velocity);
let now = this.audioContext.currentTime;
scheduledNote.oscGain.gain.setValueAtTime(0.0, now + note.startTime);
scheduledNote.oscGain.gain.linearRampToValueAtTime(targetGain, now + note.startTime + this.rampTime);
scheduledNote.oscGain.gain.setValueAtTime(targetGain, now + note.stopTime - this.rampTime);
scheduledNote.oscGain.gain.linearRampToValueAtTime(0.0, now + note.stopTime);
scheduledNote.osc.start(now + note.startTime);
scheduledNote.osc.stop(now + note.stopTime);
this.scheduledNotes.push(scheduledNote);
});
}
/**
* Stop all scheduled notes. This is mainly to stop scheduled notes that are currently playing.
*/
stopScheduledNotes() {
this.readyPromise.then(() => {
let now = this.audioContext.currentTime;
for (let i = this.scheduledNotes.length-1; i >= 0; --i) {
let noteToStop = this.scheduledNotes.splice(i, 1)[0];
noteToStop.oscGain.gain.setValueAtTime(noteToStop.oscGain.gain.value, now);
noteToStop.oscGain.gain.linearRampToValueAtTime(0.0, now + this.rampTime);
noteToStop.osc.stop(now + this.rampTime);
}
});
}
/**
* Starts playing the specified note immediately.
* @param {Object} note - The note to play.
*/
startNote(note) {
this.readyPromise.then(() => {
let noteToStart = {
number: note.number,
osc: new OscillatorNode(this.audioContext),
oscGain: new GainNode(this.audioContext)
};
noteToStart.osc.setPeriodicWave(this.wave);
noteToStart.osc.frequency.value = this.noteToFrequency(note.number);
noteToStart.oscGain.gain.value = 0.0;
noteToStart.osc.connect(noteToStart.oscGain);
noteToStart.oscGain.connect(this.inputGainNode);
let now = this.audioContext.currentTime;
noteToStart.oscGain.gain.setValueAtTime(0.0, now);
noteToStart.osc.start(now);
noteToStart.oscGain.gain.linearRampToValueAtTime(this.velocityToGain(note.velocity), now + this.rampTime);
this.activeNotes.push(noteToStart);
});
}
/**
* Stops playing the specified note immediately.
* @param {Object} note - The note to stop.
*/
stopNote(note) {
this.readyPromise.then(() => {
let noteIndex = this.activeNotes.findIndex(activeNote => activeNote.number === note.number);
if (noteIndex > -1) {
let noteToStop = this.activeNotes.splice(noteIndex, 1)[0];
let now = this.audioContext.currentTime;
noteToStop.oscGain.gain.setValueAtTime(noteToStop.oscGain.gain.value, now);
noteToStop.oscGain.gain.linearRampToValueAtTime(0.0, now + this.rampTime);
noteToStop.osc.stop(now + this.rampTime);
}
});
}
}
/**
* A SynthesizerPlayer uses the specified synthesizer to play the specified notes.
*/
class SynthesizerPlayer {
/**
* Create a SynthesizerPlayer.
* @param {Object} audioContext - An AudioContext.
* @param {Object} synthesizer - A synthesizer for playing notes.
* @param {Object} notes - The notes to play.
* @param {number} duration - The duration of the notes.
* @author Lawrence Fyfe
*/
constructor(audioContext, synthesizer, notes, duration) {
this.audioContext = audioContext;
this.synthesizer = synthesizer;
this.notes = notes;
this.duration = duration;
this.type = "synthesizer";
this.playing = false;
this.paused = false;
this.skipped = false;
this.muted = false;
this.offsetStartTime = 0;
this.offsetStopTime = null;
this.notesOffsetStartTime = 0;
this.notesOffsetEndTime = 0;
this.playStartedClockTime = 0;
this.playOffsetTime = 0;
this.contextStarter;
this.currentNotes;
this.noteIndex;
this.noteCount;
this.interval = 0.1;
this.intervalStart;
this.intervalEnd;
this.intervalID;
this.timerIntervalID;
this.onEnded;
}
/**
* Is this player ready to play? This returns a Promise that is already resolved to true.
* Returning a Promise makes this method compatible with the isReady() method of the AudioFilePlayer.
* @return {Promise} A promise resolved to true.
*/
async isReady() {
return Promise.resolve(true);
}
/**
* Sets the specified time range for playing notes.
* @param {number} start - The start time.
* @param {number} stop - The end time.
*/
setTimeRange(start, stop) {
this.offsetStartTime = start;
this.offsetStopTime = stop;
}
/**
* Schedules notes to be played in the current scheduling interval when called by setInterval().
*/
scheduleCurrentNotes() {
for (let i = this.noteIndex; i < this.noteCount; i++) {
if (this.currentNotes[i].startTime > this.intervalEnd) {
this.noteIndex = i;
break;
}
let noteStartTime;
let noteEndTime;
if (this.noteIndex === 0 && this.currentNotes[i].startTime < this.notesOffsetStartTime) {
noteStartTime = this.notesOffsetStartTime;
}
else {
noteStartTime = this.currentNotes[i].startTime;
}
if (this.currentNotes[i].endTime > this.notesOffsetEndTime) {
noteEndTime = this.notesOffsetEndTime;
}
else {
noteEndTime = this.currentNotes[i].endTime;
}
if (noteStartTime >= this.intervalStart && noteStartTime < this.intervalEnd) {
this.synthesizer.scheduleNote({
number: this.currentNotes[i].note,
velocity: this.currentNotes[i].velocity,
startTime: noteStartTime-this.intervalStart,
stopTime: noteEndTime-this.intervalStart
});
}
}
this.intervalStart += this.interval;
this.intervalEnd += this.interval;
if (this.intervalEnd > this.notesOffsetEndTime) {
this.intervalEnd = this.notesOffsetEndTime;
}
}
/**
* Plays notes by determining which notes need to be played and starting note scheduling via setInterval().
*/
playNotes() {
this.playStartedClockTime = this.audioContext.currentTime;
this.notesOffsetStartTime = this.offsetStartTime+this.playOffsetTime;
this.notesOffsetEndTime = this.offsetStopTime === null ? this.duration : this.offsetStopTime;
this.currentNotes = this.notes.data.filter((note) => note.endTime > this.notesOffsetStartTime && note.startTime < this.notesOffsetEndTime);
this.noteCount = this.currentNotes.length;
this.noteIndex = 0;
// Start the context first or the play line will stutter (because the audioContext won't do anything until some kind of processing is started)
this.contextStarter = new ConstantSourceNode(this.audioContext);
this.contextStarter.start(this.playStartedClockTime);
this.contextStarter.stop(this.playStartedClockTime + this.notesOffsetEndTime - this.notesOffsetStartTime);
this.intervalStart = this.notesOffsetStartTime;
this.intervalEnd = this.intervalStart+this.interval;
this.scheduleCurrentNotes();
this.intervalID = setInterval(() => this.scheduleCurrentNotes(), this.interval*1000);
this.timerIntervalID = setInterval(() => {
this.stopNotes();
this.playing = false;
this.playOffsetTime = 0;
if (this.onEnded !== undefined) {
this.onEnded();
}
}, (this.notesOffsetEndTime - this.notesOffsetStartTime)*1000);
}
/**
* Stops notes by clearing both the note scheduling interval and the ending interval.
*/
stopNotes() {
this.synthesizer.stopScheduledNotes();
clearInterval(this.intervalID);
clearInterval(this.timerIntervalID);
if (this.contextStarter !== undefined) {
this.contextStarter.stop(this.audioContext.currentTime);
}
}
/**
* Play the notes with this synthesizer.
*/
play() {
this.playing = true;
this.paused = false;
this.playNotes();
}
/**
* Pause playing.
*/
pause() {
this.paused = true;
this.playOffsetTime += (this.audioContext.currentTime-this.playStartedClockTime);
this.stopNotes();
}
/**
* Skip to playing at the specified time. If this player is not paused, playing will stop and then immediately continue at the specified time.
* @param {number} time - The time to skip to.
*/
skip(time) {
this.playOffsetTime = time-this.offsetStartTime;
this.skipped = true;
if (!this.paused) {
this.stopNotes();
this.playNotes();
}
}
/**
* Stop playing.
*/
stop() {
this.playing = false;
this.paused = false;
this.playOffsetTime = 0;
this.stopNotes();
}
}