// 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'; 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) { 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 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:', err && err.stack ? err.stack : 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: 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:', error && error.stack ? error.stack : error); } getBlob(): Blob { if (!this.blob) { throw new Error('no blob found'); } return this.blob; } } export const recorder = new RecorderClass();