UNPKG

wavtools

Version:

Record and stream WAV audio data in the browser across all platforms

549 lines (519 loc) 16.6 kB
import { AudioProcessorSrc } from './worklets/audio_processor.js'; import { AudioAnalysis } from './analysis/audio_analysis.js'; import { WavPacker } from './wav_packer.js'; /** * Decodes audio into a wav file * @typedef {Object} DecodedAudioType * @property {Blob} blob * @property {string} url * @property {Float32Array} values * @property {AudioBuffer} audioBuffer */ /** * Records live stream of user audio as PCM16 "audio/wav" data * @class */ export class WavRecorder { /** * Create a new WavRecorder instance * @param {{sampleRate?: number, outputToSpeakers?: boolean, debug?: boolean}} [options] * @returns {WavRecorder} */ constructor({ sampleRate = 44100, outputToSpeakers = false, debug = false, } = {}) { // Script source this.scriptSrc = AudioProcessorSrc; // Config this.sampleRate = sampleRate; this.outputToSpeakers = outputToSpeakers; this.debug = !!debug; this._deviceChangeCallback = null; this._devices = []; // State variables this.stream = null; this.processor = null; this.source = null; this.node = null; this.recording = false; // Event handling with AudioWorklet this._lastEventId = 0; this.eventReceipts = {}; this.eventTimeout = 5000; // Process chunks of audio this._chunkProcessor = () => {}; this._chunkProcessorSize = void 0; this._chunkProcessorBuffer = { raw: new ArrayBuffer(0), mono: new ArrayBuffer(0), }; } /** * Decodes audio data from multiple formats to a Blob, url, Float32Array and AudioBuffer * @param {Blob|Float32Array|Int16Array|ArrayBuffer|number[]} audioData * @param {number} sampleRate * @param {number} fromSampleRate * @returns {Promise<DecodedAudioType>} */ static async decode(audioData, sampleRate = 44100, fromSampleRate = -1) { const context = new AudioContext({ sampleRate }); let arrayBuffer; let blob; if (audioData instanceof Blob) { if (fromSampleRate !== -1) { throw new Error( `Can not specify "fromSampleRate" when reading from Blob`, ); } blob = audioData; arrayBuffer = await blob.arrayBuffer(); } else if (audioData instanceof ArrayBuffer) { if (fromSampleRate !== -1) { throw new Error( `Can not specify "fromSampleRate" when reading from ArrayBuffer`, ); } arrayBuffer = audioData; blob = new Blob([arrayBuffer], { type: 'audio/wav' }); } else { let float32Array; let data; if (audioData instanceof Int16Array) { data = audioData; float32Array = new Float32Array(audioData.length); for (let i = 0; i < audioData.length; i++) { float32Array[i] = audioData[i] / 0x8000; } } else if (audioData instanceof Float32Array) { float32Array = audioData; } else if (audioData instanceof Array) { float32Array = new Float32Array(audioData); } else { throw new Error( `"audioData" must be one of: Blob, Float32Arrray, Int16Array, ArrayBuffer, Array<number>`, ); } if (fromSampleRate === -1) { throw new Error( `Must specify "fromSampleRate" when reading from Float32Array, In16Array or Array`, ); } else if (fromSampleRate < 3000) { throw new Error(`Minimum "fromSampleRate" is 3000 (3kHz)`); } if (!data) { data = WavPacker.floatTo16BitPCM(float32Array); } const audio = { bitsPerSample: 16, channels: [float32Array], data, }; const packer = new WavPacker(); const result = packer.pack(fromSampleRate, audio); blob = result.blob; arrayBuffer = await blob.arrayBuffer(); } const audioBuffer = await context.decodeAudioData(arrayBuffer); const values = audioBuffer.getChannelData(0); const url = URL.createObjectURL(blob); return { blob, url, values, audioBuffer, }; } /** * Logs data in debug mode * @param {...any} arguments * @returns {true} */ log() { if (this.debug) { this.log(...arguments); } return true; } /** * Retrieves the current sampleRate for the recorder * @returns {number} */ getSampleRate() { return this.sampleRate; } /** * Retrieves the current status of the recording * @returns {"ended"|"paused"|"recording"} */ getStatus() { if (!this.processor) { return 'ended'; } else if (!this.recording) { return 'paused'; } else { return 'recording'; } } /** * Sends an event to the AudioWorklet * @private * @param {string} name * @param {{[key: string]: any}} data * @param {AudioWorkletNode} [_processor] * @returns {Promise<{[key: string]: any}>} */ async _event(name, data = {}, _processor = null) { _processor = _processor || this.processor; if (!_processor) { throw new Error('Can not send events without recording first'); } const message = { event: name, id: this._lastEventId++, data, }; _processor.port.postMessage(message); const t0 = new Date().valueOf(); while (!this.eventReceipts[message.id]) { if (new Date().valueOf() - t0 > this.eventTimeout) { throw new Error(`Timeout waiting for "${name}" event`); } await new Promise((res) => setTimeout(() => res(true), 1)); } const payload = this.eventReceipts[message.id]; delete this.eventReceipts[message.id]; return payload; } /** * Sets device change callback, remove if callback provided is `null` * @param {(Array<MediaDeviceInfo & {default: boolean}>): void|null} callback * @returns {true} */ listenForDeviceChange(callback) { if (callback === null && this._deviceChangeCallback) { navigator.mediaDevices.removeEventListener( 'devicechange', this._deviceChangeCallback, ); this._deviceChangeCallback = null; } else if (callback !== null) { // Basically a debounce; we only want this called once when devices change // And we only want the most recent callback() to be executed // if a few are operating at the same time let lastId = 0; let lastDevices = []; const serializeDevices = (devices) => devices .map((d) => d.deviceId) .sort() .join(','); const cb = async () => { let id = ++lastId; const devices = await this.listDevices(); if (id === lastId) { if (serializeDevices(lastDevices) !== serializeDevices(devices)) { lastDevices = devices; callback(devices.slice()); } } }; navigator.mediaDevices.addEventListener('devicechange', cb); cb(); this._deviceChangeCallback = cb; } return true; } /** * Manually request permission to use the microphone * @returns {Promise<true>} */ async requestPermission() { const permissionStatus = await navigator.permissions.query({ name: 'microphone', }); if (permissionStatus.state === 'denied') { window.alert('You must grant microphone access to use this feature.'); } else if (permissionStatus.state === 'prompt') { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true, }); const tracks = stream.getTracks(); tracks.forEach((track) => track.stop()); } catch (e) { window.alert('You must grant microphone access to use this feature.'); } } return true; } /** * List all eligible devices for recording, will request permission to use microphone * @returns {Promise<Array<MediaDeviceInfo & {default: boolean}>>} */ async listDevices() { if ( !navigator.mediaDevices || !('enumerateDevices' in navigator.mediaDevices) ) { throw new Error('Could not request user devices'); } await this.requestPermission(); const devices = await navigator.mediaDevices.enumerateDevices(); const audioDevices = devices.filter( (device) => device.kind === 'audioinput', ); const defaultDeviceIndex = audioDevices.findIndex( (device) => device.deviceId === 'default', ); const deviceList = []; if (defaultDeviceIndex !== -1) { let defaultDevice = audioDevices.splice(defaultDeviceIndex, 1)[0]; let existingIndex = audioDevices.findIndex( (device) => device.groupId === defaultDevice.groupId, ); if (existingIndex !== -1) { defaultDevice = audioDevices.splice(existingIndex, 1)[0]; } defaultDevice.default = true; deviceList.push(defaultDevice); } return deviceList.concat(audioDevices); } /** * Begins a recording session and requests microphone permissions if not already granted * Microphone recording indicator will appear on browser tab but status will be "paused" * @param {string} [deviceId] if no device provided, default device will be used * @returns {Promise<true>} */ async begin(deviceId) { if (this.processor) { throw new Error( `Already connected: please call .end() to start a new session`, ); } if ( !navigator.mediaDevices || !('getUserMedia' in navigator.mediaDevices) ) { throw new Error('Could not request user media'); } try { const config = { audio: true }; if (deviceId) { config.audio = { deviceId: { exact: deviceId } }; } this.stream = await navigator.mediaDevices.getUserMedia(config); } catch (err) { throw new Error('Could not start media stream'); } const context = new AudioContext({ sampleRate: this.sampleRate }); const source = context.createMediaStreamSource(this.stream); // Load and execute the module script. try { await context.audioWorklet.addModule(this.scriptSrc); } catch (e) { console.error(e); throw new Error(`Could not add audioWorklet module: ${this.scriptSrc}`); } const processor = new AudioWorkletNode(context, 'audio_processor'); processor.port.onmessage = (e) => { const { event, id, data } = e.data; if (event === 'receipt') { this.eventReceipts[id] = data; } else if (event === 'chunk') { if (this._chunkProcessorSize) { const buffer = this._chunkProcessorBuffer; this._chunkProcessorBuffer = { raw: WavPacker.mergeBuffers(buffer.raw, data.raw), mono: WavPacker.mergeBuffers(buffer.mono, data.mono), }; if ( this._chunkProcessorBuffer.mono.byteLength >= this._chunkProcessorSize ) { this._chunkProcessor(this._chunkProcessorBuffer); this._chunkProcessorBuffer = { raw: new ArrayBuffer(0), mono: new ArrayBuffer(0), }; } } else { this._chunkProcessor(data); } } }; const node = source.connect(processor); const analyser = context.createAnalyser(); analyser.fftSize = 8192; analyser.smoothingTimeConstant = 0.1; node.connect(analyser); if (this.outputToSpeakers) { // eslint-disable-next-line no-console console.warn( 'Warning: Output to speakers may affect sound quality,\n' + 'especially due to system audio feedback preventative measures.\n' + 'use only for debugging', ); analyser.connect(context.destination); } this.source = source; this.node = node; this.analyser = analyser; this.processor = processor; return true; } /** * Gets the current frequency domain data from the recording track * @param {"frequency"|"music"|"voice"} [analysisType] * @param {number} [minDecibels] default -100 * @param {number} [maxDecibels] default -30 * @returns {import('./analysis/audio_analysis.js').AudioAnalysisOutputType} */ getFrequencies( analysisType = 'frequency', minDecibels = -100, maxDecibels = -30, ) { if (!this.processor) { throw new Error('Session ended: please call .begin() first'); } return AudioAnalysis.getFrequencies( this.analyser, this.sampleRate, null, analysisType, minDecibels, maxDecibels, ); } /** * Pauses the recording * Keeps microphone stream open but halts storage of audio * @returns {Promise<true>} */ async pause() { if (!this.processor) { throw new Error('Session ended: please call .begin() first'); } else if (!this.recording) { throw new Error('Already paused: please call .record() first'); } if (this._chunkProcessorBuffer.raw.byteLength) { this._chunkProcessor(this._chunkProcessorBuffer); } this.log('Pausing ...'); await this._event('stop'); this.recording = false; return true; } /** * Start recording stream and storing to memory from the connected audio source * @param {(data: { mono: Int16Array; raw: Int16Array }) => any} [chunkProcessor] * @param {number} [chunkSize] chunkProcessor will not be triggered until this size threshold met in mono audio * @returns {Promise<true>} */ async record(chunkProcessor = () => {}, chunkSize = 8192) { if (!this.processor) { throw new Error('Session ended: please call .begin() first'); } else if (this.recording) { throw new Error('Already recording: please call .pause() first'); } else if (typeof chunkProcessor !== 'function') { throw new Error(`chunkProcessor must be a function`); } this._chunkProcessor = chunkProcessor; this._chunkProcessorSize = chunkSize; this._chunkProcessorBuffer = { raw: new ArrayBuffer(0), mono: new ArrayBuffer(0), }; this.log('Recording ...'); await this._event('start'); this.recording = true; return true; } /** * Clears the audio buffer, empties stored recording * @returns {Promise<true>} */ async clear() { if (!this.processor) { throw new Error('Session ended: please call .begin() first'); } await this._event('clear'); return true; } /** * Reads the current audio stream data * @returns {Promise<{meanValues: Float32Array, channels: Array<Float32Array>}>} */ async read() { if (!this.processor) { throw new Error('Session ended: please call .begin() first'); } this.log('Reading ...'); const result = await this._event('read'); return result; } /** * Saves the current audio stream to a file * @param {boolean} [force] Force saving while still recording * @returns {Promise<import('./wav_packer.js').WavPackerAudioType>} */ async save(force = false) { if (!this.processor) { throw new Error('Session ended: please call .begin() first'); } if (!force && this.recording) { throw new Error( 'Currently recording: please call .pause() first, or call .save(true) to force', ); } this.log('Exporting ...'); const exportData = await this._event('export'); const packer = new WavPacker(); const result = packer.pack(this.sampleRate, exportData.audio); return result; } /** * Ends the current recording session and saves the result * @returns {Promise<import('./wav_packer.js').WavPackerAudioType>} */ async end() { if (!this.processor) { throw new Error('Session ended: please call .begin() first'); } const _processor = this.processor; this.log('Stopping ...'); await this._event('stop'); this.recording = false; const tracks = this.stream.getTracks(); tracks.forEach((track) => track.stop()); this.log('Exporting ...'); const exportData = await this._event('export', {}, _processor); this.processor.disconnect(); this.source.disconnect(); this.node.disconnect(); this.analyser.disconnect(); this.stream = null; this.processor = null; this.source = null; this.node = null; const packer = new WavPacker(); const result = packer.pack(this.sampleRate, exportData.audio); return result; } /** * Performs a full cleanup of WavRecorder instance * Stops actively listening via microphone and removes existing listeners * @returns {Promise<true>} */ async quit() { this.listenForDeviceChange(null); if (this.processor) { await this.end(); } return true; } } globalThis.WavRecorder = WavRecorder;