/* Copyright 2024 New Vector Ltd. Copyright 2022 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ import { mocked } from "jest-mock"; import { Optional } from "matrix-events-sdk"; 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"; // mock VoiceRecording because it contains all the audio APIs jest.mock("../../../../src/audio/VoiceRecording", () => ({ VoiceRecording: jest.fn().mockReturnValue({ disableMaxLength: jest.fn(), emit: jest.fn(), liveData: { onUpdate: jest.fn(), }, start: jest.fn(), stop: jest.fn(), destroy: jest.fn(), }), })); jest.mock("../../../../src/settings/SettingsStore"); 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; // 0... OpusHead const headers1 = new Uint8Array([...Array(28).fill(0), 79, 112, 117, 115, 72, 101, 97, 100]); // 0... OpusTags const headers2 = new Uint8Array([...Array(28).fill(0), 79, 112, 117, 115, 84, 97, 103, 115]); 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 simulateFirstChunk = (): void => { // send headers in wrong order and multiple times to test robustness for that voiceRecording.onDataAvailable!(headers2); voiceRecording.onDataAvailable!(headers1); 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); voiceRecording.onDataAvailable!(headers1); }; const expectOnFirstChunkRecorded = (): void => { expect(onChunkRecorded).toHaveBeenNthCalledWith(1, { buffer: concat(headers1, headers2, chunk1), length: 42, }); }; const itShouldNotEmitAChunkRecordedEvent = (): void => { it("should not emit a ChunkRecorded event", (): void => { expect(voiceRecording.emit).not.toHaveBeenCalledWith( VoiceBroadcastRecorderEvent.ChunkRecorded, expect.anything(), ); }); }; beforeEach(() => { voiceRecording = new VoiceRecording(); // @ts-ignore voiceRecording.recorderSeconds = 23; // @ts-ignore voiceRecording.contentType = contentType; 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 header from recorder has been received", () => { beforeEach(() => { voiceRecording.onDataAvailable!(headers1); }); itShouldNotEmitAChunkRecordedEvent(); }); describe("when the second header 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("and calling stop", () => { let stopPayload: Optional; beforeEach(async () => { stopPayload = await voiceBroadcastRecorder.stop(); }); it("should return the remaining chunk", () => { expect(stopPayload).toEqual({ buffer: concat(headers1, headers2, chunk1), length: 23, }); }); describe("and calling start again and receiving some data", () => { beforeEach(() => { simulateFirstChunk(); }); it("should emit the ChunkRecorded event for the first chunk", () => { expectOnFirstChunkRecorded(); }); }); }); describe("and calling stop() with recording.stop error)", () => { let stopPayload: Optional; beforeEach(async () => { mocked(voiceRecording.stop).mockRejectedValue("Error"); 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(() => { simulateFirstChunk(); // simulate a second chunk voiceRecording.onDataAvailable!(chunk2a); // send headers again to test robustness for that voiceRecording.onDataAvailable!(headers2); // add another 30 seconds for the next chunk // @ts-ignore voiceRecording.recorderSeconds = 72; voiceRecording.onDataAvailable!(chunk2b); }); it("should emit ChunkRecorded events", () => { expectOnFirstChunkRecorded(); expect(onChunkRecorded).toHaveBeenNthCalledWith(2, { buffer: concat(headers1, headers2, chunk2a, chunk2b), length: 72 - 42, // 72 (position at second chunk) - 42 (position of first chunk) }); }); }); }); });