matrix-react-sdk
Version:
SDK for matrix.org using React
182 lines (150 loc) • 5.97 kB
text/typescript
/*
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 { isEqual } from "lodash";
import { Optional } from "matrix-events-sdk";
import { logger } from "matrix-js-sdk/src/logger";
import { TypedEventEmitter } from "matrix-js-sdk/src/matrix";
import { getChunkLength } from "..";
import { IRecordingUpdate, VoiceRecording } from "../../audio/VoiceRecording";
import { concat } from "../../utils/arrays";
import { IDestroyable } from "../../utils/IDestroyable";
import { Singleflight } from "../../utils/Singleflight";
export enum VoiceBroadcastRecorderEvent {
ChunkRecorded = "chunk_recorded",
CurrentChunkLengthUpdated = "current_chunk_length_updated",
}
interface EventMap {
[VoiceBroadcastRecorderEvent.ChunkRecorded]: (chunk: ChunkRecordedPayload) => void;
[VoiceBroadcastRecorderEvent.CurrentChunkLengthUpdated]: (length: number) => void;
}
export interface ChunkRecordedPayload {
buffer: Uint8Array;
length: number;
}
// char sequence of "OpusHead"
const OpusHead = [79, 112, 117, 115, 72, 101, 97, 100];
// char sequence of "OpusTags"
const OpusTags = [79, 112, 117, 115, 84, 97, 103, 115];
/**
* 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 opusHead?: Uint8Array;
private opusTags?: Uint8Array;
private chunkBuffer = new Uint8Array(0);
// position of the previous chunk in seconds
private previousChunkEndTimePosition = 0;
// current chunk length in seconds
private currentChunkLength = 0;
public constructor(
private voiceRecording: VoiceRecording,
public readonly targetChunkLength: number,
) {
super();
this.voiceRecording.onDataAvailable = this.onDataAvailable;
}
public async start(): Promise<void> {
await this.voiceRecording.start();
this.voiceRecording.liveData.onUpdate((data: IRecordingUpdate) => {
this.setCurrentChunkLength(data.timeSeconds - this.previousChunkEndTimePosition);
});
}
/**
* Stops the recording and returns the remaining chunk (if any).
*/
public async stop(): Promise<Optional<ChunkRecordedPayload>> {
try {
await this.voiceRecording.stop();
} catch (e) {
// Ignore if the recording raises any error.
}
// forget about that call, so that we can stop it again later
Singleflight.forgetAllFor(this.voiceRecording);
const chunk = this.extractChunk();
this.currentChunkLength = 0;
this.previousChunkEndTimePosition = 0;
return chunk;
}
public get contentType(): string {
return this.voiceRecording.contentType;
}
private setCurrentChunkLength(currentChunkLength: number): void {
if (this.currentChunkLength === currentChunkLength) return;
this.currentChunkLength = currentChunkLength;
this.emit(VoiceBroadcastRecorderEvent.CurrentChunkLengthUpdated, currentChunkLength);
}
public getCurrentChunkLength(): number {
return this.currentChunkLength;
}
private onDataAvailable = (data: ArrayBuffer): void => {
const dataArray = new Uint8Array(data);
// extract the part, that contains the header type info
const headerType = Array.from(dataArray.slice(28, 36));
if (isEqual(OpusHead, headerType)) {
// data seems to be an "OpusHead" header
this.opusHead = dataArray;
return;
}
if (isEqual(OpusTags, headerType)) {
// data seems to be an "OpusTags" header
this.opusTags = dataArray;
return;
}
this.setCurrentChunkLength(this.voiceRecording.recorderSeconds! - this.previousChunkEndTimePosition);
this.handleData(dataArray);
};
private handleData(data: Uint8Array): void {
this.chunkBuffer = concat(this.chunkBuffer, data);
this.emitChunkIfTargetLengthReached();
}
private emitChunkIfTargetLengthReached(): void {
if (this.getCurrentChunkLength() >= this.targetChunkLength) {
this.emitAndResetChunk();
}
}
/**
* Extracts the current chunk and resets the buffer.
*/
private extractChunk(): Optional<ChunkRecordedPayload> {
if (this.chunkBuffer.length === 0) {
return null;
}
if (!this.opusHead || !this.opusTags) {
logger.warn("Broadcast chunk cannot be extracted. OpusHead or OpusTags is missing.");
return null;
}
const currentRecorderTime = this.voiceRecording.recorderSeconds!;
const payload: ChunkRecordedPayload = {
buffer: concat(this.opusHead!, this.opusTags!, this.chunkBuffer),
length: this.getCurrentChunkLength(),
};
this.chunkBuffer = new Uint8Array(0);
this.setCurrentChunkLength(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 voiceRecording = new VoiceRecording();
voiceRecording.disableMaxLength();
return new VoiceBroadcastRecorder(voiceRecording, getChunkLength());
};