Generalise VoiceRecording (#9304)

This commit is contained in:
Michael Weimann 2022-09-21 18:46:28 +02:00 committed by GitHub
parent 71cf9bf932
commit c182c1c706
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 422 additions and 103 deletions

View file

@ -0,0 +1,166 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { IEncryptedFile, MatrixClient } from "matrix-js-sdk/src/matrix";
import { SimpleObservable } from "matrix-widget-api";
import { uploadFile } from "../ContentMessages";
import { IDestroyable } from "../utils/IDestroyable";
import { Singleflight } from "../utils/Singleflight";
import { Playback } from "./Playback";
import { IRecordingUpdate, RecordingState, VoiceRecording } from "./VoiceRecording";
export interface IUpload {
mxc?: string; // for unencrypted uploads
encrypted?: IEncryptedFile;
}
/**
* This class can be used to record a single voice message.
*/
export class VoiceMessageRecording implements IDestroyable {
private lastUpload: IUpload;
private buffer = new Uint8Array(0); // use this.audioBuffer to access
private playback: Playback;
public constructor(
private matrixClient: MatrixClient,
private voiceRecording: VoiceRecording,
) {
this.voiceRecording.onDataAvailable = this.onDataAvailable;
}
public async start(): Promise<void> {
if (this.lastUpload || this.hasRecording) {
throw new Error("Recording already prepared");
}
return this.voiceRecording.start();
}
public async stop(): Promise<Uint8Array> {
await this.voiceRecording.stop();
return this.audioBuffer;
}
public on(event: string | symbol, listener: (...args: any[]) => void): this {
this.voiceRecording.on(event, listener);
return this;
}
public off(event: string | symbol, listener: (...args: any[]) => void): this {
this.voiceRecording.off(event, listener);
return this;
}
public emit(event: string, ...args: any[]): boolean {
return this.voiceRecording.emit(event, ...args);
}
public get hasRecording(): boolean {
return this.buffer.length > 0;
}
public get isRecording(): boolean {
return this.voiceRecording.isRecording;
}
/**
* Gets a playback instance for this voice recording. Note that the playback will not
* have been prepared fully, meaning the `prepare()` function needs to be called on it.
*
* The same playback instance is returned each time.
*
* @returns {Playback} The playback instance.
*/
public getPlayback(): Playback {
this.playback = Singleflight.for(this, "playback").do(() => {
return new Playback(this.audioBuffer.buffer, this.voiceRecording.amplitudes); // cast to ArrayBuffer proper;
});
return this.playback;
}
public async upload(inRoomId: string): Promise<IUpload> {
if (!this.hasRecording) {
throw new Error("No recording available to upload");
}
if (this.lastUpload) return this.lastUpload;
try {
this.emit(RecordingState.Uploading);
const { url: mxc, file: encrypted } = await uploadFile(
this.matrixClient,
inRoomId,
new Blob(
[this.audioBuffer],
{
type: this.contentType,
},
),
);
this.lastUpload = { mxc, encrypted };
this.emit(RecordingState.Uploaded);
} catch (e) {
this.emit(RecordingState.Ended);
throw e;
}
return this.lastUpload;
}
public get durationSeconds(): number {
return this.voiceRecording.durationSeconds;
}
public get contentType(): string {
return this.voiceRecording.contentType;
}
public get contentLength(): number {
return this.buffer.length;
}
public get liveData(): SimpleObservable<IRecordingUpdate> {
return this.voiceRecording.liveData;
}
public get isSupported(): boolean {
return this.voiceRecording.isSupported;
}
destroy(): void {
this.playback?.destroy();
this.voiceRecording.destroy();
}
private onDataAvailable = (data: ArrayBuffer) => {
const buf = new Uint8Array(data);
const newBuf = new Uint8Array(this.buffer.length + buf.length);
newBuf.set(this.buffer, 0);
newBuf.set(buf, this.buffer.length);
this.buffer = newBuf;
};
private get audioBuffer(): Uint8Array {
// We need a clone of the buffer to avoid accidentally changing the position
// on the real thing.
return this.buffer.slice(0);
}
}
export const createVoiceMessageRecording = (matrixClient: MatrixClient) => {
return new VoiceMessageRecording(matrixClient, new VoiceRecording());
};

View file

@ -16,10 +16,8 @@ limitations under the License.
import * as Recorder from 'opus-recorder'; import * as Recorder from 'opus-recorder';
import encoderPath from 'opus-recorder/dist/encoderWorker.min.js'; import encoderPath from 'opus-recorder/dist/encoderWorker.min.js';
import { MatrixClient } from "matrix-js-sdk/src/client";
import { SimpleObservable } from "matrix-widget-api"; import { SimpleObservable } from "matrix-widget-api";
import EventEmitter from "events"; import EventEmitter from "events";
import { IEncryptedFile } from "matrix-js-sdk/src/@types/event";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import MediaDeviceHandler from "../MediaDeviceHandler"; import MediaDeviceHandler from "../MediaDeviceHandler";
@ -27,9 +25,7 @@ import { IDestroyable } from "../utils/IDestroyable";
import { Singleflight } from "../utils/Singleflight"; import { Singleflight } from "../utils/Singleflight";
import { PayloadEvent, WORKLET_NAME } from "./consts"; import { PayloadEvent, WORKLET_NAME } from "./consts";
import { UPDATE_EVENT } from "../stores/AsyncStore"; import { UPDATE_EVENT } from "../stores/AsyncStore";
import { Playback } from "./Playback";
import { createAudioContext } from "./compat"; import { createAudioContext } from "./compat";
import { uploadFile } from "../ContentMessages";
import { FixedRollingArray } from "../utils/FixedRollingArray"; import { FixedRollingArray } from "../utils/FixedRollingArray";
import { clamp } from "../utils/numbers"; import { clamp } from "../utils/numbers";
import mxRecorderWorkletPath from "./RecorderWorklet"; import mxRecorderWorkletPath from "./RecorderWorklet";
@ -55,11 +51,6 @@ export enum RecordingState {
Uploaded = "uploaded", Uploaded = "uploaded",
} }
export interface IUpload {
mxc?: string; // for unencrypted uploads
encrypted?: IEncryptedFile;
}
export class VoiceRecording extends EventEmitter implements IDestroyable { export class VoiceRecording extends EventEmitter implements IDestroyable {
private recorder: Recorder; private recorder: Recorder;
private recorderContext: AudioContext; private recorderContext: AudioContext;
@ -67,26 +58,16 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
private recorderStream: MediaStream; private recorderStream: MediaStream;
private recorderWorklet: AudioWorkletNode; private recorderWorklet: AudioWorkletNode;
private recorderProcessor: ScriptProcessorNode; private recorderProcessor: ScriptProcessorNode;
private buffer = new Uint8Array(0); // use this.audioBuffer to access
private lastUpload: IUpload;
private recording = false; private recording = false;
private observable: SimpleObservable<IRecordingUpdate>; private observable: SimpleObservable<IRecordingUpdate>;
private amplitudes: number[] = []; // at each second mark, generated public amplitudes: number[] = []; // at each second mark, generated
private playback: Playback;
private liveWaveform = new FixedRollingArray(RECORDING_PLAYBACK_SAMPLES, 0); private liveWaveform = new FixedRollingArray(RECORDING_PLAYBACK_SAMPLES, 0);
public onDataAvailable: (data: ArrayBuffer) => void;
public constructor(private client: MatrixClient) {
super();
}
public get contentType(): string { public get contentType(): string {
return "audio/ogg"; return "audio/ogg";
} }
public get contentLength(): number {
return this.buffer.length;
}
public get durationSeconds(): number { public get durationSeconds(): number {
if (!this.recorder) throw new Error("Duration not available without a recording"); if (!this.recorder) throw new Error("Duration not available without a recording");
return this.recorderContext.currentTime; return this.recorderContext.currentTime;
@ -165,13 +146,9 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
encoderComplexity: 3, // 0-10, 10 is slow and high quality. encoderComplexity: 3, // 0-10, 10 is slow and high quality.
resampleQuality: 3, // 0-10, 10 is slow and high quality resampleQuality: 3, // 0-10, 10 is slow and high quality
}); });
this.recorder.ondataavailable = (a: ArrayBuffer) => {
const buf = new Uint8Array(a); // not using EventEmitter here because it leads to detached bufferes
const newBuf = new Uint8Array(this.buffer.length + buf.length); this.recorder.ondataavailable = (data: ArrayBuffer) => this?.onDataAvailable(data);
newBuf.set(this.buffer, 0);
newBuf.set(buf, this.buffer.length);
this.buffer = newBuf;
};
} catch (e) { } catch (e) {
logger.error("Error starting recording: ", e); logger.error("Error starting recording: ", e);
if (e instanceof DOMException) { // Unhelpful DOMExceptions are common - parse them sanely if (e instanceof DOMException) { // Unhelpful DOMExceptions are common - parse them sanely
@ -191,12 +168,6 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
} }
} }
private get audioBuffer(): Uint8Array {
// We need a clone of the buffer to avoid accidentally changing the position
// on the real thing.
return this.buffer.slice(0);
}
public get liveData(): SimpleObservable<IRecordingUpdate> { public get liveData(): SimpleObservable<IRecordingUpdate> {
if (!this.recording) throw new Error("No observable when not recording"); if (!this.recording) throw new Error("No observable when not recording");
return this.observable; return this.observable;
@ -206,10 +177,6 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
return !!Recorder.isRecordingSupported(); return !!Recorder.isRecordingSupported();
} }
public get hasRecording(): boolean {
return this.buffer.length > 0;
}
private onAudioProcess = (ev: AudioProcessingEvent) => { private onAudioProcess = (ev: AudioProcessingEvent) => {
this.processAudioUpdate(ev.playbackTime); this.processAudioUpdate(ev.playbackTime);
@ -251,9 +218,6 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
}; };
public async start(): Promise<void> { public async start(): Promise<void> {
if (this.lastUpload || this.hasRecording) {
throw new Error("Recording already prepared");
}
if (this.recording) { if (this.recording) {
throw new Error("Recording already in progress"); throw new Error("Recording already in progress");
} }
@ -267,7 +231,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
this.emit(RecordingState.Started); this.emit(RecordingState.Started);
} }
public async stop(): Promise<Uint8Array> { public async stop(): Promise<void> {
return Singleflight.for(this, "stop").do(async () => { return Singleflight.for(this, "stop").do(async () => {
if (!this.recording) { if (!this.recording) {
throw new Error("No recording to stop"); throw new Error("No recording to stop");
@ -293,54 +257,16 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
this.recording = false; this.recording = false;
await this.recorder.close(); await this.recorder.close();
this.emit(RecordingState.Ended); this.emit(RecordingState.Ended);
return this.audioBuffer;
}); });
} }
/**
* Gets a playback instance for this voice recording. Note that the playback will not
* have been prepared fully, meaning the `prepare()` function needs to be called on it.
*
* The same playback instance is returned each time.
*
* @returns {Playback} The playback instance.
*/
public getPlayback(): Playback {
this.playback = Singleflight.for(this, "playback").do(() => {
return new Playback(this.audioBuffer.buffer, this.amplitudes); // cast to ArrayBuffer proper;
});
return this.playback;
}
public destroy() { public destroy() {
// noinspection JSIgnoredPromiseFromCall - not concerned about stop() being called async here // noinspection JSIgnoredPromiseFromCall - not concerned about stop() being called async here
this.stop(); this.stop();
this.removeAllListeners(); this.removeAllListeners();
this.onDataAvailable = undefined;
Singleflight.forgetAllFor(this); Singleflight.forgetAllFor(this);
// noinspection JSIgnoredPromiseFromCall - not concerned about being called async here // noinspection JSIgnoredPromiseFromCall - not concerned about being called async here
this.playback?.destroy();
this.observable.close(); this.observable.close();
} }
public async upload(inRoomId: string): Promise<IUpload> {
if (!this.hasRecording) {
throw new Error("No recording available to upload");
}
if (this.lastUpload) return this.lastUpload;
try {
this.emit(RecordingState.Uploading);
const { url: mxc, file: encrypted } = await uploadFile(this.client, inRoomId, new Blob([this.audioBuffer], {
type: this.contentType,
}));
this.lastUpload = { mxc, encrypted };
this.emit(RecordingState.Uploaded);
} catch (e) {
this.emit(RecordingState.Ended);
throw e;
}
return this.lastUpload;
}
} }

View file

@ -16,12 +16,13 @@ limitations under the License.
import React from "react"; import React from "react";
import { IRecordingUpdate, VoiceRecording } from "../../../audio/VoiceRecording"; import { IRecordingUpdate } from "../../../audio/VoiceRecording";
import Clock from "./Clock"; import Clock from "./Clock";
import { MarkedExecution } from "../../../utils/MarkedExecution"; import { MarkedExecution } from "../../../utils/MarkedExecution";
import { VoiceMessageRecording } from "../../../audio/VoiceMessageRecording";
interface IProps { interface IProps {
recorder: VoiceRecording; recorder: VoiceMessageRecording;
} }
interface IState { interface IState {

View file

@ -16,13 +16,14 @@ limitations under the License.
import React from "react"; import React from "react";
import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../audio/VoiceRecording"; import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES } from "../../../audio/VoiceRecording";
import { arrayFastResample, arraySeed } from "../../../utils/arrays"; import { arrayFastResample, arraySeed } from "../../../utils/arrays";
import Waveform from "./Waveform"; import Waveform from "./Waveform";
import { MarkedExecution } from "../../../utils/MarkedExecution"; import { MarkedExecution } from "../../../utils/MarkedExecution";
import { VoiceMessageRecording } from "../../../audio/VoiceMessageRecording";
interface IProps { interface IProps {
recorder: VoiceRecording; recorder: VoiceMessageRecording;
} }
interface IState { interface IState {

View file

@ -37,7 +37,7 @@ import ReplyPreview from "./ReplyPreview";
import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import VoiceRecordComposerTile from "./VoiceRecordComposerTile"; import VoiceRecordComposerTile from "./VoiceRecordComposerTile";
import { VoiceRecordingStore } from "../../../stores/VoiceRecordingStore"; import { VoiceRecordingStore } from "../../../stores/VoiceRecordingStore";
import { RecordingState, VoiceRecording } from "../../../audio/VoiceRecording"; import { RecordingState } from "../../../audio/VoiceRecording";
import Tooltip, { Alignment } from "../elements/Tooltip"; import Tooltip, { Alignment } from "../elements/Tooltip";
import ResizeNotifier from "../../../utils/ResizeNotifier"; import ResizeNotifier from "../../../utils/ResizeNotifier";
import { E2EStatus } from '../../../utils/ShieldUtils'; import { E2EStatus } from '../../../utils/ShieldUtils';
@ -53,6 +53,7 @@ import { ButtonEvent } from '../elements/AccessibleButton';
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { isLocalRoom } from '../../../utils/localRoom/isLocalRoom'; import { isLocalRoom } from '../../../utils/localRoom/isLocalRoom';
import { Features } from '../../../settings/Settings'; import { Features } from '../../../settings/Settings';
import { VoiceMessageRecording } from '../../../audio/VoiceMessageRecording';
let instanceCount = 0; let instanceCount = 0;
@ -101,7 +102,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
private ref: React.RefObject<HTMLDivElement> = createRef(); private ref: React.RefObject<HTMLDivElement> = createRef();
private instanceId: number; private instanceId: number;
private _voiceRecording: Optional<VoiceRecording>; private _voiceRecording: Optional<VoiceMessageRecording>;
public static contextType = RoomContext; public static contextType = RoomContext;
public context!: React.ContextType<typeof RoomContext>; public context!: React.ContextType<typeof RoomContext>;
@ -133,11 +134,11 @@ export default class MessageComposer extends React.Component<IProps, IState> {
SettingsStore.monitorSetting(Features.VoiceBroadcast, null); SettingsStore.monitorSetting(Features.VoiceBroadcast, null);
} }
private get voiceRecording(): Optional<VoiceRecording> { private get voiceRecording(): Optional<VoiceMessageRecording> {
return this._voiceRecording; return this._voiceRecording;
} }
private set voiceRecording(rec: Optional<VoiceRecording>) { private set voiceRecording(rec: Optional<VoiceMessageRecording>) {
if (this._voiceRecording) { if (this._voiceRecording) {
this._voiceRecording.off(RecordingState.Started, this.onRecordingStarted); this._voiceRecording.off(RecordingState.Started, this.onRecordingStarted);
this._voiceRecording.off(RecordingState.EndingSoon, this.onRecordingEndingSoon); this._voiceRecording.off(RecordingState.EndingSoon, this.onRecordingEndingSoon);

View file

@ -23,7 +23,7 @@ import { IEventRelation, MatrixEvent } from "matrix-js-sdk/src/models/event";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { IUpload, RecordingState, VoiceRecording } from "../../../audio/VoiceRecording"; import { RecordingState } from "../../../audio/VoiceRecording";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import LiveRecordingWaveform from "../audio_messages/LiveRecordingWaveform"; import LiveRecordingWaveform from "../audio_messages/LiveRecordingWaveform";
import LiveRecordingClock from "../audio_messages/LiveRecordingClock"; import LiveRecordingClock from "../audio_messages/LiveRecordingClock";
@ -44,6 +44,7 @@ import { attachRelation } from "./SendMessageComposer";
import { addReplyToMessageContent } from "../../../utils/Reply"; import { addReplyToMessageContent } from "../../../utils/Reply";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import RoomContext from "../../../contexts/RoomContext"; import RoomContext from "../../../contexts/RoomContext";
import { IUpload, VoiceMessageRecording } from "../../../audio/VoiceMessageRecording";
interface IProps { interface IProps {
room: Room; room: Room;
@ -53,7 +54,7 @@ interface IProps {
} }
interface IState { interface IState {
recorder?: VoiceRecording; recorder?: VoiceMessageRecording;
recordingPhase?: RecordingState; recordingPhase?: RecordingState;
didUploadFail?: boolean; didUploadFail?: boolean;
} }
@ -250,7 +251,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
} }
}; };
private bindNewRecorder(recorder: Optional<VoiceRecording>) { private bindNewRecorder(recorder: Optional<VoiceMessageRecording>) {
if (this.state.recorder) { if (this.state.recorder) {
this.state.recorder.off(UPDATE_EVENT, this.onRecordingUpdate); this.state.recorder.off(UPDATE_EVENT, this.onRecordingUpdate);
} }

View file

@ -22,12 +22,12 @@ import { IEventRelation } from "matrix-js-sdk/src/models/event";
import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
import defaultDispatcher from "../dispatcher/dispatcher"; import defaultDispatcher from "../dispatcher/dispatcher";
import { ActionPayload } from "../dispatcher/payloads"; import { ActionPayload } from "../dispatcher/payloads";
import { VoiceRecording } from "../audio/VoiceRecording"; import { createVoiceMessageRecording, VoiceMessageRecording } from "../audio/VoiceMessageRecording";
const SEPARATOR = "|"; const SEPARATOR = "|";
interface IState { interface IState {
[voiceRecordingId: string]: Optional<VoiceRecording>; [voiceRecordingId: string]: Optional<VoiceMessageRecording>;
} }
export class VoiceRecordingStore extends AsyncStoreWithClient<IState> { export class VoiceRecordingStore extends AsyncStoreWithClient<IState> {
@ -63,7 +63,7 @@ export class VoiceRecordingStore extends AsyncStoreWithClient<IState> {
* @param {string} voiceRecordingId The room ID (with optionally the thread ID if in one) to get the recording in. * @param {string} voiceRecordingId The room ID (with optionally the thread ID if in one) to get the recording in.
* @returns {Optional<VoiceRecording>} The recording, if any. * @returns {Optional<VoiceRecording>} The recording, if any.
*/ */
public getActiveRecording(voiceRecordingId: string): Optional<VoiceRecording> { public getActiveRecording(voiceRecordingId: string): Optional<VoiceMessageRecording> {
return this.state[voiceRecordingId]; return this.state[voiceRecordingId];
} }
@ -74,12 +74,12 @@ export class VoiceRecordingStore extends AsyncStoreWithClient<IState> {
* @param {string} voiceRecordingId The room ID (with optionally the thread ID if in one) to start recording in. * @param {string} voiceRecordingId The room ID (with optionally the thread ID if in one) to start recording in.
* @returns {VoiceRecording} The recording. * @returns {VoiceRecording} The recording.
*/ */
public startRecording(voiceRecordingId: string): VoiceRecording { public startRecording(voiceRecordingId: string): VoiceMessageRecording {
if (!this.matrixClient) throw new Error("Cannot start a recording without a MatrixClient"); if (!this.matrixClient) throw new Error("Cannot start a recording without a MatrixClient");
if (!voiceRecordingId) throw new Error("Recording must be associated with a room"); if (!voiceRecordingId) throw new Error("Recording must be associated with a room");
if (this.state[voiceRecordingId]) throw new Error("A recording is already in progress"); if (this.state[voiceRecordingId]) throw new Error("A recording is already in progress");
const recording = new VoiceRecording(this.matrixClient); const recording = createVoiceMessageRecording(this.matrixClient);
// noinspection JSIgnoredPromiseFromCall - we can safely run this async // noinspection JSIgnoredPromiseFromCall - we can safely run this async
this.updateState({ ...this.state, [voiceRecordingId]: recording }); this.updateState({ ...this.state, [voiceRecordingId]: recording });

View file

@ -0,0 +1,221 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { mocked } from "jest-mock";
import { IAbortablePromise, IEncryptedFile, IUploadOpts, MatrixClient } from "matrix-js-sdk/src/matrix";
import { createVoiceMessageRecording, VoiceMessageRecording } from "../../src/audio/VoiceMessageRecording";
import { RecordingState, VoiceRecording } from "../../src/audio/VoiceRecording";
import { uploadFile } from "../../src/ContentMessages";
import { stubClient } from "../test-utils";
import { Playback } from "../../src/audio/Playback";
jest.mock("../../src/ContentMessages", () => ({
uploadFile: jest.fn(),
}));
jest.mock("../../src/audio/Playback", () => ({
Playback: jest.fn(),
}));
describe("VoiceMessageRecording", () => {
const roomId = "!room:example.com";
const contentType = "test content type";
const durationSeconds = 23;
const testBuf = new Uint8Array([1, 2, 3]);
const testAmplitudes = [4, 5, 6];
let voiceRecording: VoiceRecording;
let voiceMessageRecording: VoiceMessageRecording;
let client: MatrixClient;
beforeEach(() => {
client = stubClient();
voiceRecording = {
contentType,
durationSeconds,
start: jest.fn().mockResolvedValue(undefined),
stop: jest.fn().mockResolvedValue(undefined),
on: jest.fn(),
off: jest.fn(),
emit: jest.fn(),
isRecording: true,
isSupported: true,
liveData: jest.fn(),
amplitudes: testAmplitudes,
} as unknown as VoiceRecording;
voiceMessageRecording = new VoiceMessageRecording(
client,
voiceRecording,
);
});
it("hasRecording should return false", () => {
expect(voiceMessageRecording.hasRecording).toBe(false);
});
it("createVoiceMessageRecording should return a VoiceMessageRecording", () => {
expect(createVoiceMessageRecording(client)).toBeInstanceOf(VoiceMessageRecording);
});
it("durationSeconds should return the VoiceRecording value", () => {
expect(voiceMessageRecording.durationSeconds).toBe(durationSeconds);
});
it("contentType should return the VoiceRecording value", () => {
expect(voiceMessageRecording.contentType).toBe(contentType);
});
it.each([true, false])("isRecording should return %s from VoiceRecording", (value: boolean) => {
// @ts-ignore
voiceRecording.isRecording = value;
expect(voiceMessageRecording.isRecording).toBe(value);
});
it.each([true, false])("isSupported should return %s from VoiceRecording", (value: boolean) => {
// @ts-ignore
voiceRecording.isSupported = value;
expect(voiceMessageRecording.isSupported).toBe(value);
});
it("should return liveData from VoiceRecording", () => {
expect(voiceMessageRecording.liveData).toBe(voiceRecording.liveData);
});
it("start should forward the call to VoiceRecording.start", async () => {
await voiceMessageRecording.start();
expect(voiceRecording.start).toHaveBeenCalled();
});
it("on should forward the call to VoiceRecording", () => {
const callback = () => {};
const result = voiceMessageRecording.on("test on", callback);
expect(voiceRecording.on).toHaveBeenCalledWith("test on", callback);
expect(result).toBe(voiceMessageRecording);
});
it("off should forward the call to VoiceRecording", () => {
const callback = () => {};
const result = voiceMessageRecording.off("test off", callback);
expect(voiceRecording.off).toHaveBeenCalledWith("test off", callback);
expect(result).toBe(voiceMessageRecording);
});
it("emit should forward the call to VoiceRecording", () => {
voiceMessageRecording.emit("test emit", 42);
expect(voiceRecording.emit).toHaveBeenCalledWith("test emit", 42);
});
it("upload should raise an error", async () => {
await expect(voiceMessageRecording.upload(roomId))
.rejects
.toThrow("No recording available to upload");
});
describe("when the first data has been received", () => {
const uploadUrl = "https://example.com/content123";
const encryptedFile = {} as unknown as IEncryptedFile;
beforeEach(() => {
voiceRecording.onDataAvailable(testBuf);
});
it("contentLength should return the buffer length", () => {
expect(voiceMessageRecording.contentLength).toBe(testBuf.length);
});
it("stop should return a copy of the data buffer", async () => {
const result = await voiceMessageRecording.stop();
expect(voiceRecording.stop).toHaveBeenCalled();
expect(result).toEqual(testBuf);
});
it("hasRecording should return true", () => {
expect(voiceMessageRecording.hasRecording).toBe(true);
});
describe("upload", () => {
let uploadFileClient: MatrixClient;
let uploadFileRoomId: string;
let uploadBlob: Blob;
beforeEach(() => {
uploadFileClient = null;
uploadFileRoomId = null;
uploadBlob = null;
mocked(uploadFile).mockImplementation((
matrixClient: MatrixClient,
roomId: string,
file: File | Blob,
_progressHandler?: IUploadOpts["progressHandler"],
): IAbortablePromise<{ url?: string, file?: IEncryptedFile }> => {
uploadFileClient = matrixClient;
uploadFileRoomId = roomId;
uploadBlob = file;
// @ts-ignore
return Promise.resolve({
url: uploadUrl,
file: encryptedFile,
});
});
});
it("should upload the file and trigger the upload events", async () => {
const result = await voiceMessageRecording.upload(roomId);
expect(voiceRecording.emit).toHaveBeenNthCalledWith(1, RecordingState.Uploading);
expect(voiceRecording.emit).toHaveBeenNthCalledWith(2, RecordingState.Uploaded);
expect(result.mxc).toBe(uploadUrl);
expect(result.encrypted).toBe(encryptedFile);
expect(mocked(uploadFile)).toHaveBeenCalled();
expect(uploadFileClient).toBe(client);
expect(uploadFileRoomId).toBe(roomId);
expect(uploadBlob.type).toBe(contentType);
const blobArray = await uploadBlob.arrayBuffer();
expect(new Uint8Array(blobArray)).toEqual(testBuf);
});
it("should reuse the result", async () => {
const result1 = await voiceMessageRecording.upload(roomId);
const result2 = await voiceMessageRecording.upload(roomId);
expect(result1).toBe(result2);
});
});
describe("getPlayback", () => {
beforeEach(() => {
mocked(Playback).mockImplementation((buf: ArrayBuffer, seedWaveform) => {
expect(new Uint8Array(buf)).toEqual(testBuf);
expect(seedWaveform).toEqual(testAmplitudes);
return {} as Playback;
});
});
it("should return a Playback with the data", () => {
voiceMessageRecording.getPlayback();
expect(mocked(Playback)).toHaveBeenCalled();
});
it("should reuse the result", () => {
const playback1 = voiceMessageRecording.getPlayback();
const playback2 = voiceMessageRecording.getPlayback();
expect(playback1).toBe(playback2);
});
});
});
});

View file

@ -21,9 +21,10 @@ import { ISendEventResponse, MatrixClient, MsgType, Room } from "matrix-js-sdk/s
import { mocked } from "jest-mock"; import { mocked } from "jest-mock";
import VoiceRecordComposerTile from "../../../../src/components/views/rooms/VoiceRecordComposerTile"; import VoiceRecordComposerTile from "../../../../src/components/views/rooms/VoiceRecordComposerTile";
import { IUpload, VoiceRecording } from "../../../../src/audio/VoiceRecording"; import { VoiceRecording } from "../../../../src/audio/VoiceRecording";
import { doMaybeLocalRoomAction } from "../../../../src/utils/local-room"; import { doMaybeLocalRoomAction } from "../../../../src/utils/local-room";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import { IUpload } from "../../../../src/audio/VoiceMessageRecording";
jest.mock("../../../../src/utils/local-room", () => ({ jest.mock("../../../../src/utils/local-room", () => ({
doMaybeLocalRoomAction: jest.fn(), doMaybeLocalRoomAction: jest.fn(),

View file

@ -17,10 +17,10 @@ limitations under the License.
import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { VoiceRecording } from '../../src/audio/VoiceRecording';
import { VoiceRecordingStore } from '../../src/stores/VoiceRecordingStore'; import { VoiceRecordingStore } from '../../src/stores/VoiceRecordingStore';
import { MatrixClientPeg } from "../../src/MatrixClientPeg"; import { MatrixClientPeg } from "../../src/MatrixClientPeg";
import { flushPromises } from "../test-utils"; import { flushPromises } from "../test-utils";
import { VoiceMessageRecording } from "../../src/audio/VoiceMessageRecording";
const stubClient = {} as undefined as MatrixClient; const stubClient = {} as undefined as MatrixClient;
jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(stubClient); jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(stubClient);
@ -29,8 +29,8 @@ describe('VoiceRecordingStore', () => {
const room1Id = '!room1:server.org'; const room1Id = '!room1:server.org';
const room2Id = '!room2:server.org'; const room2Id = '!room2:server.org';
const room3Id = '!room3:server.org'; const room3Id = '!room3:server.org';
const room1Recording = { destroy: jest.fn() } as unknown as VoiceRecording; const room1Recording = { destroy: jest.fn() } as unknown as VoiceMessageRecording;
const room2Recording = { destroy: jest.fn() } as unknown as VoiceRecording; const room2Recording = { destroy: jest.fn() } as unknown as VoiceMessageRecording;
const state = { const state = {
[room1Id]: room1Recording, [room1Id]: room1Recording,
@ -63,7 +63,7 @@ describe('VoiceRecordingStore', () => {
await flushPromises(); await flushPromises();
expect(result).toBeInstanceOf(VoiceRecording); expect(result).toBeInstanceOf(VoiceMessageRecording);
expect(store.getActiveRecording(room2Id)).toEqual(result); expect(store.getActiveRecording(room2Id)).toEqual(result);
}); });
}); });

View file

@ -49,7 +49,7 @@ import MatrixClientBackedSettingsHandler from "../../src/settings/handlers/Matri
* the react context, we can get rid of this and just inject a test client * the react context, we can get rid of this and just inject a test client
* via the context instead. * via the context instead.
*/ */
export function stubClient() { export function stubClient(): MatrixClient {
const client = createTestClient(); const client = createTestClient();
// stub out the methods in MatrixClientPeg // stub out the methods in MatrixClientPeg
@ -63,6 +63,7 @@ export function stubClient() {
// fast stub function rather than a sinon stub // fast stub function rather than a sinon stub
peg.get = function() { return client; }; peg.get = function() { return client; };
MatrixClientBackedSettingsHandler.matrixClient = client; MatrixClientBackedSettingsHandler.matrixClient = client;
return client;
} }
/** /**