// Copyright 2023 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only const DEFAULT_OPTIONS = { bufferSize: undefined, // buffer size (use browser default) encodeAfterRecord: false, mp3: { mimeType: 'audio/mpeg', bitRate: 160, // (CBR only): bit rate = [64 .. 320] }, numChannels: 2, // number of channels progressInterval: 1000, // encoding progress report interval (millisec) timeLimit: 300, // recording time limit (sec) }; type OptionsType = { bufferSize: number | undefined; numChannels: number; timeLimit?: number; }; export class WebAudioRecorder { private buffer: Array; private options: OptionsType; private context: BaseAudioContext; private input: GainNode; private onComplete: (recorder: WebAudioRecorder, blob: Blob) => unknown; private onError: (recorder: WebAudioRecorder, error: string) => unknown; private processor?: ScriptProcessorNode; public worker?: Worker; constructor( sourceNode: GainNode, options: Pick, callbacks: { onComplete: (recorder: WebAudioRecorder, blob: Blob) => unknown; onError: (recorder: WebAudioRecorder, error: string) => unknown; } ) { this.options = { ...DEFAULT_OPTIONS, ...options, }; this.context = sourceNode.context; this.input = this.context.createGain(); sourceNode.connect(this.input); this.buffer = []; this.initWorker(); this.onComplete = callbacks.onComplete; this.onError = callbacks.onError; } isRecording(): boolean { return this.processor != null; } startRecording(): void { if (this.isRecording()) { this.error('startRecording: previous recording is running'); return; } const { buffer, worker } = this; const { bufferSize, numChannels } = this.options; if (!worker) { this.error('startRecording: worker not initialized'); return; } this.processor = this.context.createScriptProcessor( bufferSize, numChannels, numChannels ); this.input.connect(this.processor); this.processor.connect(this.context.destination); this.processor.onaudioprocess = event => { // eslint-disable-next-line no-plusplus for (let ch = 0; ch < numChannels; ++ch) { buffer[ch] = event.inputBuffer.getChannelData(ch); } worker.postMessage({ command: 'record', buffer }); }; worker.postMessage({ command: 'start', bufferSize: this.processor.bufferSize, }); } cancelRecording(): void { if (!this.isRecording()) { this.error('cancelRecording: no recording is running'); return; } if (!this.worker || !this.processor) { this.error('startRecording: worker not initialized'); return; } this.input.disconnect(); this.processor.disconnect(); delete this.processor; this.worker.postMessage({ command: 'cancel' }); } finishRecording(): void { if (!this.isRecording()) { this.error('finishRecording: no recording is running'); return; } if (!this.worker || !this.processor) { this.error('startRecording: worker not initialized'); return; } this.input.disconnect(); this.processor.disconnect(); delete this.processor; this.worker.postMessage({ command: 'finish' }); } private initWorker(): void { if (this.worker != null) { this.worker.terminate(); } this.worker = new Worker('js/WebAudioRecorderMp3.js'); this.worker.onmessage = event => { const { data } = event; switch (data.command) { case 'complete': this.onComplete(this, data.blob); break; case 'error': this.error(data.message); break; default: break; } }; this.worker.postMessage({ command: 'init', config: { sampleRate: this.context.sampleRate, numChannels: this.options.numChannels, }, options: this.options, }); } error(message: string): void { this.onError(this, `WebAudioRecorder.js: ${message}`); } }