Home Reference Source Repository

src/morse-pro-player-waa.js

/*!
This code is © Copyright Stephen C. Phillips, 2018.
Email: steve@scphillips.com
*/
/*
Licensed under the EUPL, Version 1.2 or – as soon they will be approved by the European Commission - subsequent versions of the EUPL (the "Licence");
You may not use this work except in compliance with the Licence.
You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/community/eupl/
Unless required by applicable law or agreed to in writing, software distributed under the Licence is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the Licence for the specific language governing permissions and limitations under the Licence.
*/

import morseAudioContext from './morse-pro-audiocontext';

/**
 * Web browser sound player using Web Audio API.
 *
 * @example
 * import MorseCW from 'morse-pro-cw';
 * import MorsePlayerWAA from 'morse-pro-player-waa';
 * var morseCW = new MorseCW();
 * var tokens = morseCW.text2morse("abc");
 * var timings = morseCW.morseTokens2timing(tokens);
 * var morsePlayerWAA = new MorsePlayerWAA();
 * morsePlayerWAA.load({timings});
 * morsePlayerWAA.playFromStart();
 */
export default class MorsePlayerWAA {
    /**
     * @param {Object} params - lots of optional parameters.
     * @param {number} [params.defaultFrequency=550] - fallback frequency (Hz) to use if the loaded sequence does not define any.
     * @param {number} [params.startPadding=0] - number of ms to wait before playing first note after play is pressed
     * @param {number} [params.endPadding=0] - number of ms to wait at the end of a sequence before playing the next one (or looping).
     * @param {number} [params.volume=1] - volume of Morse. Takes range [0,1].
     * @param {function()} params.sequenceStartCallback - function to call each time the sequence starts.
     * @param {function()} params.sequenceEndingCallback - function to call when the sequence is nearing the end.
     * @param {function()} params.sequenceEndCallback - function to call when the sequence has ended.
     * @param {function()} params.soundStoppedCallback - function to call when the sequence stops.
     * @param {string} params.onSample - URL of the sound file to play at the start of a note.
     * @param {string} params.offSample - URL of the sound file to play at the end of a note.
     * @param {string} [params.playMode="sine"] - play mode, either "sine" or "sample".
     */
    constructor({defaultFrequency=550, startPadding=0, endPadding=0, volume=1, sequenceStartCallback, sequenceEndingCallback, sequenceEndCallback, soundStoppedCallback, onSample, offSample, playMode='sine'} = {}) {
        if (sequenceStartCallback !== undefined) this.sequenceStartCallback = sequenceStartCallback;
        if (sequenceEndingCallback !== undefined) this.sequenceEndingCallback = sequenceEndingCallback;
        if (sequenceEndCallback !== undefined) this.sequenceEndCallback = sequenceEndCallback;
        if (soundStoppedCallback !== undefined) this.soundStoppedCallback = soundStoppedCallback;

        this.playMode = playMode;
        this._noAudio = false;

        if (onSample !== undefined) {
            morseAudioContext.loadSample(onSample, "onSample");
            morseAudioContext.loadSample(offSample, "offSample");
        }

        this.loop = false;
        this.fallbackFrequency = defaultFrequency;
        this._frequency = undefined;
        this.startPadding = startPadding;
        this._initialStartPadding = 200;  // ms
        this.endPadding = endPadding;
        this.volume = volume;

        this._cTimings = [];
        this._isPlaying = false;
        this._isPaused = false;
        this._lookAheadTime = 0.1;  // how far to look ahead when scheduling notes (seconds)
        this._timerInterval = 0.05;  // how often to schedule notes (seconds)
        this._timer = undefined;  // timer for scheduling notes, repeats at _timerInterval
        this._startTimer = undefined;  // timer to send sequenceStartCallback
        this._endTimer = undefined;  // timer to send sequenceEndCallback
        this._stopTimer = undefined;  // timer to send soundStoppedCallback
        this._queue = [];

        this._initialiseAudio();
    }

    /**
     * Set up the audio graph. Should only be called once.
     * @access private
     */
    _initialiseAudio() {
        if (this.splitterNode) {
            // if we have already called this method then we must make sure to disconnect the old graph first
            this.splitterNode.disconnect();
        }
        let ac = morseAudioContext.getAudioContext();
        let now = ac.currentTime;

        this.oscillatorNode = ac.createOscillator();  // make an oscillator at the right frequency, always on
        this.oscillatorNode.type = "sine";
        this.oscillatorNode.start(now);

        this.onOffNode = ac.createGain();  // modulate the oscillator with an on/off gain node
        this.onOffNode.gain.setValueAtTime(0, now);

        this.bandpassNode = ac.createBiquadFilter();  // cleans up the waveform
        this.bandpassNode.type = "bandpass";
        this.bandpassNode.Q.setValueAtTime(1, now);

        this.splitterNode = ac.createGain();  // this node is here to attach other nodes to in subclass
        this.splitterNode.gain.setValueAtTime(1, now);

        this.volumeNode = ac.createGain();  // this node is actually used for volume
        
        this.muteAudioNode = ac.createGain();  // used to temporarily mute the sound (e.g. if just light is needed)

        this.oscillatorNode.connect(this.onOffNode);
        this.onOffNode.connect(this.bandpassNode);
        this.bandpassNode.connect(this.splitterNode);
        this.splitterNode.connect(this.volumeNode);
        this.volumeNode.connect(this.muteAudioNode);
        this.muteAudioNode.connect(ac.destination);

        this.frequency = this._frequency;  // set up oscillator and bandpass nodes
        this.volume = this._volume;  // set up gain node
        this.muteAudio(false);
    }

    set frequency(freq) {
        this._frequency = freq;
        try {
            let now = morseAudioContext.getAudioContext().currentTime;
            this.oscillatorNode.frequency.setValueAtTime(freq, now);
            this.bandpassNode.frequency.setValueAtTime(freq, now);
        } catch (e) {
            // getting here means _initialiseAudio() has not yet been called: that's okay
        }
    }

    get frequency() {
        return this._frequency;
    }

    /**
     * Set the play mode (one of 'sine' and 'sample'). Also corrects the volume and low-pass filter.
     * @param {String} mode - the play mode to use
     */
    set playMode(mode) {
        // TODO: check value is in ['sine', 'sample']
        this._playMode = mode;
        // force re-evaluation of volume and frequency in case the mode has been changed during playback
        this.volume = this._volume;
        this.frequency = this._frequency;
    }

    get playMode() {
        return this._playMode;
    }

    /**
     * Set the volume for the player. Sets the gain as a side effect.
     * @param {number} v - the volume, should be in range [0,1]
     */
    set volume(v) {
        let oldGain = this._gain;
        this._volume = Math.min(Math.max(v, 0), 1);  // clamp into range [0,1]
        if (this._volume === 0) {
            this._gain = 0;  // make sure 0 volume is actually silent
        } else {
            // see https://teropa.info/blog/2016/08/30/amplitude-and-loudness.html
            let dbfs = -60 + this._volume * 60;  // changes [0,1] to [-60,0]
            this._gain = Math.pow(10, dbfs / 20);  // change from decibels to amplitude
        }
        try {
            let now = morseAudioContext.getAudioContext().currentTime;
            // change volume linearly over 30ms to avoid discontinuities and resultant popping
            this.volumeNode.gain.setValueAtTime(oldGain, now);
            this.volumeNode.gain.linearRampToValueAtTime(this._gain, now + 0.03);
        } catch (e) {
            // getting here means _initialiseAudio() has not yet been called: that's okay
        }
    }

    /**
     * @returns {number} the current volume [0,1]
     */
    get volume() {
        return this._volume;
    }

    /**
     * @returns {number} the current gain [0,1]
     */
    get gain() {
        return this._gain;
    }

    /**
     * Mutes or unmutes the audio (leaving the volume setting alone)
     * @param {Boolean} mute - true to mute, false to unmute
     */
    muteAudio(mute) {
        let now = morseAudioContext.getAudioContext().currentTime;
        this.muteAudioNode.gain.linearRampToValueAtTime(mute ? 0 : 1, now + 0.03);
    }

    /**
     * Load timing sequence, replacing any existing sequence.
     * If this.endPadding is non-zero then an appropriate pause is added to the end.
     * @param {Object} sequence - the sequence to play.
     * @param {number[]} sequence.timings - list of millisecond timings; +ve numbers are beeps, -ve numbers are silence.
     * @param {number} sequence.frequencies - a single frequency to be used for all beeps. If not set, the fallback frequency defined in the constructor is used.
     */
    load(sequence) {
        let timings = sequence.timings;
        let frequencies = sequence.frequencies || this.fallbackFrequency;
        // TODO: add volume array
        // let volumes = sequence.volumes;
        if (Array.isArray(frequencies)) {
            // TODO: add frequency arrays; set this.frequency to the highest value to make the low-pass filter work
            throw "Arrays of frequencies not yet supported"
        } else {
            this.frequency = frequencies;
        }

        // TODO: undefined behaviour if this is called in the middle of a sequence

        // console.log('Timings: ' + timings);
        /*
            The ith element of the sequence starts at _cTimings[i] and ends at _cTimings[i+1] (in fractional seconds)
            It is a note (i.e. not silence) if isNote[i] === True
        */

        if (this.endPadding > 0) {
            timings.push(-this.endPadding);
        }

        this._cTimings = [0];
        this.isNote = [];
        for (var i = 0; i < timings.length; i++) {
            this._cTimings[i + 1] = this._cTimings[i] + Math.abs(timings[i]) / 1000;  // AudioContext runs in seconds not ms
            this.isNote[i] = timings[i] > 0;
        }
        this.sequenceLength = this.isNote.length;
    }

    /**
     * Load timing sequence which will be played when the current sequence is completed (current queue is deleted).
     * @param {Object} sequence - see load() method for object description
     * @deprecated - use queue() instead
     */
    loadNext(sequence) {
        this._queue = [sequence];
    }

    /**
     * Queue up a timing sequence (add to the end of the queue)
     * @param {Object} sequence - see load() method for object description
     */
    queue(sequence) {
        this._queue.push(sequence);
    }

    /**
     * Plays the loaded timing sequence from the start, regardless of whether playback is ongoing or paused.
     */
    playFromStart() {
        // TODO: why do we have this method at all? Better just to have play() and if user needs playFromStart, just call stop() first?
        if (this._noAudio || this._cTimings.length === 0) {
            return;
        }
        this.stop();
        this._nextNote = 0;
        this._isPlaying = true;
        this._isPaused = true;  // pretend we were paused so that play() "resumes" playback
        this.play();
    }

    /**
     * Starts or resumes playback of the loaded timing sequence.
     */
    play() {
        if (!this._isPlaying) {
            // if we're not actually playing then play from start
            this.playFromStart();
        }
        // otherwise we are resuming playback after a pause
        if (!this._isPaused) {
            // if we're not actually paused then do nothing
            return;
        }
        // otherwise we really are resuming playback (or pretending we are, and actually playing from start...)
        clearInterval(this._stopTimer);  // if we were going to send a soundStoppedCallback then don't
        clearInterval(this._startTimer);  // ditto
        clearInterval(this._endTimer);
        clearInterval(this._timer);
        this._isPaused = false;
        // basically set the time base to now but
        //    - to work after a pause: subtract the start time of the next note so that it will play immediately
        //    - to avoid clipping the first note: add on startPadding
        this._tZero = morseAudioContext.getAudioContext().currentTime - 
            this._cTimings[this._nextNote] + 
            Math.max(this.startPadding, this._initialStartPadding) / 1000;
        this._initialStartPadding = 0;  // only use it once
        // schedule the first note ASAP (directly) and then if there is more to schedule, set up an interval timer
        if (this._scheduleNotes()) {
            this._timer = setInterval(function() {
                this._scheduleNotes();
            }.bind(this), 1000 * this._timerInterval);  // regularly check to see if there are more notes to schedule
        }
    }

    /**
     * Pause playback (resume with play())
     */
    pause() {
        if (!this._isPlaying) {
            // if we're not actually playing then ignore this
            return;
        }
        this._isPaused = true;
        clearInterval(this._timer);

        // ensure that the next note that is scheduled is a beep, not a pause (to help sync with vibration patterns)
        if (!this.isNote[this._nextNote]) {

            this._nextNote++;

            // if we've got to the end of the sequence, then loop or load next sequence as appropriate
            if (this._nextNote === this.sequenceLength) {
                if (this.loop || this._queue.length > 0) {
                    this._nextNote = 0;
                    if (this._queue.length > 0) {
                        this.load(this._queue.shift());
                    }
                }
            }
        }
    }

    /**
     * Stop playback (calling play() afterwards will start from the beginning)
     */
    stop() {
        if (this._isPlaying) {
            let now = morseAudioContext.getAudioContext().currentTime;
            this.onOffNode.gain.cancelScheduledValues(now);
            this.onOffNode.gain.linearRampToValueAtTime(0, now + 0.03);
            this._stop();
        }
    }

    /** 
     * Internal clean stop that doesn't destroy audiocontext
     * @access private
     */
    _stop() {
        this._isPlaying = false;
        this._isPaused = false;
        clearInterval(this._timer);
        clearInterval(this._stopTimer);
        clearInterval(this._startTimer);
        this.soundStoppedCallback();
    }

    /**
     * Schedule notes that start before now + lookAheadTime.
     * @return {boolean} true if there is more to schedule, false if sequence is complete
     * @access private
     */
    _scheduleNotes() {
        // console.log('Scheduling:');
        let start, start2, stop, stop2, bsn;
        let ac = morseAudioContext.getAudioContext();
        let nowAbsolute = ac.currentTime;

        while (this._nextNote < this.sequenceLength &&
                (this._cTimings[this._nextNote] < (nowAbsolute - this._tZero) + this._lookAheadTime)) {

            // this._notPlayedANote = false;
            var nowRelative = nowAbsolute - this._tZero;

            // console.log('T: ' + Math.round(1000 * nowAbsolute)/1000 + ' (+' + Math.round(1000 * nowRelative)/1000 + ')');
            // console.log(this._nextNote + ': ' +
            //     (this.isNote[this._nextNote] ? 'Note  ' : 'Pause ') +
            //     Math.round(1000 * this._cTimings[this._nextNote])/1000 + ' - ' +
            //     Math.round(1000 * this._cTimings[this._nextNote + 1])/1000 + ' (' +
            //     Math.round(1000 * (this._cTimings[this._nextNote + 1] - this._cTimings[this._nextNote]))/1000 + ')');

            if (this._nextNote === 0) {
                // when scheduling the first note, schedule a callback as well
                this._startTimer = setTimeout(function() {
                    this.sequenceStartCallback();
                }.bind(this), 1000 * (this._cTimings[0] - nowRelative));
            }

            if (this.isNote[this._nextNote]) {
                // TODO: enable choice of waveform
                if (this._playMode === 'sine') {
                    start = this._tZero + this._cTimings[this._nextNote];
                    stop  = this._tZero + this._cTimings[this._nextNote + 1];
                    this._soundEndTime = stop;  // we need to store this for the stop() callback
                    this.onOffNode.gain.setTargetAtTime(1, start - 0.0015, 0.001);
                    this.onOffNode.gain.setTargetAtTime(0, stop - 0.0015, 0.001);
                } else {
                    // only other option for 'mode' is 'sample'
                    start  = this._tZero + this._cTimings[this._nextNote];
                    start2 = this._tZero + this._cTimings[this._nextNote + 1];
                    stop   = this._tZero + this._cTimings[this._nextNote + 2];  // will sometimes be undefined but that's okay
                    stop2  = this._tZero + this._cTimings[this._nextNote + 3];  // TODO: improve this so it handles looping better?
                    this._soundEndTime = start2;  // the start of the end click. We need to store this for the stop() callback

                    let sounds = morseAudioContext.getSounds();
                    // start and stop the "on" sound
                    bsn = ac.createBufferSource();
                    try {
                        bsn.buffer = sounds["onSample"];
                        bsn.start(start);
                        if (stop) { bsn.stop(stop); }  // if we don't schedule a stop then the sound file plays until it completes
                        bsn.connect(this.splitterNode);
                    } catch (ex) {
                        console.log("onSample not decoded yet");
                    }

                    // start and stop the "off" sound (which is assumed to follow)
                    bsn = ac.createBufferSource();
                    try {
                        bsn.buffer = sounds["offSample"];
                        bsn.start(start2);
                        if (stop2) { bsn.stop(stop2); }  // we won't have the stop time for the final off sound, so just let it run
                        bsn.connect(this.splitterNode);
                    } catch (ex) {
                        console.log("offSample not decoded yet");
                    }
                }
            }
            this._nextNote++;

            if (this._nextNote === this.sequenceLength) {
                // we've just scheduled the last note of a sequence
                this.sequenceEndingCallback();
                this._endTimer = setTimeout(this.sequenceEndCallback, 1000 * (this._soundEndTime - nowAbsolute));
                if (this.loop || this._queue.length > 0) {
                    // there's more to play
                    // increment time base to be the absolute end time of the final element in the sequence
                    this._tZero += this._cTimings[this.sequenceLength];
                    this._nextNote = 0;
                    if (this._queue.length > 0) {
                        this.load(this._queue.shift());
                    }
                }
            }
        }

        if (this._nextNote === this.sequenceLength) {
            // then all notes have been scheduled and we are not looping/going to next in queue
            clearInterval(this._timer);
            // schedule stop() for after when the scheduled sequence ends
            // adding on 3 * lookAheadTime for safety but shouldn't be needed
            this._stopTimer = setTimeout(function() {
                this._stop();
            }.bind(this), 1000 * (this._soundEndTime - nowAbsolute + 3 * this._lookAheadTime));
            return false;  // indicate that sequence is complete
        }

        return true;  // indicate there are more notes to schedule
    }

    /**
     * @returns {boolean} whether there was an error in initialisation
     */
    hasError() {
        return this._noAudio;
    }

    /**
     * @returns {boolean} whether a sequence is being played or not (still true even when paused); becomes false when stop is used
     */
    get isPlaying() {
        return this._isPlaying;
    }

    /**
     * @returns {boolean} whether the playback is paused or not
     */
    get isPaused() {
        return this._isPaused;
    }

    /**
     * Return the index of the next note in the sequence to be scheduled.
     * Useful if the sequence has been paused.
     * @returns {number} note index
     */
    get nextNote() {
        return this._nextNote;
    }

    /**
     * @returns {number} representing this audio player type: 4
     */
    get audioType() {
        return 4;
        // 4: Web Audio API using oscillators
        // 3: Audio element using media stream worker (using PCM audio data)
        // 2: Flash (using PCM audio data)
        // 1: Web Audio API with webkit and native support (using PCM audio data)
        // 0: Audio element using Mozilla Audio Data API (https://wiki.mozilla.org/Audio_Data_API) (using PCM audio data)
        // -1: no audio support
    }

    // empty callbacks in case user does not define any

    /**
     * Called to coincide with the start of the first note in a sequence.
     */
    sequenceStartCallback() { }

    /**
     * Called at the point of the last notes of a sequence being scheduled. Designed to provide the opportunity to schedule some more notes.
     */
    sequenceEndingCallback() { }

    /**
     * Called at the end of the last beep of a sequence. Does not wait for endPadding.
     */
    sequenceEndCallback() { }

    /**
     * Called when all sounds have definitely stopped.
     */
    soundStoppedCallback() { }
}