// Meeting transcription: one gated capture and one Deepgram connection per // participant, feeding a single time-ordered transcript. // // Only the person who entered an API key runs any of this. They already have // every participant's audio locally (that is what a mesh call is), so one // browser can transcribe the whole room. Note the trade-off: peers' audio has // been through Opus at the room's bitrate by the time we see it, so it is not // as clean as it would be captured at the source. The alternative — everyone // transcribing their own microphone and publishing the text — would need a // credential for each participant and a way to share it, and would put the // authorship of every line in the hands of whoever sent it. // // Attribution is structural rather than inferred: each socket carries exactly // one speaker, so no diarization is involved and there is nothing to // misattribute. import {registerCaptureWorklet, SpeechCapture} from './capture' import {DeepgramStream} from './deepgram' import type {TranscriptStore} from './store' /** Preferred capture rate. Speech models work at 16 kHz, and sending 16-bit * samples at that rate is a third of the bytes of a 48 kHz stream. */ const PREFERRED_RATE = 16000 export interface TranscriptSource { id: string name: string stream: MediaStream | null } interface Pipeline { /** Source stream plus track identity: a microphone swapped in mid-call is a * new track, and the Web Audio source node does not follow the change. */ key: string name: string capture: SpeechCapture stream: DeepgramStream } export class Transcriber { private ctx: AudioContext | null = null private pipelines = new Map() private running = false constructor( readonly apiKey: string, private store: TranscriptStore, private onChange: () => void, private onFailure: (message: string, fatal: boolean) => void ) {} get active(): boolean { return this.running } async start(): Promise { if (this.running) return true if (!this.ctx) { const fail = (err: unknown) => { const why = err instanceof Error ? err.message : 'audio setup failed' this.onFailure(`Transcription could not start — ${why}.`, true) return false } let ctx: AudioContext try { // Not every browser will honor an explicit rate; falling back costs // bandwidth, not correctness, since the real rate is what we declare // to Deepgram. try { ctx = new AudioContext({sampleRate: PREFERRED_RATE}) } catch { ctx = new AudioContext() } } catch (err) { return fail(err) } try { await registerCaptureWorklet(ctx) } catch (err) { void ctx.close().catch(() => undefined) return fail(err) } this.ctx = ctx } // Starting transcription is a click, so the context is allowed to run; // resuming matters when a previous stop left it suspended. await this.ctx.resume().catch(() => undefined) this.running = true return true } /** Reconcile the running pipelines with who is in the room. Cheap to call on * every snapshot: it only acts on what actually changed. */ setSources(sources: TranscriptSource[]) { if (!this.running || !this.ctx) return const seen = new Set() for (const src of sources) { const media = src.stream const track = media?.getAudioTracks()[0] if (!media || !track) continue seen.add(src.id) const key = `${media.id}:${track.id}` const existing = this.pipelines.get(src.id) if (existing) { existing.name = src.name if (existing.key === key) continue this.destroy(src.id) } this.create(src.id, src.name, key, media) } for (const id of [...this.pipelines.keys()]) { if (!seen.has(id)) this.destroy(id) } } private create(id: string, name: string, key: string, media: MediaStream) { const ctx = this.ctx if (!ctx) return const stream = new DeepgramStream(this.apiKey, ctx.sampleRate, { // Look the name up late, so a rename is reflected — but fall back to the // one we had, since a result can still arrive after the speaker left and // an unattributed transcript line is worse than a stale name. transcript: text => this.store.append(id, this.pipelines.get(id)?.name ?? name, text), sent: samples => this.store.addAudioSeconds(samples / ctx.sampleRate), failure: (message, fatal) => this.onFailure(message, fatal) }) let capture: SpeechCapture try { capture = new SpeechCapture(ctx, media, { frame: pcm => stream.send(pcm), end: () => stream.finalize() }) } catch { // A stream whose track vanished between the snapshot and here; the next // reconcile will pick it up again if it comes back. stream.stop() return } this.pipelines.set(id, {key, name, capture, stream}) } private destroy(id: string) { const p = this.pipelines.get(id) if (!p) return this.pipelines.delete(id) p.capture.close() p.stream.stop() } /** Stop capturing. The sockets wind down gracefully, so a sentence that was * in flight still reaches the transcript, and the transcript itself stays. */ stop() { this.running = false for (const id of [...this.pipelines.keys()]) this.destroy(id) void this.ctx?.suspend().catch(() => undefined) this.onChange() } dispose() { this.stop() const ctx = this.ctx this.ctx = null if (ctx) void ctx.close().catch(() => undefined) } }