Source: audio.js

/**
 * @license MIT
 * Copyright © 2025 Steve Butler (henspace.com)
 * Permission is hereby granted, free of charge, to any person obtaining a
 * copy of this software and associated documentation files (the “Software”),
 * to deal in the Software without restriction, including without limitation
 * the rights to use, copy, modify, merge, publish, distribute, sublicense,
 * and/or sell copies of the Software, and to permit persons to whom the
 * Software is furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 * 
 * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM,OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
 * IN THE SOFTWARE.
 */

/**
 * @module hcje/audio
 * @description
 * Module containing utilities for managing audio. This includes a simple synthesiser for simple music and effects
 * which merely require a text definition of the sound and no supporting media file.
 */

import * as utils from './utils.js';

/**
 * Definition of sound based on a media file. 
 * @typedef {Object} AudioDefinition
 * @property {string} title - Identification for music. This is used as its look up key.
 * @property {string} url - The url to the media file.
 * @property {boolean} loop - Flag determining whether the tracks loop. This is ignored when as a sound effect.
 * @property {number} fadeSeconds - Soft stop and start fade time. This is only used for music and ignored for sound
 * effects.
 */


/**
 * Definition of synthesised sound effect or music to play.
 * @typedef {Object} SynthAudioDefinition
 * @property {string} title - Identification for music. This is used in messages and the look-up key for sound effects.
 * @property {number} bpm - Beats per minute.
 * @property {number} fadeSeconds - only used for music; ignored for effects. Allows soft start and stop.
 * @property {boolean} loop - Flag determining whether the tracks loop. This is ignored when as a sound effect.
 * @property {Array<module:hcje/audio~SynthTrack>} tracks - The individual tracks that are used to generate the music
 * or sound effect.. 
 */

/**
 * Details of an individual track in synthesised sounds.
 * @typedef {Object} SynthTrack 
 * @property {number} detune - Value by which to detune the oscillator. 
 *   See [OscillatorNode detune property]{@link https://developer.mozilla.org/en-US/docs/Web/API/OscillatorNode/detune}.
 * @property {number} maxGain - Maximum gain at end of attack. 
 * @property {module:hcje/audio~SynthInstrument} instrument - The instrument for the track.
 * @property {number} octave - The octave for the notes. There are eight available octaves from 0 to 7 with octave
 * 4 tuned to middle A.
 * @property {module:hcje/audio~SynthNoteSequence} notes - The notes to decode. If set to just the = character, 
 * the notes and instruments are copied from the previous track. Only the detune property is used to adjust the copy.
 */

/**
 * The music score for synthesised sounds.
 * Each note in the sequence is defined as follows. Characters that do not match a note expression are ignored, so
 * spaces and | characters can be inserted as required to clarify bars.
 * Note that characters shown in square brackets are optional; the square brackets do not form part of the notation.
 * + [O]N[M][F][.][A]
 *     + O: octave modifier: + or -: shifts the octave for the note up or down one octave from the track octave set
 *     by the [SynthTrack]{@link module:hcje/audio~SynthTrack} octave property. More than one symbol can be included, so
 *     the sequence +++ would shift a base octave up three.
 *     + N: note: A, B, C, D, E, F, G or ~ for a rest. These must be capital letters.
 *     + M: modifier: # or b for sharps and flats.
 *     + F: fraction of note; e.g. note duration is 1/F. Defaults to a quarter note, 4. The smallest fraction is 1/8th.
 *     + .: whether the note duration is dotted.
 *     + A: arpeggio modifier @ or $. This creates an arpeggio using up to 2 extra notes to create a triad. The @
 *       character uses a major triad and a $ character a minor triad. Note that the note duration needs to be 
 *       long enough to fit in the extra notes. An example would be "C2@".
 * @typedef {string} SynthNoteSequence
 */

/**
 * ADSR envelope for synthesised sounds. This is an array of four numbers.
 *
 * + adsr[0]: attack in seconds.
 * + adsr[1]: decay in seconds.
 * + adsr[2]: sustain as proportion of max gain. Note although sustain is normally in percent, for this module it is a proportion.
 * + adsr[3]: release in seconds.
 *
 * @typedef {Array<number>} SynthEnvelope
 */

/**
 * Information for a synthesised instrument.
 * @typedef {Object} SynthInstrument
 * @property {string} waveform - See [OscillatorNode: type property]{@link https://developer.mozilla.org/en-US/docs/Web/API/OscillatorNode/type} 
 * @property {module:hcje/audio~SynthEnvelope} adsr - ADSR envelope.
 * @property {number} sustainTime - Duration in seconds of the sustain in the ADSR envelope.
 * @property {boolean} allowMerge - Merger notes. If true, a note that is played before the previous note starts to
 *   release, will just continue at the previous sustain level if the note is the the same frequency as the previous.
 * @property {number} sweepFactor - Sweep the frequency to an end frequency calculated as the starting frequency times
 * the sweepFactor.
 */

/**
 * Sequence of notes to play and instrument details for synthesised sounds.
 * @typedef {Array<number>} SynthDecodedSequence
 * @property {number} detune - detune value for oscillator.
 * @property {Array<number>} freqs - array of frequencies.
 * @property {number} maxGain - maximum gain applied after attack.
 *
 * @private
 */


/**
 * Class which provides a connection for all outputs and controls for all music and sound effects. The class is not
 * exported by the module and can only be instantiated by calling the module's 
 * [getAudioManager]{@link module:hcje/audio.getAudioManager} method.
 */
class AudioManager {
  /** @type {module:hcje/audio~AudioPlayerFactory} */
  #audioPlayerFactory;
  /** Map of sound effects.
   * @type {Map<string, HTMLAudioElement>} */
  #soundEffects = new Map();
  /** @type {module:hcje/audio~MusicPlayer} */
  #backgroundMusic;
  /** The underlying [AudioContext]{@link https://developer.mozilla.org/en-US/docs/Web/API/AudioContext}.
   * @type {AudioContext} */
  context;
  /** Dynamics compressor.
   * @type {DynamicsCompressorNode} */
  #decompressor;
  /** Global gain.
   * @type {GainNode} */
  #gain
  
  /**
   * Construct the AudioManager
   */
  constructor() {
    this.context = new AudioContext();
    this.#decompressor = this.context.createDynamicsCompressor();
    this.#decompressor.connect(this.context.destination);
    this.#gain = new GainNode(this.context);
    this.#gain.connect(this.#decompressor);
    this.#audioPlayerFactory = new AudioPlayerFactory();
  }
  
  /**
   * The input for the audio manager. This is where all nodes should connect if they want to 
   * connect to the destination of the underlying destination.
   * @returns {AudioNode}
   * @readonly
   */
  get inputNode() {
    return this.#gain;
  }

  /**
   * The gain value. When changed, this is almost instantaneous but a small time constant is applied to minimise clicks.
   * @type {AudioParam}
   * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/GainNode/gain}
   */ 
  get gain() {
    return this.#gain.gain
  }

  /**
   * Set the gain value. This is an almost instantaneous change but a time constant is applied to minimise clicks.
   * @type {number}
   * @ignore
   */
  set gain(value) {
    this.#gain.gain.setTargetAtTime(value, this.context.currentTime, 0.08);
  }
  
  /**
   * Create an [AudioSfxPlayer]{@link module:hcje/audio~AudioSfxPlayer}. It is stored in a map of sound effects. It is only stored once
   * ready to play, so it may not be available in the map immediately. If the definition includes a **loop** property, 
   * it is set to false as sound effects are not allowed to loop.
   * @param {module:hcje/audio~AudioDefinition|module:hcje/audio~SynthAudioDefinition} definition - Details of the sound
   *   to add.
   * @returns {Promise} Fulfils to true on success. 
   */
  addAudioSfx(definition) {
    if (definition.loop) {
      definition.loop = false;
    }
    return this.#audioPlayerFactory.createAudioSfxFromDefinition(definition)
      .then((sfx) => {
        this.#soundEffects.set(definition.title, sfx);
        return true;
      })
  }

  /**
   * Play audio effect.
   * The title is used as a lookup into the map of existing sound effects.
   * @param {string} title - Title of the sound effect.
   */
  playAudioSfx(title) {
    try {
      this.#soundEffects.get(title)?.play();
    } catch (error) {
      console.error(`Unable to play sound effect ${title}: ${error}`);
    }
  }

  
  /**
   * Set the background music. The background music will automatically start.
   * Any stop or start commands to the player are cached, so that if it takes a long time to 
   * load, the final playing state will still reflect the last command received.
   * @param {module:hcje/audio~AudioDefinition|module:hcje/audio~SynthAudioDefinition} definition - Details of the music
   *   to add.
   * @returns {Promise} Fulfils to true on success.
   */
  setMusic(definition) {
    console.debug(`Set music ${definition.title} called.`);
    
    if (this.#backgroundMusic) {
      console.debug('Stop existing music.');
      this.#backgroundMusic.stop();
    }
    this.#backgroundMusic = new MusicPlayer(definition.title, definition.fadeSeconds);
    this.#backgroundMusic.connect(this.#gain);
    this.#backgroundMusic.start();
    return this.#audioPlayerFactory.createPlayerFromDefinition(definition)
      .then((player) => this.#backgroundMusic.setPlayer(player))
      .then(() => {
        this.#backgroundMusic.actionCachedCommand();
        return true;
      })
      .catch((error) => {
        console.error(`Unable to create this.#background music player ${definition.title}: ${error}`);
        this.#backgroundMusic = undefined;
        return false;
      });
  }


  /**
   * Stop background music.
   */
  stopMusic() {
    this.#backgroundMusic?.stop();
  }

  /**
   * Start playing the background music.
   */
  startMusic() {
    if (this.#backgroundMusic && !this.#backgroundMusic.isPlaying()) {
      this.#backgroundMusic?.start();
    }
  }
}

/**
 * Class for playing synthesised sequences.
 * @private
 */
class SequencePlayer {
  /** Number of time constants to reach effective end @type {number} */
  static TC_COMPLETE = 3;
  /** @type {module:hcje/audio~SynthDecodedSequence}*/
  #sequence;
  /** Position in the sequence @type {number} */
  #position;
  /** Last frequency played @type {number} */
  #lastFrequency;
  /** @type {GainNode} */
  #gainNode;
  /** {OscillatorNode | AudioScheduledSourceNode} */
  #sourceNode;
  /** Attack time constant @type {number} */
  #attackTc;
  /** Decay time constant @type {number} */
  #decayTc;
  /** Release time constant @type {number} */
  #releaseTc;
  /** Time constant for frequency change @type {number} */
  #freqTc;
  /** Start for decay @type {number} */
  #decayStart;
  /** Start for sustain @type {number} */
  #sustainStart;
  /** Start for release @type {number} */
  #releaseStart;
  /** Sustain level @type {number} */
  #sustainLevel;
  /** Uses ADSR @type {boolean} */
  #usesAdsr;
  /** Should notes merge if possible @type {boolean} */
  #merge;
  /** Max gain @type {number} */
  #maxGain;
  /** Factor to multiply the frequency by to get frequency at end @type {number} */
  #sweepFactor;
    
  /**
   * Construct sequence player.
   * @param {Object} config
   * @param {module:hcje/audio~SynthDecodedSequence} config.sequence - decoded sequence to play.
   * @param {GainNode} config.gainNode - gain  node.
   * @param {OscillatorNode | AudioScheduledSourceNode} config.sourceNode - source used by the player.
   * @param {number} config.interval - interval between notes in seconds.
   */
  constructor(config) {
    this.#sequence = config.sequence;
    this.#gainNode = config.gainNode;
    this.#sourceNode = config.sourceNode;
    this.#merge = !!config.sequence.instrument.merge;
    this.#maxGain = config.sequence.maxGain ?? 1;
    this.#position = 0;
    this.#lastFrequency = 0;
    this.#usesAdsr = !!config.sequence.instrument.adsr;
    this.#sweepFactor = config.sequence.instrument.sweepFactor ?? 1;
    if (this.#usesAdsr) {
      const attack = config.sequence.instrument.adsr[0];
      const decay = config.sequence.instrument.adsr[1];
      this.#sustainLevel = config.sequence.instrument.adsr[2]
      const release = config.sequence.instrument.adsr[3];

      this.#attackTc = attack / SequencePlayer.TC_COMPLETE;
      this.#decayTc = decay / SequencePlayer.TC_COMPLETE;
      this.#releaseTc = release / SequencePlayer.TC_COMPLETE;
      this.#freqTc = this.#attackTc + this.#decayTc + this.#releaseTc;
      this.#decayStart = attack;
      this.#sustainStart = attack + decay;
      this.#releaseStart = config.sequence.instrument.sustainTime + this.#sustainStart;
      this.#merge = config.sequence.instrument.allowMerge && this.#releaseStart >= config.interval;
    }
  }
  
  /**
   * Apply the envelope.
   * @param {number} newFreq - the new frequency
   * @private
   */
  #applyEnvelope(newFreq) {
    let start = audioMgr.context.currentTime;
    this.#gainNode.gain.cancelScheduledValues(start);
    if (this.#usesAdsr) {
      if (newFreq !== this.#lastFrequency || !this.#merge) {
        this.#gainNode.gain.setTargetAtTime(this.#maxGain, start, this.#attackTc);
        this.#gainNode.gain.setTargetAtTime(this.#sustainLevel * this.#maxGain, start + this.#decayStart, this.#decayTc);  
      } 
      this.#gainNode.gain.setTargetAtTime(0, start + this.#releaseStart, this.#releaseTc);
    } else {
      this.#gainNode.gain.setTargetAtTime(this.#maxGain, start, 0.01);
    }
  }
  
  /**
   * Apply a rest. If an envelope is in use, nothing is done as the previous note will be allowed to release.
   * If no envelope is in use, the gain is immediately set to 0;
   * @private
   */
  #applyRest() {
    if (!this.#usesAdsr) {
      this.#gainNode.gain.setTargetAtTime(0, audioMgr.context.currentTime, 0.01);
    }
  }
  
  /**
   * Set the frequency.
   * @param {number} newFreq
   * @private
   */
  #applyFreq(newFreq) {
    if (this.#sourceNode.frequency) {
      const start = audioMgr.context.currentTime;
      this.#sourceNode.frequency.cancelScheduledValues(start);
      this.#sourceNode.frequency.setValueAtTime(newFreq, start);
      if (this.#sweepFactor !== 1) {
        this.#sourceNode.frequency.setTargetAtTime(newFreq * this.#sweepFactor, start, this.#freqTc);
      }
      
    } 
  }

  /**
   * Play the next note in the sequence. The method returns false if the last note has been played.
   * if the method continues to be called, the sequence play will loop, returning false each time the
   * end is reached.
   * @returns {boolean} true if there are more notes to play.
   */
  playNext() {  
    if (!this.#sequence) {
      console.log(`No sequence for player to play.`);
      return false;
    }
    const newFreq = this.#sequence.freqs[this.#position++];
    if (newFreq === 0) {
      this.#applyRest();
    } else {
      this.#applyFreq(newFreq); 
      this.#applyEnvelope(newFreq);
    }
    this.#lastFrequency = newFreq;
    if (this.#position >= this.#sequence.freqs.length) {
      this.#position = 0;
      return false;
    } else {
      return true;
    }
  }
  
  /**
   * Stop the player. This stops the oscillator.
   * If an envelope is in use the stop is delayed by the time it takes to fully play a note.
   */
  stop() {
    this.#position = 0;
    if (this.#usesAdsr) {
      setTimeout(() => {
        this.#sourceNode.stop();
      }, 1000 * this.#releaseStart + SequencePlayer.TC_COMPLETE * this.#releaseTc);
    } else {
      this.#sourceNode.stop();  
    }
  }
}

/** 
 * Sound effect player
 * @interface AudioSfxPlayer
 * @private
 */
/**
 * Play the effect.
 * @function module:hcje/audio~AudioSfxPlayer#play
 */

/**
 * Simple sound player.
 * @interface AudioPlayer
 * @private
 */

/**
 * Check if the audio is ready to play.
 * @function module:hcje/audio~AudioPlayer#isReady
 * @returns {boolean}
 */ 
 
/**
 * Check if the audio is playing.
 * @function module:hcje/audio~AudioPlayer#isPlaying
 * @returns {boolean}
 */ 
 
/**
 * Connect the audio to a target sound. Depending on the audio type, this may occur immediately or be delayed
 * until the audio is actually played. 
 * @function module:hcje/audio~AudioPlayer#connect
 * @param {AudioNode} destination
 * @returns {AudioNode}
 */ 

/**
 * Start the associated sound.
 * @function module:hcje/audio~AudioPlayer#start
 */ 

/**
 * Stop the associated sound.
 * This is not guaranteed to stop the sound. For example, sound effects,
 * which are too short to warrant stopping, may not do anything when stop 
 * is called.
 * @function module:hcje/audio~AudioPlayer#stop
 */ 
 

  
/**
 * Synthesiser
 * @implements module:hcje/audio~AudioPlayer
 * @implements module:hcje/audio~AudioSfxPlayer
 * @private
 */
class Synthesiser {
  /** @type {Array<GainNode>} */
  #gainNodes;
  /** @type {Array<module:hcje/audio~SynthDecodedSequence>}*/
  #sequences;
  /** Loop @type {boolean} */
  #loop;
  /** Duration of a quaver in ms @type {number} */
  #quaverMs;
  /** Stop signal. @type {boolean} */
  #stopSignal;
  /** Audio source factories @type {Array<BaseAudioSourceFactory>} */
  #audioSourceFactories;
  /** Playing flag @type {boolean} */
  #playing;

   
  /**
   * Constructor for the dynamic sound 
   * @param {Array<module:hcje/audio~SynthDecodedSequence>} sequences - sequences to play.
   * @param {Object} options
   * @param {number} options.quaverMs - milliseconds per quaver
   * @param {boolean} options.loop - true if the sound just loops.
   */
  constructor(sequences, options) {
    this.#sequences = sequences;
    this.#quaverMs = options.quaverMs;
    this.#loop = options.loop ?? false;
    console.debug(`Synthesiser loop set to ${this.#loop}`);
    this.#stopSignal = false;
    this.#audioSourceFactories = [];
    this.#gainNodes = [];
    this.#playing = false;
    for (const sequence of this.#sequences) {
      const gainNode = new GainNode(audioMgr.context, {gain: 0});
      this.#gainNodes.push(gainNode);
      this.#audioSourceFactories.push(this.#createAudioSourceFactory(sequence));
    }
  } 
  
  /**
   * Play all sequences to completion.
   * @returns {Promise}
   * @private
   */
  #playSequencesToCompletion() {
    const players = [];
    this.#playing = true;
    return new Promise((resolve) => {
      let moreToPlay = this.#loop;
      for (let n = 0; n < this.#sequences.length; n++) {
        const sequence = this.#sequences[n];
        const gainNode = this.#gainNodes[n];
        const sourceNode = this.#audioSourceFactories[n].createSource();
        sourceNode.connect(gainNode);
        sourceNode.start();
        const player = new SequencePlayer({sequence, gainNode, sourceNode, interval: this.#quaverMs / 1000});
        players.push(player);
        moreToPlay = player.playNext() || moreToPlay;
      }
      if (!moreToPlay) {
        this.#stopAllPlayers(players);
        resolve();
        return;
      }
      let intervalTimer;
      intervalTimer = setInterval(() => {
        moreToPlay = this.#loop;
        for (const player of players) {
          moreToPlay = player.playNext() || moreToPlay;
        }
        if (!moreToPlay || this.#stopSignal) {
          clearInterval(intervalTimer);
          this.#stopAllPlayers(players);
          resolve();
        }
      }, this.#quaverMs);  
    });
  }

  /**
   * Stop all the players.
   * @param {Array<module:hcje/audio~AudioPlayer>} players - the players to stop.
   * @private
   */
  #stopAllPlayers(players) {
    for (const player of players) {
      player.stop();
    }
    this.#playing = false;
  }
  
  /**
   * @borrows module:hcje/audio~AudioPlayer#isReady
   */
  isReady() {
    return true;
  }
  
  /**
   * @borrows module:hcje/audio~AudioPlayer#isPlaying
   */
  isPlaying() {
    return this.#playing;
  }
  /**
   * @borrows module:hcje/audio~AudioPlayer#connect
   */
  connect(destination) {
    for (const gainNode of this.#gainNodes) {
      gainNode.connect(destination);
    }
  }

  /**
   * @borrows module:hcje/audio~AudioSfxPlayer
   */
  play() {
    if (this.#loop) {
      console.warn('Attempt to play looping sound as sound effect ignored.');
      return;
    }
    this.start();
  }

  /**
   * @borrows module:hcje/audio~AudioPlayer#start
   */
  start() {
    this.#stopSignal = false;
    if (!this.#loop || !this.#playing) {
      this.#playSequencesToCompletion();
    }
  }
  
  /**
   * @borrows module:hcje/audio~AudioPlayer#stop
   */
  stop() {
    this.#stopSignal = true; 
  }
  
  /**
   * Create an [AudioSourceFactory]{module:hcje.audio~AudioSourceFactory} based on an oscillator type.
   * @param {Array<module:hcje/audio~SynthDecodedSequence>} sequences - sequences to play.
   * @returns {module:hcje/audio~BaseAudioSourceFactory}
   * @private
   */
  #createAudioSourceFactory(sequence) {
    switch (sequence.instrument.waveform) {
      case 'noise':
        return new WhiteNoiseFactory(1, {
          frequency: 440,
          detune: sequence.detune,
          type: 'bandpass'
        });
      default:
        return new OscillatorNodeFactory({
          frequency: 440,
          detune: sequence.detune,
          type: sequence.instrument.waveform || 'sine' 
        }); 
    }
  }
}

/**
 * @interface AudioSourceFactory
 * @see [AudioScheduledSourceNode]{@link https://developer.mozilla.org/en-US/docs/Web/API/AudioScheduledSourceNode}
 * @private
 */

/**
 * Create audio source.
 * @function module:hcje/audio~AudioSourceFactory#createSource
 * @param {Object} options - properties depend on the implementation.
 * @return {AudioScheduledSourceNode}
 */

/**
 * Factory for creating oscillator nodes.
 * @implements module:hcje/audio~AudioSourceFactory
 * @private
 */
class OscillatorNodeFactory {
  /** Options for the oscillator node. @type {OscillatorNode} */
  #options;

  /**
   * Construct the factory.
   */ 
  constructor(options) {
    this.#options = options;
  }
  
  /**
   * @borrows module:hcje/audio~AudioSourceFactory#createSource
   */
  createSource() {
    return new OscillatorNode(audioMgr.context, this.#options);
  }  
  
}

/**
 * White noise which partially implements the
 * [AudioScheduledSourceNode]{@link https://developer.mozilla.org/en-US/docs/Web/API/AudioScheduledSourceNode}
 * interface.
 * @implements {AudioScheduledSourceNode} 
 * @private
 */
class WhiteNoiseSource extends BiquadFilterNode {
  /** @type {AudioScheduledSourceNode} */
  #source;
  /** 
   * Create WhiteNoiseSource
   * @param {AudioBufferSourceNode} source - The white noise source.
   * @param {Object} options - See {@link https://developer.mozilla.org/en-US/docs/Web/API/BiquadFilterNode} 
   */
  constructor(source, options) {
    super(audioMgr.context, options);
    this.#source = source;
    // const filter = new BiquadFilterNode(audioMgr.context, options);
    this.#source.connect(this);
  }

  /**
   * Start the source.
   * @param {number} [when] - When to start.
   */ 
  start(when) {
    this.#source.start(when);
  }
  /**
   * Stop the source.
   * @param {number} [when] - When to stop.
   */ 
  stop(when) {
    this.#source.stop(when);
  }
}

/**
 * White noise audio source.
 * @implements module:hcje/audio~AudioSourceFactory
 * @private
 */
class WhiteNoiseFactory {
  /** @type {AudioBuffer} */
  #buffer;
  /** Filter options. See {@link https://developer.mozilla.org/en-US/docs/Web/API/BiquadFilterNode} @type {Object} */
  #options; 
  /** 
   * Create WhiteNoise
   * @param {number} duration - duration of the sample
   * @param {Object} options - See {@link https://developer.mozilla.org/en-US/docs/Web/API/BiquadFilterNode} 
   */
  constructor(duration, options) {
    this.#options = options;
    this.#buffer = audioMgr.context.createBuffer(1, audioMgr.context.sampleRate * duration, audioMgr.context.sampleRate);  
    const channelData = this.#buffer.getChannelData(0);
    for (let n = 0; n < this.#buffer.length; n++) {
      channelData[n] = 2 * Math.random() - 1;
    }
  }
  
  /**
   * @borrows module:hcje/audio~AudioSourceFactory#createSource
   */
  createSource() {
    const source = audioMgr.context.createBufferSource();
    source.buffer = this.#buffer;
    source.loop = true;
    
    return new WhiteNoiseSource(source, this.#options);
  }

}


/**
 * Factory for creating [AudioPlayer]{@link module:hcje/audio~AudioPlayer}s and 
 * [AudioSfxPlayer]{@link module:hcje/audio~AudioSfxPlayer}s.
 * @private
 */
class AudioPlayerFactory {
  /** Table of frequencies for different octaves. @type {Array<Map<note:string,frequency:number>>} */
  #frequencyTables;
  /** Triads. See {@link https://splice.com/blog/triads-music-theory/} @type {Map<string,Array<string>>} */
  #majorTriads = new Map([
    ['A', ['C#', 'E']],

    ['A#', ['D', 'F']],
    ['Bb', ['D', 'F']],
    
    ['B', ['D#', 'F#']],

    ['C', ['E', 'G']],

    ['C#', ['E#', 'G#']],
    ['Db', ['F', 'Ab']],

    ['D', ['F#', 'A']],

    ['D#', ['G', 'A#']],
    ['Eb', ['G', 'Bb']],

    ['E', ['G#', 'B']],

    ['F', ['A', 'C']],

    ['F#', ['A#', 'C#']],
    ['Gb', ['Bb', 'Db']],

    ['G', ['B', 'D']],

    ['G#', ['B#', 'D#']],
    ['Ab', ['C', 'Eb']],
  ]);
  
  #minorTriads = new Map([
    ['A', ['C', 'E']],

    ['A#', ['C#', 'E#']],
    ['Bb', ['Db', 'F']],
    
    ['B', ['D', 'F#']],

    ['C', ['Eb', 'G']],

    ['C#', ['E', 'G#']],
    ['Db', ['Fb', 'Ab']],

    ['D', ['F', 'A']],

    ['D#', ['F#', 'A#']],
    ['Eb', ['Gb', 'Bb']],

    ['E', ['G', 'B']],

    ['F', ['Ab', 'C']],

    ['F#', ['A', 'C#']],
    ['Gb', ['A', 'Db']],

    ['G', ['Bb', 'D']],

    ['G#', ['B', 'D#']],
    ['Ab', ['Cb', 'Eb']],
  ]);
  
  /**
   * Calculate the table of note frequencies. This only runs once to populate the #frequencyTables property.
   * @private
   */
  #buildFrequencyTables() {
    if (this.#frequencyTables) {
      return;
    }
    const minOctave = 0;
    const maxOctave = 7;
    const baseNote = 'A';
    const baseOctave = 4
    const baseFreq = 440;

    const OCTAVE_NOTES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
    const indexOfBaseNote = OCTAVE_NOTES.indexOf(baseNote);
    const octaveFreqs = [];
    for (let octave = minOctave; octave <= maxOctave; octave++) {
      const frequencies = new Map();
      for (let index = 0; index < OCTAVE_NOTES.length; index++) {
        const semitonesFromBase = (octave - baseOctave) * OCTAVE_NOTES.length   - indexOfBaseNote + index;
        const freq = baseFreq * Math.pow(2, semitonesFromBase/12);
        frequencies.set(OCTAVE_NOTES[index], freq);
      }
      // add flat aliases for sharps and flats which are already in octave under a different note.
      frequencies.set('Cb', frequencies.get('B'));
      frequencies.set('Db', frequencies.get('C#'));
      frequencies.set('Eb', frequencies.get('D#'));
      frequencies.set('E#', frequencies.get('F'));
      frequencies.set('Fb', frequencies.get('E'));
      frequencies.set('Gb', frequencies.get('F#'));
      frequencies.set('Ab', frequencies.get('G#'));
      frequencies.set('Bb', frequencies.get('A#'));
      frequencies.set('B#', frequencies.get('C'));
      octaveFreqs.push(frequencies);
    }
 
    return this.#frequencyTables = octaveFreqs;
  }
  
  /**
   * Log frequency tables for all supported octaves.
   */
  logFrequencyTables() {
    for (const octave of this.#frequencyTables) {
      let notes = '';
      for (const entry of octave.entries()) {
        notes = `${notes}${entry[0]}: ${entry[1]}, `;
      }
      console.log(notes);
    }
  }
  
  /**
   * Decode music track. Character sequences that do not match 
   * @param {module:hcje/audio~SynthTrack} track - track definition
   * @returns {SynthDecodedSequence} undefined if invalid music or no notes. 
   * @private
   */
  #decodeTrack(track) {
    if (!track?.notes || track.notes.length === 0) {
      console.error(`No notes provided.`);
      return;
    }
    this.#buildFrequencyTables();
    const trackOctave = track.octave ?? 4;
    let detune = track.detune ?? 0;
    let maxGain = track.maxGain ?? 1; 
    const noteDefns = [...track.notes.matchAll(/(\+*|-*)?([ABCDEFG~](?:[#b])?)(\d)?(\.)?([@$])?/gm)];
    const freqData = [];
    for (const noteDefn of noteDefns) {
      let octave = trackOctave;
      const octaveModifier = noteDefn[1] ?? '';
      if (octaveModifier.startsWith('+')) {
        octave = utils.clamp(trackOctave + octaveModifier.length, 0, this.#frequencyTables.length - 1);  
      } else if (octaveModifier.startsWith('-')) {
        octave = utils.clamp(trackOctave - octaveModifier.length, 0, this.#frequencyTables.length - 1);  
      }
      const note = noteDefn[2];

      const noteFraction = noteDefn[3] ?? 4; // default to quarter note
      const dotted = !!noteDefn[4];
      const arpeggio = !!noteDefn[5];
      let triads;
      if (arpeggio) {

        triads = noteDefn[5] === '@' ? this.#majorTriads : this.#minorTriads;
      }
      let quavers = 8 / noteFraction;
      if (dotted) {
          quavers *= 1.5;  
      }
      quavers = Math.floor(quavers);
      const freq = note === '~' ? 0 : this.#frequencyTables[octave].get(note);
      if (freq === undefined) {
        console.error(`Illegal note ${note} converted to rest.`);
        freq = 0;
      }
      for (let q = 0; q < quavers; q++) {
        if (q === 0) {
          freqData.push(freq);
        } else if (arpeggio && freq !== 0 && q < 3) {
          const chordNote = triads.get(note)[q - 1];
          const chordFreq = this.#frequencyTables[octave].get(chordNote);
          if (!chordFreq) {
            console.warn(`Illegal chord note ${chordNote} for note ${note} converted to rest.`);
            chordFreq = 0;
          }
          freqData.push(chordFreq);
        } else {
          freqData.push(0);
        }
      }
    }
    return freqData.length > 0 ? {detune, instrument: track.instrument, freqs: freqData, maxGain} : null;
  }
  
  /**
   * Create a synthesiser.
   * @param {module:hcje/audio~SynthAudioDefinition} definition - the audio to create.
   * @returns {Promise} fulfils to {@link module:hcje/audio~Synthesiser}
   * @throws {Error} thrown if sound cannot be created.
   * @private
   */
  #createSynthesiser(definition) {
    if (!definition) {
      throw new Error ('Cannot create dynamic sound as no definition provided.');
    }
         
    const quaverMs = Math.round(60000 / (definition.bpm * 2));
    
    const decodedTracks = [];
    for (let index = 0; index < definition.tracks.length; index++) {
      const track = definition.tracks[index];
      let decodedTrack;
      if (index > 0 && track.notes === '=') {
        console.debug(`Track ${index} is copy of previous detuned by ${track.detune} cents.`);
        decodedTrack = {
          detune: track.detune,
          freqs: decodedTracks[index - 1].freqs,
          instrument: decodedTracks[index - 1].instrument
        }
      } else if (track.notes) {
        decodedTrack = this.#decodeTrack(track);  
      }
      if (decodedTrack) {
        decodedTracks.push(decodedTrack);         
      }
    }
    
    return new Synthesiser(decodedTracks, {quaverMs, loop: definition.loop});
  }

  /**
   * Create a player from a definition.
   * @param {module:hcje/audio~AudioDefinition|module:hcje.audio~SynthAudioDefinition} definition
   * @returns {Promise} fulfils to [AudioPlayer]{@link module:hcje/audio~AudioPlayer}
   */ 

  createPlayerFromDefinition(definition) {
    if (definition.tracks) {
      return Promise.resolve(this.#createSynthesiser(definition));
    } else {
      return MediaFilePlayer.createFromDefinition(definition);
    }
  }

  /**
   * Create a sound effect from a definition. For synthesised audio, the effect is automatically connected to the
   * **audioMgr.inputNode**.
   * @param {module:hcje/audio~AudioDefinition | module:hcje/audio~SynthAudioDefinition} definition - definition
   *    for audio.
   * @returns {Promise} fulfils to a [AudioSfxPlayer]{@link module:hcje.audio~AudioSfxPlayer}
   * @throws {Error} if definition is set to loop.
   */
  createAudioSfxFromDefinition(definition) {
    if (definition.tracks) {
      if (definition.loop) {
        throw new Error(`Sound effects cannot loop. Check the definition.`);
      }
      return Promise.resolve(this.#createSynthesiser(definition))
        .then((sfx) => {
          sfx.connect(audioMgr.inputNode);
          return sfx;
        });
    } else {
      return MediaFileSfx.createFromDefinition(definition);
    }
  }

}




/**
 * Sound effect created from a media file.
 * @private
 */
class MediaFileSfx {
  /** @type {HTMLAudioElement} */
  #audioElement;

  /**
   * Construct from url.
   * @param {string} url - url to the media file
   */ 
  constructor(url) {
    this.#audioElement = new Audio(url);
  }

  /**
   * @borrows module:hcje.audio~AudioSfxPlayer#play
   */
  play() {
    this.#audioElement.play();
  }

  /**
   * Create **MediaFileSfx** from the definition.
   * @param {module:hcje/audio~AudioDefinition}
   * @returns {module:hcje/audio~MediaFileSfx}
   */
  static createFromDefinition(definition) {
   const sfx = new MediaFileSfx(definition.url);
    return new Promise((resolve) => {
      sfx.#audioElement.addEventListener('canplaythrough',
        (event) => resolve(sfx), {once:true});
    });
  }
}

/**
 * Buffer based sound from a media file.
 * @implements module:hcje/audio~AudioPlayer
 * @private
 */
class MediaFilePlayer {
  /** @type {AudioNode} */
  #destination;
  /** @type {AudioBuffer} */
  #buffer;
  /** @type {boolean} */
  #loop;
  /** @type {AudioBufferSourceNode} */
  #source;
  /** Flag to determine if sound is ready to play. @type {boolean} */
  #ready;
  /** Flag to determine if sound is playing. @type {boolean} */
  #playing;

  /**
   * Construct the buffer based sound.
   * @param {Object} options   
   * @param {boolean} options.loop - true to loop sound.
   */
  constructor(options) {
    this.#ready = false;
    this.#playing = false;
    this.#loop = !!options.loop;
  }

  /**
   * Create **MediaFilePlayer** from the definition.
   * @param {module:hcje/audio~AudioDefinition} definition - details of the music to add.
   * @returns {Promise} fulfils to {@link module:hcje/audio~MediaFilePlayer}
   */
  static createFromDefinition(definition) {
    const audioPlayer= new MediaFilePlayer({loop: !!definition.loop});
    return utils.fetchArrayBuffer(definition.url)
      .then((arrayBuffer) => audioPlayer.#setBufferFromData(arrayBuffer))
      .then(() => audioPlayer);
  }

  /**
   * Decode audio data and load into the buffer.
   * @param {ArrayBuffer} data
   * @returns {Promise}
   * @private
   */
  #setBufferFromData(data) {
    this.#ready = false;
    this.#buffer = undefined;
    return audioMgr.context.decodeAudioData(data)
      .then((buffer) => {
        this.#buffer = buffer;
        this.#ready = true;
      })
      .catch((error) => {
        console.error(`Unable to decode audio data: ${error}`);
        return;
      });
  }

  /**
   * @borrows module:hcje/audio~AudioPlayer#isReady
   */
  isReady() {
    return this.#ready;
  }
  
  /**
   * @borrows module:hcje/audio~AudioPlayer#isPlaying
   */
  isPlaying() {
    return this.#playing;
  }

  /**
   * @borrows module:hcje/audio~AudioPlayer#connect
   */
  connect(destination) {
    this.#destination = destination;
  }

  /**
   * @borrows module:hcje/audio~AudioPlayer#start
   */
  start() {
    if (!this.#buffer) {
      console.warn(`Attempt to start sound when no buffer available to play.`);
      return;
    }
    
    if (this.#playing) {
      console.warn(`Attempt to start sound while already started ignored.`);
      return;
    }
    try {
      this.#source = new AudioBufferSourceNode(audioMgr.context, {
        buffer: this.#buffer,
        loop: this.#loop
      })
      this.#source.connect(this.#destination);
      this.#source.start();
      this.#playing = true;
    } catch (error) {
      console.error(`Unable to start audio: ${error}`);
    }
  }
  
  /**
   * @borrows module:hcje/audio~AudioPlayer#stop
   */
  stop() {
    if (!this.#playing) {
      console.warn(`Attempt to stop audio when not playing ignored.`);
      return;
    }
    this.#source.stop(audioMgr.context.currentTime);  
    this.#playing = false;
  }

}


/**
 * Music player class used for handling music via the Web Audio API.
 * This class is a wrapper for other players but provides fade in and out options and caching of commands
 * while players are getting ready.
 * {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API}
 * @implements module:hcje/audio~AudioPlayer
 * @private
 */
class MusicPlayer {
  /**
   * @typedef {number} MusicCommandEnumValue
   * @private
   */

  /** 
   * Possible commands sent to a music player
   * @enum {module:hcje/audio~MusicCommandEnumValue}
   * @property {module:hcje/audio~MusicCommandEnumValue} NONE - No command.
   * @property {module:hcje/audio~MusicCommandEnumValue} START - Start playing.
   * @property {module:hcje/audio~MusicCommandEnumValue} STOP - Stop playing.
   */
  static MusicCommand = {
    NONE: 0, 
    START: 1,
    STOP: 2
  };

  /** Title @type{string} */
  #title;

  /** Gain node @type {GainNode} */
  #gainNode;
  
  /** @type {module:hcje/audio~AudioPlayer} */
  #audioPlayer;

  /** @type {number} */
  #fadeSeconds;

  /** Cached command @type{module:hcje/audio~MusicCommandEnum} */
  #cachedCommand = MusicPlayer.MusicCommand.NONE;

  /** Flag indicates that music was playing when focus lost. @type {boolean} */
  #playingWhenFocusLost;

  /**
   * Create a music player
   * @param {string} title - Name of music just used for identification in messages.
   * @param {number} [fadeSeconds = 1] - Time in seconds to fade in and out when stopping or starting.
   * @returns {module:hcje/audio~MusicPlayer}
   */ 
  constructor(title, fadeSeconds = 1) {
    this.#title = title;
    this.#gainNode = audioMgr.context.createGain();
    this.#fadeSeconds = fadeSeconds;
    window.addEventListener('blur', () => {
      if (this.isPlaying()) {
        console.debug(`Lost focus so stop ${this.#title}`)
        this.#playingWhenFocusLost = true;
        this.stop();
      } else {
        this.#playingWhenFocusLost = false;
      }
    });

    window.addEventListener('focus', () => {
      if (this.#playingWhenFocusLost) {
        console.debug(`Was playing when focus originally lost, so resume ${this.#title}`)
        this.start();
      }
    });
  }
  /**
   * Add player to the music player
   * @param {module:hcje/audio~AudioPlayer} audioPlayer - Player to use for playback.
   */ 
  setPlayer(audioPlayer) {
    if (this.#audioPlayer) {
      throw new Error(`Only one player permitted for ${this.#title}`);
    }
    this.#audioPlayer = audioPlayer;
    audioPlayer.connect(this.#gainNode);
  }
  
  /**
   * Set gain to value.
   * @param {number} value - Gain value. 1 is 100%.
   * @private
   */
  #setGain(value) {
    if (this.#gainNode) {
      this.#gainNode.gain.setValueAtTime(value, audioMgr.context.currentTime);
    }
  }
 
  /**
   * Fade in music.
   * @private
   */
  #fadeIn() {
    if (this.#fadeSeconds <= 0 || !this.#gainNode) {
      return;
    }
    try {
      this.#setGain(0);
      this.#gainNode.gain.setTargetAtTime(
        1.0, audioMgr.context.currentTime, this.#fadeSeconds / 3);
    } catch (error) {
      console.error(`Unable to fade in music ${this.#title}: ${error}`);
    }
  }

  /**
   * Fade out music.
   * @private
   */
  #fadeOut() {
    if (this.#fadeSeconds <= 0 || !this.#gainNode) {
      return;
    }
    try {
      this.#gainNode.gain.setTargetAtTime(
        0.0, audioMgr.context.currentTime, this.#fadeSeconds / 3);
    } catch (error) {
      console.error(`Unable to fade out music ${this.#title}: ${error}`);
    }
  }


  /**
   * Play music. If audioBuffer not set, the command is cached.
   * @borrows module:hcje/audio~AudioPlayer#start
   */
  start() {
    if (!this.#audioPlayer?.isReady()) {
      console.debug(`Music ${this.#title} not ready so cache start.`);
      this.#cachedCommand = MusicPlayer.MusicCommand.START;
      return;
    }
    console.debug(`Start music ${this.#title}.`);
    this.#cachedCommand = MusicPlayer.MusicCommand.NONE;
    try {
      if (this.#fadeSeconds) {
        this.#fadeIn();
      }
      this.#audioPlayer.start();
    } catch (error) {
      console.error(`Unable to start music ${this.#title}: ${error}`);
    }
  }
  
  /**
   * Stop music. If audioBuffer not set, the command is cached. 
   * @borrows module:hcje/audio~AudioPlayer#start
   */
  stop() {
    if (!this.#audioPlayer.isReady()) {
      console.debug(`Audio buffer for music ${this.#title} not ready so STOP command cached.`);
      this.#cachedCommand = MusicPlayer.MusicCommand.STOP;
      return;
    }
    console.debug(`Stop music ${this.#title}.`);
    this.#fadeOut();
    setTimeout(() => {
      this.#audioPlayer.stop(this.#fadeSeconds);
    }, this.#fadeSeconds * 1000);
  }

  /**
   * Action cached command. The cached command is then removed.
   */
  actionCachedCommand() {
    if (!this.#audioPlayer.isReady()) {
      console.debug('Attempt to action cached command before track is ready ignored.');
    } else if (this.#cachedCommand === MusicPlayer.MusicCommand.START) {
      this.start();
    }
    this.#cachedCommand = MusicPlayer.MusicCommand.NONE;
  }

  /**
   * @borrows module:hcje/audio~AudioPlayer#isPlaying
   */
  isPlaying() {
    return this.#audioPlayer?.isPlaying();
  }

  /**
   * @borrows module:hcje/audio~AudioPlayer#isReady
   */
  isReady() {
    return this.#audioPlayer?.isReady();
  }

  /**
   * @borrows module:hcje/audio~AudioPlayer#connect
   */
  connect(destination) {
    this.#gainNode.connect(destination);
  }
}



/**
 * Enumeration of some standard instrument definitions. They're not particularly accurate but can serve as a starting
 * point.
 * @readonly
 * @enum {module:hcje/audio~SynthInstrument}
 * @property {module:hcje/audio~SynthInstrument} CYMBAL - Simple cymbal.
 * @property {module:hcje/audio~SynthInstrument} DRUM - Approximation of drum for backbeat.
 * @property {module:hcje/audio~SynthInstrument} PIANO - Approximation of a piano sound.
 * @property {module:hcje/audio~SynthInstrument} SNARE - Approximation of a snare.
 */ 
export const Instrument = {
  /** Simple cymbal sound */
  CYMBAL: {
    adsr: [0.01, 0.01, 0.3, 0.3],
    allowMerge: false,
    sustainTime: 0.05,
    sweepFactor: 2,
    waveform: 'noise',
  },
  /** Drum sound typically for backbeat. */
  DRUM: {
    adsr: [0.01, 0.05, 0.1, 0.2],
    allowMerge: false,
    sustainTime: 0.1,
    sweepFactor: 0.8,
    waveform: 'sine',
  },
  /** Approximation to a piano. */
  PIANO: {
    adsr: [0.01, 0.25, 0.1, 0.1],
    allowMerge: false,
    sustainTime: 0.1,
    sweepFactor: 1,
    waveform: 'sine',
  },
  /** Approximation to a snare. */
  SNARE: {
    adsr: [0.01, 0.01, 0.1, 0.01],
    allowMerge: false,
    sustainTime: 0.05,
    sweepFactor: 1,
    waveform: 'noise',
  }
};

/** Internal global manager used for all internal sound players. @type{module:hcje/audio~AudioManager}
 * @private
 */
let audioMgr = new AudioManager();

/**
 * Get the internal audio manager. The first call intialises the module's internal audio manager. The returned audio manager
 * is a singleton.
 * @returns {module:hcje/audio~AudioManager}
 */
export function getAudioManager() {
  if (!audioMgr) {
    audioMgr = new AudioManager();
  }
  return audioMgr;
}