5ba09a5f90
Voice messages were being recorded using the system default microphone instead of the device selected in Element settings. This was fixed by ensuring the preferred deviceId is correctly passed to the MediaStream constraints in VoiceRecording.ts. Added unit tests in VoiceRecording-test.ts to verify that the application correctly requests the user-selected device. Co-authored-by: Will Hunt <2072976+Half-Shot@users.noreply.github.com>
317 lines
14 KiB
TypeScript
317 lines
14 KiB
TypeScript
/*
|
|
Copyright 2024 New Vector Ltd.
|
|
Copyright 2021 The Matrix.org Foundation C.I.C.
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
|
Please see LICENSE files in the repository root for full details.
|
|
*/
|
|
|
|
import Recorder from "opus-recorder/dist/recorder.min.js";
|
|
import encoderPath from "opus-recorder/dist/encoderWorker.min.js";
|
|
import { SimpleObservable } from "matrix-widget-api";
|
|
import EventEmitter from "events";
|
|
import { logger } from "matrix-js-sdk/src/logger";
|
|
import { clamp } from "@element-hq/web-shared-components";
|
|
|
|
import MediaDeviceHandler from "../MediaDeviceHandler";
|
|
import { type IDestroyable } from "../utils/IDestroyable";
|
|
import { Singleflight } from "../utils/Singleflight";
|
|
import { PayloadEvent, WORKLET_NAME } from "./consts";
|
|
import { UPDATE_EVENT } from "../stores/AsyncStore";
|
|
import { createAudioContext } from "./compat";
|
|
import { FixedRollingArray } from "../utils/FixedRollingArray";
|
|
import recorderWorkletFactory from "./recorderWorkletFactory";
|
|
|
|
const CHANNELS = 1; // stereo isn't important
|
|
export const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality.
|
|
const TARGET_MAX_LENGTH = 900; // 15 minutes in seconds. Somewhat arbitrary, though longer == larger files.
|
|
const TARGET_WARN_TIME_LEFT = 10; // 10 seconds, also somewhat arbitrary.
|
|
|
|
export const RECORDING_PLAYBACK_SAMPLES = 44;
|
|
|
|
interface RecorderOptions {
|
|
bitrate: number;
|
|
encoderApplication: number;
|
|
}
|
|
|
|
export const voiceRecorderOptions: RecorderOptions = {
|
|
bitrate: 24000, // recommended Opus bitrate for high-quality VoIP
|
|
encoderApplication: 2048, // voice
|
|
};
|
|
|
|
export const highQualityRecorderOptions: RecorderOptions = {
|
|
bitrate: 96000, // recommended Opus bitrate for high-quality music/audio streaming
|
|
encoderApplication: 2049, // full band audio
|
|
};
|
|
|
|
export interface IRecordingUpdate {
|
|
waveform: number[]; // floating points between 0 (low) and 1 (high).
|
|
timeSeconds: number; // float
|
|
}
|
|
|
|
export enum RecordingState {
|
|
Started = "started",
|
|
EndingSoon = "ending_soon", // emits an object with a single numerical value: secondsLeft
|
|
Ended = "ended",
|
|
Uploading = "uploading",
|
|
Uploaded = "uploaded",
|
|
}
|
|
|
|
export class VoiceRecording extends EventEmitter implements IDestroyable {
|
|
private recorder?: Recorder;
|
|
private recorderContext?: AudioContext;
|
|
private recorderSource?: MediaStreamAudioSourceNode;
|
|
private recorderStream?: MediaStream;
|
|
private recorderWorklet?: AudioWorkletNode;
|
|
private recorderProcessor?: ScriptProcessorNode;
|
|
private recording = false;
|
|
private observable?: SimpleObservable<IRecordingUpdate>;
|
|
private targetMaxLength: number | null = TARGET_MAX_LENGTH;
|
|
public amplitudes: number[] = []; // at each second mark, generated
|
|
private liveWaveform = new FixedRollingArray(RECORDING_PLAYBACK_SAMPLES, 0);
|
|
public onDataAvailable?: (data: ArrayBuffer) => void;
|
|
|
|
public get contentType(): string {
|
|
return "audio/ogg";
|
|
}
|
|
|
|
public get durationSeconds(): number {
|
|
if (!this.recorder || !this.recorderContext) throw new Error("Duration not available without a recording");
|
|
return this.recorderContext.currentTime;
|
|
}
|
|
|
|
public get isRecording(): boolean {
|
|
return this.recording;
|
|
}
|
|
|
|
public emit(event: string, ...args: any[]): boolean {
|
|
super.emit(event, ...args);
|
|
super.emit(UPDATE_EVENT, event, ...args);
|
|
return true; // we don't ever care if the event had listeners, so just return "yes"
|
|
}
|
|
|
|
public disableMaxLength(): void {
|
|
this.targetMaxLength = null;
|
|
}
|
|
|
|
private shouldRecordInHighQuality(): boolean {
|
|
// Non-voice use case is suspected when noise suppression is disabled by the user.
|
|
// When recording complex audio, higher quality is required to avoid audio artifacts.
|
|
// This is a really arbitrary decision, but it can be refined/replaced at any time.
|
|
return !MediaDeviceHandler.getAudioNoiseSuppression();
|
|
}
|
|
|
|
private async makeRecorder(): Promise<void> {
|
|
try {
|
|
const requestedDeviceId = MediaDeviceHandler.getAudioInput();
|
|
const deviceIdConstraint =
|
|
requestedDeviceId && requestedDeviceId !== "default" ? { deviceId: { exact: requestedDeviceId } } : {};
|
|
|
|
this.recorderStream = await navigator.mediaDevices.getUserMedia({
|
|
audio: {
|
|
channelCount: CHANNELS,
|
|
...deviceIdConstraint,
|
|
autoGainControl: { ideal: MediaDeviceHandler.getAudioAutoGainControl() },
|
|
echoCancellation: { ideal: MediaDeviceHandler.getAudioEchoCancellation() },
|
|
noiseSuppression: { ideal: MediaDeviceHandler.getAudioNoiseSuppression() },
|
|
},
|
|
});
|
|
this.recorderContext = createAudioContext({
|
|
// latencyHint: "interactive", // we don't want a latency hint (this causes data smoothing)
|
|
});
|
|
this.recorderSource = this.recorderContext.createMediaStreamSource(this.recorderStream);
|
|
|
|
// Connect our inputs and outputs
|
|
if (this.recorderContext.audioWorklet) {
|
|
// Set up our worklet. We use this for timing information and waveform analysis: the
|
|
// web audio API prefers this be done async to avoid holding the main thread with math.
|
|
await recorderWorkletFactory(this.recorderContext);
|
|
|
|
this.recorderWorklet = new AudioWorkletNode(this.recorderContext, WORKLET_NAME);
|
|
this.recorderSource.connect(this.recorderWorklet);
|
|
this.recorderWorklet.connect(this.recorderContext.destination);
|
|
|
|
// Dev note: we can't use `addEventListener` for some reason. It just doesn't work.
|
|
this.recorderWorklet.port.onmessage = (ev) => {
|
|
switch (ev.data["ev"]) {
|
|
case PayloadEvent.Timekeep:
|
|
this.processAudioUpdate(ev.data["timeSeconds"]);
|
|
break;
|
|
case PayloadEvent.AmplitudeMark:
|
|
// Sanity check to make sure we're adding about one sample per second
|
|
if (ev.data["forIndex"] === this.amplitudes.length) {
|
|
this.amplitudes.push(ev.data["amplitude"]);
|
|
this.liveWaveform.pushValue(ev.data["amplitude"]);
|
|
}
|
|
break;
|
|
}
|
|
};
|
|
} else {
|
|
// Safari fallback: use a processor node instead, buffered to 1024 bytes of data
|
|
// like the worklet is.
|
|
this.recorderProcessor = this.recorderContext.createScriptProcessor(1024, CHANNELS, CHANNELS);
|
|
this.recorderSource.connect(this.recorderProcessor);
|
|
this.recorderProcessor.connect(this.recorderContext.destination);
|
|
this.recorderProcessor.addEventListener("audioprocess", this.onAudioProcess);
|
|
}
|
|
|
|
const recorderOptions = this.shouldRecordInHighQuality()
|
|
? highQualityRecorderOptions
|
|
: voiceRecorderOptions;
|
|
const { encoderApplication, bitrate } = recorderOptions;
|
|
|
|
this.recorder = new Recorder({
|
|
encoderPath, // magic from webpack
|
|
encoderSampleRate: SAMPLE_RATE,
|
|
encoderApplication: encoderApplication,
|
|
streamPages: true, // this speeds up the encoding process by using CPU over time
|
|
encoderFrameSize: 20, // ms, arbitrary frame size we send to the encoder
|
|
numberOfChannels: CHANNELS,
|
|
sourceNode: this.recorderSource,
|
|
encoderBitRate: bitrate,
|
|
|
|
// We use low values for the following to ease CPU usage - the resulting waveform
|
|
// is indistinguishable for a voice message. Note that the underlying library will
|
|
// pick defaults which prefer the highest possible quality, CPU be damned.
|
|
encoderComplexity: 3, // 0-10, 10 is slow and high quality.
|
|
resampleQuality: 3, // 0-10, 10 is slow and high quality
|
|
});
|
|
|
|
// not using EventEmitter here because it leads to detached bufferes
|
|
this.recorder.ondataavailable = (data: ArrayBuffer) => this.onDataAvailable?.(data);
|
|
} catch (e) {
|
|
logger.error("Error starting recording: ", e);
|
|
if (e instanceof DOMException) {
|
|
// Unhelpful DOMExceptions are common - parse them sanely
|
|
logger.error(`${e.name} (${e.code}): ${e.message}`);
|
|
}
|
|
|
|
// Clean up as best as possible
|
|
if (this.recorderStream) this.recorderStream.getTracks().forEach((t) => t.stop());
|
|
if (this.recorderSource) this.recorderSource.disconnect();
|
|
if (this.recorder) this.recorder.close();
|
|
if (this.recorderContext) {
|
|
// noinspection ES6MissingAwait - not important that we wait
|
|
this.recorderContext.close();
|
|
}
|
|
|
|
throw e; // rethrow so upstream can handle it
|
|
}
|
|
}
|
|
|
|
public get liveData(): SimpleObservable<IRecordingUpdate> {
|
|
if (!this.recording || !this.observable) throw new Error("No observable when not recording");
|
|
return this.observable;
|
|
}
|
|
|
|
public get isSupported(): boolean {
|
|
return !!Recorder.isRecordingSupported();
|
|
}
|
|
|
|
private onAudioProcess = (ev: AudioProcessingEvent): void => {
|
|
this.processAudioUpdate(ev.playbackTime);
|
|
|
|
// We skip the functionality of the worklet regarding waveform calculations: we
|
|
// should get that information pretty quick during the playback info.
|
|
};
|
|
|
|
private processAudioUpdate = (timeSeconds: number): void => {
|
|
if (!this.recording) return;
|
|
|
|
this.observable!.update({
|
|
waveform: this.liveWaveform.value.map((v) => clamp(v, 0, 1)),
|
|
timeSeconds: timeSeconds,
|
|
});
|
|
|
|
// Now that we've updated the data/waveform, let's do a time check. We don't want to
|
|
// go horribly over the limit. We also emit a warning state if needed.
|
|
//
|
|
// We use the recorder's perspective of time to make sure we don't cut off the last
|
|
// frame of audio, otherwise we end up with a 14:59 clip (899.68 seconds). This extra
|
|
// safety can allow us to overshoot the target a bit, but at least when we say 15min
|
|
// maximum we actually mean it.
|
|
//
|
|
// In testing, recorder time and worker time lag by about 400ms, which is roughly the
|
|
// time needed to encode a sample/frame.
|
|
//
|
|
|
|
if (!this.targetMaxLength) {
|
|
// skip time checks if max length has been disabled
|
|
return;
|
|
}
|
|
|
|
const secondsLeft = TARGET_MAX_LENGTH - this.recorderSeconds!;
|
|
if (secondsLeft < 0) {
|
|
// go over to make sure we definitely capture that last frame
|
|
// noinspection JSIgnoredPromiseFromCall - we aren't concerned with it overlapping
|
|
this.stop();
|
|
} else if (secondsLeft <= TARGET_WARN_TIME_LEFT) {
|
|
Singleflight.for(this, "ending_soon").do(() => {
|
|
this.emit(RecordingState.EndingSoon, { secondsLeft });
|
|
return Singleflight.Void;
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* {@link https://github.com/chris-rudmin/opus-recorder#instance-fields ref for recorderSeconds}
|
|
*/
|
|
public get recorderSeconds(): number | undefined {
|
|
if (!this.recorder) return undefined;
|
|
return this.recorder.encodedSamplePosition / 48000;
|
|
}
|
|
|
|
public async start(): Promise<void> {
|
|
if (this.recording) {
|
|
throw new Error("Recording already in progress");
|
|
}
|
|
if (this.observable) {
|
|
this.observable.close();
|
|
}
|
|
this.observable = new SimpleObservable<IRecordingUpdate>();
|
|
await this.makeRecorder();
|
|
await this.recorder?.start();
|
|
this.recording = true;
|
|
this.emit(RecordingState.Started);
|
|
}
|
|
|
|
public async stop(): Promise<void> {
|
|
return Singleflight.for(this, "stop").do(async (): Promise<void> => {
|
|
if (!this.recording) {
|
|
throw new Error("No recording to stop");
|
|
}
|
|
|
|
// Disconnect the source early to start shutting down resources
|
|
await this.recorder!.stop(); // stop first to flush the last frame
|
|
this.recorderSource!.disconnect();
|
|
if (this.recorderWorklet) this.recorderWorklet.disconnect();
|
|
if (this.recorderProcessor) {
|
|
this.recorderProcessor.disconnect();
|
|
this.recorderProcessor.removeEventListener("audioprocess", this.onAudioProcess);
|
|
}
|
|
|
|
// close the context after the recorder so the recorder doesn't try to
|
|
// connect anything to the context (this would generate a warning)
|
|
await this.recorderContext!.close();
|
|
|
|
// Now stop all the media tracks so we can release them back to the user/OS
|
|
this.recorderStream!.getTracks().forEach((t) => t.stop());
|
|
|
|
// Finally do our post-processing and clean up
|
|
this.recording = false;
|
|
await this.recorder!.close();
|
|
this.emit(RecordingState.Ended);
|
|
});
|
|
}
|
|
|
|
public destroy(): void {
|
|
// noinspection JSIgnoredPromiseFromCall - not concerned about stop() being called async here
|
|
this.stop();
|
|
this.removeAllListeners();
|
|
this.onDataAvailable = undefined;
|
|
Singleflight.forgetAllFor(this);
|
|
// noinspection JSIgnoredPromiseFromCall - not concerned about being called async here
|
|
this.observable?.close();
|
|
}
|
|
}
|