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:
parent
03182d03be
commit
bac6e12946
19 changed files with 773 additions and 104 deletions
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue