signal-desktop/ts/WebAudioRecorder.ts
2023-01-26 09:57:39 -07:00

159 lines
4 KiB
TypeScript

// 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<Float32Array>;
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<OptionsType, 'timeLimit'>,
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}`);
}
}