160 lines
4 KiB
TypeScript
160 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}`);
|
||
|
}
|
||
|
}
|