1// Meeting transcription: one gated capture and one Deepgram connection per
2// participant, feeding a single time-ordered transcript.
3//
4// Only the person who entered an API key runs any of this. They already have
5// every participant's audio locally (that is what a mesh call is), so one
6// browser can transcribe the whole room. Note the trade-off: peers' audio has
7// been through Opus at the room's bitrate by the time we see it, so it is not
8// as clean as it would be captured at the source. The alternative — everyone
9// transcribing their own microphone and publishing the text — would need a
10// credential for each participant and a way to share it, and would put the
11// authorship of every line in the hands of whoever sent it.
12//
13// Attribution is structural rather than inferred: each socket carries exactly
14// one speaker, so no diarization is involved and there is nothing to
15// misattribute.
17import {registerCaptureWorklet, SpeechCapture} from './capture'
18import {DeepgramStream} from './deepgram'
19import type {TranscriptStore} from './store'
21/** Preferred capture rate. Speech models work at 16 kHz, and sending 16-bit
22 * samples at that rate is a third of the bytes of a 48 kHz stream. */
23const PREFERRED_RATE = 16000
25export interface TranscriptSource {
26 id: string
27 name: string
28 stream: MediaStream | null
29}
31interface Pipeline {
32 /** Source stream plus track identity: a microphone swapped in mid-call is a
33 * new track, and the Web Audio source node does not follow the change. */
34 key: string
35 name: string
36 capture: SpeechCapture
37 stream: DeepgramStream
38}
40export class Transcriber {
41 private ctx: AudioContext | null = null
42 private pipelines = new Map<string, Pipeline>()
43 private running = false
45 constructor(
46 readonly apiKey: string,
47 private store: TranscriptStore,
48 private onChange: () => void,
49 private onFailure: (message: string, fatal: boolean) => void
50 ) {}
52 get active(): boolean {
53 return this.running
54 }
56 async start(): Promise<boolean> {
57 if (this.running) return true
58 if (!this.ctx) {
59 const fail = (err: unknown) => {
60 const why = err instanceof Error ? err.message : 'audio setup failed'
61 this.onFailure(`Transcription could not start — ${why}.`, true)
62 return false
63 }
64 let ctx: AudioContext
65 try {
66 // Not every browser will honor an explicit rate; falling back costs
67 // bandwidth, not correctness, since the real rate is what we declare
68 // to Deepgram.
69 try {
70 ctx = new AudioContext({sampleRate: PREFERRED_RATE})
71 } catch {
72 ctx = new AudioContext()
73 }
74 } catch (err) {
75 return fail(err)
76 }
77 try {
78 await registerCaptureWorklet(ctx)
79 } catch (err) {
80 void ctx.close().catch(() => undefined)
81 return fail(err)
82 }
83 this.ctx = ctx
84 }
85 // Starting transcription is a click, so the context is allowed to run;
86 // resuming matters when a previous stop left it suspended.
87 await this.ctx.resume().catch(() => undefined)
88 this.running = true
89 return true
90 }
92 /** Reconcile the running pipelines with who is in the room. Cheap to call on
93 * every snapshot: it only acts on what actually changed. */
94 setSources(sources: TranscriptSource[]) {
95 if (!this.running || !this.ctx) return
96 const seen = new Set<string>()
97 for (const src of sources) {
98 const media = src.stream
99 const track = media?.getAudioTracks()[0]
100 if (!media || !track) continue
101 seen.add(src.id)
102 const key = `${media.id}:${track.id}`
103 const existing = this.pipelines.get(src.id)
104 if (existing) {
105 existing.name = src.name
106 if (existing.key === key) continue
107 this.destroy(src.id)
108 }
109 this.create(src.id, src.name, key, media)
110 }
111 for (const id of [...this.pipelines.keys()]) {
112 if (!seen.has(id)) this.destroy(id)
113 }
114 }
116 private create(id: string, name: string, key: string, media: MediaStream) {
117 const ctx = this.ctx
118 if (!ctx) return
119 const stream = new DeepgramStream(this.apiKey, ctx.sampleRate, {
120 // Look the name up late, so a rename is reflected — but fall back to the
121 // one we had, since a result can still arrive after the speaker left and
122 // an unattributed transcript line is worse than a stale name.
123 transcript: text =>
124 this.store.append(id, this.pipelines.get(id)?.name ?? name, text),
125 sent: samples => this.store.addAudioSeconds(samples / ctx.sampleRate),
126 failure: (message, fatal) => this.onFailure(message, fatal)
127 })
128 let capture: SpeechCapture
129 try {
130 capture = new SpeechCapture(ctx, media, {
131 frame: pcm => stream.send(pcm),
132 end: () => stream.finalize()
133 })
134 } catch {
135 // A stream whose track vanished between the snapshot and here; the next
136 // reconcile will pick it up again if it comes back.
137 stream.stop()
138 return
139 }
140 this.pipelines.set(id, {key, name, capture, stream})
141 }
143 private destroy(id: string) {
144 const p = this.pipelines.get(id)
145 if (!p) return
146 this.pipelines.delete(id)
147 p.capture.close()
148 p.stream.stop()
149 }
151 /** Stop capturing. The sockets wind down gracefully, so a sentence that was
152 * in flight still reaches the transcript, and the transcript itself stays. */
153 stop() {
154 this.running = false
155 for (const id of [...this.pipelines.keys()]) this.destroy(id)
156 void this.ctx?.suspend().catch(() => undefined)
157 this.onChange()
158 }
160 dispose() {
161 this.stop()
162 const ctx = this.ctx
163 this.ctx = null
164 if (ctx) void ctx.close().catch(() => undefined)
165 }
166}