@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,508 @@
1
+ // Session ambience: the bed that plays under a Dialt call, rendered in the SDK and played
2
+ // THROUGH the StreamingPlayer as an underlay (see player.js `setUnderlay`). Two modes:
3
+ //
4
+ // 'continuous' the bed runs for the whole call from the first reply onward, so the silence
5
+ // between turns feels connected rather than dead (the playground's background
6
+ // music).
7
+ // 'thinking' the bed is silent except while Dialt is blocking on a tool result with
8
+ // nothing to say (the server's `working` frame): after a short threshold it fades
9
+ // in, and it fades out again under the reply's first syllables. A caller waiting
10
+ // on a slow backend hears "still working" instead of dead air - and nothing else.
11
+ //
12
+ // WHY IT PLAYS THROUGH THE PLAYER AND NOT ON ITS OWN AudioContext. An earlier bed (web/ambience.js,
13
+ // deleted) ran on a separate context, which is invisible to the SDK's own WASM echo canceller on
14
+ // WebKit: its far-end reference is built solely from what the player schedules, so the bed reached
15
+ // the mic as if the user were speaking, and the playground had to disable it on iOS/Safari. Mixed
16
+ // into the player's scheduled chunks it is part of that reference by construction, including on
17
+ // iOS. It also gives the thinking sound a real crossfade: the fade-out is baked into the same
18
+ // buffers as the reply's first chunks, not queued behind them. This is the WebSocket transport's
19
+ // path: over WebRTC reply audio is a remote track that never passes through this player, so the
20
+ // controller stays silent there and the server-mixed `mode.background_audio` bed is the option.
21
+ //
22
+ // THE MUSIC follows serving/ambience.py's design and constants (E flat minor <-> F sharp swells
23
+ // over a low D sharp pedal, a soft arpeggio and sparse high colour tones), every layer on a
24
+ // jittered clock so the bed has no repeating phrase for an echo canceller's delay estimator to
25
+ // lock onto (that estimator once mis-locked on a self-similar ready chime and caused relay
26
+ // self-barge). It is rendered once per page, deterministically; the two renderers share the
27
+ // score, not the samples (different PRNGs), so the server-mixed WebRTC bed and this one are the
28
+ // same piece, not the same recording. The bed never plays as a session's first audio: continuous
29
+ // mode starts behind the first reply, and the thinking sound can only follow a spoken bridge.
30
+ import { SAMPLE_RATE } from './audio.js';
31
+
32
+ // Pitches and timings, straight from serving/ambience.py.
33
+ export const EBM = [155.56, 185.00, 233.08]; // D#3 F#3 A#3 - E flat minor core
34
+ export const FS = [185.00, 233.08, 277.18]; // F#3 A#3 C#4 - F sharp core
35
+ export const PEDAL = [77.78, 155.56]; // D#2 D#3
36
+ export const COLOR = [466.16, 554.37, 622.25]; // A#4 C#5 D#5
37
+ export const CHORD_S = 6.5;
38
+ export const OSTINATO_S = 0.155;
39
+ export const COLOR_S = 9.0;
40
+ export const BED_SECONDS = 120;
41
+ export const SEAM_S = 2.0;
42
+ // Peak level of the rendered bed, linear (about -21 dBFS): it sums with speech that already
43
+ // peaks near full scale, so headroom is what keeps the mix from clipping under a loud reply.
44
+ export const BED_PEAK = 0.09;
45
+ export const BED_SEED = 20260818;
46
+
47
+ // Thinking-sound envelope defaults. The threshold counts from the spoken bridge's last word,
48
+ // which is itself ~2 s after the user stopped speaking, so nothing is heard until roughly 3.5 s
49
+ // of user-perceived wait; a tool that answers within the threshold costs no blip at all.
50
+ export const THINKING_AFTER_S = 1.5;
51
+ export const FADE_IN_S = 1.5;
52
+ export const FADE_OUT_S = 0.3;
53
+
54
+ export const AMBIENCE_MODES = ['off', 'thinking', 'continuous'];
55
+
56
+ // --- rendering ------------------------------------------------------------------------------
57
+
58
+ // mulberry32: tiny, seedable, good enough for musical jitter. The bed must be identical across
59
+ // sessions (and across runs, so a test can assert on it) yet never a short repeating pattern -
60
+ // that is what the per-event jitter below is for.
61
+ function makeRng(seed) {
62
+ let a = seed >>> 0;
63
+ return () => {
64
+ a = (a + 0x6D2B79F5) >>> 0;
65
+ let t = a;
66
+ t = Math.imul(t ^ (t >>> 15), t | 1);
67
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
68
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
69
+ };
70
+ }
71
+ const uniform = (rng, lo, hi) => lo + (hi - lo) * rng();
72
+ const choice = (rng, items) => items[Math.floor(rng() * items.length)];
73
+
74
+ // Slow contours (envelopes, pitch drift) are evaluated on a coarse grid and interpolated: they are
75
+ // tens of thousands of samples long and a per-sample Math.pow was most of the render's cost.
76
+ const ENV_GRID = 64;
77
+
78
+ // A slow asymmetric rise and fall: a longer rise than fall reads as breathing, not pulsing, and
79
+ // the exponent jitters so no two swells share a shape.
80
+ function swell(n, rng) {
81
+ const rise = uniform(rng, 0.45, 0.65);
82
+ const curve = uniform(rng, 1.6, 2.4);
83
+ const peak = Math.max(1, Math.floor(n * rise));
84
+ const fall = Math.max(1, n - peak);
85
+ const shape = (i) => (i < peak ? (i / peak) ** curve : (Math.max(0, 1 - (i - peak) / fall)) ** curve);
86
+ const env = new Float32Array(n);
87
+ let prevI = 0;
88
+ let prev = shape(0);
89
+ for (let g = ENV_GRID; g < n + ENV_GRID; g += ENV_GRID) {
90
+ const nextI = Math.min(n - 1, g);
91
+ // the peak is a corner: land a knot on it so interpolation does not round it off
92
+ const stops = prevI < peak && peak <= nextI ? [peak, nextI] : [nextI];
93
+ for (const stopI of stops) {
94
+ if (stopI <= prevI) continue;
95
+ const next = shape(stopI);
96
+ const span = stopI - prevI;
97
+ for (let i = prevI; i <= stopI; i += 1) env[i] = prev + (next - prev) * ((i - prevI) / span);
98
+ prevI = stopI;
99
+ prev = next;
100
+ }
101
+ if (nextI >= n - 1) break;
102
+ }
103
+ return env;
104
+ }
105
+
106
+ // A smooth contour in roughly [-1, 1] that never repeats: a random walk between knots, not a sum
107
+ // of slow sines (those realign, and measured over the loop that showed up as self-correlation).
108
+ // `n` points spanning `seconds` of time.
109
+ function slowNoise(n, seconds, rng, perS = 2.0) {
110
+ const knots = Math.max(2, Math.floor(seconds * perS) + 2);
111
+ const values = new Float32Array(knots);
112
+ for (let k = 0; k < knots; k += 1) values[k] = uniform(rng, -1, 1);
113
+ const out = new Float32Array(n);
114
+ const scale = (knots - 1) / Math.max(1, n - 1);
115
+ for (let i = 0; i < n; i += 1) {
116
+ const x = i * scale;
117
+ const k = Math.min(knots - 2, Math.floor(x));
118
+ const frac = x - k;
119
+ const smooth = frac * frac * (3 - 2 * frac); // smoothstep: continuous slope at each knot
120
+ out[i] = values[k] * (1 - smooth) + values[k + 1] * smooth;
121
+ }
122
+ return out;
123
+ }
124
+
125
+ // A tone whose pitch wanders by a fraction of a percent along a non-repeating contour, far too
126
+ // small to hear as pitch movement (0.4% is under a tenth of a semitone) but enough that no two
127
+ // seconds line up - a held pure sine is stationary and correlates with itself at every period.
128
+ // Generated as a rotating phasor: the rotation step is re-evaluated from the (block-rate) contour
129
+ // every ENV_GRID samples and the phasor renormalised each block, which keeps the waveform
130
+ // continuous while the frequency moves. Mixes straight into `into` at `at` (with wrap), scaled
131
+ // by the per-sample `gain` envelope, so the long pedal tones never materialise a second
132
+ // full-length array.
133
+ // `gain` is per sample, or per ENV_GRID block when its length is the block count.
134
+ function driftingTone(freq, n, sr, rng, { into, at = 0, gain = null, depth = 0.004 }) {
135
+ const blocks = Math.ceil(n / ENV_GRID);
136
+ const contour = slowNoise(blocks, n / sr, rng);
137
+ const blockGain = gain != null && gain.length === blocks && blocks !== n;
138
+ const len = into.length;
139
+ const phase0 = uniform(rng, 0, 2 * Math.PI);
140
+ let x = Math.cos(phase0);
141
+ let y = Math.sin(phase0);
142
+ const k = (2 * Math.PI) / sr;
143
+ let pos = ((at % len) + len) % len;
144
+ for (let b = 0; b < blocks; b += 1) {
145
+ const start = b * ENV_GRID;
146
+ const end = Math.min(n, start + ENV_GRID);
147
+ const step = k * freq * (1 + depth * contour[b]);
148
+ const c = Math.cos(step);
149
+ const sn = Math.sin(step);
150
+ const g = blockGain ? gain[b] : 1;
151
+ for (let i = start; i < end; i += 1) {
152
+ const nx = x * c - y * sn;
153
+ y = x * sn + y * c;
154
+ x = nx;
155
+ into[pos] += y * (blockGain ? g : (gain ? gain[i] : 1));
156
+ pos += 1;
157
+ if (pos === len) pos = 0;
158
+ }
159
+ const mag = Math.hypot(x, y) || 1;
160
+ x /= mag; y /= mag;
161
+ }
162
+ }
163
+
164
+ // A short pluck: constant-frequency phasor with an exponential decay, mixed in at `at`.
165
+ function pluck(freq, n, sr, phase, decay, level, into, at) {
166
+ const len = into.length;
167
+ const step = (2 * Math.PI * freq) / sr;
168
+ const c = Math.cos(step);
169
+ const sn = Math.sin(step);
170
+ let x = Math.cos(phase);
171
+ let y = Math.sin(phase);
172
+ let pos = ((at % len) + len) % len;
173
+ const tau = Math.max(1, n - 1);
174
+ for (let i = 0; i < n; i += 1) {
175
+ into[pos] += y * Math.exp(-decay * (i / tau)) * level;
176
+ pos += 1;
177
+ if (pos === len) pos = 0;
178
+ const nx = x * c - y * sn;
179
+ y = x * sn + y * c;
180
+ x = nx;
181
+ }
182
+ }
183
+
184
+ // The render as a sequence of steps, so the async path can yield to the event loop every few
185
+ // notes (the whole bed is several hundred ms of CPU on a laptop, more on a phone - never something
186
+ // to do on the main thread in one go while a session is live; sliced this finely it is invisible).
187
+ function* bedSteps(sampleRate, seconds, seed) {
188
+ const rng = makeRng(seed);
189
+ const n = Math.round(sampleRate * seconds);
190
+ const buf = new Float32Array(n);
191
+ let sinceYield = 0;
192
+
193
+ // Pedal: two low sines held for the whole loop, drifting slowly in level so the floor of the
194
+ // bed is never perfectly static. One tone per slice; the level contour lives on the block grid.
195
+ for (let i = 0; i < PEDAL.length; i += 1) {
196
+ const depth = uniform(rng, 0.15, 0.3);
197
+ const blocks = Math.ceil(n / ENV_GRID);
198
+ const env = slowNoise(blocks, seconds, rng, 0.05);
199
+ const level = i ? 0.30 : 0.22;
200
+ for (let b = 0; b < blocks; b += 1) env[b] = (1 - depth + depth * (0.5 + 0.5 * env[b])) * level;
201
+ driftingTone(PEDAL[i], n, sampleRate, rng, { into: buf, gain: env });
202
+ yield;
203
+ }
204
+
205
+ // Harmony: swells alternating between the two chords, each note entering slightly apart from
206
+ // its neighbours so the chord blooms rather than switches.
207
+ let pos = 0;
208
+ let toggle = false;
209
+ while (pos < seconds) {
210
+ const chord = toggle ? FS : EBM;
211
+ toggle = !toggle;
212
+ const length = CHORD_S * uniform(rng, 0.75, 1.35);
213
+ for (const freq of chord) {
214
+ const offset = uniform(rng, 0, 0.9);
215
+ const segN = Math.round(sampleRate * length);
216
+ if (segN <= 0) continue;
217
+ const env = swell(segN, rng);
218
+ const level = uniform(rng, 0.10, 0.17);
219
+ for (let s = 0; s < segN; s += 1) env[s] *= level;
220
+ driftingTone(freq, segN, sampleRate, rng, {
221
+ into: buf, at: Math.round((pos + offset) * sampleRate), gain: env,
222
+ });
223
+ }
224
+ pos += length * uniform(rng, 0.55, 0.8); // overlap successive chords
225
+ if ((sinceYield += 1) % 2 === 0) yield;
226
+ }
227
+ yield;
228
+
229
+ // Arpeggio: short plucks, spacing and note choice both jittered - the "life" layer, and the one
230
+ // most likely to hand an estimator a pattern, hence the heaviest jitter.
231
+ pos = 0;
232
+ while (pos < seconds) {
233
+ const chord = Math.floor(pos / CHORD_S) % 2 ? FS : EBM;
234
+ const freq = choice(rng, chord) * choice(rng, [1, 2]);
235
+ const segN = Math.round(sampleRate * uniform(rng, 0.10, 0.22));
236
+ const phase = uniform(rng, 0, 2 * Math.PI);
237
+ const decay = uniform(rng, 5.0, 9.0);
238
+ const level = uniform(rng, 0.05, 0.09);
239
+ pluck(freq, segN, sampleRate, phase, decay, level, buf, Math.round(pos * sampleRate));
240
+ pos += OSTINATO_S * uniform(rng, 0.6, 1.9);
241
+ if ((sinceYield += 1) % 64 === 0) yield;
242
+ }
243
+ yield;
244
+
245
+ // Colour: sparse high tones, rare enough to register as events rather than texture.
246
+ pos = uniform(rng, 0, COLOR_S);
247
+ while (pos < seconds) {
248
+ const freq = choice(rng, COLOR);
249
+ const segN = Math.round(sampleRate * uniform(rng, 1.4, 2.6));
250
+ const env = swell(segN, rng);
251
+ const level = uniform(rng, 0.03, 0.05);
252
+ for (let s = 0; s < segN; s += 1) env[s] *= level;
253
+ driftingTone(freq, segN, sampleRate, rng, { into: buf, at: Math.round(pos * sampleRate), gain: env });
254
+ pos += COLOR_S * uniform(rng, 0.5, 1.8);
255
+ }
256
+
257
+ // Seam crossfade, in place: fold the tail over the head so playback wraps without a click - a
258
+ // click every N seconds would be exactly the periodic landmark the jitter is there to avoid.
259
+ // Done before normalisation so the peak measured below is the peak actually played.
260
+ const seam = Math.min(Math.round(SEAM_S * sampleRate), Math.floor(n / 4));
261
+ const len = n - seam;
262
+ for (let i = 0; i < seam; i += 1) {
263
+ const fade = i / seam;
264
+ buf[i] = buf[i] * fade + buf[len + i] * (1 - fade);
265
+ }
266
+ const out = buf.subarray(0, len);
267
+ let peak = 0;
268
+ for (let i = 0; i < out.length; i += 1) peak = Math.max(peak, Math.abs(out[i]));
269
+ const scale = BED_PEAK / (peak || 1);
270
+ for (let i = 0; i < out.length; i += 1) out[i] *= scale;
271
+ return out;
272
+ }
273
+
274
+ /** Render the bed synchronously: Float32Array mono, peak BED_PEAK. Deterministic for a given
275
+ * (sampleRate, seconds, seed). Tests; the SDK itself uses renderBedAsync. */
276
+ export function renderBed(sampleRate = SAMPLE_RATE, seconds = BED_SECONDS, seed = BED_SEED) {
277
+ for (const steps = bedSteps(sampleRate, seconds, seed); ;) {
278
+ const r = steps.next();
279
+ if (r.done) return r.value;
280
+ }
281
+ }
282
+
283
+ // A macrotask yield that is not subject to the nested-setTimeout 4 ms clamp (MessageChannel),
284
+ // falling back to setTimeout(0) where it does not exist (node tests).
285
+ function yieldToLoop() {
286
+ if (typeof MessageChannel === 'function') {
287
+ return new Promise((resolve) => {
288
+ const ch = new MessageChannel();
289
+ ch.port1.onmessage = () => { ch.port1.close(); resolve(); };
290
+ ch.port2.postMessage(0);
291
+ });
292
+ }
293
+ return new Promise((resolve) => setTimeout(resolve, 0));
294
+ }
295
+
296
+ /** Render the bed a few notes at a time, yielding to the event loop in between. */
297
+ export async function renderBedAsync(sampleRate = SAMPLE_RATE, seconds = BED_SECONDS,
298
+ seed = BED_SEED) {
299
+ for (const steps = bedSteps(sampleRate, seconds, seed); ;) {
300
+ const r = steps.next();
301
+ if (r.done) return r.value;
302
+ await yieldToLoop();
303
+ }
304
+ }
305
+
306
+ // One shared render per (sampleRate, seconds, seed) per page: every client on the page reads the
307
+ // same buffer and holds only a cursor into it.
308
+ const _cache = new Map();
309
+ export function sharedBed(sampleRate = SAMPLE_RATE, seconds = BED_SECONDS, seed = BED_SEED) {
310
+ const key = `${sampleRate}:${seconds}:${seed}`;
311
+ let pending = _cache.get(key);
312
+ if (!pending) {
313
+ pending = renderBedAsync(sampleRate, seconds, seed);
314
+ _cache.set(key, pending);
315
+ pending.catch(() => _cache.delete(key));
316
+ }
317
+ return pending;
318
+ }
319
+
320
+ // --- playback cursor + envelope ------------------------------------------------------------
321
+
322
+ /** A gain-enveloped cursor over a rendered bed. `start()`/`stop()` set the envelope target;
323
+ * `mixInto(dst)` adds the next samples at the ramping gain into a buffer in place (false while
324
+ * silent, so the player can skip the mix entirely); `next(n)` is the allocating form.
325
+ * `snapshot()`/`restore()` let the player take back audio it scheduled but then cut before it
326
+ * played. The player drives it; apps never touch it directly. */
327
+ export class AmbienceBed {
328
+ constructor(bed, { sampleRate = SAMPLE_RATE, fadeInS = FADE_IN_S, fadeOutS = FADE_OUT_S,
329
+ level = 1, offset = null } = {}) {
330
+ this.bed = bed;
331
+ this.level = level;
332
+ this._inStep = 1 / Math.max(1, Math.round(fadeInS * sampleRate));
333
+ this._outStep = 1 / Math.max(1, Math.round(fadeOutS * sampleRate));
334
+ // Each session starts at its own point in the loop so two calls placed together are not
335
+ // phase-locked to the same phrase.
336
+ this._pos = bed.length ? (offset ?? Math.floor(Math.random() * bed.length)) % bed.length : 0;
337
+ this._gain = 0;
338
+ this._target = 0;
339
+ }
340
+
341
+ get audible() { return this._gain > 0 || this._target > 0; } // something to render
342
+ get playing() { return this._target > 0; } // not fading out
343
+ get gain() { return this._gain; }
344
+
345
+ start() { this._target = 1; }
346
+ stop() { this._target = 0; }
347
+
348
+ snapshot() { return { pos: this._pos, gain: this._gain }; }
349
+ restore(snap) { this._pos = snap.pos; this._gain = snap.gain; }
350
+
351
+ /** Add the next `dst.length` samples (envelope applied) into `dst`; false while silent. */
352
+ mixInto(dst) {
353
+ if (!dst.length || (this._gain === 0 && this._target === 0)) return false;
354
+ const bed = this.bed;
355
+ const len = bed.length;
356
+ const n = dst.length;
357
+ const step = this._target > this._gain ? this._inStep
358
+ : (this._target < this._gain ? -this._outStep : 0);
359
+ let g = this._gain;
360
+ let pos = this._pos;
361
+ if (step === 0) {
362
+ const k = g * this.level; // steady state: a constant multiply
363
+ for (let i = 0; i < n; i += 1) { dst[i] += bed[pos] * k; pos += 1; if (pos >= len) pos = 0; }
364
+ } else {
365
+ for (let i = 0; i < n; i += 1) {
366
+ g = Math.min(1, Math.max(0, g + step));
367
+ dst[i] += bed[pos] * g * this.level;
368
+ pos += 1;
369
+ if (pos >= len) pos = 0;
370
+ }
371
+ }
372
+ this._gain = g;
373
+ this._pos = pos;
374
+ return true;
375
+ }
376
+
377
+ /** The next `n` samples (Float32Array) with the envelope applied, or null while silent. */
378
+ next(n) {
379
+ if (n <= 0) return null;
380
+ const out = new Float32Array(n);
381
+ return this.mixInto(out) ? out : null;
382
+ }
383
+ }
384
+
385
+ // --- controller ------------------------------------------------------------------------------
386
+
387
+ /** Drives one AmbienceBed on the client's player from the session's events. Owned by
388
+ * ConverseClient (its `ambience` option); apps interact through `client.setAmbience(mode)`.
389
+ *
390
+ * Holds facts only - mode, whether a reply has played this session, whether Dialt is working
391
+ * and whether the thinking threshold has elapsed - and derives the bed's envelope target from
392
+ * them in one place (`_reconcile`), so every event and the render's completion just update a
393
+ * fact and reconcile. */
394
+ export class Ambience {
395
+ constructor({ player, mode = 'off', afterS = THINKING_AFTER_S, fadeInS = FADE_IN_S,
396
+ fadeOutS = FADE_OUT_S, level = 1, seed = BED_SEED, seconds = BED_SECONDS, enabled = true,
397
+ setTimeoutImpl = (fn, ms) => globalThis.setTimeout(fn, ms),
398
+ clearTimeoutImpl = (id) => globalThis.clearTimeout(id) } = {}) {
399
+ if (!Number.isFinite(level) || level < 0) throw new TypeError('ambience level must be >= 0');
400
+ this.player = player;
401
+ this.enabled = !!enabled; // false where this player is not in the audio path (WebRTC)
402
+ this.mode = 'off';
403
+ this.afterS = afterS;
404
+ this.fadeInS = fadeInS;
405
+ this.fadeOutS = fadeOutS;
406
+ this.level = level;
407
+ this.seed = seed;
408
+ this.seconds = seconds;
409
+ this._setTimeout = setTimeoutImpl;
410
+ this._clearTimeout = clearTimeoutImpl;
411
+ this.bed = null; // AmbienceBed once rendered and attached
412
+ this._render = null; // in-flight render, if any
413
+ this._timer = null; // thinking-mode threshold
414
+ this._sessionStarted = false; // a reply has played: the bed may follow it, never lead it
415
+ this._working = false; // server `working` state
416
+ this._thresholdElapsed = false; // thinking: THINKING_AFTER_S of working with nothing audible
417
+ this.setMode(mode);
418
+ }
419
+
420
+ /** Render (or reuse) the bed and attach it to the player. Idempotent; started by `setMode`
421
+ * for any non-'off' mode so the render is never on the reply's path. */
422
+ prerender() {
423
+ this._render ??= sharedBed(SAMPLE_RATE, this.seconds, this.seed).then((samples) => {
424
+ this.bed = new AmbienceBed(samples, {
425
+ fadeInS: this.fadeInS, fadeOutS: this.fadeOutS, level: this.level,
426
+ });
427
+ this.player?.setUnderlay?.(this.bed);
428
+ this._reconcile();
429
+ return this.bed;
430
+ });
431
+ return this._render;
432
+ }
433
+
434
+ /** Switch modes live (the playground's toggle). Continuous engages at once if a reply has
435
+ * already played this session; thinking waits for the next `working`. */
436
+ setMode(mode) {
437
+ if (!AMBIENCE_MODES.includes(mode)) {
438
+ throw new TypeError(`ambience must be one of ${AMBIENCE_MODES.join(', ')}`);
439
+ }
440
+ if (mode === this.mode) return;
441
+ this.mode = mode;
442
+ if (mode !== 'off' && this.enabled) this.prerender().catch(() => {});
443
+ if (mode === 'thinking' && this._working) this._armThreshold();
444
+ else this._clearThreshold();
445
+ this._reconcile();
446
+ }
447
+
448
+ /** Server `working` frame: Dialt is blocking on a tool result with nothing audible. */
449
+ onWorking(active) {
450
+ this._working = !!active;
451
+ if (this._working && this.mode === 'thinking') this._armThreshold();
452
+ else this._clearThreshold();
453
+ this._reconcile();
454
+ }
455
+
456
+ /** A reply is starting (the `turn` frame / first reply audio). Continuous: the bed may now
457
+ * begin, behind the reply. Thinking: whatever is playing fades out under the reply. */
458
+ onReplyStart() {
459
+ this._sessionStarted = true;
460
+ this._clearThreshold(); // the reply ends this wait's cover
461
+ this._reconcile();
462
+ }
463
+
464
+ /** A reply finished (`done`). If Dialt is still working (a narration or ask voiced in the
465
+ * middle of the wait), the silence clock starts again from here. */
466
+ onReplyEnd() {
467
+ if (this._working && this.mode === 'thinking') this._armThreshold();
468
+ }
469
+
470
+ /** Session over (close, session_end): silence and forget session state; keeps the render. */
471
+ stop() {
472
+ this._working = false;
473
+ this._sessionStarted = false;
474
+ this._clearThreshold();
475
+ this._reconcile();
476
+ }
477
+
478
+ _armThreshold() {
479
+ if (this._timer != null || this._thresholdElapsed) return;
480
+ this._timer = this._setTimeout(() => {
481
+ this._timer = null;
482
+ this._thresholdElapsed = true;
483
+ this._reconcile();
484
+ }, Math.max(0, this.afterS * 1000));
485
+ }
486
+
487
+ _clearThreshold() {
488
+ if (this._timer != null) {
489
+ this._clearTimeout(this._timer);
490
+ this._timer = null;
491
+ }
492
+ this._thresholdElapsed = false;
493
+ }
494
+
495
+ _reconcile() {
496
+ const wanted = !this.enabled ? false
497
+ : this.mode === 'continuous' ? this._sessionStarted
498
+ : this.mode === 'thinking' ? (this._working && this._thresholdElapsed)
499
+ : false;
500
+ if (!this.bed) return; // the render's completion reconciles again
501
+ if (wanted) {
502
+ this.bed.start();
503
+ this.player?.resumeUnderlay?.();
504
+ } else {
505
+ this.bed.stop();
506
+ }
507
+ }
508
+ }
package/src/audio.js ADDED
@@ -0,0 +1,79 @@
1
+ export const SAMPLE_RATE = 16000;
2
+ export const FRAME_SAMPLES = 512;
3
+ export const UPLINK_FORMAT_TAGGED = 'tagged-pcm16-v1';
4
+ export const UPLINK_CHANNEL_PROCESSED = 0;
5
+ export const UPLINK_CHANNEL_RAW = 1;
6
+ const UPLINK_HEADER_BYTES = 12;
7
+
8
+ export function floatToPcm16Bytes(audio) {
9
+ if (audio instanceof Uint8Array) return audio;
10
+ if (audio instanceof Int16Array) return new Uint8Array(audio.buffer, audio.byteOffset, audio.byteLength);
11
+ const pcm = new Int16Array(audio.length);
12
+ for (let i = 0; i < audio.length; i += 1) {
13
+ const s = Math.max(-1, Math.min(1, audio[i]));
14
+ pcm[i] = s < 0 ? s * 32768 : s * 32767;
15
+ }
16
+ return new Uint8Array(pcm.buffer);
17
+ }
18
+
19
+ // Negotiated v1 uplink: "VL", version, channel, uint32 sequence, uint32 capture clock ms,
20
+ // then PCM16. The capture clock is performance.now() on the page's shared monotonic timeline,
21
+ // so independently-opened desktop streams can still be correlated server-side.
22
+ export function encodeTaggedPcm16(audio, { channel, sequence, captureMs }) {
23
+ if (channel !== UPLINK_CHANNEL_PROCESSED && channel !== UPLINK_CHANNEL_RAW) {
24
+ throw new RangeError('unknown uplink channel');
25
+ }
26
+ const pcm = floatToPcm16Bytes(audio);
27
+ const out = new Uint8Array(UPLINK_HEADER_BYTES + pcm.byteLength);
28
+ out[0] = 0x56; out[1] = 0x4c; out[2] = 1; out[3] = channel;
29
+ const view = new DataView(out.buffer);
30
+ view.setUint32(4, sequence >>> 0, true);
31
+ view.setUint32(8, Math.max(0, Math.round(captureMs || 0)) >>> 0, true);
32
+ out.set(pcm, UPLINK_HEADER_BYTES);
33
+ return out;
34
+ }
35
+
36
+ // Base64-encode bytes in chunks (String.fromCharCode.apply caps out on large arrays). Used for
37
+ // the optional raw-mic ablation track, which rides a JSON control rather than a binary frame.
38
+ export function bytesToBase64(bytes) {
39
+ let bin = '';
40
+ for (let i = 0; i < bytes.length; i += 0x8000) {
41
+ bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
42
+ }
43
+ return btoa(bin);
44
+ }
45
+
46
+ // Decodes assistant audio binary frames into Web-Audio-ready Float32 samples. The server's wire
47
+ // default is now pcm16 (audio.output_encoding defaults to "pcm16" when the start frame omits it —
48
+ // see serving/broker_ws.py); this client never sends that field, so it always gets pcm16 and must
49
+ // decode int16 samples, not float32. (A client wanting the old always-float wire format would
50
+ // send start.audio.output_encoding="pcm_f32le" and decode differently — not needed here.)
51
+ export async function binaryToFloat32(data) {
52
+ let bytes;
53
+ if (data instanceof ArrayBuffer) bytes = new Uint8Array(data);
54
+ else if (data instanceof Uint8Array) bytes = data;
55
+ else if (ArrayBuffer.isView(data)) bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
56
+ else if (data instanceof Blob) bytes = new Uint8Array(await data.arrayBuffer());
57
+ else throw new TypeError('unsupported binary audio payload');
58
+ if (bytes.byteLength % 2 !== 0) throw new Error('pcm16 payload must be divisible by 2');
59
+ const pcm16 = new Int16Array(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
60
+ const out = new Float32Array(pcm16.length);
61
+ for (let i = 0; i < pcm16.length; i += 1) {
62
+ out[i] = pcm16[i] / (pcm16[i] < 0 ? 32768 : 32767);
63
+ }
64
+ return out;
65
+ }
66
+
67
+ export function toWebSocketUrl(url) {
68
+ const u = new URL(url);
69
+ if (u.protocol === 'https:') u.protocol = 'wss:';
70
+ if (u.protocol === 'http:') u.protocol = 'ws:';
71
+ return u.toString();
72
+ }
73
+
74
+ export function createSessionId() {
75
+ if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
76
+ const bytes = new Uint8Array(16);
77
+ globalThis.crypto.getRandomValues(bytes);
78
+ return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
79
+ }