Implement Voice Broadcast recording (#9307)

* Implement VoiceBroadcastRecording

* Implement PR feedback

* Add voice broadcast recording stores

* Refactor startNewVoiceBroadcastRecording

* Refactor VoiceBroadcastRecordingsStore to VoiceBroadcastRecording

* Rename VoiceBroadcastRecording to VoiceBroadcastRecorder

* Return remaining chunk on stop

* Extract createVoiceMessageContent

* Implement recording

* Replace dev value with config

* Fix clientInformation-test

* Refactor VoiceBroadcastRecording

* Fix VoiceBroadcastRecording types

* Re-order getter

* Mark voice_broadcast config as optional

* Merge voice-broadcast modules

* Remove underscore props

* Add Optional types

* Add return types everywhere

* Remove test casts

* Add magic comments

* Trigger CI

* Switch VoiceBroadcastRecorder to TypedEventEmitter

* Trigger CI

* Add voice broadcast chunk event content

Co-authored-by: Travis Ralston <travisr@matrix.org>
This commit is contained in:
Michael Weimann 2022-10-12 00:31:28 +02:00 committed by GitHub
parent 03182d03be
commit bac6e12946
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 773 additions and 104 deletions

View file

@ -181,6 +181,11 @@ export interface IConfigOptions {
sync_timeline_limit?: number;
dangerously_allow_unsafe_and_insecure_passwords?: boolean; // developer option
voice_broadcast?: {
// length per voice chunk in seconds
chunk_length?: number;
};
}
export interface ISsoRedirectOptions {

View file

@ -46,6 +46,9 @@ export const DEFAULTS: IConfigOptions = {
logo: require("../res/img/element-desktop-logo.svg").default,
url: "https://element.io/get-started",
},
voice_broadcast: {
chunk_length: 60 * 1000, // one minute
},
};
export default class SdkConfig {

View file

@ -203,9 +203,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
// In testing, recorder time and worker time lag by about 400ms, which is roughly the
// time needed to encode a sample/frame.
//
// Ref for recorderSeconds: https://github.com/chris-rudmin/opus-recorder#instance-fields
const recorderSeconds = this.recorder.encodedSamplePosition / 48000;
const secondsLeft = TARGET_MAX_LENGTH - recorderSeconds;
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();
@ -217,6 +215,13 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
}
};
/**
* {@link https://github.com/chris-rudmin/opus-recorder#instance-fields ref for recorderSeconds}
*/
public get recorderSeconds() {
return this.recorder.encodedSamplePosition / 48000;
}
public async start(): Promise<void> {
if (this.recording) {
throw new Error("Recording already in progress");

View file

@ -0,0 +1,141 @@
/*
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 { Optional } from "matrix-events-sdk";
import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter";
import { VoiceRecording } from "../../audio/VoiceRecording";
import SdkConfig, { DEFAULTS } from "../../SdkConfig";
import { concat } from "../../utils/arrays";
import { IDestroyable } from "../../utils/IDestroyable";
export enum VoiceBroadcastRecorderEvent {
ChunkRecorded = "chunk_recorded",
}
interface EventMap {
[VoiceBroadcastRecorderEvent.ChunkRecorded]: (chunk: ChunkRecordedPayload) => void;
}
export interface ChunkRecordedPayload {
buffer: Uint8Array;
length: number;
}
/**
* This class provides the function to seamlessly record fixed length chunks.
* Subscribe with on(VoiceBroadcastRecordingEvents.ChunkRecorded, (payload: ChunkRecordedPayload) => {})
* to retrieve chunks while recording.
*/
export class VoiceBroadcastRecorder
extends TypedEventEmitter<VoiceBroadcastRecorderEvent, EventMap>
implements IDestroyable {
private headers = new Uint8Array(0);
private chunkBuffer = new Uint8Array(0);
private previousChunkEndTimePosition = 0;
private pagesFromRecorderCount = 0;
public constructor(
private voiceRecording: VoiceRecording,
public readonly targetChunkLength: number,
) {
super();
this.voiceRecording.onDataAvailable = this.onDataAvailable;
}
public async start(): Promise<void> {
return this.voiceRecording.start();
}
/**
* Stops the recording and returns the remaining chunk (if any).
*/
public async stop(): Promise<Optional<ChunkRecordedPayload>> {
await this.voiceRecording.stop();
return this.extractChunk();
}
public get contentType(): string {
return this.voiceRecording.contentType;
}
private get chunkLength(): number {
return this.voiceRecording.recorderSeconds - this.previousChunkEndTimePosition;
}
private onDataAvailable = (data: ArrayBuffer): void => {
const dataArray = new Uint8Array(data);
this.pagesFromRecorderCount++;
if (this.pagesFromRecorderCount <= 2) {
// first two pages contain the headers
this.headers = concat(this.headers, dataArray);
return;
}
this.handleData(dataArray);
};
private handleData(data: Uint8Array): void {
this.chunkBuffer = concat(this.chunkBuffer, data);
this.emitChunkIfTargetLengthReached();
}
private emitChunkIfTargetLengthReached(): void {
if (this.chunkLength >= this.targetChunkLength) {
this.emitAndResetChunk();
}
}
/**
* Extracts the current chunk and resets the buffer.
*/
private extractChunk(): Optional<ChunkRecordedPayload> {
if (this.chunkBuffer.length === 0) {
return null;
}
const currentRecorderTime = this.voiceRecording.recorderSeconds;
const payload: ChunkRecordedPayload = {
buffer: concat(this.headers, this.chunkBuffer),
length: this.chunkLength,
};
this.chunkBuffer = new Uint8Array(0);
this.previousChunkEndTimePosition = currentRecorderTime;
return payload;
}
private emitAndResetChunk(): void {
if (this.chunkBuffer.length === 0) {
return;
}
this.emit(
VoiceBroadcastRecorderEvent.ChunkRecorded,
this.extractChunk(),
);
}
public destroy(): void {
this.removeAllListeners();
this.voiceRecording.destroy();
}
}
export const createVoiceBroadcastRecorder = (): VoiceBroadcastRecorder => {
const targetChunkLength = SdkConfig.get("voice_broadcast")?.chunk_length || DEFAULTS.voice_broadcast!.chunk_length;
return new VoiceBroadcastRecorder(new VoiceRecording(), targetChunkLength);
};

View file

@ -31,7 +31,7 @@ export const VoiceBroadcastBody: React.FC<IBodyProps> = ({ mxEvent }) => {
const client = MatrixClientPeg.get();
const room = client.getRoom(mxEvent.getRoomId());
const recording = VoiceBroadcastRecordingsStore.instance().getByInfoEvent(mxEvent, client);
const [recordingState, setRecordingState] = useState(recording.state);
const [recordingState, setRecordingState] = useState(recording.getState());
useTypedEventEmitter(
recording,

View file

@ -1,19 +0,0 @@
/*
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.
*/
export * from "./atoms/LiveBadge";
export * from "./molecules/VoiceBroadcastRecordingBody";
export * from "./VoiceBroadcastBody";

View file

@ -21,10 +21,14 @@ limitations under the License.
import { RelationType } from "matrix-js-sdk/src/matrix";
export * from "./components";
export * from "./models";
export * from "./utils";
export * from "./stores";
export * from "./audio/VoiceBroadcastRecorder";
export * from "./components/VoiceBroadcastBody";
export * from "./components/atoms/LiveBadge";
export * from "./components/molecules/VoiceBroadcastRecordingBody";
export * from "./models/VoiceBroadcastRecording";
export * from "./stores/VoiceBroadcastRecordingsStore";
export * from "./utils/shouldDisplayAsVoiceBroadcastTile";
export * from "./utils/startNewVoiceBroadcastRecording";
export const VoiceBroadcastInfoEventType = "io.element.voice_broadcast_info";

View file

@ -14,10 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixClient, MatrixEvent, RelationType } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { IAbortablePromise, MatrixClient, MatrixEvent, RelationType } from "matrix-js-sdk/src/matrix";
import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter";
import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from "..";
import {
ChunkRecordedPayload,
createVoiceBroadcastRecorder,
VoiceBroadcastInfoEventType,
VoiceBroadcastInfoState,
VoiceBroadcastRecorder,
VoiceBroadcastRecorderEvent,
} from "..";
import { uploadFile } from "../../ContentMessages";
import { IEncryptedFile } from "../../customisations/models/IMediaEventContent";
import { createVoiceMessageContent } from "../../utils/createVoiceMessageContent";
import { IDestroyable } from "../../utils/IDestroyable";
export enum VoiceBroadcastRecordingEvent {
StateChanged = "liveness_changed",
@ -27,8 +39,12 @@ interface EventMap {
[VoiceBroadcastRecordingEvent.StateChanged]: (state: VoiceBroadcastInfoState) => void;
}
export class VoiceBroadcastRecording extends TypedEventEmitter<VoiceBroadcastRecordingEvent, EventMap> {
private _state: VoiceBroadcastInfoState;
export class VoiceBroadcastRecording
extends TypedEventEmitter<VoiceBroadcastRecordingEvent, EventMap>
implements IDestroyable {
private state: VoiceBroadcastInfoState;
private recorder: VoiceBroadcastRecorder;
private sequence = 1;
public constructor(
public readonly infoEvent: MatrixEvent,
@ -43,21 +59,89 @@ export class VoiceBroadcastRecording extends TypedEventEmitter<VoiceBroadcastRec
VoiceBroadcastInfoEventType,
);
const relatedEvents = relations?.getRelations();
this._state = !relatedEvents?.find((event: MatrixEvent) => {
this.state = !relatedEvents?.find((event: MatrixEvent) => {
return event.getContent()?.state === VoiceBroadcastInfoState.Stopped;
}) ? VoiceBroadcastInfoState.Started : VoiceBroadcastInfoState.Stopped;
// TODO Michael W: add listening for updates
}
public async start(): Promise<void> {
return this.getRecorder().start();
}
public async stop(): Promise<void> {
this.setState(VoiceBroadcastInfoState.Stopped);
await this.stopRecorder();
await this.sendStoppedStateEvent();
}
public getState(): VoiceBroadcastInfoState {
return this.state;
}
private getRecorder(): VoiceBroadcastRecorder {
if (!this.recorder) {
this.recorder = createVoiceBroadcastRecorder();
this.recorder.on(VoiceBroadcastRecorderEvent.ChunkRecorded, this.onChunkRecorded);
}
return this.recorder;
}
public destroy(): void {
if (this.recorder) {
this.recorder.off(VoiceBroadcastRecorderEvent.ChunkRecorded, this.onChunkRecorded);
this.recorder.stop();
}
this.removeAllListeners();
}
private setState(state: VoiceBroadcastInfoState): void {
this._state = state;
this.state = state;
this.emit(VoiceBroadcastRecordingEvent.StateChanged, this.state);
}
public async stop() {
this.setState(VoiceBroadcastInfoState.Stopped);
// TODO Michael W: add error handling
private onChunkRecorded = async (chunk: ChunkRecordedPayload): Promise<void> => {
const { url, file } = await this.uploadFile(chunk);
await this.sendVoiceMessage(chunk, url, file);
};
private uploadFile(chunk: ChunkRecordedPayload): IAbortablePromise<{ url?: string, file?: IEncryptedFile }> {
return uploadFile(
this.client,
this.infoEvent.getRoomId(),
new Blob(
[chunk.buffer],
{
type: this.getRecorder().contentType,
},
),
);
}
private async sendVoiceMessage(chunk: ChunkRecordedPayload, url: string, file: IEncryptedFile): Promise<void> {
const content = createVoiceMessageContent(
url,
this.getRecorder().contentType,
Math.round(chunk.length * 1000),
chunk.buffer.length,
file,
);
content["m.relates_to"] = {
rel_type: RelationType.Reference,
event_id: this.infoEvent.getId(),
};
content["io.element.voice_broadcast_chunk"] = {
sequence: this.sequence++,
};
await this.client.sendMessage(this.infoEvent.getRoomId(), content);
}
private async sendStoppedStateEvent(): Promise<void> {
// TODO Michael W: add error handling for state event
await this.client.sendStateEvent(
this.infoEvent.getRoomId(),
VoiceBroadcastInfoEventType,
@ -72,7 +156,18 @@ export class VoiceBroadcastRecording extends TypedEventEmitter<VoiceBroadcastRec
);
}
public get state(): VoiceBroadcastInfoState {
return this._state;
private async stopRecorder(): Promise<void> {
if (!this.recorder) {
return;
}
try {
const lastChunk = await this.recorder.stop();
if (lastChunk) {
await this.onChunkRecorded(lastChunk);
}
} catch (err) {
logger.warn("error stopping voice broadcast recorder", err);
}
}
}

View file

@ -1,17 +0,0 @@
/*
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.
*/
export * from "./VoiceBroadcastRecording";

View file

@ -31,7 +31,7 @@ interface EventMap {
* This store provides access to the current and specific Voice Broadcast recordings.
*/
export class VoiceBroadcastRecordingsStore extends TypedEventEmitter<VoiceBroadcastRecordingsStoreEvent, EventMap> {
private _current: VoiceBroadcastRecording | null;
private current: VoiceBroadcastRecording | null;
private recordings = new Map<string, VoiceBroadcastRecording>();
public constructor() {
@ -39,15 +39,15 @@ export class VoiceBroadcastRecordingsStore extends TypedEventEmitter<VoiceBroadc
}
public setCurrent(current: VoiceBroadcastRecording): void {
if (this._current === current) return;
if (this.current === current) return;
this._current = current;
this.current = current;
this.recordings.set(current.infoEvent.getId(), current);
this.emit(VoiceBroadcastRecordingsStoreEvent.CurrentChanged, current);
}
public get current(): VoiceBroadcastRecording {
return this._current;
public getCurrent(): VoiceBroadcastRecording {
return this.current;
}
public getByInfoEvent(infoEvent: MatrixEvent, client: MatrixClient): VoiceBroadcastRecording {
@ -60,12 +60,12 @@ export class VoiceBroadcastRecordingsStore extends TypedEventEmitter<VoiceBroadc
return this.recordings.get(infoEventId);
}
public static readonly _instance = new VoiceBroadcastRecordingsStore();
private static readonly cachedInstance = new VoiceBroadcastRecordingsStore();
/**
* TODO Michael W: replace when https://github.com/matrix-org/matrix-react-sdk/pull/9293 has been merged
*/
public static instance() {
return VoiceBroadcastRecordingsStore._instance;
public static instance(): VoiceBroadcastRecordingsStore {
return VoiceBroadcastRecordingsStore.cachedInstance;
}
}

View file

@ -1,17 +0,0 @@
/*
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.
*/
export * from "./VoiceBroadcastRecordingsStore";

View file

@ -1,18 +0,0 @@
/*
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.
*/
export * from "./shouldDisplayAsVoiceBroadcastTile";
export * from "./startNewVoiceBroadcastRecording";

View file

@ -53,6 +53,7 @@ export const startNewVoiceBroadcastRecording = async (
client,
);
recordingsStore.setCurrent(recording);
recording.start();
resolve(recording);
}
};

41
test/SdkConfig-test.ts Normal file
View file

@ -0,0 +1,41 @@
/*
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 SdkConfig, { DEFAULTS } from "../src/SdkConfig";
describe("SdkConfig", () => {
describe("with default values", () => {
it("should return the default config", () => {
expect(SdkConfig.get()).toEqual(DEFAULTS);
});
});
describe("with custom values", () => {
beforeEach(() => {
SdkConfig.put({
voice_broadcast: {
chunk_length: 1337,
},
});
});
it("should return the custom config", () => {
const customConfig = JSON.parse(JSON.stringify(DEFAULTS));
customConfig.voice_broadcast.chunk_length = 1337;
expect(SdkConfig.get()).toEqual(customConfig);
});
});
});

View file

@ -0,0 +1,209 @@
/*
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 { VoiceRecording } from "../../../src/audio/VoiceRecording";
import SdkConfig from "../../../src/SdkConfig";
import { concat } from "../../../src/utils/arrays";
import {
ChunkRecordedPayload,
createVoiceBroadcastRecorder,
VoiceBroadcastRecorder,
VoiceBroadcastRecorderEvent,
} from "../../../src/voice-broadcast";
describe("VoiceBroadcastRecorder", () => {
describe("createVoiceBroadcastRecorder", () => {
beforeEach(() => {
jest.spyOn(SdkConfig, "get").mockImplementation((key: string) => {
if (key === "voice_broadcast") {
return {
chunk_length: 1337,
};
}
});
});
afterEach(() => {
mocked(SdkConfig.get).mockRestore();
});
it("should return a VoiceBroadcastRecorder instance with targetChunkLength from config", () => {
const voiceBroadcastRecorder = createVoiceBroadcastRecorder();
expect(voiceBroadcastRecorder).toBeInstanceOf(VoiceBroadcastRecorder);
expect(voiceBroadcastRecorder.targetChunkLength).toBe(1337);
});
});
describe("instance", () => {
const chunkLength = 30;
const headers1 = new Uint8Array([1, 2]);
const headers2 = new Uint8Array([3, 4]);
const chunk1 = new Uint8Array([5, 6]);
const chunk2a = new Uint8Array([7, 8]);
const chunk2b = new Uint8Array([9, 10]);
const contentType = "test content type";
let voiceRecording: VoiceRecording;
let voiceBroadcastRecorder: VoiceBroadcastRecorder;
let onChunkRecorded: (chunk: ChunkRecordedPayload) => void;
const itShouldNotEmitAChunkRecordedEvent = () => {
it("should not emit a ChunkRecorded event", () => {
expect(voiceRecording.emit).not.toHaveBeenCalledWith(
VoiceBroadcastRecorderEvent.ChunkRecorded,
expect.anything(),
);
});
};
beforeEach(() => {
voiceRecording = {
contentType,
start: jest.fn().mockResolvedValue(undefined),
stop: jest.fn().mockResolvedValue(undefined),
on: jest.fn(),
off: jest.fn(),
emit: jest.fn(),
destroy: jest.fn(),
recorderSeconds: 23,
} as unknown as VoiceRecording;
voiceBroadcastRecorder = new VoiceBroadcastRecorder(voiceRecording, chunkLength);
jest.spyOn(voiceBroadcastRecorder, "removeAllListeners");
onChunkRecorded = jest.fn();
voiceBroadcastRecorder.on(VoiceBroadcastRecorderEvent.ChunkRecorded, onChunkRecorded);
});
afterEach(() => {
voiceBroadcastRecorder.destroy();
});
it("start should forward the call to VoiceRecording.start", async () => {
await voiceBroadcastRecorder.start();
expect(voiceRecording.start).toHaveBeenCalled();
});
describe("stop", () => {
beforeEach(async () => {
await voiceBroadcastRecorder.stop();
});
it("should forward the call to VoiceRecording.stop", async () => {
expect(voiceRecording.stop).toHaveBeenCalled();
});
itShouldNotEmitAChunkRecordedEvent();
});
describe("when calling destroy", () => {
beforeEach(() => {
voiceBroadcastRecorder.destroy();
});
it("should call VoiceRecording.destroy", () => {
expect(voiceRecording.destroy).toHaveBeenCalled();
});
it("should remove all listeners", () => {
expect(voiceBroadcastRecorder.removeAllListeners).toHaveBeenCalled();
});
});
it("contentType should return the value from VoiceRecording", () => {
expect(voiceBroadcastRecorder.contentType).toBe(contentType);
});
describe("when the first page from recorder has been received", () => {
beforeEach(() => {
voiceRecording.onDataAvailable(headers1);
});
itShouldNotEmitAChunkRecordedEvent();
});
describe("when a second page from recorder has been received", () => {
beforeEach(() => {
voiceRecording.onDataAvailable(headers1);
voiceRecording.onDataAvailable(headers2);
});
itShouldNotEmitAChunkRecordedEvent();
});
describe("when a third page from recorder has been received", () => {
beforeEach(() => {
voiceRecording.onDataAvailable(headers1);
voiceRecording.onDataAvailable(headers2);
voiceRecording.onDataAvailable(chunk1);
});
itShouldNotEmitAChunkRecordedEvent();
describe("stop", () => {
let stopPayload: ChunkRecordedPayload;
beforeEach(async () => {
stopPayload = await voiceBroadcastRecorder.stop();
});
it("should return the remaining chunk", () => {
expect(stopPayload).toEqual({
buffer: concat(headers1, headers2, chunk1),
length: 23,
});
});
});
});
describe("when some chunks have been received", () => {
beforeEach(() => {
// simulate first chunk
voiceRecording.onDataAvailable(headers1);
voiceRecording.onDataAvailable(headers2);
// set recorder seconds to something greater than the test chunk length of 30
// @ts-ignore
voiceRecording.recorderSeconds = 42;
voiceRecording.onDataAvailable(chunk1);
// simulate a second chunk
voiceRecording.onDataAvailable(chunk2a);
// add another 30 seconds for the next chunk
// @ts-ignore
voiceRecording.recorderSeconds = 72;
voiceRecording.onDataAvailable(chunk2b);
});
it("should emit ChunkRecorded events", () => {
expect(onChunkRecorded).toHaveBeenNthCalledWith(
1,
{
buffer: concat(headers1, headers2, chunk1),
length: 42,
},
);
expect(onChunkRecorded).toHaveBeenNthCalledWith(
2,
{
buffer: concat(headers1, headers2, chunk2a, chunk2b),
length: 72 - 42, // 72 (position at second chunk) - 42 (position of first chunk)
},
);
});
});
});
});

View file

@ -155,7 +155,7 @@ describe("VoiceBroadcastBody", () => {
itShouldRenderANonLiveVoiceBroadcast();
it("should call stop on the recording", () => {
expect(recording.state).toBe(VoiceBroadcastInfoState.Stopped);
expect(recording.getState()).toBe(VoiceBroadcastInfoState.Stopped);
expect(onRecordingStateChanged).toHaveBeenCalledWith(VoiceBroadcastInfoState.Stopped);
});
});

View file

@ -15,25 +15,57 @@ limitations under the License.
*/
import { mocked } from "jest-mock";
import { EventTimelineSet, EventType, MatrixClient, MatrixEvent, RelationType, Room } from "matrix-js-sdk/src/matrix";
import {
EventTimelineSet,
EventType,
MatrixClient,
MatrixEvent,
MsgType,
RelationType,
Room,
} from "matrix-js-sdk/src/matrix";
import { Relations } from "matrix-js-sdk/src/models/relations";
import { uploadFile } from "../../../src/ContentMessages";
import { IEncryptedFile } from "../../../src/customisations/models/IMediaEventContent";
import { createVoiceMessageContent } from "../../../src/utils/createVoiceMessageContent";
import {
ChunkRecordedPayload,
createVoiceBroadcastRecorder,
VoiceBroadcastInfoEventContent,
VoiceBroadcastInfoEventType,
VoiceBroadcastInfoState,
VoiceBroadcastRecorder,
VoiceBroadcastRecorderEvent,
VoiceBroadcastRecording,
VoiceBroadcastRecordingEvent,
} from "../../../src/voice-broadcast";
import { mkEvent, mkStubRoom, stubClient } from "../../test-utils";
jest.mock("../../../src/voice-broadcast/audio/VoiceBroadcastRecorder", () => ({
...jest.requireActual("../../../src/voice-broadcast/audio/VoiceBroadcastRecorder") as object,
createVoiceBroadcastRecorder: jest.fn(),
}));
jest.mock("../../../src/ContentMessages", () => ({
uploadFile: jest.fn(),
}));
jest.mock("../../../src/utils/createVoiceMessageContent", () => ({
createVoiceMessageContent: jest.fn(),
}));
describe("VoiceBroadcastRecording", () => {
const roomId = "!room:example.com";
const uploadedUrl = "mxc://example.com/vb";
const uploadedFile = { file: true } as unknown as IEncryptedFile;
let room: Room;
let client: MatrixClient;
let infoEvent: MatrixEvent;
let voiceBroadcastRecording: VoiceBroadcastRecording;
let onStateChanged: (state: VoiceBroadcastInfoState) => void;
let voiceBroadcastRecorder: VoiceBroadcastRecorder;
let onChunkRecorded: (chunk: ChunkRecordedPayload) => Promise<void>;
const mkVoiceBroadcastInfoEvent = (content: VoiceBroadcastInfoEventContent) => {
return mkEvent({
@ -48,6 +80,7 @@ describe("VoiceBroadcastRecording", () => {
const setUpVoiceBroadcastRecording = () => {
voiceBroadcastRecording = new VoiceBroadcastRecording(infoEvent, client);
voiceBroadcastRecording.on(VoiceBroadcastRecordingEvent.StateChanged, onStateChanged);
jest.spyOn(voiceBroadcastRecording, "removeAllListeners");
};
beforeEach(() => {
@ -59,6 +92,65 @@ describe("VoiceBroadcastRecording", () => {
}
});
onStateChanged = jest.fn();
voiceBroadcastRecorder = {
contentType: "audio/ogg",
on: jest.fn(),
off: jest.fn(),
start: jest.fn(),
stop: jest.fn(),
} as unknown as VoiceBroadcastRecorder;
mocked(createVoiceBroadcastRecorder).mockReturnValue(voiceBroadcastRecorder);
onChunkRecorded = jest.fn();
mocked(voiceBroadcastRecorder.on).mockImplementation(
(event: VoiceBroadcastRecorderEvent, listener: any): VoiceBroadcastRecorder => {
if (event === VoiceBroadcastRecorderEvent.ChunkRecorded) {
onChunkRecorded = listener;
}
return voiceBroadcastRecorder;
},
);
mocked(uploadFile).mockResolvedValue({
url: uploadedUrl,
file: uploadedFile,
});
mocked(createVoiceMessageContent).mockImplementation((
mxc: string,
mimetype: string,
duration: number,
size: number,
file?: IEncryptedFile,
waveform?: number[],
) => {
return {
body: "Voice message",
msgtype: MsgType.Audio,
url: mxc,
file,
info: {
duration,
mimetype,
size,
},
["org.matrix.msc1767.text"]: "Voice message",
["org.matrix.msc1767.file"]: {
url: mxc,
file,
name: "Voice message.ogg",
mimetype,
size,
},
["org.matrix.msc1767.audio"]: {
duration,
// https://github.com/matrix-org/matrix-doc/pull/3246
waveform,
},
["org.matrix.msc3245.voice"]: {}, // No content, this is a rendering hint
};
});
});
afterEach(() => {
@ -74,7 +166,7 @@ describe("VoiceBroadcastRecording", () => {
});
it("should be in Started state", () => {
expect(voiceBroadcastRecording.state).toBe(VoiceBroadcastInfoState.Started);
expect(voiceBroadcastRecording.getState()).toBe(VoiceBroadcastInfoState.Started);
});
describe("and calling stop()", () => {
@ -98,13 +190,155 @@ describe("VoiceBroadcastRecording", () => {
});
it("should be in state stopped", () => {
expect(voiceBroadcastRecording.state).toBe(VoiceBroadcastInfoState.Stopped);
expect(voiceBroadcastRecording.getState()).toBe(VoiceBroadcastInfoState.Stopped);
});
it("should emit a stopped state changed event", () => {
expect(onStateChanged).toHaveBeenCalledWith(VoiceBroadcastInfoState.Stopped);
});
});
describe("and calling start", () => {
beforeEach(async () => {
await voiceBroadcastRecording.start();
});
it("should start the recorder", () => {
expect(voiceBroadcastRecorder.start).toHaveBeenCalled();
});
describe("and a chunk has been recorded", () => {
beforeEach(async () => {
await onChunkRecorded({
buffer: new Uint8Array([1, 2, 3]),
length: 23,
});
});
it("should send a voice message", () => {
expect(uploadFile).toHaveBeenCalledWith(
client,
roomId,
new Blob([new Uint8Array([1, 2, 3])], { type: voiceBroadcastRecorder.contentType }),
);
expect(mocked(client.sendMessage)).toHaveBeenCalledWith(
roomId,
{
body: "Voice message",
file: {
file: true,
},
info: {
duration: 23000,
mimetype: "audio/ogg",
size: 3,
},
["m.relates_to"]: {
event_id: infoEvent.getId(),
rel_type: "m.reference",
},
msgtype: "m.audio",
["org.matrix.msc1767.audio"]: {
duration: 23000,
waveform: undefined,
},
["org.matrix.msc1767.file"]: {
file: {
file: true,
},
mimetype: "audio/ogg",
name: "Voice message.ogg",
size: 3,
url: "mxc://example.com/vb",
},
["org.matrix.msc1767.text"]: "Voice message",
["org.matrix.msc3245.voice"]: {},
url: "mxc://example.com/vb",
["io.element.voice_broadcast_chunk"]: {
sequence: 1,
},
},
);
});
});
describe("and calling stop", () => {
beforeEach(async () => {
await onChunkRecorded({
buffer: new Uint8Array([1, 2, 3]),
length: 23,
});
mocked(voiceBroadcastRecorder.stop).mockResolvedValue({
buffer: new Uint8Array([4, 5, 6]),
length: 42,
});
await voiceBroadcastRecording.stop();
});
it("should send the last chunk", () => {
expect(uploadFile).toHaveBeenCalledWith(
client,
roomId,
new Blob([new Uint8Array([4, 5, 6])], { type: voiceBroadcastRecorder.contentType }),
);
expect(mocked(client.sendMessage)).toHaveBeenCalledWith(
roomId,
{
body: "Voice message",
file: {
file: true,
},
info: {
duration: 42000,
mimetype: "audio/ogg",
size: 3,
},
["m.relates_to"]: {
event_id: infoEvent.getId(),
rel_type: "m.reference",
},
msgtype: "m.audio",
["org.matrix.msc1767.audio"]: {
duration: 42000,
waveform: undefined,
},
["org.matrix.msc1767.file"]: {
file: {
file: true,
},
mimetype: "audio/ogg",
name: "Voice message.ogg",
size: 3,
url: "mxc://example.com/vb",
},
["org.matrix.msc1767.text"]: "Voice message",
["org.matrix.msc3245.voice"]: {},
url: "mxc://example.com/vb",
["io.element.voice_broadcast_chunk"]: {
sequence: 2,
},
},
);
});
});
describe("and calling destroy", () => {
beforeEach(() => {
voiceBroadcastRecording.destroy();
});
it("should stop the recorder and remove all listeners", () => {
expect(mocked(voiceBroadcastRecorder.stop)).toHaveBeenCalled();
expect(mocked(voiceBroadcastRecorder.off)).toHaveBeenCalledWith(
VoiceBroadcastRecorderEvent.ChunkRecorded,
onChunkRecorded,
);
expect(mocked(voiceBroadcastRecording.removeAllListeners)).toHaveBeenCalled();
});
});
});
});
describe("when created for a Voice Broadcast Info with a Stopped relation", () => {
@ -152,7 +386,7 @@ describe("VoiceBroadcastRecording", () => {
});
it("should be in Stopped state", () => {
expect(voiceBroadcastRecording.state).toBe(VoiceBroadcastInfoState.Stopped);
expect(voiceBroadcastRecording.getState()).toBe(VoiceBroadcastInfoState.Stopped);
});
});
});

View file

@ -76,7 +76,7 @@ describe("VoiceBroadcastRecordingsStore", () => {
});
it("should return it as current", () => {
expect(recordings.current).toBe(recording);
expect(recordings.getCurrent()).toBe(recording);
});
it("should return it by id", () => {

View file

@ -109,6 +109,7 @@ describe("startNewVoiceBroadcastRecording", () => {
return {
infoEvent,
client,
start: jest.fn(),
} as unknown as VoiceBroadcastRecording;
});
});
@ -120,6 +121,7 @@ describe("startNewVoiceBroadcastRecording", () => {
expect(ok).toBe(true);
expect(mocked(room.off)).toHaveBeenCalledWith(RoomStateEvent.Events, roomOnStateEventsCallback);
expect(recording.infoEvent).toBe(infoEvent);
expect(recording.start).toHaveBeenCalled();
done();
});