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>pull/28788/head^2
parent
03182d03be
commit
bac6e12946
|
@ -181,6 +181,11 @@ export interface IConfigOptions {
|
||||||
|
|
||||||
sync_timeline_limit?: number;
|
sync_timeline_limit?: number;
|
||||||
dangerously_allow_unsafe_and_insecure_passwords?: boolean; // developer option
|
dangerously_allow_unsafe_and_insecure_passwords?: boolean; // developer option
|
||||||
|
|
||||||
|
voice_broadcast?: {
|
||||||
|
// length per voice chunk in seconds
|
||||||
|
chunk_length?: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISsoRedirectOptions {
|
export interface ISsoRedirectOptions {
|
||||||
|
|
|
@ -46,6 +46,9 @@ export const DEFAULTS: IConfigOptions = {
|
||||||
logo: require("../res/img/element-desktop-logo.svg").default,
|
logo: require("../res/img/element-desktop-logo.svg").default,
|
||||||
url: "https://element.io/get-started",
|
url: "https://element.io/get-started",
|
||||||
},
|
},
|
||||||
|
voice_broadcast: {
|
||||||
|
chunk_length: 60 * 1000, // one minute
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default class SdkConfig {
|
export default class SdkConfig {
|
||||||
|
|
|
@ -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
|
// In testing, recorder time and worker time lag by about 400ms, which is roughly the
|
||||||
// time needed to encode a sample/frame.
|
// time needed to encode a sample/frame.
|
||||||
//
|
//
|
||||||
// Ref for recorderSeconds: https://github.com/chris-rudmin/opus-recorder#instance-fields
|
const secondsLeft = TARGET_MAX_LENGTH - this.recorderSeconds;
|
||||||
const recorderSeconds = this.recorder.encodedSamplePosition / 48000;
|
|
||||||
const secondsLeft = TARGET_MAX_LENGTH - recorderSeconds;
|
|
||||||
if (secondsLeft < 0) { // go over to make sure we definitely capture that last frame
|
if (secondsLeft < 0) { // go over to make sure we definitely capture that last frame
|
||||||
// noinspection JSIgnoredPromiseFromCall - we aren't concerned with it overlapping
|
// noinspection JSIgnoredPromiseFromCall - we aren't concerned with it overlapping
|
||||||
this.stop();
|
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> {
|
public async start(): Promise<void> {
|
||||||
if (this.recording) {
|
if (this.recording) {
|
||||||
throw new Error("Recording already in progress");
|
throw new Error("Recording already in progress");
|
||||||
|
|
|
@ -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);
|
||||||
|
};
|
|
@ -31,7 +31,7 @@ export const VoiceBroadcastBody: React.FC<IBodyProps> = ({ mxEvent }) => {
|
||||||
const client = MatrixClientPeg.get();
|
const client = MatrixClientPeg.get();
|
||||||
const room = client.getRoom(mxEvent.getRoomId());
|
const room = client.getRoom(mxEvent.getRoomId());
|
||||||
const recording = VoiceBroadcastRecordingsStore.instance().getByInfoEvent(mxEvent, client);
|
const recording = VoiceBroadcastRecordingsStore.instance().getByInfoEvent(mxEvent, client);
|
||||||
const [recordingState, setRecordingState] = useState(recording.state);
|
const [recordingState, setRecordingState] = useState(recording.getState());
|
||||||
|
|
||||||
useTypedEventEmitter(
|
useTypedEventEmitter(
|
||||||
recording,
|
recording,
|
||||||
|
|
|
@ -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";
|
|
|
@ -21,10 +21,14 @@ limitations under the License.
|
||||||
|
|
||||||
import { RelationType } from "matrix-js-sdk/src/matrix";
|
import { RelationType } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
export * from "./components";
|
export * from "./audio/VoiceBroadcastRecorder";
|
||||||
export * from "./models";
|
export * from "./components/VoiceBroadcastBody";
|
||||||
export * from "./utils";
|
export * from "./components/atoms/LiveBadge";
|
||||||
export * from "./stores";
|
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";
|
export const VoiceBroadcastInfoEventType = "io.element.voice_broadcast_info";
|
||||||
|
|
||||||
|
|
|
@ -14,10 +14,22 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
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 { 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 {
|
export enum VoiceBroadcastRecordingEvent {
|
||||||
StateChanged = "liveness_changed",
|
StateChanged = "liveness_changed",
|
||||||
|
@ -27,8 +39,12 @@ interface EventMap {
|
||||||
[VoiceBroadcastRecordingEvent.StateChanged]: (state: VoiceBroadcastInfoState) => void;
|
[VoiceBroadcastRecordingEvent.StateChanged]: (state: VoiceBroadcastInfoState) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class VoiceBroadcastRecording extends TypedEventEmitter<VoiceBroadcastRecordingEvent, EventMap> {
|
export class VoiceBroadcastRecording
|
||||||
private _state: VoiceBroadcastInfoState;
|
extends TypedEventEmitter<VoiceBroadcastRecordingEvent, EventMap>
|
||||||
|
implements IDestroyable {
|
||||||
|
private state: VoiceBroadcastInfoState;
|
||||||
|
private recorder: VoiceBroadcastRecorder;
|
||||||
|
private sequence = 1;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
public readonly infoEvent: MatrixEvent,
|
public readonly infoEvent: MatrixEvent,
|
||||||
|
@ -43,21 +59,89 @@ export class VoiceBroadcastRecording extends TypedEventEmitter<VoiceBroadcastRec
|
||||||
VoiceBroadcastInfoEventType,
|
VoiceBroadcastInfoEventType,
|
||||||
);
|
);
|
||||||
const relatedEvents = relations?.getRelations();
|
const relatedEvents = relations?.getRelations();
|
||||||
this._state = !relatedEvents?.find((event: MatrixEvent) => {
|
this.state = !relatedEvents?.find((event: MatrixEvent) => {
|
||||||
return event.getContent()?.state === VoiceBroadcastInfoState.Stopped;
|
return event.getContent()?.state === VoiceBroadcastInfoState.Stopped;
|
||||||
}) ? VoiceBroadcastInfoState.Started : VoiceBroadcastInfoState.Stopped;
|
}) ? VoiceBroadcastInfoState.Started : VoiceBroadcastInfoState.Stopped;
|
||||||
|
|
||||||
// TODO Michael W: add listening for updates
|
// 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 {
|
private setState(state: VoiceBroadcastInfoState): void {
|
||||||
this._state = state;
|
this.state = state;
|
||||||
this.emit(VoiceBroadcastRecordingEvent.StateChanged, this.state);
|
this.emit(VoiceBroadcastRecordingEvent.StateChanged, this.state);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async stop() {
|
private onChunkRecorded = async (chunk: ChunkRecordedPayload): Promise<void> => {
|
||||||
this.setState(VoiceBroadcastInfoState.Stopped);
|
const { url, file } = await this.uploadFile(chunk);
|
||||||
// TODO Michael W: add error handling
|
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(
|
await this.client.sendStateEvent(
|
||||||
this.infoEvent.getRoomId(),
|
this.infoEvent.getRoomId(),
|
||||||
VoiceBroadcastInfoEventType,
|
VoiceBroadcastInfoEventType,
|
||||||
|
@ -72,7 +156,18 @@ export class VoiceBroadcastRecording extends TypedEventEmitter<VoiceBroadcastRec
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public get state(): VoiceBroadcastInfoState {
|
private async stopRecorder(): Promise<void> {
|
||||||
return this._state;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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";
|
|
|
@ -31,7 +31,7 @@ interface EventMap {
|
||||||
* This store provides access to the current and specific Voice Broadcast recordings.
|
* This store provides access to the current and specific Voice Broadcast recordings.
|
||||||
*/
|
*/
|
||||||
export class VoiceBroadcastRecordingsStore extends TypedEventEmitter<VoiceBroadcastRecordingsStoreEvent, EventMap> {
|
export class VoiceBroadcastRecordingsStore extends TypedEventEmitter<VoiceBroadcastRecordingsStoreEvent, EventMap> {
|
||||||
private _current: VoiceBroadcastRecording | null;
|
private current: VoiceBroadcastRecording | null;
|
||||||
private recordings = new Map<string, VoiceBroadcastRecording>();
|
private recordings = new Map<string, VoiceBroadcastRecording>();
|
||||||
|
|
||||||
public constructor() {
|
public constructor() {
|
||||||
|
@ -39,15 +39,15 @@ export class VoiceBroadcastRecordingsStore extends TypedEventEmitter<VoiceBroadc
|
||||||
}
|
}
|
||||||
|
|
||||||
public setCurrent(current: VoiceBroadcastRecording): void {
|
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.recordings.set(current.infoEvent.getId(), current);
|
||||||
this.emit(VoiceBroadcastRecordingsStoreEvent.CurrentChanged, current);
|
this.emit(VoiceBroadcastRecordingsStoreEvent.CurrentChanged, current);
|
||||||
}
|
}
|
||||||
|
|
||||||
public get current(): VoiceBroadcastRecording {
|
public getCurrent(): VoiceBroadcastRecording {
|
||||||
return this._current;
|
return this.current;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getByInfoEvent(infoEvent: MatrixEvent, client: MatrixClient): VoiceBroadcastRecording {
|
public getByInfoEvent(infoEvent: MatrixEvent, client: MatrixClient): VoiceBroadcastRecording {
|
||||||
|
@ -60,12 +60,12 @@ export class VoiceBroadcastRecordingsStore extends TypedEventEmitter<VoiceBroadc
|
||||||
return this.recordings.get(infoEventId);
|
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
|
* TODO Michael W: replace when https://github.com/matrix-org/matrix-react-sdk/pull/9293 has been merged
|
||||||
*/
|
*/
|
||||||
public static instance() {
|
public static instance(): VoiceBroadcastRecordingsStore {
|
||||||
return VoiceBroadcastRecordingsStore._instance;
|
return VoiceBroadcastRecordingsStore.cachedInstance;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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";
|
|
|
@ -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";
|
|
|
@ -53,6 +53,7 @@ export const startNewVoiceBroadcastRecording = async (
|
||||||
client,
|
client,
|
||||||
);
|
);
|
||||||
recordingsStore.setCurrent(recording);
|
recordingsStore.setCurrent(recording);
|
||||||
|
recording.start();
|
||||||
resolve(recording);
|
resolve(recording);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -155,7 +155,7 @@ describe("VoiceBroadcastBody", () => {
|
||||||
itShouldRenderANonLiveVoiceBroadcast();
|
itShouldRenderANonLiveVoiceBroadcast();
|
||||||
|
|
||||||
it("should call stop on the recording", () => {
|
it("should call stop on the recording", () => {
|
||||||
expect(recording.state).toBe(VoiceBroadcastInfoState.Stopped);
|
expect(recording.getState()).toBe(VoiceBroadcastInfoState.Stopped);
|
||||||
expect(onRecordingStateChanged).toHaveBeenCalledWith(VoiceBroadcastInfoState.Stopped);
|
expect(onRecordingStateChanged).toHaveBeenCalledWith(VoiceBroadcastInfoState.Stopped);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -15,25 +15,57 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { mocked } from "jest-mock";
|
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 { 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 {
|
import {
|
||||||
|
ChunkRecordedPayload,
|
||||||
|
createVoiceBroadcastRecorder,
|
||||||
VoiceBroadcastInfoEventContent,
|
VoiceBroadcastInfoEventContent,
|
||||||
VoiceBroadcastInfoEventType,
|
VoiceBroadcastInfoEventType,
|
||||||
VoiceBroadcastInfoState,
|
VoiceBroadcastInfoState,
|
||||||
|
VoiceBroadcastRecorder,
|
||||||
|
VoiceBroadcastRecorderEvent,
|
||||||
VoiceBroadcastRecording,
|
VoiceBroadcastRecording,
|
||||||
VoiceBroadcastRecordingEvent,
|
VoiceBroadcastRecordingEvent,
|
||||||
} from "../../../src/voice-broadcast";
|
} from "../../../src/voice-broadcast";
|
||||||
import { mkEvent, mkStubRoom, stubClient } from "../../test-utils";
|
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", () => {
|
describe("VoiceBroadcastRecording", () => {
|
||||||
const roomId = "!room:example.com";
|
const roomId = "!room:example.com";
|
||||||
|
const uploadedUrl = "mxc://example.com/vb";
|
||||||
|
const uploadedFile = { file: true } as unknown as IEncryptedFile;
|
||||||
let room: Room;
|
let room: Room;
|
||||||
let client: MatrixClient;
|
let client: MatrixClient;
|
||||||
let infoEvent: MatrixEvent;
|
let infoEvent: MatrixEvent;
|
||||||
let voiceBroadcastRecording: VoiceBroadcastRecording;
|
let voiceBroadcastRecording: VoiceBroadcastRecording;
|
||||||
let onStateChanged: (state: VoiceBroadcastInfoState) => void;
|
let onStateChanged: (state: VoiceBroadcastInfoState) => void;
|
||||||
|
let voiceBroadcastRecorder: VoiceBroadcastRecorder;
|
||||||
|
let onChunkRecorded: (chunk: ChunkRecordedPayload) => Promise<void>;
|
||||||
|
|
||||||
const mkVoiceBroadcastInfoEvent = (content: VoiceBroadcastInfoEventContent) => {
|
const mkVoiceBroadcastInfoEvent = (content: VoiceBroadcastInfoEventContent) => {
|
||||||
return mkEvent({
|
return mkEvent({
|
||||||
|
@ -48,6 +80,7 @@ describe("VoiceBroadcastRecording", () => {
|
||||||
const setUpVoiceBroadcastRecording = () => {
|
const setUpVoiceBroadcastRecording = () => {
|
||||||
voiceBroadcastRecording = new VoiceBroadcastRecording(infoEvent, client);
|
voiceBroadcastRecording = new VoiceBroadcastRecording(infoEvent, client);
|
||||||
voiceBroadcastRecording.on(VoiceBroadcastRecordingEvent.StateChanged, onStateChanged);
|
voiceBroadcastRecording.on(VoiceBroadcastRecordingEvent.StateChanged, onStateChanged);
|
||||||
|
jest.spyOn(voiceBroadcastRecording, "removeAllListeners");
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
@ -59,6 +92,65 @@ describe("VoiceBroadcastRecording", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
onStateChanged = jest.fn();
|
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(() => {
|
afterEach(() => {
|
||||||
|
@ -74,7 +166,7 @@ describe("VoiceBroadcastRecording", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should be in Started state", () => {
|
it("should be in Started state", () => {
|
||||||
expect(voiceBroadcastRecording.state).toBe(VoiceBroadcastInfoState.Started);
|
expect(voiceBroadcastRecording.getState()).toBe(VoiceBroadcastInfoState.Started);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("and calling stop()", () => {
|
describe("and calling stop()", () => {
|
||||||
|
@ -98,13 +190,155 @@ describe("VoiceBroadcastRecording", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should be in state stopped", () => {
|
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", () => {
|
it("should emit a stopped state changed event", () => {
|
||||||
expect(onStateChanged).toHaveBeenCalledWith(VoiceBroadcastInfoState.Stopped);
|
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", () => {
|
describe("when created for a Voice Broadcast Info with a Stopped relation", () => {
|
||||||
|
@ -152,7 +386,7 @@ describe("VoiceBroadcastRecording", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should be in Stopped state", () => {
|
it("should be in Stopped state", () => {
|
||||||
expect(voiceBroadcastRecording.state).toBe(VoiceBroadcastInfoState.Stopped);
|
expect(voiceBroadcastRecording.getState()).toBe(VoiceBroadcastInfoState.Stopped);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -76,7 +76,7 @@ describe("VoiceBroadcastRecordingsStore", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return it as current", () => {
|
it("should return it as current", () => {
|
||||||
expect(recordings.current).toBe(recording);
|
expect(recordings.getCurrent()).toBe(recording);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return it by id", () => {
|
it("should return it by id", () => {
|
||||||
|
|
|
@ -109,6 +109,7 @@ describe("startNewVoiceBroadcastRecording", () => {
|
||||||
return {
|
return {
|
||||||
infoEvent,
|
infoEvent,
|
||||||
client,
|
client,
|
||||||
|
start: jest.fn(),
|
||||||
} as unknown as VoiceBroadcastRecording;
|
} as unknown as VoiceBroadcastRecording;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -120,6 +121,7 @@ describe("startNewVoiceBroadcastRecording", () => {
|
||||||
expect(ok).toBe(true);
|
expect(ok).toBe(true);
|
||||||
expect(mocked(room.off)).toHaveBeenCalledWith(RoomStateEvent.Events, roomOnStateEventsCallback);
|
expect(mocked(room.off)).toHaveBeenCalledWith(RoomStateEvent.Events, roomOnStateEventsCallback);
|
||||||
expect(recording.infoEvent).toBe(infoEvent);
|
expect(recording.infoEvent).toBe(infoEvent);
|
||||||
|
expect(recording.start).toHaveBeenCalled();
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue