148 lines
3.8 KiB
TypeScript
148 lines
3.8 KiB
TypeScript
// Copyright 2016-2020 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import { requestMicrophonePermissions } from '../util/requestMicrophonePermissions';
|
|
import * as log from '../logging/log';
|
|
import type { WebAudioRecorderClass } from '../window.d';
|
|
import * as Errors from '../types/errors';
|
|
|
|
export class RecorderClass {
|
|
private context?: AudioContext;
|
|
private input?: GainNode;
|
|
private recorder?: WebAudioRecorderClass;
|
|
private source?: MediaStreamAudioSourceNode;
|
|
private stream?: MediaStream;
|
|
private blob?: Blob;
|
|
private resolve?: (blob: Blob) => void;
|
|
|
|
clear(): void {
|
|
this.blob = undefined;
|
|
this.resolve = undefined;
|
|
|
|
if (this.source) {
|
|
this.source.disconnect();
|
|
this.source = undefined;
|
|
}
|
|
|
|
if (this.recorder) {
|
|
if (this.recorder.isRecording()) {
|
|
this.recorder.cancelRecording();
|
|
}
|
|
|
|
// Reach in and terminate the web worker used by WebAudioRecorder, otherwise
|
|
// it gets leaked due to a reference cycle with its onmessage listener
|
|
this.recorder.worker.terminate();
|
|
this.recorder = undefined;
|
|
}
|
|
|
|
this.input = undefined;
|
|
this.stream = undefined;
|
|
|
|
if (this.context) {
|
|
void this.context.close();
|
|
this.context = undefined;
|
|
}
|
|
}
|
|
|
|
async start(): Promise<boolean> {
|
|
const hasMicrophonePermission = await requestMicrophonePermissions(false);
|
|
if (!hasMicrophonePermission) {
|
|
log.info(
|
|
'Recorder/start: Microphone permission was denied, new audio recording not allowed.'
|
|
);
|
|
return false;
|
|
}
|
|
|
|
this.clear();
|
|
|
|
this.context = new AudioContext();
|
|
this.input = this.context.createGain();
|
|
|
|
this.recorder = new window.WebAudioRecorder(this.input, {
|
|
encoding: 'mp3',
|
|
workerDir: 'js/', // must end with slash
|
|
options: {
|
|
timeLimit: 60 + 3600, // one minute more than our UI-imposed limit
|
|
},
|
|
});
|
|
this.recorder.onComplete = this.onComplete.bind(this);
|
|
this.recorder.onError = this.onError.bind(this);
|
|
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
// TypeScript doesn't know about these options.
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
audio: { mandatory: { googAutoGainControl: false } } as any,
|
|
});
|
|
if (!this.context || !this.input) {
|
|
const err = new Error(
|
|
'Recorder/getUserMedia/stream: Missing context or input!'
|
|
);
|
|
this.onError(this.recorder, err);
|
|
throw err;
|
|
}
|
|
this.source = this.context.createMediaStreamSource(stream);
|
|
this.source.connect(this.input);
|
|
this.stream = stream;
|
|
} catch (err) {
|
|
log.error('Recorder.onGetUserMediaError:', Errors.toLogFormat(err));
|
|
this.clear();
|
|
throw err;
|
|
}
|
|
|
|
if (this.recorder) {
|
|
this.recorder.startRecording();
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
async stop(): Promise<Blob | undefined> {
|
|
if (!this.recorder) {
|
|
return;
|
|
}
|
|
|
|
if (this.stream) {
|
|
this.stream.getTracks().forEach(track => track.stop());
|
|
}
|
|
|
|
if (this.blob) {
|
|
return this.blob;
|
|
}
|
|
|
|
const promise = new Promise<Blob>(resolve => {
|
|
this.resolve = resolve;
|
|
});
|
|
|
|
this.recorder.finishRecording();
|
|
|
|
return promise;
|
|
}
|
|
|
|
onComplete(_recorder: WebAudioRecorderClass, blob: Blob): void {
|
|
this.blob = blob;
|
|
this.resolve?.(blob);
|
|
}
|
|
|
|
onError(_recorder: WebAudioRecorderClass, error: Error): void {
|
|
if (!this.recorder) {
|
|
log.warn('Recorder/onError: Called with no recorder');
|
|
return;
|
|
}
|
|
|
|
this.clear();
|
|
|
|
log.error('Recorder/onError:', Errors.toLogFormat(error));
|
|
}
|
|
|
|
getBlob(): Blob {
|
|
if (!this.blob) {
|
|
throw new Error('no blob found');
|
|
}
|
|
|
|
return this.blob;
|
|
}
|
|
}
|
|
|
|
export const recorder = new RecorderClass();
|