Home Reference Source Repository

src/morse-pro-audiocontext.js

class MorseAudioContext {
    constructor() {
        this.AudioContext = window.AudioContext || window.webkitAudioContext;
        if (this.AudioContext === undefined) {
            this.hasAudioContext = false;
            console.log("Web Audio API unavailable");
            return;
            // throw (new Error("No AudioContext class defined"));
        }
        this.hasAudioContext = true;
        this.sounds = {};
        this._unlocked = false;
        let ua = navigator.userAgent.toLowerCase();
        this.isIOS = (ua.indexOf("iphone") >= 0 && ua.indexOf("like iphone") < 0 || ua.indexOf("ipad") >= 0 && ua.indexOf("like ipad") < 0 || ua.indexOf("ipod") >= 0 && ua.indexOf("like ipod") < 0);
        // if (this.isIOS) this.playHTMLaudio();
    }

    /**
     * Get an AudioContext. The state of the AudioContext may be "suspended".
     * In Chrome (v83 Windows), Safari (v13.1 Mac Catalina), iOS (v11) you get a running context (and runUnlockedActions executes) upon user interaction.
     * In Edge (v44.18362.449.0 Windows) you get a running AudioContext straight away but the runUnlockedActions executes.
     * In Firefox (v75 Windows) you get a suspended AudioContext but it resumes (and runUnlockedActions executes) after a short while without interaction.
     */
    getAudioContext() {
        // console.log("Getting AC");
        if (this.audioContext !== undefined) {
            if (this.audioContext.state === "running") {
                // console.log("AC is running");
            } else {
                // console.log("AudioContext is suspended");
                this.audioContext.resume().then(() => {this.runUnlockedActions(1)});
            }
        } else {
            console.log("Creating AudioContext");
            this.audioContext = new this.AudioContext();
            this.audioContext.createGain();  // Can help on Safari. Probably not needed but can't hurt
            console.log(`AudioContext state: ${this.audioContext.state}`);
            // Will only work if using Firefox (and will take a short while) or where this method is called the first time with a user interaction, otherwise will be ignored
            this.audioContext.resume().then(() => {this.runUnlockedActions(2)});  
        }
        return this.audioContext;
    }

    /**
     * Called when we get a running AudioContext
     */
    runUnlockedActions(code) {
        if (this._unlocked) return;
        this._unlocked = true;
        console.log(`AudioContext unlocked (${code})`);
    }

    playHTMLaudio() {
        // https://github.com/swevans/unmute/blob/master/dev/src/unmute.ts
        // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/audio
        console.log("Playing HTML audio");
        let tmp = document.createElement("div");
        tmp.innerHTML = "<audio x-webkit-airplay='deny'></audio>";  // Need this tag for Safari as disableRemotePlayback doesn't work
        let tag = tmp.children.item(0);
        tag.controls = false;  // don't show playback controls
        tag.disableRemotePlayback = true; // disables casting of audio to another device (and associated control appearing)
        tag.preload = "auto";
        // Set the src to a short bit of url encoded as a silent mp3
        // NOTE The silence MP3 must be high quality, when web audio sounds are played in parallel the web audio sound is mixed to match the bitrate of the html sound
        // 0.01 seconds of silence VBR220-260 Joint Stereo 859B
        tag.src = "data:audio/mpeg;base64,//uQxAAAAAAAAAAAAAAAAAAAAAAAWGluZwAAAA8AAAACAAACcQCAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA//////////////////////////////////////////////////////////////////8AAABhTEFNRTMuMTAwA8MAAAAAAAAAABQgJAUHQQAB9AAAAnGMHkkIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//sQxAADgnABGiAAQBCqgCRMAAgEAH///////////////7+n/9FTuQsQH//////2NG0jWUGlio5gLQTOtIoeR2WX////X4s9Atb/JRVCbBUpeRUq//////////////////9RUi0f2jn/+xDECgPCjAEQAABN4AAANIAAAAQVTEFNRTMuMTAwVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVQ==";
        // The str below is a "compressed" version using poor mans huffman encoding, saves about 0.5kb
        // tag.src = "data:audio/mpeg;base64,//uQx" + poorManHuffman(23, "A") + "WGluZwAAAA8AAAACAAACcQCA" + poorManHuffman(16, "gICA") + poorManHuffman(66, "/") + "8AAABhTEFNRTMuMTAwA8MAAAAAAAAAABQgJAUHQQAB9AAAAnGMHkkI" + poorManHuffman(320, "A") + "//sQxAADgnABGiAAQBCqgCRMAAgEAH" + poorManHuffman(15, "/") + "7+n/9FTuQsQH//////2NG0jWUGlio5gLQTOtIoeR2WX////X4s9Atb/JRVCbBUpeRUq" + poorManHuffman(18, "/") + "9RUi0f2jn/+xDECgPCjAEQAABN4AAANIAAAAQVTEFNRTMuMTAw" + poorManHuffman(97, "V") + "Q==";
        tag.loop = true;
        tag.load();
        tag.play();
    }

    closeAudioContext() {
        if (this.audioContext !== undefined) {
            this.audioContext.close();
            this.audioContext = undefined;
        }
    }

    isUnlocked() {
        return this.audioContext && this.audioContext.state === "running";
    }

    loadSample(url, key) {
        console.log(`Loading audio file (${key})`);
        let request = new XMLHttpRequest();
        request.open('GET', url, true);
        request.responseType = 'arraybuffer';
        request.onload = () => {
            // Load the data and keep a reference to it
            // console.log("File loaded");
            this.sounds[key] = request.response;
            this.decodeSample(key);
        };
        request.send();
    }

    decodeSample(key) {
        // Decoding seems to work even when AudioContext is suspended
        let ac = this.getAudioContext();
        console.log(`Decoding audio file (${key})`);
        // Promise-based syntax does not work for Safari desktop, need to use callback variant
        // https://developer.mozilla.org/en-US/docs/Web/API/BaseAudioContext/decodeAudioData
        ac.decodeAudioData(this.sounds[key], (buffer) => {
            this.sounds[key] = buffer;
        }, (e) => {
            console.log("Error decoding audio data: " + e);
        });
    }

    getSounds() {
        return this.sounds;
    }

    init() {
        if (!this.hasAudioContext) return;
        function startAudio() {
            console.log("Starting audio via user interaction");
            document.removeEventListener("mousedown", startAudio);
            document.removeEventListener("touchend", startAudio);
            morseAudioContext.getAudioContext();
        }
        document.addEventListener("mousedown", startAudio);
        document.addEventListener("touchend", startAudio);
    }
}

const morseAudioContext = new MorseAudioContext();

export default morseAudioContext;