@dialt/sdk 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,75 @@
1
+ class VoiceLoopMicProcessor extends AudioWorkletProcessor {
2
+ constructor(options) {
3
+ super();
4
+ this.targetRate = options.processorOptions?.targetRate || 16000;
5
+ this.sourceRate = sampleRate;
6
+ this.frameSize = options.processorOptions?.frameSize || 512;
7
+ this.buffer = [];
8
+ this.sourcePos = 0;
9
+ this.carry = 0; // last sample of the previous block (seamless interpolation across blocks)
10
+ }
11
+
12
+ process(inputs) {
13
+ const input = inputs[0];
14
+ if (!input || input.length === 0 || input[0].length === 0) {
15
+ return true;
16
+ }
17
+
18
+ const mono = this.mixToMono(input);
19
+ if (this.sourceRate === this.targetRate) {
20
+ this.pushSamples(mono);
21
+ } else {
22
+ this.resampleAndPush(mono);
23
+ }
24
+ this.flushFrames();
25
+ return true;
26
+ }
27
+
28
+ mixToMono(channels) {
29
+ if (channels.length === 1) {
30
+ return channels[0];
31
+ }
32
+ const n = channels[0].length;
33
+ const out = new Float32Array(n);
34
+ for (let ch = 0; ch < channels.length; ch += 1) {
35
+ const data = channels[ch];
36
+ for (let i = 0; i < n; i += 1) out[i] += data[i] / channels.length;
37
+ }
38
+ return out;
39
+ }
40
+
41
+ // Carrying the previous block's last sample lets read positions near a block seam interpolate
42
+ // across it — without it, non-integer ratios (44.1 kHz mics) clamp the read position at every
43
+ // 128-sample block and drop/repeat samples, roughening the STT/barge-VAD uplink. Branch-indexed
44
+ // (no per-block allocation): this runs on the realtime audio thread every ~3 ms.
45
+ resampleAndPush(input) {
46
+ const ratio = this.sourceRate / this.targetRate;
47
+ const n = input.length;
48
+ while (this.sourcePos < n) {
49
+ // Read position sits between carry⌢input[pos-1] and input[pos] — position 0 is the seam.
50
+ const i = Math.floor(this.sourcePos);
51
+ const frac = this.sourcePos - i;
52
+ const a = i === 0 ? this.carry : input[i - 1];
53
+ const b = input[i];
54
+ this.buffer.push(a + (b - a) * frac);
55
+ this.sourcePos += ratio;
56
+ }
57
+ this.sourcePos -= n;
58
+ this.carry = input[n - 1];
59
+ }
60
+
61
+ pushSamples(input) {
62
+ for (let i = 0; i < input.length; i += 1) this.buffer.push(input[i]);
63
+ }
64
+
65
+ flushFrames() {
66
+ while (this.buffer.length >= this.frameSize) {
67
+ const frame = new Float32Array(this.frameSize);
68
+ for (let i = 0; i < this.frameSize; i += 1) frame[i] = this.buffer.shift();
69
+ this.port.postMessage({ type: "frame", frame }, [frame.buffer]);
70
+ }
71
+ }
72
+ }
73
+
74
+ registerProcessor("voice-loop-mic", VoiceLoopMicProcessor);
75
+
package/src/mic.js ADDED
@@ -0,0 +1,155 @@
1
+ import { FRAME_SAMPLES, SAMPLE_RATE } from './audio.js';
2
+ import { addWorkletModule, defaultWorkletModuleUrls } from './worklet-url.js';
3
+
4
+ export class CaptureStalledError extends Error {
5
+ constructor(message = 'Microphone capture opened but produced no audio frames') {
6
+ super(message);
7
+ this.name = 'CaptureStalledError';
8
+ this.code = 'capture_stalled';
9
+ this.retryable = true;
10
+ }
11
+ }
12
+
13
+ export class CaptureAbortedError extends Error {
14
+ constructor(message = 'Microphone capture was stopped during startup') {
15
+ super(message);
16
+ this.name = 'AbortError';
17
+ this.code = 'capture_aborted';
18
+ this.retryable = true;
19
+ }
20
+ }
21
+
22
+ // SDK-owned mic capture. Exists so the AEC-only front-end spec is the default, not a doc the app
23
+ // must re-read: echo cancellation ON, noise suppression + AGC OFF. Browser defaults enable BOTH —
24
+ // NS is neutral-to-harmful for the ASR (finding #80: bundled NS +12.5 WER at −15 dB) and AGC
25
+ // distorts levels — so an app that hand-rolls getUserMedia({audio:true}) silently degrades the
26
+ // loop. Frames come out as 16 kHz 512-sample Float32 via an AudioWorklet resampler.
27
+ // `processing:false` opens the mic fully raw (AEC+NS+AGC off): used for the optional raw ablation
28
+ // track, and by ConverseClient.startMic on WebKit where the SDK's own AEC3 cancels instead.
29
+ export class MicCapture {
30
+ constructor({ onFrame, processing = true, workletUrl, deviceId } = {}) {
31
+ this.onFrame = onFrame;
32
+ this.processing = processing;
33
+ this.deviceId = deviceId || null;
34
+ // The worklet ships with the SDK; apps only override this if their bundler relocates assets.
35
+ // Keep the primary URL literal at this call site: Vite/Rollup/Webpack discover and emit
36
+ // worklet assets from this exact new URL(<literal>, import.meta.url) shape.
37
+ const [defaultWorkletUrl, fallbackWorkletUrl] = defaultWorkletModuleUrls(
38
+ new URL('./mic-worklet.js', import.meta.url), 'mic-worklet.js', import.meta.url,
39
+ );
40
+ this.workletUrl = workletUrl || defaultWorkletUrl;
41
+ this.fallbackWorkletUrl = workletUrl ? null : fallbackWorkletUrl;
42
+ this.context = null;
43
+ this.stream = null;
44
+ this.source = null;
45
+ this.worklet = null;
46
+ this.silentSink = null;
47
+ this._startToken = null;
48
+ this._firstFrameReject = null;
49
+ }
50
+
51
+ async start({ firstFrameTimeoutMs = 2000 } = {}) {
52
+ if (this.context) return;
53
+ if (!Number.isFinite(firstFrameTimeoutMs) || firstFrameTimeoutMs <= 0) {
54
+ throw new RangeError('firstFrameTimeoutMs must be a positive finite number');
55
+ }
56
+ const token = {};
57
+ this._startToken = token;
58
+ const stream = await navigator.mediaDevices.getUserMedia({
59
+ audio: {
60
+ echoCancellation: this.processing, // AEC on for the loop; off for the raw track
61
+ noiseSuppression: false, // AEC-only spec: NS off (neutral-to-harmful for ASR)
62
+ autoGainControl: false, // AEC-only spec: AGC off (distorts levels)
63
+ channelCount: 1,
64
+ sampleRate: SAMPLE_RATE,
65
+ ...(this.deviceId ? { deviceId: { exact: this.deviceId } } : {}),
66
+ },
67
+ });
68
+ if (this._startToken !== token) {
69
+ for (const track of stream.getTracks()) track.stop();
70
+ throw new CaptureAbortedError();
71
+ }
72
+ this.stream = stream;
73
+ try {
74
+ this.context = new AudioContext();
75
+ await addWorkletModule(
76
+ this.context.audioWorklet, this.workletUrl, this.fallbackWorkletUrl,
77
+ );
78
+ if (this._startToken !== token) throw new CaptureAbortedError();
79
+ this.source = this.context.createMediaStreamSource(this.stream);
80
+ this.worklet = new AudioWorkletNode(this.context, 'voice-loop-mic', {
81
+ processorOptions: { targetRate: SAMPLE_RATE, frameSize: FRAME_SAMPLES },
82
+ });
83
+ let firstFrameResolve;
84
+ const firstFrame = new Promise((resolve, reject) => {
85
+ firstFrameResolve = resolve;
86
+ this._firstFrameReject = reject;
87
+ });
88
+ this.worklet.port.onmessage = (event) => {
89
+ if (event.data?.type === 'frame') {
90
+ // Frame arrival, not signal amplitude, proves that the capture graph is operational.
91
+ // An all-zero Float32Array is valid silence and must satisfy the startup health gate.
92
+ firstFrameResolve();
93
+ this._firstFrameReject = null;
94
+ const monotonic = globalThis.performance?.now?.();
95
+ this.onFrame?.(event.data.frame,
96
+ Number.isFinite(monotonic) ? monotonic : Date.now());
97
+ }
98
+ };
99
+ this.silentSink = this.context.createGain();
100
+ this.silentSink.gain.value = 0;
101
+ this.source.connect(this.worklet);
102
+ this.worklet.connect(this.silentSink);
103
+ this.silentSink.connect(this.context.destination);
104
+ await this.context.resume();
105
+ if (this._startToken !== token) throw new CaptureAbortedError();
106
+ let timer;
107
+ try {
108
+ await Promise.race([
109
+ firstFrame,
110
+ new Promise((_, reject) => {
111
+ timer = setTimeout(() => reject(new CaptureStalledError()), firstFrameTimeoutMs);
112
+ }),
113
+ ]);
114
+ } finally {
115
+ clearTimeout(timer);
116
+ this._firstFrameReject = null;
117
+ }
118
+ } catch (err) {
119
+ // Partial start (e.g. addModule failed): release the live tracks and the AudioContext, or
120
+ // the mic indicator stays lit and the leaked context counts against Chrome's per-page cap.
121
+ // stop() failing here must not mask the original error or leave `context` set (which
122
+ // would make the started-guard block a retry).
123
+ try { await this.stop(); } catch { this.context = null; this.stream = null; }
124
+ throw err;
125
+ }
126
+ }
127
+
128
+ async stop() {
129
+ this._startToken = null;
130
+ this._firstFrameReject?.(new CaptureAbortedError());
131
+ this._firstFrameReject = null;
132
+ const worklet = this.worklet;
133
+ const source = this.source;
134
+ const silentSink = this.silentSink;
135
+ const stream = this.stream;
136
+ const context = this.context;
137
+ // Clear ownership synchronously so concurrent stop/restart calls cannot release a resource
138
+ // twice while AudioContext.close() is awaiting its browser task.
139
+ this.context = null;
140
+ this.stream = null;
141
+ this.source = null;
142
+ this.worklet = null;
143
+ this.silentSink = null;
144
+ if (worklet?.port) worklet.port.onmessage = null;
145
+ try { worklet?.disconnect(); } catch { /* already disconnected */ }
146
+ try { source?.disconnect(); } catch { /* already disconnected */ }
147
+ try { silentSink?.disconnect(); } catch { /* already disconnected */ }
148
+ for (const track of stream?.getTracks() || []) {
149
+ try { track.stop(); } catch { /* already stopped */ }
150
+ }
151
+ if (context && context.state !== 'closed') {
152
+ try { await context.close(); } catch { /* release references even if the browser rejects */ }
153
+ }
154
+ }
155
+ }
package/src/player.js ADDED
@@ -0,0 +1,347 @@
1
+ import { SAMPLE_RATE } from './audio.js';
2
+
3
+ // Initial buffer before playback starts (and on recovery after an underrun) to absorb
4
+ // network jitter from the streamed TTS. Trades a little first-audio latency for gap-free
5
+ // playback. 100 ms suits the binary WebSocket transport.
6
+ const JITTER_LEAD = 0.1;
7
+
8
+ // How far ahead of the playhead we keep audio scheduled. Committing only a small horizon keeps
9
+ // clear() (barge/interrupt) responsive — little audio is locked into already-started sources.
10
+ const SCHEDULE_AHEAD = 0.2;
11
+ // Hidden tabs throttle setInterval to ~1 Hz (and the AudioContext keeps running), so the visible
12
+ // horizon starves playback into 0.2 s bursts with ~0.8 s gaps. When the page is hidden, commit a
13
+ // horizon comfortably past one throttled tick instead. clear() stays sound at any horizon — it
14
+ // stops every tracked source, started or not — so the only cost is barge fade granularity in a
15
+ // tab nobody is looking at.
16
+ const HIDDEN_SCHEDULE_AHEAD = 2.5;
17
+ const TICK_MS = 25;
18
+
19
+ // clear() fades the master bus out over this long before stopping sources — a hard stop() cuts
20
+ // the waveform mid-sample and pops audibly (canceled/reset/reconnect). Must stay well under
21
+ // JITTER_LEAD so audio enqueued right after a clear starts at full gain.
22
+ const CLEAR_FADE = 0.025;
23
+
24
+ // Underlay (ambience.js): while nothing is queued, bed-only chunks of UNDERLAY_CHUNK samples are
25
+ // kept scheduled UNDERLAY_AHEAD past the playhead, enough to ride out a sub-second main-thread
26
+ // stall (GC, a WASM AEC burst, layout). A reply arriving mid-bed is NOT queued behind that lead:
27
+ // the scheduled bed is cut at the reply's start (see _cutUnderlay), so a reply costs the same
28
+ // JITTER_LEAD it always did, with the bed's tail re-rendered into the reply's own first chunks
29
+ // (which is also what makes the thinking sound's fade-out a crossfade).
30
+ const UNDERLAY_CHUNK = SAMPLE_RATE / 10; // 100 ms at 16 kHz
31
+ const UNDERLAY_AHEAD = 0.5;
32
+
33
+ // Streaming PCM player for gapless assistant audio.
34
+ //
35
+ // enqueue(samples) — append assistant audio (16 kHz f32) to the play queue.
36
+ // clear() — discard queued audio (a barge/interrupt) and stop playing now.
37
+ // stop() — clear() + tear down (full stop / disconnect).
38
+ // setUnderlay(bed) — an ambience.js AmbienceBed mixed under everything this player plays and,
39
+ // while nothing is queued, played on its own; the bed's own envelope decides
40
+ // when it is audible, the player only renders it.
41
+ //
42
+ // The queue holds 16 kHz PCM, resampled to the context rate as it is scheduled into short
43
+ // BufferSources (resampling at schedule time is what lets the underlay be mixed in at the exact
44
+ // playout moment). Every scheduled source is one entry in `scheduled`:
45
+ // {source, startAt, endAt, underlay, before}: `underlay` marks bed-only audio and `before` is
46
+ // the bed's state from just before that chunk was rendered, so a cut can hand it back exactly.
47
+ //
48
+ // Far-end tap (SDK AEC): `onScheduled(samples16k, startAt)` fires when a chunk is committed to
49
+ // a BufferSource, with its playout time on this context's clock; `onCleared(cutAt)` fires on a
50
+ // barge/clear (and an underlay cut) so the not-yet-played reference tail is dropped. See aec.js.
51
+ // The samples handed over are the MIX (reply + underlay), exactly what reaches the speaker.
52
+ // Playback intentionally remains a unity-gain AudioContext path. Physical output routing, maximum
53
+ // loudness, and full-duplex attenuation belong to the browser/OS; WebKit device tests found no
54
+ // reliable web override. Do not add software boost, output-route switching, or audio-session
55
+ // manipulation here. Native media integrations are the boundary for route guarantees.
56
+ export class StreamingPlayer {
57
+ constructor() {
58
+ this.onScheduled = null;
59
+ this.onCleared = null;
60
+ this.context = null;
61
+ this.master = null; // master gain bus — lets clear() fade out instead of popping
62
+ this.ratio = 1; // ctxRate / SAMPLE_RATE
63
+ this.phase = 0; // carried fractional read position across chunks (resampler)
64
+ this.carry = 0; // last input sample of the previous chunk (seamless interpolation)
65
+ this.fadeEnd = 0; // ctx time a clear()'s fade completes — new audio never starts inside it
66
+ this.queue = []; // Float32Array chunks at 16 kHz, awaiting scheduling
67
+ this.nextTime = 0; // ctx time of the next sample to schedule
68
+ // Reply-audio starvation telemetry: an underrun is the reply queue draining mid-reply and
69
+ // playback re-buffering. Session totals; takePlaybackStats() reads and resets them, so the
70
+ // client can attribute a window (one reply) and report it upstream. Purely observational.
71
+ this._replyAudioScheduled = false; // a reply chunk was scheduled since markReplyStart()
72
+ this._stats = { underruns: 0, starved_ms: 0, max_gap_ms: 0 };
73
+ this.scheduled = []; // in-flight sources (see module comment)
74
+ this.underlay = null; // AmbienceBed (ambience.js) or null
75
+ this.timer = null;
76
+ this._onVisibility = () => this._schedule();
77
+ }
78
+
79
+ async ensureContext() {
80
+ if (!this.context || this.context.state === 'closed') {
81
+ this.context = new AudioContext();
82
+ this.master = this.context.createGain();
83
+ this.master.connect(this.context.destination);
84
+ this.ratio = this.context.sampleRate / SAMPLE_RATE;
85
+ this.phase = 0;
86
+ this.carry = 0;
87
+ this.fadeEnd = 0;
88
+ this.nextTime = this.context.currentTime;
89
+ }
90
+ if (this.context.state === 'suspended') await this.context.resume();
91
+ }
92
+
93
+ // Continuous linear resample of a 16 kHz chunk up to the context's native rate, carrying the
94
+ // fractional read position AND the previous chunk's last sample across chunks — read positions
95
+ // near a chunk seam interpolate across it instead of flat-holding (an audible tick per chunk).
96
+ _resample(input) {
97
+ if (this.ratio === 1) return Float32Array.from(input);
98
+ const step = 1 / this.ratio;
99
+ const n = input.length;
100
+ const out = new Float32Array(Math.ceil((n - this.phase) / step) + 1);
101
+ let pos = this.phase;
102
+ let k = 0;
103
+ while (pos < n) {
104
+ // Read position pos sits between carry⌢input[pos-1] and input[pos] — position 0 is the seam.
105
+ const i = Math.floor(pos);
106
+ const frac = pos - i;
107
+ const a = i === 0 ? this.carry : input[i - 1];
108
+ const b = input[i];
109
+ out[k++] = a + (b - a) * frac;
110
+ pos += step;
111
+ }
112
+ this.phase = pos - n; // remainder feeds the start of the next chunk
113
+ this.carry = input[n - 1];
114
+ return k === out.length ? out : out.subarray(0, k);
115
+ }
116
+
117
+ async enqueue(samples) {
118
+ if (!samples || samples.length === 0) return;
119
+ await this.ensureContext();
120
+ this.queue.push(Float32Array.from(samples));
121
+ this._ensureTimer();
122
+ this._schedule();
123
+ }
124
+
125
+ /** Attach (or detach, with null) the ambience bed. Rendering is the bed's job; this player
126
+ * just mixes whatever it renders. */
127
+ setUnderlay(bed) {
128
+ this.underlay = bed;
129
+ if (bed) this.resumeUnderlay();
130
+ }
131
+
132
+ /** The bed became audible (its envelope target went up) while nothing may be queued: make sure
133
+ * the scheduler is ticking so bed-only chunks start flowing. Never creates a context: the bed
134
+ * only ever follows reply audio, which already built one, and after stop() (a render landing
135
+ * on a closed client) there must be nothing to resume into. */
136
+ resumeUnderlay() {
137
+ if (!this.underlay || !this.context || this.context.state === 'closed') return;
138
+ this.ensureContext().then(() => {
139
+ if (!this.context || !this.underlay) return; // stopped/detached while the context resumed
140
+ this._ensureTimer();
141
+ this._schedule();
142
+ }).catch(() => {});
143
+ }
144
+
145
+ /** A reply boundary (`turn`): underruns before any of this reply's audio belong to no reply. */
146
+ markReplyStart() {
147
+ this._replyAudioScheduled = false;
148
+ }
149
+
150
+ /** Read and reset the starvation counters (see the constructor note). */
151
+ takePlaybackStats() {
152
+ const out = { underruns: this._stats.underruns,
153
+ starved_ms: Math.round(this._stats.starved_ms),
154
+ max_gap_ms: Math.round(this._stats.max_gap_ms) };
155
+ this._stats = { underruns: 0, starved_ms: 0, max_gap_ms: 0 };
156
+ return out;
157
+ }
158
+
159
+ // Schedule queued PCM up to SCHEDULE_AHEAD past the playhead. Each call drains whole chunks
160
+ // off the front of the queue into a BufferSource, then tops up bed-only underlay if idle.
161
+ _schedule() {
162
+ if (!this.context) return;
163
+ const now = this.context.currentTime;
164
+ const hidden = typeof document !== 'undefined' && document.visibilityState === 'hidden';
165
+ // Reply audio arriving while bed-only chunks are scheduled ahead: cut the bed at the reply's
166
+ // start instead of queueing the reply behind it. Never earlier than reply audio already in
167
+ // flight (a mid-reply top-up after a momentary underrun) or the new chunk would overlap it.
168
+ if (this.queue.length) {
169
+ const replyEnd = this.scheduled.reduce((t, e) => (e.underlay ? t : Math.max(t, e.endAt)), 0);
170
+ if (this._cutUnderlay(Math.max(now + JITTER_LEAD, this.fadeEnd, replyEnd))) {
171
+ this.onCleared?.(this.nextTime);
172
+ }
173
+ }
174
+ // Re-buffer JITTER_LEAD when the queue had drained (first chunk or underrun) — and never
175
+ // start inside a clear()'s fade, however the two constants are tuned relative to each other.
176
+ if (this.nextTime <= now) {
177
+ if (this.queue.length && this._replyAudioScheduled) {
178
+ // Mid-reply drain: the playhead passed the last scheduled reply sample before the next
179
+ // chunk arrived. The gap is how long the speaker starved (plus the re-buffer lead).
180
+ const gap = (now - this.nextTime) * 1000;
181
+ this._stats.underruns += 1;
182
+ this._stats.starved_ms += gap;
183
+ if (gap > this._stats.max_gap_ms) this._stats.max_gap_ms = gap;
184
+ }
185
+ this.nextTime = Math.max(now + JITTER_LEAD, this.fadeEnd);
186
+ }
187
+ const horizon = hidden ? HIDDEN_SCHEDULE_AHEAD : SCHEDULE_AHEAD;
188
+ while (this.queue.length && this.nextTime < now + horizon) {
189
+ const src = this.queue.shift(); // the player's own copy: mixed in place
190
+ const before = this.underlay?.snapshot();
191
+ this.underlay?.mixInto(src);
192
+ this._scheduleChunk(src, false, before);
193
+ }
194
+ if (!this.queue.length && this.underlay?.audible) {
195
+ const ahead = hidden ? HIDDEN_SCHEDULE_AHEAD : UNDERLAY_AHEAD;
196
+ while (this.nextTime < now + ahead) {
197
+ const before = this.underlay.snapshot();
198
+ const chunk = this.underlay.next(UNDERLAY_CHUNK);
199
+ if (!chunk) break; // the envelope just reached silence
200
+ this._scheduleChunk(chunk, true, before);
201
+ }
202
+ }
203
+ }
204
+
205
+ _scheduleChunk(src, underlay, before) {
206
+ if (!underlay) this._replyAudioScheduled = true;
207
+ const now = this.context.currentTime;
208
+ const data = this._resample(src);
209
+ if (!data.length) return;
210
+ const buffer = this.context.createBuffer(1, data.length, this.context.sampleRate);
211
+ buffer.copyToChannel(data, 0);
212
+ const source = this.context.createBufferSource();
213
+ source.buffer = buffer;
214
+ source.connect(this.master);
215
+ const startAt = Math.max(now, this.nextTime);
216
+ source.start(startAt);
217
+ this.onScheduled?.(src, startAt);
218
+ this.nextTime = startAt + buffer.duration;
219
+ const entry = { source, startAt, endAt: this.nextTime, underlay, before };
220
+ this.scheduled.push(entry);
221
+ source.onended = () => { this.scheduled = this.scheduled.filter((e) => e !== entry); };
222
+ }
223
+
224
+ // Stop every bed-only source past `cutAt` and give the bed back what will now not play: restore
225
+ // its state from before the earliest chunk of ANY kind past the cut (reply chunks carry bed
226
+ // samples too, and clear() stops those as well), then advance it over the part of that chunk
227
+ // that does play. The next scheduled audio starts at cutAt. True if anything was cut.
228
+ _cutUnderlay(cutAt) {
229
+ const past = this.scheduled.filter((e) => e.endAt > cutAt);
230
+ const cut = past.filter((e) => e.underlay);
231
+ if (!cut.length && !past.length) return false;
232
+ for (const entry of cut) {
233
+ try { entry.source.stop(cutAt); } catch { /* already stopped */ }
234
+ }
235
+ this.scheduled = this.scheduled.filter((e) => !cut.includes(e));
236
+ const first = past.reduce((a, b) => (a.startAt <= b.startAt ? a : b));
237
+ if (first.before && this.underlay) {
238
+ this.underlay.restore(first.before);
239
+ const played = Math.round(Math.max(0, cutAt - first.startAt) * SAMPLE_RATE);
240
+ if (played > 0) this.underlay.next(played); // advance over what still plays
241
+ }
242
+ if (cut.length) this.nextTime = Math.max(this.context.currentTime, Math.min(this.nextTime, cutAt));
243
+ return cut.length > 0;
244
+ }
245
+
246
+ // Released-but-unplayed REPLY audio right now, in ms: scheduled-but-unplayed + queued-unscheduled,
247
+ // not counting bed-only underlay (the bed is not the reply and must not count as audio the user
248
+ // "didn't hear"). On a barge this is what a drain would still play, and what a clear() throws
249
+ // away (discarded_ms in the playback_stopped report, which re-truncates the server's heard text).
250
+ pendingMs() {
251
+ if (!this.context) return 0;
252
+ const now = this.context.currentTime;
253
+ let replyEnd = 0;
254
+ let underlayAhead = 0;
255
+ for (const e of this.scheduled) {
256
+ if (e.underlay) underlayAhead += Math.max(0, e.endAt - Math.max(now, e.startAt));
257
+ else replyEnd = Math.max(replyEnd, e.endAt);
258
+ }
259
+ // No reply in flight: whatever is scheduled is bed only, and the lead before it is not reply
260
+ // audio either. Otherwise keep the pre-underlay meaning (lead gap + reply), minus bed-only.
261
+ const scheduled = replyEnd > now ? Math.max(0, this.nextTime - now - underlayAhead) : 0;
262
+ const queued = this.queue.reduce((s, c) => s + c.length, 0) / SAMPLE_RATE;
263
+ return (scheduled + queued) * 1000;
264
+ }
265
+
266
+ // Device output latency (context clock -> speaker), in ms.
267
+ deviceLatencyMs() {
268
+ if (!this.context) return 0;
269
+ return (this.context.outputLatency || this.context.baseLatency || 0) * 1000;
270
+ }
271
+
272
+ // Time to actual silence at the speaker if nothing else happens: pending playout plus the
273
+ // device output latency. This is what the server timestamps to measure the stop half of
274
+ // barge latency.
275
+ remainingMs() {
276
+ return this.pendingMs() + this.deviceLatencyMs();
277
+ }
278
+
279
+ _ensureTimer() {
280
+ if (this.timer != null) return;
281
+ this.timer = setInterval(() => this._schedule(), TICK_MS);
282
+ // Top up the schedule the instant the tab hides — the first throttled tick can be a full
283
+ // second away, which would otherwise leave a one-off gap at the visibility transition.
284
+ // (visibilitychange itself fires unthrottled; document is absent under node tests.)
285
+ if (typeof document !== 'undefined') {
286
+ document.addEventListener('visibilitychange', this._onVisibility);
287
+ }
288
+ }
289
+
290
+ _clearTimer() {
291
+ if (this.timer != null) {
292
+ clearInterval(this.timer);
293
+ this.timer = null;
294
+ }
295
+ if (typeof document !== 'undefined') {
296
+ document.removeEventListener('visibilitychange', this._onVisibility);
297
+ }
298
+ }
299
+
300
+ // Discard queued audio (barge/interrupt) and stop playing — via a short master-bus fade, so
301
+ // the cut lands on silence instead of popping mid-waveform. `fadeS` overrides the fade for a
302
+ // barge hard-clear (~150 ms reads as a yield, not a glitch); the default stays pop-guard short.
303
+ // The underlay goes with it (same sources) and is handed back its unplayed tail, then resumes
304
+ // from the cut on the next tick if its envelope is still up: a barge dips the bed, it does not
305
+ // end it.
306
+ clear(fadeS = CLEAR_FADE) {
307
+ this.queue = [];
308
+ this.phase = 0;
309
+ this.carry = 0;
310
+ const ctx = this.context;
311
+ if (ctx && this.scheduled.length) {
312
+ const now = ctx.currentTime;
313
+ const stopAt = now + fadeS;
314
+ const gain = this.master.gain;
315
+ gain.cancelScheduledValues(now);
316
+ gain.setValueAtTime(gain.value, now);
317
+ gain.linearRampToValueAtTime(0, stopAt);
318
+ // Restore the instant the sources stop: same-time automation events apply in insertion
319
+ // order, so the bus is back at 1 exactly when nothing is left playing. _schedule() keeps
320
+ // any new audio out of the fade window via fadeEnd.
321
+ gain.setValueAtTime(1, stopAt);
322
+ this._cutUnderlay(stopAt);
323
+ for (const { source } of this.scheduled) {
324
+ try { source.stop(stopAt); } catch { /* already stopped */ }
325
+ }
326
+ this.scheduled = [];
327
+ this.fadeEnd = stopAt;
328
+ this.onCleared?.(stopAt);
329
+ }
330
+ this.nextTime = ctx?.currentTime || 0;
331
+ }
332
+
333
+ stop() {
334
+ this.clear(0);
335
+ this._clearTimer();
336
+ // Full teardown must release the AudioContext: browsers cap live contexts per tab (~6 in
337
+ // Chrome), and every session/connect-attempt builds a fresh player — leaking contexts here
338
+ // made audio silently die after a few Start/Stop or provider switches until a page refresh.
339
+ // ensureContext() recreates on next use (it already handles state === 'closed').
340
+ const ctx = this.context;
341
+ this.context = null;
342
+ this.master = null;
343
+ if (ctx && ctx.state !== 'closed' && typeof ctx.close === 'function') {
344
+ ctx.close().catch(() => {});
345
+ }
346
+ }
347
+ }
@@ -0,0 +1,80 @@
1
+ // AudioWorkletProcessor that re-injects mic frames into a real audio graph so they can be captured
2
+ // back out via a MediaStreamAudioDestinationNode and handed to RTCPeerConnection.addTrack() as the
3
+ // outbound mic track for the webrtc transport. This is what negotiates the offer's audio m-line at
4
+ // signaling time, and stays the ACTIVE uplink only for custom-capture callers (no startMic()) or
5
+ // the SDK's own AEC3-canceller path — see src/webrtc.js's header for the full rationale; the normal
6
+ // startMic() path replaces this feeder's track with the real device track once available, at which
7
+ // point the ~PREBUFFER_FRAMES*32ms latency this file adds no longer applies to the live uplink.
8
+ // Frames arrive one 512-sample/16 kHz Float32Array at a time via postMessage.
9
+ //
10
+ // The feeder's AudioContext is created at 16 kHz (src/webrtc.js), matching this source rate exactly,
11
+ // so process() is a straight queue-fed passthrough: no resampling here. (An earlier version of this
12
+ // file linearly interpolated 16k -> the device's default context rate, typically 48 kHz — a
13
+ // low-quality resampler whose artifacts Opus baked into the encoded stream, diagnosed live as
14
+ // mid-stream ASR degradation. Chrome/Firefox now resample the outbound track to Opus's 48k
15
+ // internally with their own production resampler instead.)
16
+ //
17
+ // Frames arrive via postMessage from the main thread in bursts (batched with UI/network work),
18
+ // not steadily every 32 ms — draining the queue the instant any audio is available reproduces that
19
+ // burstiness as mid-word gaps whenever a burst is late. So draining is gated by a small adaptive
20
+ // prebuffer: process() emits silence until PREBUFFER_FRAMES have queued, and re-arms that wait
21
+ // after any underrun rather than resuming on the very next single frame (which would just re-open
22
+ // the same gap on the next burst gap). This trades ~PREBUFFER_FRAMES * 32ms of extra uplink latency
23
+ // for gap-free audio.
24
+ const PREBUFFER_FRAMES = 3; // 3 * 32 ms/frame = ~96 ms of buffer before (re-)starting playback.
25
+ // 512 samples/frame @ 16 kHz = 32 ms/frame, so 250 frames ~= 8 s of buffered mic audio. Caps
26
+ // memory if the outbound track ever falls behind (e.g. a stalled encoder) — process() consumes
27
+ // oldest-first, so an unbounded queue would otherwise grow forever and the caller would hear
28
+ // increasingly stale audio rather than current audio.
29
+ const MAX_QUEUE_FRAMES = 250;
30
+
31
+ class TrackFeederProcessor extends AudioWorkletProcessor {
32
+ constructor() {
33
+ super();
34
+ this.queue = []; // pending 16 kHz Float32Array frames, oldest first
35
+ this.offset = 0; // read position (samples) into queue[0]
36
+ this.muted = false; // true feeds silence without discarding queued frames
37
+ this.prebuffering = true; // true until PREBUFFER_FRAMES have queued; re-armed on underrun
38
+ this.port.onmessage = (event) => {
39
+ const msg = event.data;
40
+ if (!msg) return;
41
+ if (msg.type === 'frame') {
42
+ if (this.queue.length >= MAX_QUEUE_FRAMES) this.queue.shift(); // drop oldest, never block
43
+ this.queue.push(msg.frame);
44
+ } else if (msg.type === 'mute') this.muted = !!msg.value;
45
+ else if (msg.type === 'reset') { this.queue = []; this.offset = 0; this.prebuffering = true; }
46
+ };
47
+ }
48
+
49
+ process(_inputs, outputs) {
50
+ const output = outputs[0]?.[0];
51
+ if (!output) return true;
52
+ if (this.muted) { output.fill(0); return true; }
53
+
54
+ if (this.prebuffering) {
55
+ if (this.queue.length < PREBUFFER_FRAMES) { output.fill(0); return true; }
56
+ this.prebuffering = false;
57
+ }
58
+
59
+ for (let i = 0; i < output.length; i += 1) {
60
+ if (this.queue.length === 0) {
61
+ // Underrun: fill the rest of this quantum with silence and wait for a fresh prebuffer
62
+ // rather than draining the very next single frame the instant it arrives — that would just
63
+ // reopen the same gap on the next delivery burst.
64
+ output.fill(0, i);
65
+ this.prebuffering = true;
66
+ return true;
67
+ }
68
+ const frame = this.queue[0];
69
+ output[i] = frame[this.offset];
70
+ this.offset += 1;
71
+ if (this.offset >= frame.length) {
72
+ this.queue.shift();
73
+ this.offset = 0;
74
+ }
75
+ }
76
+ return true;
77
+ }
78
+ }
79
+
80
+ registerProcessor('voice-loop-track-feeder', TrackFeederProcessor);