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

@ -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);
});
});
});