// Copyright 2016 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as log from '../logging/log'; import * as Errors from '../types/errors'; import { requestMicrophonePermissions } from '../util/requestMicrophonePermissions'; import { WebAudioRecorder } from '../WebAudioRecorder'; export class RecorderClass { private context?: AudioContext; private input?: GainNode; private recorder?: WebAudioRecorder; 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 { 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 WebAudioRecorder( this.input, { timeLimit: 60 + 3600, // one minute more than our UI-imposed limit }, { onComplete: this.onComplete.bind(this), 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, String(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 { if (!this.recorder) { return; } if (this.stream) { this.stream.getTracks().forEach(track => track.stop()); } if (this.blob) { return this.blob; } const promise = new Promise(resolve => { this.resolve = resolve; }); this.recorder.finishRecording(); return promise; } onComplete(_recorder: WebAudioRecorder, blob: Blob): void { this.blob = blob; this.resolve?.(blob); } onError(_recorder: WebAudioRecorder, error: string): 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();