All checks were successful
Build and Release / build-and-release (push) Successful in 12m29s
418 lines
14 KiB
TypeScript
418 lines
14 KiB
TypeScript
/**
|
|
* 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<any>;
|
|
openTrack: (payload: any) => Promise<any>;
|
|
append: (payload: any) => Promise<any>;
|
|
closeTrack: (payload: any) => Promise<any>;
|
|
finalize: (payload: any) => Promise<any>;
|
|
} | 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<any>;
|
|
}
|
|
|
|
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<any> {
|
|
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<string, TrackRecorder>();
|
|
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<void> {
|
|
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<void> {
|
|
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<Promise<void>> = [];
|
|
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<void> {
|
|
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<void> {
|
|
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<void>((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 {}
|
|
}
|
|
}
|