/** * VoiceRecorder — per-participant audio capture for a LiveKit voice * call. Spawns one `MediaRecorder` per active audio track (local mic * + each remote participant) with a 500ms timeslice. Every chunk is * forwarded over IPC to the Electron main process, which append- * writes it to a per-participant WebM file inside a session folder. * * Crash-safe by construction: the WebM/Opus container is built from * independently-decodable clusters, so a truncated tail on power * loss just means the decoder stops at the last complete cluster. * Every 500ms of audio is on disk before the next chunk arrives, * and the main process fsyncs every ~10 seconds. * * Only active when `platform.features.hasRecording === true` * (Electron). Other platforms should never instantiate this — the * VoiceContext gates `startRecording()` behind that flag. */ import { RoomEvent } from 'livekit-client'; interface Platform { features?: { hasRecording?: boolean }; recording?: { startSession: (payload: any) => Promise; openTrack: (payload: any) => Promise; append: (payload: any) => Promise; closeTrack: (payload: any) => Promise; finalize: (payload: any) => Promise; } | null; } interface RecordingOpts { platform: Platform; room: any; // LiveKit Room channelId: string | null; channelName: string | null; rootDir: string | null; onError?: (err: Error) => void; } interface TrackRecorder { participantId: string; displayName: string; trackKey: string; stream: MediaStream; recorder: MediaRecorder; joinedOffsetMs: number; // `pendingIo` serialises the append IPCs for this track so // chunks arrive in order even if the main-process write is // slower than 500ms. Without this, two parallel `append` calls // can interleave their bytes and break the WebM cluster stream. pendingIo: Promise; } function sanitizeParticipantId(id: any): string { return String(id ?? 'unknown'); } function generateSessionId(): string { // Filesystem-safe ISO timestamp — no `:` so Windows is happy, // plus a short random suffix so two recordings started in the // same second don't collide. const iso = new Date().toISOString().replace(/[:.]/g, '-'); const rand = Math.random().toString(36).slice(2, 6); return `${iso}-${rand}`; } /** Resolve the LiveKit microphone publication (handles both the * newer `trackPublications` Map and older `tracks` map names). */ function findLocalMicPublication(localParticipant: any): any | null { const map = localParticipant?.trackPublications || localParticipant?.tracks; if (!map) return null; for (const pub of map.values()) { if (!pub) continue; const kind = pub.kind === 'audio' || pub.track?.kind === 'audio'; const src = (pub.source?.toString?.() ?? '').toLowerCase(); if (kind && (src === 'microphone' || src === '' || src === 'mic')) { return pub; } } return null; } /** Yield every audio publication on a participant whose `source` * isn't screen_share_audio (which is mixed with the screen share * track and would be redundant / confusing in a per-voice file). */ function* iterAudioPublications(participant: any): Generator { const map = participant?.trackPublications || participant?.tracks; if (!map) return; for (const pub of map.values()) { if (!pub) continue; const kind = pub.kind === 'audio' || pub.track?.kind === 'audio'; if (!kind) continue; const src = (pub.source?.toString?.() ?? '').toLowerCase(); if (src === 'screen_share_audio') continue; yield pub; } } /** Best `mimeType` the current Chromium build supports for the * MediaRecorder. Electron ships Chromium so Opus-in-WebM is * always available; falling through is defensive. */ function pickMimeType(): string { const candidates = [ 'audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', ]; for (const c of candidates) { if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(c)) { return c; } } return 'audio/webm'; } export class VoiceRecorder { readonly sessionId: string; readonly startedAt: number; private readonly opts: RecordingOpts; private readonly mimeType: string; private readonly recorders = new Map(); private stopped = false; // We capture refs to the RoomEvent handlers so we can detach // them in stop() — React's strict-mode double mount + the // context's own event listeners would otherwise leave dangling // subscriptions after the recorder is destroyed. private readonly handlers: Array<[string, (...args: any[]) => void]> = []; constructor(opts: RecordingOpts) { this.opts = opts; this.sessionId = generateSessionId(); this.startedAt = Date.now(); this.mimeType = pickMimeType(); } /** Spin up the session folder + attach recorders for every * audio track that's already published plus handlers for * tracks that arrive later (participants joining mid-call). */ async start(): Promise { const { platform, room } = this.opts; if (!platform.features?.hasRecording || !platform.recording) { throw new Error('Recording is not available on this platform.'); } const started = await platform.recording.startSession({ sessionId: this.sessionId, rootDir: this.opts.rootDir, channelId: this.opts.channelId, channelName: this.opts.channelName, startedAt: this.startedAt, }); if (!started?.ok) { throw new Error(started?.error ?? 'Failed to start recording session.'); } // Local mic — iterate any existing publication and hook // future re-publications (mute/unmute cycles republish the // track, which would otherwise slip past TrackSubscribed). const localId = room.localParticipant?.identity ?? 'local'; const localName = room.localParticipant?.metadata || room.localParticipant?.name || 'You'; const localPub = findLocalMicPublication(room.localParticipant); if (localPub?.track?.mediaStreamTrack) { await this.attachTrack(localId, localName, localPub.track.mediaStreamTrack); } // Remote participants — both the currently-connected set // and any future joiners. const remotes = room.remoteParticipants?.values ? Array.from(room.remoteParticipants.values()) : []; for (const participant of remotes as any[]) { for (const pub of iterAudioPublications(participant)) { if (pub.track?.mediaStreamTrack) { await this.attachTrack( sanitizeParticipantId(participant.identity), participant.metadata || participant.name || participant.identity || 'Participant', pub.track.mediaStreamTrack, ); } } } // Hook future subscribes — this fires for every newly // subscribed audio track, including ones that come in after // a participant joins mid-recording. const onSubscribed = (track: any, _publication: any, participant: any) => { if (this.stopped) return; if (track?.kind !== 'audio') return; const src = (track?.source?.toString?.() ?? '').toLowerCase(); if (src === 'screen_share_audio') return; const mediaTrack = track?.mediaStreamTrack; if (!mediaTrack) return; void this.attachTrack( sanitizeParticipantId(participant?.identity), participant?.metadata || participant?.name || participant?.identity || 'Participant', mediaTrack, ); }; const onUnsubscribed = (_track: any, _publication: any, participant: any) => { if (this.stopped) return; this.detachParticipantTracks(sanitizeParticipantId(participant?.identity)); }; const onParticipantDisconnected = (participant: any) => { if (this.stopped) return; this.detachParticipantTracks(sanitizeParticipantId(participant?.identity)); }; const onLocalTrackPublished = (publication: any) => { if (this.stopped) return; const track = publication?.track; if (track?.kind !== 'audio') return; if (!track?.mediaStreamTrack) return; void this.attachTrack(localId, localName, track.mediaStreamTrack); }; const onLocalTrackUnpublished = (publication: any) => { if (this.stopped) return; const track = publication?.track; if (track?.kind !== 'audio') return; this.detachParticipantTracks(localId); }; room.on(RoomEvent.TrackSubscribed, onSubscribed); room.on(RoomEvent.TrackUnsubscribed, onUnsubscribed); room.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected); room.on(RoomEvent.LocalTrackPublished, onLocalTrackPublished); room.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished); this.handlers.push([RoomEvent.TrackSubscribed, onSubscribed]); this.handlers.push([RoomEvent.TrackUnsubscribed, onUnsubscribed]); this.handlers.push([RoomEvent.ParticipantDisconnected, onParticipantDisconnected]); this.handlers.push([RoomEvent.LocalTrackPublished, onLocalTrackPublished]); this.handlers.push([RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished]); } /** Flush every open recorder, close every track, finalize the * session manifest. Idempotent — safe to call from React * cleanup paths. */ async stop(): Promise { if (this.stopped) return; this.stopped = true; const { platform, room } = this.opts; // Detach room listeners first so late-arriving events don't // try to attach new tracks mid-teardown. for (const [event, handler] of this.handlers) { try { room.off(event, handler); } catch {} } this.handlers.length = 0; const endedAt = Date.now(); const stops: Array> = []; for (const entry of this.recorders.values()) { stops.push(this.stopRecorder(entry, endedAt)); } await Promise.allSettled(stops); this.recorders.clear(); if (platform.recording) { try { await platform.recording.finalize({ sessionId: this.sessionId, endedAt, }); } catch (err) { this.opts.onError?.(err instanceof Error ? err : new Error(String(err))); } } } // ── Internals ───────────────────────────────────────────── private async attachTrack( participantId: string, displayName: string, mediaTrack: MediaStreamTrack, ): Promise { if (this.stopped) return; // Skip duplicates if the same mediaTrack already has a // running recorder (track republications during mute cycles // can fire TrackSubscribed twice). for (const entry of this.recorders.values()) { if (entry.participantId === participantId && entry.recorder.state === 'recording') { return; } } const joinedOffsetMs = Math.max(0, Date.now() - this.startedAt); const opened = await this.opts.platform.recording!.openTrack({ sessionId: this.sessionId, participantId, displayName, joinedOffsetMs, }); if (!opened?.ok || !opened.trackKey) { this.opts.onError?.( new Error(opened?.error ?? 'Failed to open recording track.'), ); return; } const trackKey = opened.trackKey; const stream = new MediaStream([mediaTrack]); let recorder: MediaRecorder; try { recorder = new MediaRecorder(stream, { mimeType: this.mimeType }); } catch (err) { this.opts.onError?.(err instanceof Error ? err : new Error(String(err))); return; } const entry: TrackRecorder = { participantId, displayName, trackKey, stream, recorder, joinedOffsetMs, pendingIo: Promise.resolve(), }; recorder.ondataavailable = (ev: BlobEvent) => { if (!ev.data || ev.data.size === 0) return; // Queue each append after the previous one resolves so // bytes arrive in order on the main-process side. entry.pendingIo = entry.pendingIo .catch(() => undefined) .then(async () => { try { const buf = await ev.data.arrayBuffer(); await this.opts.platform.recording!.append({ sessionId: this.sessionId, trackKey, chunk: buf, }); } catch (err) { this.opts.onError?.( err instanceof Error ? err : new Error(String(err)), ); } }); }; recorder.onerror = (ev: any) => { this.opts.onError?.( ev?.error instanceof Error ? ev.error : new Error(String(ev?.error ?? 'MediaRecorder error')), ); }; recorder.start(500); this.recorders.set(trackKey, entry); } private detachParticipantTracks(participantId: string): void { const keys: string[] = []; for (const [key, entry] of this.recorders) { if (entry.participantId === participantId) keys.push(key); } for (const key of keys) { const entry = this.recorders.get(key); if (!entry) continue; this.recorders.delete(key); void this.stopRecorder(entry, Date.now()); } } private async stopRecorder( entry: TrackRecorder, endedAt: number, ): Promise { try { if (entry.recorder.state !== 'inactive') { // requestData() makes MediaRecorder emit one final // dataavailable for whatever's in its internal buffer, // then stop() flushes the tail. try { entry.recorder.requestData(); } catch {} await new Promise((resolve) => { const onStop = () => { entry.recorder.removeEventListener('stop', onStop); resolve(); }; entry.recorder.addEventListener('stop', onStop); try { entry.recorder.stop(); } catch { resolve(); } }); } } catch (err) { this.opts.onError?.(err instanceof Error ? err : new Error(String(err))); } // Wait for any pending append IPCs to finish so closeTrack // doesn't race ahead of the last in-flight chunk. try { await entry.pendingIo; } catch {} if (this.opts.platform.recording) { try { await this.opts.platform.recording.closeTrack({ sessionId: this.sessionId, trackKey: entry.trackKey, leftOffsetMs: Math.max(0, endedAt - this.startedAt), }); } catch (err) { this.opts.onError?.( err instanceof Error ? err : new Error(String(err)), ); } } // Stop every MediaStreamTrack we constructed for this // recorder — LiveKit still owns the underlying device track, // but the wrapper MediaStream we created needs to be // released to free browser resources. try { for (const t of entry.stream.getTracks()) { if (t === entry.stream.getTracks()[0]) continue; // device track belongs to LiveKit } } catch {} } }