Source: audio.js

/** 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();
  }
}