Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit c182c1c

Browse files
authored
Generalise VoiceRecording (#9304)
1 parent 71cf9bf commit c182c1c

File tree

11 files changed

+422
-103
lines changed

11 files changed

+422
-103
lines changed

src/audio/VoiceMessageRecording.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { IEncryptedFile, MatrixClient } from "matrix-js-sdk/src/matrix";
18+
import { SimpleObservable } from "matrix-widget-api";
19+
20+
import { uploadFile } from "../ContentMessages";
21+
import { IDestroyable } from "../utils/IDestroyable";
22+
import { Singleflight } from "../utils/Singleflight";
23+
import { Playback } from "./Playback";
24+
import { IRecordingUpdate, RecordingState, VoiceRecording } from "./VoiceRecording";
25+
26+
export interface IUpload {
27+
mxc?: string; // for unencrypted uploads
28+
encrypted?: IEncryptedFile;
29+
}
30+
31+
/**
32+
* This class can be used to record a single voice message.
33+
*/
34+
export class VoiceMessageRecording implements IDestroyable {
35+
private lastUpload: IUpload;
36+
private buffer = new Uint8Array(0); // use this.audioBuffer to access
37+
private playback: Playback;
38+
39+
public constructor(
40+
private matrixClient: MatrixClient,
41+
private voiceRecording: VoiceRecording,
42+
) {
43+
this.voiceRecording.onDataAvailable = this.onDataAvailable;
44+
}
45+
46+
public async start(): Promise<void> {
47+
if (this.lastUpload || this.hasRecording) {
48+
throw new Error("Recording already prepared");
49+
}
50+
51+
return this.voiceRecording.start();
52+
}
53+
54+
public async stop(): Promise<Uint8Array> {
55+
await this.voiceRecording.stop();
56+
return this.audioBuffer;
57+
}
58+
59+
public on(event: string | symbol, listener: (...args: any[]) => void): this {
60+
this.voiceRecording.on(event, listener);
61+
return this;
62+
}
63+
64+
public off(event: string | symbol, listener: (...args: any[]) => void): this {
65+
this.voiceRecording.off(event, listener);
66+
return this;
67+
}
68+
69+
public emit(event: string, ...args: any[]): boolean {
70+
return this.voiceRecording.emit(event, ...args);
71+
}
72+
73+
public get hasRecording(): boolean {
74+
return this.buffer.length > 0;
75+
}
76+
77+
public get isRecording(): boolean {
78+
return this.voiceRecording.isRecording;
79+
}
80+
81+
/**
82+
* Gets a playback instance for this voice recording. Note that the playback will not
83+
* have been prepared fully, meaning the `prepare()` function needs to be called on it.
84+
*
85+
* The same playback instance is returned each time.
86+
*
87+
* @returns {Playback} The playback instance.
88+
*/
89+
public getPlayback(): Playback {
90+
this.playback = Singleflight.for(this, "playback").do(() => {
91+
return new Playback(this.audioBuffer.buffer, this.voiceRecording.amplitudes); // cast to ArrayBuffer proper;
92+
});
93+
return this.playback;
94+
}
95+
96+
public async upload(inRoomId: string): Promise<IUpload> {
97+
if (!this.hasRecording) {
98+
throw new Error("No recording available to upload");
99+
}
100+
101+
if (this.lastUpload) return this.lastUpload;
102+
103+
try {
104+
this.emit(RecordingState.Uploading);
105+
const { url: mxc, file: encrypted } = await uploadFile(
106+
this.matrixClient,
107+
inRoomId,
108+
new Blob(
109+
[this.audioBuffer],
110+
{
111+
type: this.contentType,
112+
},
113+
),
114+
);
115+
this.lastUpload = { mxc, encrypted };
116+
this.emit(RecordingState.Uploaded);
117+
} catch (e) {
118+
this.emit(RecordingState.Ended);
119+
throw e;
120+
}
121+
return this.lastUpload;
122+
}
123+
124+
public get durationSeconds(): number {
125+
return this.voiceRecording.durationSeconds;
126+
}
127+
128+
public get contentType(): string {
129+
return this.voiceRecording.contentType;
130+
}
131+
132+
public get contentLength(): number {
133+
return this.buffer.length;
134+
}
135+
136+
public get liveData(): SimpleObservable<IRecordingUpdate> {
137+
return this.voiceRecording.liveData;
138+
}
139+
140+
public get isSupported(): boolean {
141+
return this.voiceRecording.isSupported;
142+
}
143+
144+
destroy(): void {
145+
this.playback?.destroy();
146+
this.voiceRecording.destroy();
147+
}
148+
149+
private onDataAvailable = (data: ArrayBuffer) => {
150+
const buf = new Uint8Array(data);
151+
const newBuf = new Uint8Array(this.buffer.length + buf.length);
152+
newBuf.set(this.buffer, 0);
153+
newBuf.set(buf, this.buffer.length);
154+
this.buffer = newBuf;
155+
};
156+
157+
private get audioBuffer(): Uint8Array {
158+
// We need a clone of the buffer to avoid accidentally changing the position
159+
// on the real thing.
160+
return this.buffer.slice(0);
161+
}
162+
}
163+
164+
export const createVoiceMessageRecording = (matrixClient: MatrixClient) => {
165+
return new VoiceMessageRecording(matrixClient, new VoiceRecording());
166+
};

src/audio/VoiceRecording.ts

Lines changed: 7 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,16 @@ limitations under the License.
1616

1717
import * as Recorder from 'opus-recorder';
1818
import encoderPath from 'opus-recorder/dist/encoderWorker.min.js';
19-
import { MatrixClient } from "matrix-js-sdk/src/client";
2019
import { SimpleObservable } from "matrix-widget-api";
2120
import EventEmitter from "events";
22-
import { IEncryptedFile } from "matrix-js-sdk/src/@types/event";
2321
import { logger } from "matrix-js-sdk/src/logger";
2422

2523
import MediaDeviceHandler from "../MediaDeviceHandler";
2624
import { IDestroyable } from "../utils/IDestroyable";
2725
import { Singleflight } from "../utils/Singleflight";
2826
import { PayloadEvent, WORKLET_NAME } from "./consts";
2927
import { UPDATE_EVENT } from "../stores/AsyncStore";
30-
import { Playback } from "./Playback";
3128
import { createAudioContext } from "./compat";
32-
import { uploadFile } from "../ContentMessages";
3329
import { FixedRollingArray } from "../utils/FixedRollingArray";
3430
import { clamp } from "../utils/numbers";
3531
import mxRecorderWorkletPath from "./RecorderWorklet";
@@ -55,38 +51,23 @@ export enum RecordingState {
5551
Uploaded = "uploaded",
5652
}
5753

58-
export interface IUpload {
59-
mxc?: string; // for unencrypted uploads
60-
encrypted?: IEncryptedFile;
61-
}
62-
6354
export class VoiceRecording extends EventEmitter implements IDestroyable {
6455
private recorder: Recorder;
6556
private recorderContext: AudioContext;
6657
private recorderSource: MediaStreamAudioSourceNode;
6758
private recorderStream: MediaStream;
6859
private recorderWorklet: AudioWorkletNode;
6960
private recorderProcessor: ScriptProcessorNode;
70-
private buffer = new Uint8Array(0); // use this.audioBuffer to access
71-
private lastUpload: IUpload;
7261
private recording = false;
7362
private observable: SimpleObservable<IRecordingUpdate>;
74-
private amplitudes: number[] = []; // at each second mark, generated
75-
private playback: Playback;
63+
public amplitudes: number[] = []; // at each second mark, generated
7664
private liveWaveform = new FixedRollingArray(RECORDING_PLAYBACK_SAMPLES, 0);
77-
78-
public constructor(private client: MatrixClient) {
79-
super();
80-
}
65+
public onDataAvailable: (data: ArrayBuffer) => void;
8166

8267
public get contentType(): string {
8368
return "audio/ogg";
8469
}
8570

86-
public get contentLength(): number {
87-
return this.buffer.length;
88-
}
89-
9071
public get durationSeconds(): number {
9172
if (!this.recorder) throw new Error("Duration not available without a recording");
9273
return this.recorderContext.currentTime;
@@ -165,13 +146,9 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
165146
encoderComplexity: 3, // 0-10, 10 is slow and high quality.
166147
resampleQuality: 3, // 0-10, 10 is slow and high quality
167148
});
168-
this.recorder.ondataavailable = (a: ArrayBuffer) => {
169-
const buf = new Uint8Array(a);
170-
const newBuf = new Uint8Array(this.buffer.length + buf.length);
171-
newBuf.set(this.buffer, 0);
172-
newBuf.set(buf, this.buffer.length);
173-
this.buffer = newBuf;
174-
};
149+
150+
// not using EventEmitter here because it leads to detached bufferes
151+
this.recorder.ondataavailable = (data: ArrayBuffer) => this?.onDataAvailable(data);
175152
} catch (e) {
176153
logger.error("Error starting recording: ", e);
177154
if (e instanceof DOMException) { // Unhelpful DOMExceptions are common - parse them sanely
@@ -191,12 +168,6 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
191168
}
192169
}
193170

194-
private get audioBuffer(): Uint8Array {
195-
// We need a clone of the buffer to avoid accidentally changing the position
196-
// on the real thing.
197-
return this.buffer.slice(0);
198-
}
199-
200171
public get liveData(): SimpleObservable<IRecordingUpdate> {
201172
if (!this.recording) throw new Error("No observable when not recording");
202173
return this.observable;
@@ -206,10 +177,6 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
206177
return !!Recorder.isRecordingSupported();
207178
}
208179

209-
public get hasRecording(): boolean {
210-
return this.buffer.length > 0;
211-
}
212-
213180
private onAudioProcess = (ev: AudioProcessingEvent) => {
214181
this.processAudioUpdate(ev.playbackTime);
215182

@@ -251,9 +218,6 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
251218
};
252219

253220
public async start(): Promise<void> {
254-
if (this.lastUpload || this.hasRecording) {
255-
throw new Error("Recording already prepared");
256-
}
257221
if (this.recording) {
258222
throw new Error("Recording already in progress");
259223
}
@@ -267,7 +231,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
267231
this.emit(RecordingState.Started);
268232
}
269233

270-
public async stop(): Promise<Uint8Array> {
234+
public async stop(): Promise<void> {
271235
return Singleflight.for(this, "stop").do(async () => {
272236
if (!this.recording) {
273237
throw new Error("No recording to stop");
@@ -293,54 +257,16 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
293257
this.recording = false;
294258
await this.recorder.close();
295259
this.emit(RecordingState.Ended);
296-
297-
return this.audioBuffer;
298260
});
299261
}
300262

301-
/**
302-
* Gets a playback instance for this voice recording. Note that the playback will not
303-
* have been prepared fully, meaning the `prepare()` function needs to be called on it.
304-
*
305-
* The same playback instance is returned each time.
306-
*
307-
* @returns {Playback} The playback instance.
308-
*/
309-
public getPlayback(): Playback {
310-
this.playback = Singleflight.for(this, "playback").do(() => {
311-
return new Playback(this.audioBuffer.buffer, this.amplitudes); // cast to ArrayBuffer proper;
312-
});
313-
return this.playback;
314-
}
315-
316263
public destroy() {
317264
// noinspection JSIgnoredPromiseFromCall - not concerned about stop() being called async here
318265
this.stop();
319266
this.removeAllListeners();
267+
this.onDataAvailable = undefined;
320268
Singleflight.forgetAllFor(this);
321269
// noinspection JSIgnoredPromiseFromCall - not concerned about being called async here
322-
this.playback?.destroy();
323270
this.observable.close();
324271
}
325-
326-
public async upload(inRoomId: string): Promise<IUpload> {
327-
if (!this.hasRecording) {
328-
throw new Error("No recording available to upload");
329-
}
330-
331-
if (this.lastUpload) return this.lastUpload;
332-
333-
try {
334-
this.emit(RecordingState.Uploading);
335-
const { url: mxc, file: encrypted } = await uploadFile(this.client, inRoomId, new Blob([this.audioBuffer], {
336-
type: this.contentType,
337-
}));
338-
this.lastUpload = { mxc, encrypted };
339-
this.emit(RecordingState.Uploaded);
340-
} catch (e) {
341-
this.emit(RecordingState.Ended);
342-
throw e;
343-
}
344-
return this.lastUpload;
345-
}
346272
}

src/components/views/audio_messages/LiveRecordingClock.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,13 @@ limitations under the License.
1616

1717
import React from "react";
1818

19-
import { IRecordingUpdate, VoiceRecording } from "../../../audio/VoiceRecording";
19+
import { IRecordingUpdate } from "../../../audio/VoiceRecording";
2020
import Clock from "./Clock";
2121
import { MarkedExecution } from "../../../utils/MarkedExecution";
22+
import { VoiceMessageRecording } from "../../../audio/VoiceMessageRecording";
2223

2324
interface IProps {
24-
recorder: VoiceRecording;
25+
recorder: VoiceMessageRecording;
2526
}
2627

2728
interface IState {

src/components/views/audio_messages/LiveRecordingWaveform.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,14 @@ limitations under the License.
1616

1717
import React from "react";
1818

19-
import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../audio/VoiceRecording";
19+
import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES } from "../../../audio/VoiceRecording";
2020
import { arrayFastResample, arraySeed } from "../../../utils/arrays";
2121
import Waveform from "./Waveform";
2222
import { MarkedExecution } from "../../../utils/MarkedExecution";
23+
import { VoiceMessageRecording } from "../../../audio/VoiceMessageRecording";
2324

2425
interface IProps {
25-
recorder: VoiceRecording;
26+
recorder: VoiceMessageRecording;
2627
}
2728

2829
interface IState {

0 commit comments

Comments
 (0)