/* 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 { MatrixClient } from "matrix-js-sdk/src/matrix"; import { EncryptedFile } from "matrix-js-sdk/src/types"; import { SimpleObservable } from "matrix-widget-api"; import { uploadFile } from "../ContentMessages"; import { concat } from "../utils/arrays"; import { IDestroyable } from "../utils/IDestroyable"; import { Singleflight } from "../utils/Singleflight"; import { Playback } from "./Playback"; import { IRecordingUpdate, RecordingState, VoiceRecording } from "./VoiceRecording"; export interface IUpload { mxc?: string; // for unencrypted uploads encrypted?: EncryptedFile; } /** * This class can be used to record a single voice message. */ export class VoiceMessageRecording implements IDestroyable { private lastUpload?: IUpload; private buffer = new Uint8Array(0); // use this.audioBuffer to access private playback?: Playback; public constructor( private matrixClient: MatrixClient, private voiceRecording: VoiceRecording, ) { this.voiceRecording.onDataAvailable = this.onDataAvailable; } public async start(): Promise { if (this.lastUpload || this.hasRecording) { throw new Error("Recording already prepared"); } return this.voiceRecording.start(); } public async stop(): Promise { await this.voiceRecording.stop(); return this.audioBuffer; } public on(event: string | symbol, listener: (...args: any[]) => void): this { this.voiceRecording.on(event, listener); return this; } public off(event: string | symbol, listener: (...args: any[]) => void): this { this.voiceRecording.off(event, listener); return this; } public emit(event: string, ...args: any[]): boolean { return this.voiceRecording.emit(event, ...args); } public get hasRecording(): boolean { return this.buffer.length > 0; } public get isRecording(): boolean { return this.voiceRecording.isRecording; } /** * Gets a playback instance for this voice recording. Note that the playback will not * have been prepared fully, meaning the `prepare()` function needs to be called on it. * * The same playback instance is returned each time. * * @returns {Playback} The playback instance. */ public getPlayback(): Playback { this.playback = Singleflight.for(this, "playback").do(() => { return new Playback(this.audioBuffer.buffer, this.voiceRecording.amplitudes); // cast to ArrayBuffer proper; }); return this.playback; } public async upload(inRoomId: string): Promise { if (!this.hasRecording) { throw new Error("No recording available to upload"); } if (this.lastUpload) return this.lastUpload; try { this.emit(RecordingState.Uploading); const { url: mxc, file: encrypted } = await uploadFile( this.matrixClient, inRoomId, new Blob([this.audioBuffer], { type: this.contentType, }), ); this.lastUpload = { mxc, encrypted }; this.emit(RecordingState.Uploaded); } catch (e) { this.emit(RecordingState.Ended); throw e; } return this.lastUpload; } public get durationSeconds(): number { return this.voiceRecording.durationSeconds; } public get contentType(): string { return this.voiceRecording.contentType; } public get contentLength(): number { return this.buffer.length; } public get liveData(): SimpleObservable { return this.voiceRecording.liveData; } public get isSupported(): boolean { return this.voiceRecording.isSupported; } public destroy(): void { this.playback?.destroy(); this.voiceRecording.destroy(); } private onDataAvailable = (data: ArrayBuffer): void => { const buf = new Uint8Array(data); this.buffer = concat(this.buffer, buf); }; private get audioBuffer(): Uint8Array { // We need a clone of the buffer to avoid accidentally changing the position // on the real thing. return this.buffer.slice(0); } } export const createVoiceMessageRecording = (matrixClient: MatrixClient): VoiceMessageRecording => { return new VoiceMessageRecording(matrixClient, new VoiceRecording()); };