UNPKG

wavewave

Version:

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

549 lines (519 loc) 16.4 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