diff --git a/README.md b/README.md index 0684793..0686de6 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,9 @@ const characters = morsify.characters(); // {'1': {'A': '.-', ...}, ..., '11': { const audio = morsify.audio('SOS'); audio.play(); // play audio audio.stop(); // stop audio +audio.exportWave(); // download audio wave file (promise) +const url = await audio.getWaveUrl(); // get audio wave url (promise) +const blob = await audio.getWaveBlob(); // get audio wave blob (promise) ``` Alternatively, you can use the library directly with including the source file. @@ -45,6 +48,9 @@ Alternatively, you can use the library directly with including the source file. var gainNode = audio.gainNode; // GainNode audio.play(); // play audio audio.stop(); // stop audio + audio.exportWave(); // download audio wave file (promise) + var url = await audio.getWaveUrl(); // get audio wave url (promise) + var blob = await audio.getWaveBlob(); // get audio wave blob (promise) ``` diff --git a/src/morsify.js b/src/morsify.js index 872da9c..43dff7a 100644 --- a/src/morsify.js +++ b/src/morsify.js @@ -178,79 +178,182 @@ }).join(''); }; - let AudioContext = null; - let context = null; + const getGainTimings = (morse, opts, currentTime = 0) => { + let timings = []; + let time = 0; - const audio = (text, opts, morseString) => { - - if (AudioContext === null && typeof window !== 'undefined') { - AudioContext = window.AudioContext || window.webkitAudioContext; - context = new AudioContext(); - } - - const options = getOptions(opts); - const morse = morseString || encode(text, opts); - const oscillator = context.createOscillator(); - const gainNode = context.createGain(); - - let timeout; - let t = context.currentTime; - - oscillator.type = options.oscillator.type; - oscillator.frequency.value = options.oscillator.frequency; - oscillator.onended = options.oscillator.onended; - - gainNode.gain.setValueAtTime(0, t); + timings.push([0, time]); const tone = (i) => { - gainNode.gain.setValueAtTime(1, t); - t += i * options.unit; + timings.push([1, currentTime + time]); + time += i * opts.unit; }; const silence = (i) => { - gainNode.gain.setValueAtTime(0, t); - t += i * options.unit; + timings.push([0, currentTime + time]); + time += i * opts.unit; }; const gap = (i) => { - gainNode.gain.setValueAtTime(0, t); - t += i * options.fwUnit; + timings.push([0, currentTime + time]); + time += i * opts.fwUnit; }; for (let i = 0; i <= morse.length; i++) { - if (morse[i] === options.space) { + if (morse[i] === opts.space) { gap(7); - } else if (morse[i] === options.dot) { + } else if (morse[i] === opts.dot) { tone(1); silence(1); - } else if (morse[i] === options.dash) { + } else if (morse[i] === opts.dash) { tone(3); silence(1); } else if ( - (typeof morse[i + 1] !== 'undefined' && morse[i + 1] !== options.space) && - (typeof morse[i - 1] !== 'undefined' && morse[i - 1] !== options.space) + (typeof morse[i + 1] !== 'undefined' && morse[i + 1] !== opts.space) && + (typeof morse[i - 1] !== 'undefined' && morse[i - 1] !== opts.space) ) { gap(3); } } + return [timings, time]; + } + + const floatTo16BitPCM = (output, offset, input) => { + for (let i = 0; i < input.length; i++, offset += 2) { + let s = Math.max(-1, Math.min(1, input[i])); + output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true); + } + } + + const writeString = (view, offset, string) => { + for (let i = 0; i < string.length; i++) { + view.setUint8(offset + i, string.charCodeAt(i)); + } + } + + // graciously borrowed from: https://github.com/mattdiamond/Recorderjs/blob/master/src/recorder.js + const encodeWAV = (sampleRate, samples) => { + let buffer = new ArrayBuffer(44 + samples.length * 2); + let view = new DataView(buffer); + + /* RIFF identifier */ + writeString(view, 0, 'RIFF'); + /* RIFF chunk length */ + view.setUint32(4, 36 + samples.length * 2, true); + /* RIFF type */ + writeString(view, 8, 'WAVE'); + /* format chunk identifier */ + writeString(view, 12, 'fmt '); + /* format chunk length */ + view.setUint32(16, 16, true); + /* sample format (raw) */ + view.setUint16(20, 1, true); + /* channel count */ + view.setUint16(22, 1, true); + /* sample rate */ + view.setUint32(24, sampleRate, true); + /* byte rate (sample rate * block align) */ + view.setUint32(28, sampleRate * 4, true); + /* block align (channel count * bytes per sample) */ + view.setUint16(32, 2, true); + /* bits per sample */ + view.setUint16(34, 16, true); + /* data chunk identifier */ + writeString(view, 36, 'data'); + /* data chunk length */ + view.setUint32(40, samples.length * 2, true); + + floatTo16BitPCM(view, 44, samples); + + return view; + } + + const audio = (text, opts, morseString) => { + let AudioContext = null; + let OfflineAudioContext = null; + let context = null; + let offlineContext = null; + let source; + const options = getOptions(opts); + const morse = morseString || encode(text, opts); + const [gainValues, totalTime] = getGainTimings(morse, options); + + if (AudioContext === null && typeof window !== 'undefined') { + AudioContext = window.AudioContext || window.webkitAudioContext; + context = new AudioContext(); + source = context.createBufferSource(); + source.connect(context.destination); + } + + if (OfflineAudioContext === null && typeof window !== 'undefined') { + OfflineAudioContext = window.OfflineAudioContext; + offlineContext = new OfflineAudioContext(1, 22050 * totalTime, 22050); + } + + const oscillator = offlineContext.createOscillator(); + const gainNode = offlineContext.createGain(); + + oscillator.type = options.oscillator.type; + oscillator.frequency.value = options.oscillator.frequency; + + gainValues.forEach(([value, time]) => gainNode.gain.setValueAtTime(value, time)); + oscillator.connect(gainNode); - gainNode.connect(context.destination); + gainNode.connect(offlineContext.destination); + source.onended = options.oscillator.onended; + + // offline rendering as a promise inspired by: http://joesul.li/van/tale-of-no-clocks/ + let render = new Promise(resolve => { + oscillator.start(0); + offlineContext.startRendering(); + offlineContext.oncomplete = (e) => { + source.buffer = e.renderedBuffer; + resolve(); + } + }); + + let timeout; - const play = () => { - oscillator.start(context.currentTime); - timeout = setTimeout(() => stop(), (t - context.currentTime) * 1000); + const play = async () => { + await render; + source.start(context.currentTime); + timeout = setTimeout(() => stop(), totalTime * 1000); }; const stop = () => { clearTimeout(timeout); timeout = 0; - oscillator.stop(0); + source.stop(0); + }; + + const getWaveBlob = async () => { + await render; + const waveData = encodeWAV(offlineContext.sampleRate, source.buffer.getChannelData(0)); + const audioBlob = new Blob([waveData], { 'type': 'audio/wav' }); + return audioBlob; + } + + const getWaveUrl = async () => { + const audioBlob = await getWaveBlob(); + return URL.createObjectURL(audioBlob); + }; + + const exportWave = async () => { + let waveUrl = await getWaveUrl(); + var anchor = document.createElement('a'); + anchor.href = waveUrl; + anchor.target = '_blank'; + anchor.download = 'morsify.wav'; + anchor.click(); }; return { play, stop, + getWaveBlob, + getWaveUrl, + exportWave, context, oscillator, gainNode