@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.
package/src/index.js ADDED
@@ -0,0 +1,1798 @@
1
+ import {
2
+ binaryToFloat32, bytesToBase64, createSessionId, encodeTaggedPcm16,
3
+ floatToPcm16Bytes, SAMPLE_RATE, toWebSocketUrl,
4
+ UPLINK_CHANNEL_PROCESSED, UPLINK_CHANNEL_RAW, UPLINK_FORMAT_TAGGED,
5
+ } from './audio.js';
6
+ import { StreamingPlayer } from './player.js';
7
+ import { Ambience } from './ambience.js';
8
+ import { EchoCanceller, needsSdkAec } from './aec.js';
9
+ import { CaptureAbortedError, CaptureStalledError, MicCapture } from './mic.js';
10
+ import { TrackFeeder, WebRtcSession } from './webrtc.js';
11
+
12
+ export {
13
+ FRAME_SAMPLES, SAMPLE_RATE, binaryToFloat32, createSessionId, encodeTaggedPcm16,
14
+ floatToPcm16Bytes, toWebSocketUrl,
15
+ UPLINK_CHANNEL_PROCESSED, UPLINK_CHANNEL_RAW, UPLINK_FORMAT_TAGGED,
16
+ } from './audio.js';
17
+ export { StreamingPlayer } from './player.js';
18
+ export { Ambience, AMBIENCE_MODES, renderBed, BED_PEAK } from './ambience.js';
19
+ export { EchoCanceller, needsSdkAec } from './aec.js';
20
+ export { CaptureAbortedError, CaptureStalledError, MicCapture } from './mic.js';
21
+ export { TrackFeeder, WebRtcSession } from './webrtc.js';
22
+
23
+ // NO OUTPUT-ROUTING CODE, BY EXPERIMENT (2026-07-30): never touch navigator.audioSession —
24
+ // 'playback' breaks getUserMedia on real iPhones (a mic outage), and an 11-configuration
25
+ // on-device test showed modern iOS gives web pages no earpiece/speaker control at all (see
26
+ // StreamingPlayer's header + docs/user-feedback.md). A regression test pins this.
27
+ const LISTENING_WARMUP_FRAMES = 16; // Custom capture readiness; startMic uses its first-frame gate.
28
+
29
+ // Barge hard-clear fade (server sends `interrupted` with clear:true): long enough to read as a
30
+ // yield rather than a glitch, short enough that silence lands ~immediately vs the old drain.
31
+ const BARGE_CLEAR_FADE_S = 0.15;
32
+ const captureClockMs = () => {
33
+ const monotonic = globalThis.performance?.now?.();
34
+ return Number.isFinite(monotonic) ? monotonic : Date.now();
35
+ };
36
+
37
+ // The browser SDK is thin: stream mic frames up (via pushMicFrame), play assistant audio down, and
38
+ // reflect server events. Barge-in is SERVER-side — the broker runs the Silero reflex on the AEC'd
39
+ // mic uplink and yields the floor itself — so the client has no VAD/detection at all. This keeps
40
+ // every client (browser, native, phone) identical, and there's no onnxruntime-web to load.
41
+ //
42
+ // Server events: `turn` (reply starting), `asr`/`utterance` (transcripts), `done` (reply finished),
43
+ // `interrupted` (a final barge — the server stopped sending), and `canceled` (eager speculation
44
+ // retracted).
45
+ // One short-lived authorized control frame (feedback, client_error): open a fresh socket — the
46
+ // session's own socket is usually closed by the time these fire — send the frame, resolve on the
47
+ // server's ok. `kind` only labels the errors.
48
+ function brokerError(frame, fallback) {
49
+ const err = new Error(frame?.detail || fallback);
50
+ err.code = frame?.code;
51
+ err.retryable = frame?.retryable;
52
+ return err;
53
+ }
54
+
55
+ // A promise can reject with anything, not just an Error -- WASM module instantiation across
56
+ // some bundler/browser combos is one real case. String(plainObject) yields the useless
57
+ // "[object Object]"; JSON.stringify at least surfaces its fields, with String() as the final
58
+ // fallback for values JSON.stringify can't render (undefined, a function, a circular ref).
59
+ function _describeThrown(err) {
60
+ if (typeof err?.message === 'string') return err.message;
61
+ try {
62
+ const json = JSON.stringify(err);
63
+ if (json !== undefined) return json;
64
+ } catch {
65
+ // circular reference or similar -- fall through to String()
66
+ }
67
+ return String(err ?? '');
68
+ }
69
+
70
+ function sendOneShotFrame(kind, frame, { url, WebSocketImpl = globalThis.WebSocket, timeoutMs = 5000 }) {
71
+ return new Promise((resolve, reject) => {
72
+ const ws = new WebSocketImpl(toWebSocketUrl(url));
73
+ const timer = setTimeout(() => {
74
+ try { ws.close(); } catch { /* noop */ }
75
+ reject(new Error(`${kind} timed out`));
76
+ }, timeoutMs);
77
+ const settle = (fn, value) => { clearTimeout(timer); fn(value); };
78
+ ws.addEventListener('open', () => { ws.send(JSON.stringify(frame)); }, { once: true });
79
+ ws.addEventListener('message', (ev) => {
80
+ try {
81
+ const m = JSON.parse(ev.data);
82
+ if (m.type === 'ok') settle(resolve, true);
83
+ else settle(reject, new Error(m.detail || `${kind} rejected`));
84
+ } catch (err) {
85
+ settle(reject, err);
86
+ }
87
+ try { ws.close(1000); } catch { /* noop */ }
88
+ }, { once: true });
89
+ ws.addEventListener('error', () => settle(reject, new Error(`${kind} socket failed`)), { once: true });
90
+ // A clean close without a reply (proxy cut the socket, server died between accept and ack)
91
+ // must fail fast, not wait out timeoutMs. settle() on an already-settled promise is a no-op.
92
+ ws.addEventListener('close', () => settle(reject, new Error(`${kind} socket closed before ok`)),
93
+ { once: true });
94
+ });
95
+ }
96
+
97
+ // Post-session feedback (thumbs + optional comment + device/browser tags). The server files it
98
+ // next to the session's recording, so pass the ConverseClient's sessionId.
99
+ export function sendFeedback({ url, sessionId, rating, text, device, browser, apiKey,
100
+ WebSocketImpl, timeoutMs } = {}) {
101
+ if (!url || !sessionId) return Promise.reject(new Error('url and sessionId are required'));
102
+ const frame = { type: 'feedback', session_id: sessionId };
103
+ if (rating) frame.rating = rating;
104
+ if (text) frame.text = text;
105
+ if (device) frame.device = device;
106
+ if (browser) frame.browser = browser;
107
+ if (apiKey) frame.api_key = apiKey;
108
+ return sendOneShotFrame('feedback', frame, { url, WebSocketImpl, timeoutMs });
109
+ }
110
+
111
+ // Client-side failure report (mic permission denied, reconnect gave up, …): without it these are
112
+ // invisible server-side — the session log just shows a start with 0s of mic audio. The server
113
+ // journals it and, when the session's recording dir exists, files it alongside as
114
+ // client_errors.jsonl. sessionId is optional (some failures predate any session).
115
+ //
116
+ // `detail` and `context` are JOURNALED, and the journal is shipped off-box to a log backend. Send
117
+ // failure diagnostics only — never transcripts, replies, or anything the user typed or said.
118
+ export function sendClientError({ url, sessionId, detail, context, apiKey,
119
+ WebSocketImpl, timeoutMs } = {}) {
120
+ if (!url || !detail) return Promise.reject(new Error('url and detail are required'));
121
+ const frame = { type: 'client_error', detail };
122
+ if (sessionId) frame.session_id = sessionId;
123
+ if (context) frame.context = context;
124
+ if (apiKey) frame.api_key = apiKey;
125
+ return sendOneShotFrame('client_error', frame, { url, WebSocketImpl, timeoutMs });
126
+ }
127
+
128
+ const CONVERSE_MODE_FIELDS = new Set([
129
+ 'kind', 'modality', 'voice', 'instructions', 'tools', 'web_search', 'end_call', 'flow', 'greeting',
130
+ 'temperature', 'silence_nudge_s', 'silence_end_s', 'tool_choice', 'background_audio',
131
+ ]);
132
+
133
+ // OpenAI/Gemini-style generation restriction. Structural validation only — tool-name membership
134
+ // is the server's call (it errors with `invalid_tool_choice` and changes nothing).
135
+ function validatedToolChoice(value) {
136
+ if (value === 'auto' || value === 'none' || value === 'required') return value;
137
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
138
+ const keys = Object.keys(value);
139
+ if (keys.length === 1 && keys[0] === 'tool' && typeof value.tool === 'string'
140
+ && value.tool.trim()) return value;
141
+ if (keys.length === 1 && keys[0] === 'allowed' && Array.isArray(value.allowed)
142
+ && value.allowed.length
143
+ && value.allowed.every((name) => typeof name === 'string' && name.trim())) return value;
144
+ }
145
+ throw new TypeError(
146
+ 'tool_choice must be "auto", "none", "required", {allowed: [...]}, or {tool: "..."}');
147
+ }
148
+ const RELAY_MODE_FIELDS = new Set(['kind', 'provider', 'model', 'voice', 'web_search']);
149
+
150
+ function validatedMode(value = { kind: 'converse' }) {
151
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
152
+ throw new TypeError('mode must be an object');
153
+ }
154
+ const mode = { ...value };
155
+ for (const [key, item] of Object.entries(mode)) {
156
+ if (item === undefined) delete mode[key];
157
+ }
158
+ const allowed = mode.kind === 'converse' ? CONVERSE_MODE_FIELDS
159
+ : mode.kind === 'relay' ? RELAY_MODE_FIELDS : null;
160
+ if (!allowed) throw new TypeError('mode.kind must be converse or relay');
161
+ const extra = Object.keys(mode).find((key) => !allowed.has(key));
162
+ if (extra) throw new TypeError(`unexpected ${mode.kind} mode field: ${extra}`);
163
+ const optionalString = (key) => {
164
+ if (Object.hasOwn(mode, key) && typeof mode[key] !== 'string') {
165
+ throw new TypeError(`${mode.kind} ${key} must be a string`);
166
+ }
167
+ };
168
+ const optionalBoolean = (key) => {
169
+ if (Object.hasOwn(mode, key) && typeof mode[key] !== 'boolean') {
170
+ throw new TypeError(`${mode.kind} ${key} must be a boolean`);
171
+ }
172
+ };
173
+ optionalString('voice');
174
+ optionalBoolean('web_search');
175
+ mode.web_search ??= false;
176
+ if (mode.kind === 'converse') {
177
+ if (mode.modality !== undefined && mode.modality !== 'voice' && mode.modality !== 'text') {
178
+ throw new TypeError('converse modality must be voice or text');
179
+ }
180
+ mode.modality ??= 'voice';
181
+ optionalString('instructions');
182
+ // Declares the managed end_call(farewell) tool so the agent can end the session.
183
+ optionalBoolean('end_call');
184
+ mode.end_call ??= false;
185
+ optionalBoolean('flow');
186
+ optionalBoolean('background_audio');
187
+ if (Object.hasOwn(mode, 'tools') && !Array.isArray(mode.tools)) {
188
+ throw new TypeError('converse tools must be an array');
189
+ }
190
+ if (Object.hasOwn(mode, 'tool_choice')) {
191
+ if (!Array.isArray(mode.tools) || !mode.tools.length) {
192
+ throw new TypeError('converse tool_choice requires a non-empty tools list');
193
+ }
194
+ mode.tool_choice = validatedToolChoice(mode.tool_choice);
195
+ }
196
+ if (mode.greeting != null && mode.greeting !== false && typeof mode.greeting !== 'string') {
197
+ throw new TypeError('converse greeting must be a string or false');
198
+ }
199
+ if (Object.hasOwn(mode, 'temperature') && (typeof mode.temperature !== 'number'
200
+ || !Number.isFinite(mode.temperature))) {
201
+ throw new TypeError('converse temperature must be a finite number');
202
+ }
203
+ // Per-session override of the broker's two-stage silence policy (env defaults: 10s/20s) — e.g.
204
+ // a benchmark harness with long simulated-user think-time. Omit either field to keep the
205
+ // broker's default for it; the broker also falls back to its defaults if these are omitted,
206
+ // non-positive, or silence_end_s <= silence_nudge_s.
207
+ for (const key of ['silence_nudge_s', 'silence_end_s']) {
208
+ if (Object.hasOwn(mode, key) && (typeof mode[key] !== 'number'
209
+ || !Number.isFinite(mode[key]) || mode[key] <= 0)) {
210
+ throw new TypeError(`converse ${key} must be a positive finite number`);
211
+ }
212
+ }
213
+ if (Object.hasOwn(mode, 'silence_nudge_s') && Object.hasOwn(mode, 'silence_end_s')
214
+ && mode.silence_end_s <= mode.silence_nudge_s) {
215
+ throw new TypeError('converse silence_end_s must be greater than silence_nudge_s');
216
+ }
217
+ } else {
218
+ if (typeof mode.provider !== 'string' || !mode.provider.trim()) {
219
+ throw new TypeError('relay mode provider is required');
220
+ }
221
+ optionalString('model');
222
+ }
223
+ return mode;
224
+ }
225
+
226
+ function resumeTokenFromState(state) {
227
+ if (state == null) return null;
228
+ if (typeof state !== 'object' || Array.isArray(state)) {
229
+ throw new TypeError('resumeState must be an object or null');
230
+ }
231
+ if (state.version !== 1) {
232
+ throw new TypeError('resumeState.version must be 1');
233
+ }
234
+ if (typeof state.resumeToken !== 'string' || !state.resumeToken) {
235
+ throw new TypeError('resumeState.resumeToken must be a non-empty string');
236
+ }
237
+ return state.resumeToken;
238
+ }
239
+
240
+ // Shared by _openOnce/_openOnceWebRtc: marks(ms since this connect attempt began), dispatched as
241
+ // `connect_timing` once `ready` lands so an app/playground can see where connect() time went.
242
+ function connectMarks() {
243
+ const t0 = globalThis.performance?.now?.() ?? 0;
244
+ const marks = {};
245
+ const mark = (name) => { marks[name] = Math.round((globalThis.performance?.now?.() ?? 0) - t0); };
246
+ return { marks, mark };
247
+ }
248
+
249
+ export class ConverseClient extends EventTarget {
250
+ constructor({ url, sessionId = createSessionId(), player, apiKey,
251
+ mode = { kind: 'converse' }, user, timezone, rawAssist = false,
252
+ WebSocketImpl = globalThis.WebSocket,
253
+ echoCancellerFactory = () => new EchoCanceller(),
254
+ autoReconnect = true, reconnectBaseMs = 500, reconnectMaxMs = 5000,
255
+ maxReconnectAttempts = 12, captureStartupTimeoutMs = 2000, inputDeviceId = null,
256
+ listeningWarmupFrames = LISTENING_WARMUP_FRAMES,
257
+ injectionAckTimeoutMs = 10000, resumeState = null,
258
+ transport = 'ws', RTCPeerConnectionImpl = globalThis.RTCPeerConnection,
259
+ ambience = undefined } = {}) {
260
+ super();
261
+ if (!url) throw new Error('url is required');
262
+ if (!WebSocketImpl) throw new Error('WebSocket is required');
263
+ if (transport !== 'ws' && transport !== 'webrtc') {
264
+ throw new TypeError('transport must be "ws" or "webrtc"');
265
+ }
266
+ if (transport === 'webrtc' && needsSdkAec()) {
267
+ // WebKit's echo cancellation is the SDK's own AEC3 canceller, and its far-end reference
268
+ // comes from the WS player's scheduled chunks — assistant audio over webrtc bypasses that
269
+ // player, which would leave Safari/iOS with NO echo cancellation at all (barge-in and ASR
270
+ // both break against speaker bleed). So webrtc falls back to ws here.
271
+ //
272
+ // This is PERMANENT on WebKit, not pending work. An earlier note here called a
273
+ // remote-track far-end tap a tracked TODO; measured 2026-08-18, that tap does not exist
274
+ // to be written. A loopback peer connection carrying a 440 Hz tone, attached to an
275
+ // <audio> element exactly as this SDK does, read back through createMediaStreamSource
276
+ // gives peak amplitude 0.00000 on BOTH Safari 26.6 and Chrome (CriOS 151) on iOS 26 —
277
+ // the engine, not the browser app, since iOS forces every browser onto WebKit. The track
278
+ // arrives; Web Audio gets silence. Anyone revisiting this should re-run that probe before
279
+ // writing code, not after.
280
+ console.warn('[converse] webrtc transport is not yet supported on WebKit — using ws');
281
+ transport = 'ws';
282
+ }
283
+ this.url = toWebSocketUrl(url);
284
+ this.transport = transport;
285
+ this._RTCPeerConnectionImpl = RTCPeerConnectionImpl;
286
+ this.sessionId = sessionId;
287
+ this.player = player || new StreamingPlayer();
288
+ // Ambience (ambience.js): 'off' | 'thinking' | 'continuous', or an object with `mode` plus
289
+ // envelope overrides (afterS, fadeInS, fadeOutS, level). DEFAULTS TO 'thinking' - near-silent
290
+ // except while Dialt is blocking on a slow tool with nothing to say, so integrations get
291
+ // the dead-air cover just by upgrading; pass 'off' to opt out. Rendered and mixed
292
+ // client-side, through this player, so it sits in the echo canceller's far-end reference;
293
+ // the server's `working` frame and the reply's `turn`/`done` drive it (see _applyReflex).
294
+ // Over webrtc the player is not in the audio path (assistant audio is a remote track), so it
295
+ // stays silent there: the server-mixed `mode.background_audio` bed is the webrtc option.
296
+ const ambienceGiven = ambience !== undefined;
297
+ const ambienceOpts = typeof ambience === 'string' ? { mode: ambience } : { ...(ambience || {}) };
298
+ ambienceOpts.mode ??= 'thinking';
299
+ const ambienceOnWs = transport === 'ws';
300
+ if (!ambienceOnWs && ambienceGiven && ambienceOpts.mode !== 'off') {
301
+ console.warn('[converse] ambience is not available on the webrtc transport (use mode.background_audio); disabled');
302
+ }
303
+ this.ambience = new Ambience({ player: this.player, enabled: ambienceOnWs, ...ambienceOpts });
304
+ this.apiKey = apiKey || null;
305
+ // Dual real-time capture channels (tagged binary uplink) ride WS binary frames; webrtc has a
306
+ // single outbound audio track, so raw_assist ablation isn't representable there yet (TODO).
307
+ if (transport === 'webrtc' && rawAssist) rawAssist = false;
308
+ this._mode = Object.freeze(validatedMode(mode));
309
+ // Optional stable user identifier (e.g. a persistent anonymous id) — recorded server-side so
310
+ // captures can be grouped per user across sessions. Never used for auth.
311
+ this.user = user || null;
312
+ // IANA timezone of this browser (Intl API): lets the server anchor "what time is it" and
313
+ // search locale to the USER's clock instead of guessing. Omitted -> server treats as unknown.
314
+ this.timezone = timezone || null;
315
+ this.rawAssist = !!rawAssist;
316
+ this._echoCancellerFactory = echoCancellerFactory;
317
+ this.WebSocketImpl = WebSocketImpl;
318
+ // Remote deploys drop sockets (wifi handoff, sleep, transient loss) far more than localhost.
319
+ // Auto-reconnect with backoff keeps the mic alive across a blip; the server-issued resume token
320
+ // preserves conversation and deferred jobs during its bounded reconnect window.
321
+ this.autoReconnect = autoReconnect;
322
+ this.reconnectBaseMs = reconnectBaseMs;
323
+ this.reconnectMaxMs = reconnectMaxMs;
324
+ this.maxReconnectAttempts = maxReconnectAttempts;
325
+ if (!Number.isFinite(captureStartupTimeoutMs) || captureStartupTimeoutMs <= 0) {
326
+ throw new RangeError('captureStartupTimeoutMs must be a positive finite number');
327
+ }
328
+ this.captureStartupTimeoutMs = captureStartupTimeoutMs;
329
+ if (!Number.isFinite(injectionAckTimeoutMs) || injectionAckTimeoutMs <= 0) {
330
+ throw new RangeError('injectionAckTimeoutMs must be a positive finite number');
331
+ }
332
+ this.injectionAckTimeoutMs = injectionAckTimeoutMs;
333
+ this.ws = null;
334
+ this.opened = null;
335
+ this.audioQueue = Promise.resolve();
336
+ this._responding = false; // between `turn` and `done`/`interrupted`/`canceled`
337
+ this._pendingInjections = new Map(); // message_id -> authoritative broker ack promise
338
+ this._injectionSeq = 0;
339
+ this._narrationStates = new Map(); // job_id -> last known tool_job_narration state
340
+ this._narrationWaiters = new Map(); // job_id -> [{ states, resolve, reject, timer }]
341
+ this._interactionStates = new Map(); // interaction_id -> last known narration state
342
+ // interaction_id -> FIFO [{ resolve, reject, timer }]: the server answers every update in
343
+ // order, so per-id queues keep concurrent duplicate updates (the documented
344
+ // first-close-wins flow) from cross-wiring or dropping each other's acks.
345
+ this._pendingInteractionUpdates = new Map();
346
+ this._live = false; // true only while a socket is open AND past `ready`
347
+ this._closedByUser = false; // set by close() so a clean shutdown doesn't trigger reconnect
348
+ this._resumeToken = resumeTokenFromState(resumeState);
349
+ // Latest server token (possibly imported above); sent on the next continuation attempt.
350
+ this._temperature = undefined;
351
+ this._noGreeting = false;
352
+ this._listeningFired = false;
353
+ this._listeningFrames = 0;
354
+ this.listeningWarmupFrames = Math.max(1, listeningWarmupFrames | 0);
355
+ this._micGeneration = 0; // invalidates every async stage when stop/restart supersedes it
356
+ this._mic = null; // SDK-owned capture (startMic); apps with custom capture never set it
357
+ this._rawMic = null; // desktop-only second capture; WebKit tees the primary raw capture
358
+ this._aec = null; // SDK-side AEC3, only on WebKit (see startMic)
359
+ this._micStarting = null; // in-flight/settled startMic() promise (idempotence + stop race)
360
+ this._micDesired = false;
361
+ this._micOptions = null;
362
+ this._pendingMicCaptures = new Set();
363
+ this._inputDeviceId = inputDeviceId || null;
364
+ this._activeInputDeviceId = null;
365
+ this._knownInputDevices = null;
366
+ this._deviceChangeListening = false;
367
+ this._deviceRestart = null;
368
+ this._handleDeviceChangeBound = () => this._handleDeviceChange().catch(() => {});
369
+ this._uplinkSeq = [0, 0];
370
+ this._rawAssistActive = false;
371
+ this._audioFrontend = null; // actual SDK-owned mic/AEC path, persisted across reconnects
372
+ this._audioFrontendFallback = false;
373
+ this._audioFrontendFallbackError = null; // why the WASM AEC3 engine failed to init, if it did
374
+ // webrtc-transport-only state (see _openOnceWebRtc): the peer connection, its "control" data
375
+ // channel (the send-a-control-frame primitive's destination instead of `this.ws`), the mic's
376
+ // re-injection feeder (src/webrtc.js), and the hidden <audio> element assistant audio plays
377
+ // through. None of these are touched when transport === 'ws'.
378
+ this._rtcSession = null;
379
+ this._channel = null;
380
+ this._trackFeeder = null;
381
+ this._micSender = null; // the pc's outbound-audio RTCRtpSender (see startMic below)
382
+ this._directMicTrackEngaged = false; // true once startMic() swapped the raw device track in
383
+ this._remoteAudioEl = null;
384
+ }
385
+
386
+ _setCaptureState(state, detail = {}) {
387
+ this._dispatch({ type: state, state, ...detail });
388
+ }
389
+
390
+ _dispatchConnectTiming(transport, marks) {
391
+ this._dispatch({ type: 'connect_timing', transport, total_ms: marks.ready, marks });
392
+ }
393
+
394
+ async _acquireHealthyMic(options, isCurrent = () => true) {
395
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
396
+ if (!isCurrent()) throw new CaptureAbortedError();
397
+ const capture = new MicCapture(options);
398
+ this._pendingMicCaptures.add(capture);
399
+ try {
400
+ await capture.start({ firstFrameTimeoutMs: this.captureStartupTimeoutMs });
401
+ capture.recovered = attempt > 1;
402
+ return capture;
403
+ } catch (error) {
404
+ await capture.stop().catch(() => {});
405
+ // stopMic() may win while a stalled capture is being torn down. Do not reopen the
406
+ // device after cancellation merely to have the stale-generation path close it again.
407
+ if (!isCurrent()) throw new CaptureAbortedError();
408
+ if (error?.code === 'capture_stalled' && attempt === 1) {
409
+ this._setCaptureState('recovering', {
410
+ code: 'capture_stalled', attempt, next_attempt: attempt + 1,
411
+ });
412
+ continue;
413
+ }
414
+ if (error?.code === 'capture_stalled') error.retryable = false;
415
+ throw error;
416
+ } finally {
417
+ this._pendingMicCaptures.delete(capture);
418
+ }
419
+ }
420
+
421
+ throw new CaptureStalledError();
422
+ }
423
+ // The default mic path: owns getUserMedia under the locked AEC-only front-end spec (AEC on,
424
+ // NS+AGC OFF — browser defaults enable both, measurably hurting ASR) and picks the AEC engine
425
+ // per platform. Desktop Chrome/Firefox keep their native per-stream canceller; WebKit (all iOS
426
+ // browsers + Mac Safari, where platform AEC is Apple VPIO with an ASR-harmful NS welded in)
427
+ // captures fully raw and cancels with the SDK's AEC3-in-WASM fed the player's far-end tap.
428
+ // Frames flow straight into pushMicFrame (which stays public for apps with custom capture).
429
+ // Resolves {sdkAec, aecFallback} for UI messaging: sdkAec = the WASM canceller is active;
430
+ // aecFallback = WebKit but the WASM engine failed to load, so the platform canceller (VPIO —
431
+ // degraded but better than echo) is in effect. Idempotent while started; stopMic() re-arms.
432
+ // sdkAec: 'auto' (default) = WASM canceller only where the platform can't deliver
433
+ // AEC-only (WebKit/iOS, see needsSdkAec); true = force the SDK canceller everywhere
434
+ // (desktop rollout of the ONE tuned AEC — gate on run_frontend.py before flipping
435
+ // any default); false = force platform AEC.
436
+ startMic({ workletUrl, sdkAec: sdkAecMode = 'auto', deviceId = this._inputDeviceId } = {}) {
437
+ if (this._mode.modality === 'text') {
438
+ throw new Error('microphone capture is unavailable in text mode');
439
+ }
440
+ if (deviceId != null && (typeof deviceId !== 'string' || !deviceId)) {
441
+ throw new TypeError('deviceId must be a non-empty string or null');
442
+ }
443
+ if (this._micStarting) return this._micStarting;
444
+ this._micDesired = true;
445
+ this._inputDeviceId = deviceId || null;
446
+ this._micOptions = { workletUrl, sdkAec: sdkAecMode, deviceId: this._inputDeviceId };
447
+ this._setCaptureState('warming_up', { attempt: 1, device_id: this._inputDeviceId });
448
+ const generation = ++this._micGeneration;
449
+ this._watchDeviceChanges();
450
+ this.getInputDevices().then((devices) => { this._knownInputDevices = devices; }).catch(() => {});
451
+ const starting = (async () => {
452
+ const webkit = needsSdkAec();
453
+ let sdkAec = sdkAecMode === 'auto' ? webkit : !!sdkAecMode;
454
+ let aecFallback = false;
455
+ let aecFallbackError = null;
456
+ let rawAssist = this.rawAssist;
457
+ let aec = null;
458
+ if (sdkAec) {
459
+ aec = this._echoCancellerFactory();
460
+ try {
461
+ await aec.init();
462
+ aec.attachPlayer(this.player);
463
+ } catch (err) {
464
+ aec.close();
465
+ aec = null;
466
+ sdkAec = false;
467
+ aecFallback = true;
468
+ // Previously swallowed entirely, so a WASM AEC3 failure was undiagnosable after the
469
+ // fact (2026-08-20: a real WebKit user's fallback sessions self-barged on nearly every
470
+ // turn and we had no idea why). Reported to the server via _sendAudioFrontendStatus.
471
+ // A WASM module's dynamic import()/instantiation can reject with a non-Error value
472
+ // across some bundler/browser combos, so this doesn't just trust err.name/err.message
473
+ // to be strings, and doesn't treat a legitimate empty err.message as "missing".
474
+ aecFallbackError = {
475
+ name: typeof err?.name === 'string' && err.name ? err.name : 'Error',
476
+ message: _describeThrown(err),
477
+ };
478
+ console.warn('[voice-loop] SDK AEC3 init failed, falling back to platform AEC', err);
479
+ }
480
+ }
481
+ // Captured on the instance as soon as it's known, not deferred to the success tail
482
+ // below — a subsequent platform-AEC mic-acquisition failure (the catch around
483
+ // _acquireHealthyMic further down) still needs to report this diagnostic even though
484
+ // startMic() itself is about to throw; see the explicit resend there.
485
+ this._audioFrontendFallback = aecFallback;
486
+ this._audioFrontendFallbackError = aecFallbackError;
487
+ if (this._micGeneration !== generation) {
488
+ aec?.close();
489
+ return { sdkAec, aecFallback, aecFallbackError, rawAssist, stopped: true };
490
+ }
491
+ // A second WebKit capture mutates device-wide processing and stripped AEC in production.
492
+ // Raw assist there is valid only when this single raw capture feeds both WASM and raw uplink.
493
+ if (rawAssist && webkit && !sdkAec) rawAssist = false;
494
+ let mic = null;
495
+ let rawMic = null;
496
+ try {
497
+ mic = await this._acquireHealthyMic({
498
+ processing: !sdkAec, // platform AEC unless the SDK is cancelling
499
+ workletUrl, deviceId: this._inputDeviceId,
500
+ onFrame: (frame, captureMs) => {
501
+ this.pushMicFrame(aec ? aec.processCapture(frame) : frame, { captureMs });
502
+ if (rawAssist && sdkAec) this.sendRawFrame(frame, { captureMs });
503
+ },
504
+ }, () => this._micGeneration === generation && this._micDesired);
505
+ if (rawAssist && !sdkAec) {
506
+ rawMic = new MicCapture({
507
+ processing: false,
508
+ workletUrl, deviceId: this._inputDeviceId,
509
+ onFrame: (frame, captureMs) => this.sendRawFrame(frame, { captureMs }),
510
+ });
511
+ const pendingRawMic = rawMic;
512
+ this._pendingMicCaptures.add(pendingRawMic);
513
+ try {
514
+ await rawMic.start({ firstFrameTimeoutMs: this.captureStartupTimeoutMs });
515
+ } catch {
516
+ await rawMic.stop().catch(() => {});
517
+ rawMic = null;
518
+ rawAssist = false; // primary uplink remains healthy; server uses AEC-only gate
519
+ } finally {
520
+ this._pendingMicCaptures.delete(pendingRawMic);
521
+ }
522
+ }
523
+ } catch (err) {
524
+ aec?.close();
525
+ await mic?.stop().catch(() => {});
526
+ await rawMic?.stop();
527
+ if (this._micGeneration !== generation) {
528
+ return { sdkAec, aecFallback, aecFallbackError, rawAssist, stopped: true };
529
+ }
530
+ this._micDesired = false;
531
+ this._unwatchDeviceChanges();
532
+ // A WASM AEC3 failure followed by a correlated platform-AEC acquisition failure is
533
+ // exactly the messiest real-world case the diagnostic above exists for — report it
534
+ // even though startMic() is about to throw and the caller never sees a resolved info
535
+ // object at all.
536
+ if (aecFallbackError) {
537
+ this._audioFrontend = sdkAec ? 'sdk-aec3' : 'platform-aec';
538
+ this._sendAudioFrontendStatus();
539
+ }
540
+ this._setCaptureState('failed', {
541
+ code: err?.code || 'capture_failed', error: err,
542
+ });
543
+ throw err;
544
+ }
545
+ if (this._micGeneration !== generation) {
546
+ aec?.close();
547
+ await Promise.allSettled([mic.stop(), rawMic?.stop()]);
548
+ return { sdkAec, aecFallback, aecFallbackError, rawAssist, stopped: true };
549
+ }
550
+ this._mic = mic;
551
+ this._rawMic = rawMic;
552
+ this._aec = aec;
553
+ this._rawAssistActive = rawAssist;
554
+ this._audioFrontend = sdkAec ? 'sdk-aec3' : 'platform-aec';
555
+ const activeTrack = mic.stream?.getAudioTracks?.()[0] || mic.stream?.getTracks?.()[0];
556
+ this._activeInputDeviceId = activeTrack?.getSettings?.().deviceId || this._inputDeviceId;
557
+ // The normal webrtc path: swap the getUserMedia track straight into the RTCPeerConnection's
558
+ // sender instead of leaving the JS TrackFeeder re-injection in the loop. webrtc only ever
559
+ // runs on non-WebKit platforms (WebKit forces ws — see needsSdkAec() above this class), so
560
+ // `!sdkAec` here means native platform echo cancellation already produced this exact track's
561
+ // audio — it's the SAME processed audio the feeder would otherwise have re-encoded, with zero
562
+ // extra JS hops/queueing/latency. replaceTrack() needs no renegotiation (the sender/m-line
563
+ // were already established by the initial offer in _openOnceWebRtc). Only the SDK's own AEC3
564
+ // canceller (sdkAec, WebKit-only in practice) or a custom-capture caller (no startMic() at
565
+ // all) still needs the feeder — see _uplinkFrame.
566
+ if (this.transport === 'webrtc' && !sdkAec && this._micSender) {
567
+ const rawTrack = mic.stream?.getAudioTracks?.()[0];
568
+ if (rawTrack) {
569
+ try {
570
+ await this._micSender.replaceTrack(rawTrack);
571
+ if (this._micGeneration === generation) this._directMicTrackEngaged = true;
572
+ else await this._micSender.replaceTrack(this._trackFeeder?.track || null).catch(() => {});
573
+ } catch (err) {
574
+ console.warn('[voice-loop] webrtc direct mic track swap failed; staying on the feeder', err);
575
+ }
576
+ }
577
+ }
578
+ // stopMic/device switching may have won while replaceTrack was pending. Restore the feeder
579
+ // before releasing the now-stale capture so the sender never retains an ended device track.
580
+ if (this._micGeneration !== generation) {
581
+ this._directMicTrackEngaged = false;
582
+ aec?.close();
583
+ await Promise.allSettled([
584
+ this._micSender?.replaceTrack(this._trackFeeder?.track || null),
585
+ mic.stop(), rawMic?.stop(),
586
+ ]);
587
+ return { sdkAec, aecFallback, aecFallbackError, rawAssist, stopped: true };
588
+ }
589
+ this._sendRawAssistStatus(rawAssist);
590
+ this._sendAudioFrontendStatus();
591
+ this._listeningFired = true;
592
+ this._setCaptureState('listening', {
593
+ device_id: this._activeInputDeviceId, recovered: !!mic.recovered,
594
+ });
595
+ return { sdkAec, aecFallback, aecFallbackError, rawAssist, deviceId: this._activeInputDeviceId };
596
+ })();
597
+ this._micStarting = starting;
598
+ starting.catch(() => { if (this._micStarting === starting) this._micStarting = null; });
599
+ return starting;
600
+ }
601
+
602
+ // Safari requires audio playback to be unlocked from the user's gesture. Call this before any
603
+ // async connect / mic-permission wait so later streamed assistant audio can actually play.
604
+ // Over webrtc the StreamingPlayer is unused (see _attachRemoteAudio) — the hidden <audio> element
605
+ // is the only playback surface, so do not allocate a silent AudioContext for that transport.
606
+ // Nudge an existing element too (for example after a reconnect established a new remote track).
607
+ unlockAudio() {
608
+ const contextUnlock = this.transport === 'ws' ? this.player?.ensureContext?.() : null;
609
+ return Promise.all([
610
+ contextUnlock,
611
+ this._remoteAudioEl?.play?.().catch(() => {}),
612
+ ].filter(Boolean));
613
+ }
614
+
615
+ // Release the SDK-owned mic + AEC (no-op if startMic was never used). Safe to call repeatedly;
616
+ // does not touch the socket, so a session can drop the mic and still receive/play audio.
617
+ async stopMic() {
618
+ this._micDesired = false;
619
+ this._unwatchDeviceChanges();
620
+ await this._releaseMic();
621
+ }
622
+
623
+ async _releaseMic() {
624
+ const starting = this._micStarting;
625
+ this._micGeneration += 1;
626
+ this._micStarting = null;
627
+ const pendingCaptures = [...this._pendingMicCaptures];
628
+ const mic = this._mic;
629
+ const rawMic = this._rawMic;
630
+ this._mic = null;
631
+ this._rawMic = null;
632
+ this._rawAssistActive = false;
633
+ this._audioFrontend = null;
634
+ this._audioFrontendFallback = false;
635
+ this._audioFrontendFallbackError = null;
636
+ this._activeInputDeviceId = null;
637
+ this._sendRawAssistStatus(false);
638
+ this._sendAudioFrontendStatus();
639
+ this._aec?.close();
640
+ this._aec = null;
641
+ // Hand the sender back to the feeder BEFORE the capture's own track is stopped below, so the
642
+ // peer connection never ends up pointing at an ended track (and any later pushMicFrame() from
643
+ // a custom-capture caller has somewhere to go again).
644
+ let handoff = Promise.resolve();
645
+ if (this._directMicTrackEngaged && this._micSender) {
646
+ this._directMicTrackEngaged = false;
647
+ handoff = this._micSender.replaceTrack(this._trackFeeder?.track || null).catch(() => {});
648
+ }
649
+ await handoff;
650
+ await Promise.allSettled([
651
+ mic?.stop(), rawMic?.stop(), ...pendingCaptures.map((capture) => capture.stop()),
652
+ ]);
653
+ // getUserMedia is not abortable. Await the invalidated start so stopMic is a true barrier:
654
+ // any late grant is released by the stale-generation path before this method resolves.
655
+ if (starting) await starting.catch(() => {});
656
+ }
657
+
658
+ get inputDeviceId() { return this._inputDeviceId; }
659
+
660
+ async getInputDevices() {
661
+ const enumerate = navigator.mediaDevices?.enumerateDevices;
662
+ if (typeof enumerate !== 'function') return [];
663
+ const devices = await enumerate.call(navigator.mediaDevices);
664
+ return devices.filter((device) => device.kind === 'audioinput');
665
+ }
666
+
667
+ async setInputDevice(deviceId) {
668
+ if (deviceId != null && (typeof deviceId !== 'string' || !deviceId)) {
669
+ throw new TypeError('deviceId must be a non-empty string or null');
670
+ }
671
+ const normalized = deviceId || null;
672
+ if (normalized === this._inputDeviceId && this._activeInputDeviceId) {
673
+ return { deviceId: this._activeInputDeviceId };
674
+ }
675
+ const previousDeviceId = this._inputDeviceId;
676
+ this._inputDeviceId = normalized;
677
+ this._dispatch({
678
+ type: 'input_device_changed', device_id: normalized, previous_device_id: previousDeviceId,
679
+ });
680
+ if (!this._micDesired) return { deviceId: normalized };
681
+ return this._restartMic('manual_device_switch');
682
+ }
683
+
684
+ _watchDeviceChanges() {
685
+ const mediaDevices = navigator.mediaDevices;
686
+ if (this._deviceChangeListening || !mediaDevices?.addEventListener) return;
687
+ mediaDevices.addEventListener('devicechange', this._handleDeviceChangeBound);
688
+ this._deviceChangeListening = true;
689
+ }
690
+
691
+ _unwatchDeviceChanges() {
692
+ if (!this._deviceChangeListening) return;
693
+ navigator.mediaDevices?.removeEventListener?.('devicechange', this._handleDeviceChangeBound);
694
+ this._deviceChangeListening = false;
695
+ }
696
+
697
+ _defaultInput(devices) {
698
+ return devices.find((device) => device.deviceId === 'default') || devices[0] || null;
699
+ }
700
+
701
+ async _handleDeviceChange() {
702
+ const previous = this._knownInputDevices;
703
+ const devices = await this.getInputDevices();
704
+ this._knownInputDevices = devices;
705
+ this._dispatch({
706
+ type: 'devices_changed', devices, device_id: this._inputDeviceId,
707
+ active_device_id: this._activeInputDeviceId,
708
+ });
709
+ if (!this._micDesired || !previous) return;
710
+
711
+ const explicitStillAvailable = !this._inputDeviceId
712
+ || devices.some((device) => device.deviceId === this._inputDeviceId);
713
+ const activeStillAvailable = !this._activeInputDeviceId
714
+ || this._activeInputDeviceId === 'default'
715
+ || devices.some((device) => device.deviceId === this._activeInputDeviceId);
716
+ const oldDefault = this._defaultInput(previous);
717
+ const newDefault = this._defaultInput(devices);
718
+ const defaultChanged = !this._inputDeviceId && (
719
+ oldDefault?.deviceId !== newDefault?.deviceId
720
+ || oldDefault?.groupId !== newDefault?.groupId
721
+ || oldDefault?.label !== newDefault?.label
722
+ );
723
+ if (explicitStillAvailable && activeStillAvailable && !defaultChanged) return;
724
+
725
+ if (!explicitStillAvailable) {
726
+ const unavailableDeviceId = this._inputDeviceId;
727
+ this._inputDeviceId = null;
728
+ this._dispatch({
729
+ type: 'input_device_changed', device_id: null,
730
+ previous_device_id: unavailableDeviceId, reason: 'unavailable',
731
+ });
732
+ }
733
+ await this._restartMic('devicechange');
734
+ }
735
+
736
+ _restartMic(reason) {
737
+ if (!this._micDesired) return Promise.resolve(null);
738
+ if (this._deviceRestart) return this._deviceRestart;
739
+ const restarting = (async () => {
740
+ let info = null;
741
+ do {
742
+ const releasingDeviceId = this._inputDeviceId;
743
+ this._setCaptureState('recovering', { code: reason, device_id: releasingDeviceId });
744
+ await this._releaseMic();
745
+ if (!this._micDesired) return null;
746
+ // Selection is authoritative and may have changed while release awaited a pending start.
747
+ const targetDeviceId = this._inputDeviceId;
748
+ const options = { ...(this._micOptions || {}), deviceId: targetDeviceId };
749
+ info = await this.startMic(options);
750
+ // Converge on a newer selection made while this reacquisition was in flight.
751
+ if (targetDeviceId === this._inputDeviceId) return info;
752
+ } while (this._micDesired);
753
+ return info;
754
+ })();
755
+ this._deviceRestart = restarting;
756
+ restarting.finally(() => {
757
+ if (this._deviceRestart === restarting) this._deviceRestart = null;
758
+ }).catch(() => {});
759
+ return restarting;
760
+ }
761
+
762
+ /** Temporarily gate live microphone tracks without reopening the device. This is a transport
763
+ * safety primitive for explicit half-duplex integrations; normal full-duplex clients leave it
764
+ * enabled and rely on AEC. */
765
+ setMicEnabled(enabled) {
766
+ const active = !!enabled;
767
+ for (const capture of [this._mic, this._rawMic]) {
768
+ const stream = capture?.stream;
769
+ const tracks = stream?.getAudioTracks?.() || stream?.getTracks?.() || [];
770
+ for (const track of tracks) track.enabled = active;
771
+ }
772
+ // webrtc: the outbound track is the feeder's synthetic MediaStreamTrack, not the capture's own
773
+ // getUserMedia track (see src/webrtc.js) — mute it directly so custom-capture apps (which call
774
+ // pushMicFrame() without startMic() and so have no capture track above) still get silence.
775
+ this._trackFeeder?.setMuted(!active);
776
+ }
777
+
778
+ connect({ temperature, noGreeting = false } = {}) {
779
+ if (this.opened) return this.opened;
780
+ if (typeof noGreeting !== 'boolean') throw new TypeError('noGreeting must be a boolean');
781
+ this._closedByUser = false;
782
+ this._temperature = temperature;
783
+ this._noGreeting = noGreeting;
784
+ const opening = this.transport === 'webrtc' ? this._openOnceWebRtc() : this._openOnce();
785
+ this.opened = opening;
786
+ // Initial connect failed (not a live drop, so no reconnect) — clear so a later connect() retries.
787
+ // `_openOnce` never touches `this.opened` itself, so this and `_scheduleReconnect` are its sole
788
+ // owners; that's what keeps a multi-attempt reconnect from leaving `opened` null while live.
789
+ opening.catch((err) => {
790
+ if (this.opened === opening) this.opened = null;
791
+ if (err?.code === 'resume_failed' && this._resumeToken) {
792
+ this._setResumeToken(null);
793
+ this._dispatch({ type: 'resume_failed', error: err });
794
+ }
795
+ });
796
+ return this.opened;
797
+ }
798
+
799
+ /** Return the current opaque, JSON-serializable continuation state, or null before `ready` and
800
+ * after the session ends. Persist it only in storage appropriate for a short-lived credential
801
+ * (normally sessionStorage), then pass it back as `resumeState` after a page reload. */
802
+ exportResumeState() {
803
+ return this._resumeToken ? { version: 1, resumeToken: this._resumeToken } : null;
804
+ }
805
+
806
+ /** Install state previously returned by exportResumeState(). Import is deliberately restricted
807
+ * to a client that has not started connecting: replacing a live session's continuation token
808
+ * would make the next automatic reconnect resume unrelated context. */
809
+ importResumeState(state) {
810
+ if (this.opened || this._live || this.ws) {
811
+ throw new Error('resume state can only be imported before connect()');
812
+ }
813
+ this._setResumeToken(resumeTokenFromState(state));
814
+ }
815
+
816
+ _setResumeToken(token) {
817
+ const normalized = typeof token === 'string' && token ? token : null;
818
+ if (normalized === this._resumeToken) return;
819
+ this._resumeToken = normalized;
820
+ this._dispatch({ type: 'resume_state', state: this.exportResumeState() });
821
+ }
822
+
823
+ // Shared start-frame construction (mode/temperature/greeting/rawAssist) for both transports.
824
+ _buildStartFrame() {
825
+ let mode = validatedMode(this._mode);
826
+ if (mode.kind === 'converse') {
827
+ if (this._temperature != null) mode.temperature = this._temperature;
828
+ if (this._noGreeting) mode.greeting = false;
829
+ if (mode.background_audio && this.transport !== 'webrtc') {
830
+ // The server rejects background_audio outside the webrtc transport (the bed is mixed
831
+ // into the playout track, the only downlink that runs between turns). Dropping it here
832
+ // rather than letting that rejection through matters because the transport may have
833
+ // been downgraded for us: WebKit silently falls back to ws (see the constructor), so a
834
+ // caller who asked for webrtc + background_audio would otherwise lose the whole session
835
+ // on iOS instead of just the music.
836
+ console.warn('[converse] background_audio requires the webrtc transport — disabled');
837
+ delete mode.background_audio;
838
+ }
839
+ }
840
+ mode = validatedMode(mode);
841
+ const textModality = mode.kind === 'converse' && mode.modality === 'text';
842
+ // Voice is the established wire default. Keep it internally for client-side guards, but omit
843
+ // it from the frame so this SDK remains voice-compatible with pre-text Dialt servers.
844
+ if (mode.kind === 'converse' && mode.modality === 'voice') delete mode.modality;
845
+ const start = {
846
+ type: 'start',
847
+ session_id: this.sessionId,
848
+ mode,
849
+ };
850
+ if (!textModality) {
851
+ start.audio = { sr: SAMPLE_RATE, output_encoding: 'pcm16' };
852
+ } else if (this.transport !== 'ws') {
853
+ throw new TypeError('converse text modality requires the ws transport');
854
+ }
855
+ if (this.apiKey) start.api_key = this.apiKey;
856
+ if (this._resumeToken) start.resume_token = this._resumeToken;
857
+ const client = {};
858
+ client.capabilities = [];
859
+ client.audio_frontend = this._audioFrontend || 'unknown';
860
+ if (this.user) client.user = this.user;
861
+ if (this.timezone) client.timezone = this.timezone;
862
+ if (Object.keys(client).length) start.client = client;
863
+ if (this.rawAssist && start.audio) {
864
+ start.audio.raw_assist = true;
865
+ start.audio.uplink_format = UPLINK_FORMAT_TAGGED;
866
+ }
867
+ return start;
868
+ }
869
+
870
+ // One connection attempt. Resolves on `ready`; rejects if the socket fails or closes before ready.
871
+ // If an already-live socket later drops (and the user didn't close it), kicks off auto-reconnect.
872
+ // Owns only its own promise — never mutates `this.opened` (see connect()/_scheduleReconnect).
873
+ _openOnce() {
874
+ const start = this._buildStartFrame();
875
+ // Validate and serialize before opening a network resource. A circular/custom tool schema must
876
+ // fail locally without leaking a connecting WebSocket.
877
+ const startPayload = JSON.stringify(start);
878
+ const { marks, mark } = connectMarks();
879
+ const ws = new this.WebSocketImpl(this.url);
880
+ this.ws = ws;
881
+ ws.binaryType = 'arraybuffer';
882
+ return new Promise((resolve, reject) => {
883
+ let settled = false;
884
+ let liveReady = false;
885
+ const fail = (err) => {
886
+ if (!settled) {
887
+ settled = true;
888
+ // Don't leak a live socket when connect() rejects (error frame, format mismatch):
889
+ // the server would keep the session open while the app believes connect failed.
890
+ if (ws.readyState === 0 || ws.readyState === 1) {
891
+ try { ws.close(1000, 'client rejected session'); } catch { /* already closing */ }
892
+ }
893
+ reject(err instanceof Error ? err : new Error(String(err)));
894
+ }
895
+ };
896
+ ws.addEventListener('open', () => { mark('ws_open'); ws.send(startPayload); }, { once: true });
897
+ ws.addEventListener('error', () => fail(new Error('Dialt WebSocket failed')), { once: true });
898
+ ws.addEventListener('close', (ev) => {
899
+ const ownsSocket = this.ws === ws;
900
+ if (ownsSocket) {
901
+ this.ws = null;
902
+ this._live = false;
903
+ this._rejectPendingInjections(
904
+ new Error('connection closed before injection acknowledgement'));
905
+ // Narration/interaction lifecycle is connection-scoped: a resumed session restores
906
+ // deferred jobs but implicitly supersedes any open interaction (re-raise it with a
907
+ // fresh partial if still needed), so cached states would be stale, not history.
908
+ this._narrationStates.clear();
909
+ this._interactionStates.clear();
910
+ }
911
+ if (!liveReady) {
912
+ if (!settled) fail(new Error('Dialt WebSocket closed before ready'));
913
+ return;
914
+ }
915
+ if (!ownsSocket) return; // a failed older attempt closed after a newer retry opened
916
+ if (!this._closedByUser && ev.code === 1000) {
917
+ // The server hung up ON PURPOSE (only intentional ends close 1000 — e.g. the idle
918
+ // sign-off's `close(1000, "idle")`). Redialing here would open a fresh session and
919
+ // replay the greeting; stay closed and let the app decide. Abnormal drops (1006 loss,
920
+ // 1011 upstream lost, 1013 drain) still reconnect below.
921
+ this.opened = null;
922
+ this._setResumeToken(null); // an ended session must not resume on a later connect()
923
+ this._responding = false; // a reused client must not carry reply/ack state into
924
+ this.ambience.stop();
925
+ this._dispatch({ type: 'session_end', code: ev.code, reason: ev.reason || '' });
926
+ }
927
+ else if (!this._closedByUser && this.autoReconnect) this._scheduleReconnect();
928
+ else this.opened = null;
929
+ });
930
+ ws.addEventListener('message', async (ev) => {
931
+ if (this.ws !== ws) return; // ignore queued messages from an obsolete failed attempt
932
+ try {
933
+ const detail = await this._message(ev.data);
934
+ if (!settled && detail?.type === 'ready') {
935
+ // The ready frame states the negotiated downlink format; a mismatch here would
936
+ // otherwise surface as noise in the speakers, so fail the connect loudly instead.
937
+ const fmt = detail.audio;
938
+ if (fmt && (fmt.output_encoding !== 'pcm16' || fmt.output_sr !== SAMPLE_RATE)) {
939
+ fail(new Error(`server negotiated unsupported downlink audio ${JSON.stringify(fmt)}; `
940
+ + `this SDK plays pcm16 at ${SAMPLE_RATE} Hz`));
941
+ return;
942
+ }
943
+ if (typeof detail.resume_token === 'string') this._setResumeToken(detail.resume_token);
944
+ mark('ready');
945
+ settled = true;
946
+ liveReady = true;
947
+ this._live = true;
948
+ this._listeningFired = false; // re-arm: this session emits `listening` after warmup
949
+ this._listeningFrames = 0;
950
+ this._uplinkSeq = [0, 0];
951
+ if (this.rawAssist) this._sendRawAssistStatus(this._rawAssistActive);
952
+ this._sendAudioFrontendStatus();
953
+ this._dispatchConnectTiming('ws', marks);
954
+ resolve(this);
955
+ } else if (!settled && detail?.type === 'error') {
956
+ fail(brokerError(detail, 'Dialt WebSocket rejected connection'));
957
+ }
958
+ } catch (err) {
959
+ fail(err);
960
+ }
961
+ });
962
+ });
963
+ }
964
+
965
+ // The webrtc counterpart of _openOnce(): signaling still rides a plain WebSocket to the same
966
+ // /ws URL (see serving/broker_webrtc.py), but only long enough to exchange the SDP offer/answer —
967
+ // once the "control" data channel is open, every protocol frame this class sends/receives moves
968
+ // through _sendControl()/_message() over the channel instead, unchanged.
969
+ //
970
+ // Reconnect TODO: unlike _openOnce, this never calls _scheduleReconnect() — autoReconnect stays a
971
+ // WS-transport-only feature for this first implementation (no ICE-restart support yet). A
972
+ // dropped/failed peer connection surfaces exactly the events a dead WS with autoReconnect:false
973
+ // would (see the channel/connectionstatechange handlers below), so callers see a consistent
974
+ // "terminal" shape either way; a future revision can add ICE-restart-based reconnect here without
975
+ // changing that external contract.
976
+ async _openOnceWebRtc() {
977
+ const start = this._buildStartFrame();
978
+ start.transport = { kind: 'webrtc' }; // no sdp yet — TURN creds must exist before we gather
979
+ this._teardownWebRtc(); // a stale peer connection/feeder from a previous failed attempt
980
+
981
+ const startPayload = JSON.stringify(start);
982
+ // WebRTC has several sequential steps _openOnce doesn't (two-step signaling, ICE gathering,
983
+ // feeder setup) that dispatching only a single total would hide; each is marked individually
984
+ // (see connectMarks()) so a slow connect can be attributed to a specific step instead of
985
+ // guessed at.
986
+ const { marks, mark } = connectMarks();
987
+ const ws = new this.WebSocketImpl(this.url); // signaling only — closed once the answer lands
988
+ this.ws = ws;
989
+
990
+ return new Promise((resolve, reject) => {
991
+ let settled = false;
992
+ let session = null;
993
+ let feeder = null;
994
+ let channel = null;
995
+ // The signaling socket is deliberately self-closed right after the answer is applied (its
996
+ // job is done — wire contract note #5); that close must NOT be mistaken for the signaling
997
+ // socket dying before an answer ever arrived.
998
+ let signalingDone = false;
999
+ const fail = (err) => {
1000
+ if (!settled) {
1001
+ settled = true;
1002
+ this._teardownWebRtc();
1003
+ try { ws.close(); } catch { /* already closing/closed */ }
1004
+ reject(err instanceof Error ? err : new Error(String(err)));
1005
+ }
1006
+ };
1007
+
1008
+ ws.addEventListener('open', () => { mark('ws_open'); ws.send(startPayload); }, { once: true });
1009
+ ws.addEventListener('error', () => fail(new Error('Dialt signaling WebSocket failed')), { once: true });
1010
+ ws.addEventListener('close', () => {
1011
+ if (this.ws === ws) this.ws = null;
1012
+ if (!settled && !signalingDone) fail(new Error('signaling socket closed before webrtc_answer'));
1013
+ // Once settled (or once we closed it ourselves post-answer) the signaling socket has done
1014
+ // its job — a close here, expected or not, has no bearing on the live call.
1015
+ });
1016
+ ws.addEventListener('message', async (ev) => {
1017
+ if (typeof ev.data !== 'string') return; // signaling only ever carries JSON
1018
+ let msg;
1019
+ try { msg = JSON.parse(ev.data); } catch (err) { fail(err); return; }
1020
+ if (msg.type === 'webrtc_ice') {
1021
+ // Step 1 of signaling: build the peer connection WITH the server's (possibly
1022
+ // TURN-bearing) ice_servers, gather, then offer — see the module-level wire contract
1023
+ // note. Building the RTCPeerConnection any earlier would gather before TURN creds
1024
+ // exist, making TURN permanently unusable.
1025
+ if (session) return; // duplicate webrtc_ice — ignore, first one wins
1026
+ mark('ice_recv');
1027
+ session = new WebRtcSession({
1028
+ RTCPeerConnectionImpl: this._RTCPeerConnectionImpl,
1029
+ iceServers: Array.isArray(msg.ice_servers) && msg.ice_servers.length
1030
+ ? msg.ice_servers : undefined,
1031
+ });
1032
+ this._rtcSession = session;
1033
+ session.onRemoteTrack((stream) => this._attachRemoteAudio(stream));
1034
+ session.onConnectionStateChange((state) => {
1035
+ if (state === 'failed' || state === 'closed') fail(new Error(`webrtc connection ${state}`));
1036
+ });
1037
+ feeder = new TrackFeeder();
1038
+ this._trackFeeder = feeder;
1039
+ try {
1040
+ await feeder.start();
1041
+ mark('feeder_ready');
1042
+ channel = session.createControlChannel();
1043
+ // This feeder track is what negotiates the offer's audio m-line (startMic() hasn't
1044
+ // necessarily run yet — connect() resolves before the app calls it). Keep the sender
1045
+ // so a later startMic() can replaceTrack() the real getUserMedia track in directly,
1046
+ // with no renegotiation needed (see startMic()).
1047
+ this._micSender = session.addAudioTrack(feeder.track);
1048
+ const sdp = await session.createOfferWithGatheredIce();
1049
+ mark('offer_ready'); // includes createOffer/setLocalDescription + ICE gather wait
1050
+ ws.send(JSON.stringify({ type: 'webrtc_offer', sdp }));
1051
+ mark('offer_sent');
1052
+ } catch (err) { fail(err); return; }
1053
+ channel.addEventListener('message', async (chEv) => {
1054
+ let detail;
1055
+ try { detail = await this._message(chEv.data); } catch (err) { fail(err); return; }
1056
+ if (detail?.type === 'bye') {
1057
+ // Server-initiated close over the channel — treat exactly like a WS close with
1058
+ // that code (wire contract note #5a).
1059
+ if (!settled) fail(new Error(detail.reason || 'webrtc session closed'));
1060
+ else this._handleTransportClose(detail.code, detail.reason);
1061
+ return;
1062
+ }
1063
+ if (!settled && detail?.type === 'ready') {
1064
+ if (typeof detail.resume_token === 'string') this._setResumeToken(detail.resume_token);
1065
+ mark('ready');
1066
+ settled = true;
1067
+ this._channel = channel;
1068
+ this._live = true;
1069
+ this._listeningFired = false;
1070
+ this._listeningFrames = 0;
1071
+ this._uplinkSeq = [0, 0];
1072
+ if (this.rawAssist) this._sendRawAssistStatus(this._rawAssistActive);
1073
+ this._sendAudioFrontendStatus();
1074
+ this._dispatchConnectTiming('webrtc', marks);
1075
+ resolve(this);
1076
+ } else if (!settled && detail?.type === 'error') {
1077
+ fail(brokerError(detail, 'Dialt webrtc session rejected'));
1078
+ }
1079
+ });
1080
+ channel.addEventListener('close', () => {
1081
+ if (!settled) { fail(new Error('control channel closed before ready')); return; }
1082
+ if (!this._closedByUser) this._handleTransportClose(1006, ''); // abnormal drop, no reconnect (see TODO above)
1083
+ });
1084
+ } else if (msg.type === 'webrtc_answer') {
1085
+ if (!session) { fail(new Error('webrtc_answer before webrtc_ice')); return; }
1086
+ mark('answer_recv');
1087
+ try {
1088
+ await session.applyAnswer(msg.sdp);
1089
+ } catch (err) { fail(err); return; }
1090
+ mark('answer_applied');
1091
+ signalingDone = true;
1092
+ try { ws.close(1000); } catch { /* noop */ } // signaling's job is done
1093
+ } else if (msg.type === 'error') {
1094
+ fail(brokerError(msg, 'Dialt webrtc connect rejected'));
1095
+ }
1096
+ });
1097
+ });
1098
+ }
1099
+
1100
+ // Mirrors the WS 'close' handler's non-reconnect branches (autoReconnect:false shape) for a
1101
+ // channel/PC teardown that happens after `ready` — see _openOnceWebRtc's reconnect TODO.
1102
+ _handleTransportClose(code, reason) {
1103
+ if (!this._live) return; // already handled (e.g. 'bye' then the channel's own 'close' event)
1104
+ this._live = false;
1105
+ this._channel = null;
1106
+ this.opened = null;
1107
+ this._rejectPendingInjections(
1108
+ new Error('connection closed before injection acknowledgement'));
1109
+ if (code === 1000) {
1110
+ // Only an intentional server end closes 1000 (mirrors the WS idle sign-off) — surface it the
1111
+ // same way so app code doesn't need transport-specific handling.
1112
+ this._responding = false;
1113
+ this._setResumeToken(null);
1114
+ this.ambience.stop();
1115
+ this._dispatch({ type: 'session_end', code, reason: reason || '' });
1116
+ }
1117
+ this._teardownWebRtc();
1118
+ }
1119
+
1120
+ // Assistant audio over webrtc arrives as a remote Opus track, not binary WS frames — StreamingPlayer
1121
+ // is unused here. Attach it to a hidden <audio> element instead; unlockAudio() also nudges this
1122
+ // element's play() for browsers that gate autoplay on a user gesture.
1123
+ _attachRemoteAudio(stream) {
1124
+ if (typeof document === 'undefined') return; // non-browser test environment
1125
+ if (!this._remoteAudioEl) {
1126
+ const el = document.createElement('audio');
1127
+ el.autoplay = true;
1128
+ el.playsInline = true;
1129
+ el.style.display = 'none';
1130
+ (document.body || document.documentElement)?.appendChild(el);
1131
+ this._remoteAudioEl = el;
1132
+ }
1133
+ this._remoteAudioEl.srcObject = stream;
1134
+ this._remoteAudioEl.play?.().catch(() => {}); // best-effort; unlockAudio() retries post-gesture
1135
+ }
1136
+
1137
+ _teardownWebRtc() {
1138
+ this._channel = null;
1139
+ const session = this._rtcSession;
1140
+ const feeder = this._trackFeeder;
1141
+ this._rtcSession = null;
1142
+ this._trackFeeder = null;
1143
+ this._micSender = null;
1144
+ this._directMicTrackEngaged = false;
1145
+ session?.close();
1146
+ feeder?.stop().catch(() => {});
1147
+ if (this._remoteAudioEl) {
1148
+ try { this._remoteAudioEl.pause?.(); } catch { /* noop */ }
1149
+ this._remoteAudioEl.srcObject = null;
1150
+ this._remoteAudioEl.remove?.();
1151
+ this._remoteAudioEl = null;
1152
+ }
1153
+ }
1154
+
1155
+ // A live socket dropped unexpectedly. Reconnect with exponential backoff. `this.opened` tracks the
1156
+ // WHOLE reconnect chain (not each attempt) so in-flight callers (connect()/appendAudio) await the
1157
+ // live socket and never spawn a duplicate; it stays the eventually-resolved chain on success and is
1158
+ // cleared only on terminal give-up. Emits `reconnecting` then `reconnected` (or terminal `error`).
1159
+ _scheduleReconnect() {
1160
+ this._live = false;
1161
+ this._responding = false;
1162
+ this.ambience.stop(); // per-session facts (working, first reply) do not survive a reconnect
1163
+ this.player?.clear?.();
1164
+ this._dispatch({ type: 'reconnecting' });
1165
+ let attempt = 0;
1166
+ const attemptOnce = () => {
1167
+ if (this._closedByUser) return Promise.reject(new Error('closed by user'));
1168
+ attempt += 1;
1169
+ return this._openOnce().then((self) => {
1170
+ if (this._closedByUser) { try { this.ws?.close(1000); } catch { /* noop */ } return self; }
1171
+ this._dispatch({ type: 'reconnected' });
1172
+ return self;
1173
+ }).catch((err) => {
1174
+ if (this._closedByUser) throw err;
1175
+ if (err?.code === 'resume_failed') {
1176
+ this._setResumeToken(null);
1177
+ this._dispatch({ type: 'resume_failed', error: err });
1178
+ throw err;
1179
+ }
1180
+ if (attempt >= this.maxReconnectAttempts) {
1181
+ this._dispatch({ type: 'error', detail: 'reconnect failed', error: err });
1182
+ throw err;
1183
+ }
1184
+ const delay = Math.min(this.reconnectMaxMs, this.reconnectBaseMs * 2 ** (attempt - 1));
1185
+ return new Promise((res) => setTimeout(res, delay)).then(attemptOnce);
1186
+ });
1187
+ };
1188
+ const chain = attemptOnce();
1189
+ this.opened = chain;
1190
+ // On terminal give-up (or user-close), drop the rejected chain so a later connect() can retry.
1191
+ chain.then(null, () => { if (this.opened === chain) this.opened = null; });
1192
+ }
1193
+
1194
+ _dispatch(event) {
1195
+ this.dispatchEvent(new CustomEvent(event.type, { detail: event }));
1196
+ this.dispatchEvent(new CustomEvent('event', { detail: event }));
1197
+ }
1198
+
1199
+ // The one primitive every caller uses to send protocol JSON, so index.js has exactly one place
1200
+ // that knows the wire differs by transport: over 'ws' it's ws.send(); over 'webrtc' every frame
1201
+ // that would have gone over the socket instead rides the "control" RTCDataChannel byte-for-byte
1202
+ // (see serving/broker_webrtc.py's module docstring). Returns whether the frame reached a live
1203
+ // transport; controls are usually best-effort, while injectContext uses this as a delivery gate.
1204
+ _sendControl(obj) {
1205
+ if (this.transport === 'webrtc') {
1206
+ if (this._channel?.readyState === 'open') {
1207
+ this._channel.send(JSON.stringify(obj));
1208
+ return true;
1209
+ }
1210
+ } else if (this.ws?.readyState === 1) {
1211
+ this.ws.send(JSON.stringify(obj));
1212
+ return true;
1213
+ }
1214
+ return false;
1215
+ }
1216
+
1217
+ // Deliberately NOT sendClientError() (see below): that path opens a fresh short-lived
1218
+ // socket for out-of-band failures (some predate any session), which is the wrong shape
1219
+ // for a diagnostic that's tightly correlated with THIS session's already-live audio_frontend
1220
+ // status and needs no extra connection to report.
1221
+ _sendAudioFrontendStatus() {
1222
+ const err = this._audioFrontendFallbackError;
1223
+ this._sendControl({
1224
+ type: 'client_event', event: 'audio_frontend',
1225
+ frontend: this._audioFrontend || 'unknown',
1226
+ fallback: this._audioFrontendFallback,
1227
+ ...(err ? { error_name: err.name, error_message: err.message } : {}),
1228
+ });
1229
+ }
1230
+
1231
+ _sendRawAssistStatus(active) {
1232
+ this._sendControl({ type: 'raw_assist_status', active: !!active });
1233
+ }
1234
+
1235
+ _uplink(frame, channel, captureMs) {
1236
+ const sequence = this._uplinkSeq[channel] >>> 0;
1237
+ this._uplinkSeq[channel] = (sequence + 1) >>> 0;
1238
+ return encodeTaggedPcm16(frame, { channel, sequence, captureMs });
1239
+ }
1240
+
1241
+ // Push one mic frame onto the wire: WS binary frame, or (webrtc) into the TrackFeeder that backs
1242
+ // the outbound RTCPeerConnection audio track. rawAssist's dual tagged channel is WS-only (see the
1243
+ // constructor), so webrtc always takes the plain branch here.
1244
+ // Once startMic() has swapped the raw getUserMedia track directly into the peer connection (see
1245
+ // startMic()), the outbound audio no longer depends on this re-injection at all — pushing these
1246
+ // frames into the feeder too would just be wasted work, since its output track is no longer the
1247
+ // one actually wired to the sender. Only a custom-capture caller (pushMicFrame()/appendAudio()
1248
+ // without startMic()) or the SDK's own AEC3 canceller path still needs the feeder engaged.
1249
+ _uplinkFrame(frame, captureMs) {
1250
+ if (this.transport === 'webrtc') {
1251
+ if (!this._directMicTrackEngaged) this._trackFeeder?.push(frame);
1252
+ return;
1253
+ }
1254
+ this.ws.send(this.rawAssist
1255
+ ? this._uplink(frame, UPLINK_CHANNEL_PROCESSED, captureMs)
1256
+ : floatToPcm16Bytes(frame));
1257
+ }
1258
+
1259
+ async appendAudio(frame, { temperature, captureMs = captureClockMs() } = {}) {
1260
+ if (this._mode.modality === 'text') {
1261
+ throw new Error('audio input is unavailable in text mode');
1262
+ }
1263
+ await this.connect({ temperature });
1264
+ if (!this._live) return; // dropped mid-flight — skip this realtime frame
1265
+ if (temperature != null) this._sendControl({ type: 'config', temperature });
1266
+ this._uplinkFrame(frame, captureMs);
1267
+ }
1268
+
1269
+ // Optional DEV ablation: send an UN-processed mic frame (a parallel getUserMedia track with
1270
+ // browser DSP off) on a `raw_audio` control. The server records it to raw.wav only — it never
1271
+ // drives the conversation. Fire-and-forget; silently no-ops if the socket isn't open.
1272
+ // rawAssist is forced off over webrtc (constructor), so only the plain `raw_audio` control frame
1273
+ // path applies there — it still works since it's a data-channel JSON frame like any other.
1274
+ sendRawFrame(frame, { captureMs = captureClockMs() } = {}) {
1275
+ if (this._mode.modality === 'text') {
1276
+ throw new Error('audio input is unavailable in text mode');
1277
+ }
1278
+ if (this.rawAssist) {
1279
+ if (this.transport === 'webrtc' || !this.ws || this.ws.readyState !== 1) return;
1280
+ // Custom capture integrations do not call startMic(), so the first actual raw frame is
1281
+ // their availability signal. This keeps the server fail-closed until both channels exist.
1282
+ if (!this._rawAssistActive) {
1283
+ this._rawAssistActive = true;
1284
+ this._sendRawAssistStatus(true);
1285
+ }
1286
+ this.ws.send(this._uplink(frame, UPLINK_CHANNEL_RAW, captureMs));
1287
+ } else {
1288
+ this._sendControl({ type: 'raw_audio', pcm_b64: bytesToBase64(floatToPcm16Bytes(frame)) });
1289
+ }
1290
+ }
1291
+
1292
+ // Hand the SDK each 512-sample mic frame; it streams the frame up. There is no local detection —
1293
+ // the server owns barge-in — so this just uploads.
1294
+ pushMicFrame(frame, { temperature, captureMs = captureClockMs() } = {}) {
1295
+ if (this._mode.modality === 'text') {
1296
+ throw new Error('audio input is unavailable in text mode');
1297
+ }
1298
+ // Not connected (initial connect still pending, or mid-reconnect) — drop the frame rather than
1299
+ // buffer it. Buffered mic audio would flush as a stale burst into the fresh session on reconnect.
1300
+ if (!this._live) return;
1301
+ // Caller-owned capture has no startMic() promise, so retain its established warmup signal.
1302
+ if (!this._micDesired && !this._listeningFired) {
1303
+ this._listeningFrames += 1;
1304
+ if (this._listeningFrames >= this.listeningWarmupFrames) {
1305
+ this._listeningFired = true;
1306
+ this._dispatch({ type: 'listening', state: 'listening', custom_capture: true });
1307
+ }
1308
+ }
1309
+ // This path is already live, so send synchronously. Besides avoiding a needless microtask,
1310
+ // this guarantees that WebKit's processed frame is on the wire before the raw tee from the
1311
+ // same capture callback. Desktop's independent captures remain correlated by capture_ms.
1312
+ if (temperature != null) this._sendControl({ type: 'config', temperature });
1313
+ this._uplinkFrame(frame, captureMs);
1314
+ }
1315
+
1316
+ get mode() { return this._mode; }
1317
+
1318
+ get responding() { return this._responding; }
1319
+
1320
+ async reset() {
1321
+ this.player?.clear?.();
1322
+ this._responding = false;
1323
+ await this.connect();
1324
+ this._sendControl({ type: 'reset' });
1325
+ }
1326
+
1327
+ // The ambience is a nicety layered on the event stream: a failure inside it must never stop
1328
+ // the event it was reacting to from reaching the app (the reflex runs before dispatch).
1329
+ _ambienceSafe(fn) {
1330
+ try { fn(); } catch (err) {
1331
+ if (!this._ambienceWarned) {
1332
+ this._ambienceWarned = true;
1333
+ console.warn('[converse] ambience error (disabled for this session):', err);
1334
+ }
1335
+ try { this.ambience.setMode('off'); } catch { /* already off */ }
1336
+ }
1337
+ }
1338
+
1339
+ /** Switch the client-side ambience live: 'off' | 'thinking' | 'continuous'. Continuous
1340
+ * engages at once if a reply has already played this session (the bed never leads); thinking
1341
+ * waits for the next tool wait. */
1342
+ setAmbience(mode) {
1343
+ this.ambience.setMode(mode);
1344
+ }
1345
+
1346
+ /** Tell the server the client's ambience layer went on/off. Recorded in the session
1347
+ * timeline as a preference signal; has no effect on the audio pipeline. */
1348
+ sendAmbienceState(active) {
1349
+ this._sendControl({ type: 'ambience', active: !!active });
1350
+ }
1351
+
1352
+ /** Ask the server for a graceful sign-off: a
1353
+ * host-enforced time limit (e.g. the playground's 3-minute mic cap) wants the model to wrap
1354
+ * up in persona instead of the connection just dropping. Fire-and-forget, like
1355
+ * sendAmbienceState — never cuts a reply already in flight; the server's close(1000, "idle")
1356
+ * arrives as the existing `session_end` event once the sign-off finishes. */
1357
+ requestWrapUp(reason = 'time_limit') {
1358
+ this._sendControl({ type: 'wrap_up', reason: String(reason).slice(0, 40) });
1359
+ }
1360
+
1361
+ /** Add a typed user message or silent host context to the conversation, optionally asking the
1362
+ * model to reply immediately. Resolves with the broker's authoritative acceptance/rejection. */
1363
+ injectContext(text, { role = 'context', reply = false, messageId } = {}) {
1364
+ if (typeof text !== 'string') throw new TypeError('text must be a string');
1365
+ if (!text.trim() || [...text].length > 2000) {
1366
+ throw new RangeError('text must contain 1 to 2000 characters');
1367
+ }
1368
+ if (role !== 'user' && role !== 'context') {
1369
+ throw new TypeError('role must be "user" or "context"');
1370
+ }
1371
+ if (typeof reply !== 'boolean') throw new TypeError('reply must be a boolean');
1372
+ if (messageId === undefined) {
1373
+ messageId = globalThis.crypto?.randomUUID?.()
1374
+ || `${this.sessionId}-message-${++this._injectionSeq}`;
1375
+ }
1376
+ if (typeof messageId !== 'string') throw new TypeError('messageId must be a string');
1377
+ if (!messageId.trim() || [...messageId].length > 128) {
1378
+ throw new RangeError('messageId must contain 1 to 128 characters');
1379
+ }
1380
+ if (this._pendingInjections.has(messageId)) {
1381
+ throw new Error(`an injection with messageId ${messageId} is already pending`);
1382
+ }
1383
+ let resolveAck;
1384
+ let rejectAck;
1385
+ const acknowledgement = new Promise((resolve, reject) => {
1386
+ resolveAck = resolve;
1387
+ rejectAck = reject;
1388
+ });
1389
+ // Keep ignored promises from becoming unhandled while preserving rejection for callers that
1390
+ // await the returned promise. Older integrations legitimately treated this method as void.
1391
+ acknowledgement.catch(() => {});
1392
+ const timer = setTimeout(() => {
1393
+ const pending = this._pendingInjections.get(messageId);
1394
+ if (!pending) return;
1395
+ this._pendingInjections.delete(messageId);
1396
+ pending.reject(new Error(`injection acknowledgement timed out for ${messageId}`));
1397
+ }, this.injectionAckTimeoutMs);
1398
+ this._pendingInjections.set(messageId, { resolve: resolveAck, reject: rejectAck, timer });
1399
+ const sent = this._sendControl(
1400
+ { type: 'inject_context', text, role, reply, message_id: messageId });
1401
+ if (!sent) {
1402
+ this._pendingInjections.delete(messageId);
1403
+ clearTimeout(timer);
1404
+ rejectAck(new Error('cannot inject context without a live connection'));
1405
+ }
1406
+ return acknowledgement;
1407
+ }
1408
+
1409
+ /** Send a typed user turn and ask the model to reply. This is the text-chat equivalent of a
1410
+ * final spoken turn and emits the same `asr` transcript event from the server.
1411
+ *
1412
+ * Text sessions commit an `input_text` turn and return whether it was written to the live
1413
+ * connection. Voice sessions keep the pre-0.20 behaviour unchanged: the turn is a user-role
1414
+ * context injection (`injectContext(text, { role: 'user', reply: true, messageId })`) and the
1415
+ * acknowledgement promise is returned. */
1416
+ sendText(text, { messageId } = {}) {
1417
+ if (this._mode.modality !== 'text') {
1418
+ return this.injectContext(text, { role: 'user', reply: true, messageId });
1419
+ }
1420
+ if (typeof text !== 'string') throw new TypeError('text must be a string');
1421
+ if (!text.trim() || [...text].length > 20000) {
1422
+ throw new RangeError('text must contain 1 to 20000 characters');
1423
+ }
1424
+ return this._sendControl({ type: 'input_text', text });
1425
+ }
1426
+
1427
+ /** Resolve a `tool_call` with a terminal outcome. Only succeeded + verified authorizes the
1428
+ * assistant to describe the requested postcondition as established. A timeout is unknown even
1429
+ * if the operation may have happened externally. Keep content compact: the server enforces
1430
+ * its configured UTF-8 JSON byte ceiling and replaces oversized content with a bounded
1431
+ * truncation marker and preview. Listen for calls via `client.addEventListener('tool_call', …)`;
1432
+ * the content itself may be produced anywhere (e.g. relayed from your backend). */
1433
+ sendToolResult(id, content, { outcome = 'unknown', verified = false } = {}) {
1434
+ const outcomes = new Set(['succeeded', 'failed', 'cancelled', 'timed_out', 'unknown']);
1435
+ if (!outcomes.has(outcome)) throw new TypeError('invalid tool result outcome');
1436
+ if (typeof verified !== 'boolean') throw new TypeError('verified must be a boolean');
1437
+ if (verified && outcome !== 'succeeded') {
1438
+ throw new TypeError('verified may be true only when outcome is "succeeded"');
1439
+ }
1440
+ return this._sendControl({ type: 'tool_result', id, content, outcome, verified });
1441
+ }
1442
+
1443
+ /** Detach an eligible tool call from the current voice turn. The host keeps running the job and
1444
+ * may address later progress, cancellation, and the one terminal result by id or handle. */
1445
+ sendToolDeferred(id, { handle, statusLabel } = {}) {
1446
+ const frame = { type: 'tool_deferred', id, handle };
1447
+ if (statusLabel) frame.status_label = statusLabel;
1448
+ return this._sendControl(frame);
1449
+ }
1450
+
1451
+ /** Report human-readable progress on an in-flight tool call (docs/client-tool-protocol.md §3):
1452
+ * appends to the brain's context so the next turn can speak to it; never resolves the call. */
1453
+ sendToolProgress(id, note) {
1454
+ return this._sendControl({ type: 'tool_progress', id, note });
1455
+ }
1456
+
1457
+ /** Deliver a structured segment of an in-flight call's eventual answer
1458
+ * (docs/client-tool-protocol.md §3a): capped like a result envelope, and `reply: true` asks
1459
+ * the broker to proactively narrate it now. `interaction: { id, prompt, options, resolver }` marks this
1460
+ * partial as needing a user decision rather than a routine milestone: unlike `reply: true`
1461
+ * alone, it is never silently dropped when the floor is busy — it falls back to the same job
1462
+ * queue tool completions use, preempting them, and gets a far more persistent delivery retry.
1463
+ * Track its lifecycle with `narrationState`/`waitForNarrationState`. Never resolves the call —
1464
+ * the terminal sendToolResult is still required exactly once. */
1465
+ sendToolPartialResult(id, content, { reply = false, interaction } = {}) {
1466
+ return this._sendControl({
1467
+ type: 'tool_partial_result', id, content,
1468
+ ...(reply ? { reply: true } : {}),
1469
+ ...(interaction ? { interaction } : {}),
1470
+ });
1471
+ }
1472
+
1473
+ /** Last known `tool_job_narration` state ("queued"/"started"/"superseded"/"cancelled"/"failed")
1474
+ * for `jobId`, or undefined if no narration ack has arrived for it yet. */
1475
+ narrationState(jobId) {
1476
+ return this._narrationStates.get(jobId);
1477
+ }
1478
+
1479
+ /** Resolve once `jobId`'s tool_job_narration state reaches one of `states`, or reject on
1480
+ * `timeoutMs`. Unlike injectContext's one-shot ack, a queued interaction's state evolves
1481
+ * (queued -> started -> superseded/cancelled), so this is a repeatable wait keyed by jobId
1482
+ * rather than a single resolve-once promise. */
1483
+ waitForNarrationState(jobId, states, { timeoutMs = 30000 } = {}) {
1484
+ const wanted = new Set(states);
1485
+ const current = this._narrationStates.get(jobId);
1486
+ if (wanted.has(current)) return Promise.resolve(current);
1487
+ return new Promise((resolve, reject) => {
1488
+ const timer = setTimeout(() => {
1489
+ const waiters = this._narrationWaiters.get(jobId);
1490
+ if (!waiters) return;
1491
+ const remaining = waiters.filter((w) => w.resolve !== resolve);
1492
+ if (remaining.length) this._narrationWaiters.set(jobId, remaining);
1493
+ else this._narrationWaiters.delete(jobId);
1494
+ reject(new Error(`narration state wait timed out for ${jobId}`));
1495
+ }, timeoutMs);
1496
+ const waiters = this._narrationWaiters.get(jobId) || [];
1497
+ waiters.push({ states: wanted, resolve, reject, timer });
1498
+ this._narrationWaiters.set(jobId, waiters);
1499
+ });
1500
+ }
1501
+
1502
+ /** Last known narration state for a stable interaction id (host-chosen `interaction.id`, or
1503
+ * the broker-derived id echoed in tool_job_narration's `interaction_ids`), or undefined. */
1504
+ interactionState(interactionId) {
1505
+ return this._interactionStates.get(interactionId);
1506
+ }
1507
+
1508
+ /** Close an open interaction without completing its parent call
1509
+ * (docs/client-tool-protocol.md §3a): the decision was `resolved` out-of-band, `cancelled`,
1510
+ * or `superseded` by newer intent. Queued or actively-speaking narration for it stops and the
1511
+ * model is told not to act on it. Resolves with the server's deterministic
1512
+ * `tool_interaction_update_ack` (`applied: false` carries a stable `reason` for
1513
+ * late/duplicate/unknown updates); rejects on timeout or a dead connection. */
1514
+ sendToolInteractionUpdate(id, interactionId, state, { note, timeoutMs = 10000 } = {}) {
1515
+ const states = new Set(['resolved', 'cancelled', 'superseded']);
1516
+ if (!states.has(state)) throw new TypeError('invalid interaction update state');
1517
+ if (typeof interactionId !== 'string' || !interactionId.trim()) {
1518
+ throw new TypeError('interactionId must be a non-empty string');
1519
+ }
1520
+ // The server normalizes ids by stripping whitespace and echoes the STRIPPED form in the
1521
+ // ack — key and send the same form, or a padded id's applied ack surfaces as a timeout.
1522
+ interactionId = interactionId.trim();
1523
+ let pending;
1524
+ const acknowledgement = new Promise((resolve, reject) => {
1525
+ pending = { resolve, reject, timer: null, state };
1526
+ pending.timer = setTimeout(() => {
1527
+ this._removePendingInteractionUpdate(interactionId, pending);
1528
+ reject(new Error(`interaction update ack timed out for ${interactionId}`));
1529
+ }, timeoutMs);
1530
+ });
1531
+ const queue = this._pendingInteractionUpdates.get(interactionId) || [];
1532
+ queue.push(pending);
1533
+ this._pendingInteractionUpdates.set(interactionId, queue);
1534
+ const frame = { type: 'tool_interaction_update', id, interaction_id: interactionId, state };
1535
+ if (note) frame.note = note;
1536
+ if (!this._sendControl(frame)) {
1537
+ this._removePendingInteractionUpdate(interactionId, pending);
1538
+ clearTimeout(pending.timer);
1539
+ pending.reject(new Error('cannot update an interaction without a live connection'));
1540
+ }
1541
+ return acknowledgement;
1542
+ }
1543
+
1544
+ _removePendingInteractionUpdate(interactionId, pending) {
1545
+ const queue = this._pendingInteractionUpdates.get(interactionId);
1546
+ if (!queue) return;
1547
+ const remaining = queue.filter((entry) => entry !== pending);
1548
+ if (remaining.length) this._pendingInteractionUpdates.set(interactionId, remaining);
1549
+ else this._pendingInteractionUpdates.delete(interactionId);
1550
+ }
1551
+
1552
+ /** Cancel an in-flight tool call. */
1553
+ sendToolCancel(id) {
1554
+ return this._sendControl({ type: 'tool_cancel', id });
1555
+ }
1556
+
1557
+ /** Restrict (or free) tool use mid-session — the familiar OpenAI/Gemini vocabulary:
1558
+ * `"auto"` | `"none"` | `"required"` | `{allowed: [names]}` | `{tool: name}`. Applies from
1559
+ * the next reply; `required`/`allowed`/`tool` constrain the first planning round of each
1560
+ * user turn, `none` withholds declared client tools (broker-managed protocol tools stay
1561
+ * available). `oneShot: true` reverts to the previous choice after the next user turn
1562
+ * consumes it. An invalid value is rejected by the server with `invalid_tool_choice` and
1563
+ * changes nothing; a mid-session `setTools` resets tool_choice to `"auto"`. */
1564
+ setToolChoice(toolChoice, { oneShot = false } = {}) {
1565
+ const validated = validatedToolChoice(toolChoice);
1566
+ if (!oneShot && this._mode.kind === 'converse'
1567
+ && Array.isArray(this._mode.tools) && this._mode.tools.length) {
1568
+ // Durable restrictions fold into the replayed mode (like setVoice) so an auto-reconnect
1569
+ // re-applies them; a one-shot is turn-scoped and deliberately does not survive. A mode
1570
+ // with no constructor tools cannot carry one (a reconnect would have no tools either) --
1571
+ // the server's resume stash covers that case.
1572
+ this._mode = Object.freeze(validatedMode({ ...this._mode, tool_choice: validated }));
1573
+ }
1574
+ const frame = { type: 'set_tool_choice', tool_choice: validated };
1575
+ if (oneShot) frame.one_shot = true;
1576
+ return this._sendControl(frame);
1577
+ }
1578
+
1579
+ /** Switch character voice mid-session. Applies from the next reply; Dialt mode only. */
1580
+ setVoice(voice) {
1581
+ // Relay providers bind their voice when the upstream session is constructed and do not support
1582
+ // this control. Dialt reconnects replay the selected voice.
1583
+ if (this._mode.kind !== 'converse') return;
1584
+ this._mode = Object.freeze(validatedMode({ ...this._mode, voice }));
1585
+ this._sendControl({ type: 'set_voice', voice });
1586
+ }
1587
+
1588
+ close() {
1589
+ this._closedByUser = true; // stop any reconnect loop and prevent reconnect on the close event
1590
+ this._live = false;
1591
+ this._setResumeToken(null); // an intentional reuse starts a new conversation
1592
+ this._rejectPendingInjections(new Error('connection closed before injection acknowledgement'));
1593
+
1594
+ this.stopMic(); // release the SDK-owned mic (no-op for custom-capture apps)
1595
+ this.ambience.stop();
1596
+ this.player?.stop?.();
1597
+ if (this.transport === 'webrtc') this._teardownWebRtc();
1598
+ try { this.ws?.close(1000); } catch { /* closing a CONNECTING socket is allowed and harmless */ }
1599
+ }
1600
+
1601
+ /** Close cleanly and wait for the WebSocket close handshake, so callers can safely send
1602
+ * follow-up controls that depend on server-side session finalization. */
1603
+ async closeAndWait(timeoutMs = 5000) {
1604
+ const ws = this.ws;
1605
+ if (!ws || ws.readyState === 3) {
1606
+ this.close();
1607
+ return;
1608
+ }
1609
+ const closed = new Promise((resolve) => {
1610
+ const timer = setTimeout(resolve, timeoutMs);
1611
+ ws.addEventListener('close', () => {
1612
+ clearTimeout(timer);
1613
+ resolve();
1614
+ }, { once: true });
1615
+ });
1616
+ this.close();
1617
+ await closed;
1618
+ }
1619
+
1620
+ _rejectPendingInjections(error) {
1621
+ for (const pending of this._pendingInjections.values()) {
1622
+ clearTimeout(pending.timer);
1623
+ pending.reject(error);
1624
+ }
1625
+ this._pendingInjections.clear();
1626
+ for (const waiters of this._narrationWaiters.values()) {
1627
+ for (const waiter of waiters) {
1628
+ clearTimeout(waiter.timer);
1629
+ waiter.reject(error);
1630
+ }
1631
+ }
1632
+ this._narrationWaiters.clear();
1633
+ for (const queue of this._pendingInteractionUpdates.values()) {
1634
+ for (const pending of queue) {
1635
+ clearTimeout(pending.timer);
1636
+ pending.reject(error);
1637
+ }
1638
+ }
1639
+ this._pendingInteractionUpdates.clear();
1640
+ }
1641
+
1642
+ _applyNarrationAck(event) {
1643
+ const interactionIds = Array.isArray(event.interaction_ids) ? event.interaction_ids : [];
1644
+ for (const interactionId of interactionIds) {
1645
+ this._interactionStates.set(interactionId, event.state);
1646
+ }
1647
+ const jobIds = Array.isArray(event.job_ids) ? event.job_ids : [];
1648
+ for (const jobId of jobIds) {
1649
+ this._narrationStates.set(jobId, event.state);
1650
+ const waiters = this._narrationWaiters.get(jobId);
1651
+ if (!waiters) continue;
1652
+ const remaining = [];
1653
+ for (const waiter of waiters) {
1654
+ if (waiter.states.has(event.state)) {
1655
+ clearTimeout(waiter.timer);
1656
+ waiter.resolve(event.state);
1657
+ } else {
1658
+ remaining.push(waiter);
1659
+ }
1660
+ }
1661
+ if (remaining.length) this._narrationWaiters.set(jobId, remaining);
1662
+ else this._narrationWaiters.delete(jobId);
1663
+ }
1664
+ }
1665
+
1666
+ // Drive playback state off each server event, before re-dispatching it.
1667
+ _applyReflex(event) {
1668
+ const webrtc = this.transport === 'webrtc';
1669
+ switch (event.type) {
1670
+ case 'turn':
1671
+ this._responding = true;
1672
+ if (!webrtc) this.player?.markReplyStart?.();
1673
+ this._ambienceSafe(() => this.ambience.onReplyStart());
1674
+ if (webrtc) {
1675
+ // No per-chunk binary audio frames arrive over webrtc (assistant audio is a remote RTP
1676
+ // track — see _attachRemoteAudio), so app.js's `case "audio": if (client.responding && ...)
1677
+ // setState(SPEAKING)` would never fire. Rather than reinvent per-chunk detection (the
1678
+ // remote track already started/will start playing immediately), synthesize the one
1679
+ // 'audio' event apps actually key off of, right when a reply — and therefore audio — is
1680
+ // known to start. `samples` is null: there is no decoded PCM to hand a custom consumer.
1681
+ this._dispatch({ type: 'audio', sr: SAMPLE_RATE, samples: null, synthetic: true });
1682
+ }
1683
+ break;
1684
+ case 'canceled': // eager speculation retracted — the audio was a mistake, clear it
1685
+ // Over webrtc the server owns playout and already stopped sending on its own retraction —
1686
+ // there is no local queue for this client to clear.
1687
+ if (!webrtc) this.player?.clear?.();
1688
+ this._responding = false;
1689
+ break;
1690
+ case 'interrupted': // barged — stop the reply (fade-clear if the server asks, else drain).
1691
+ this._responding = false;
1692
+ // Over webrtc the server measures and reports its own discarded audio on a hard-clear barge
1693
+ // (see broker_webrtc.py's WebRtcTransport.send_json) and there is no local player queue to
1694
+ // drain/fade — the wire contract explicitly forbids the client sending playback_stopped here
1695
+ // (note #5b), so webrtc takes none of the WS path's measurement/report below.
1696
+ if (webrtc) break;
1697
+ // Report the stop so the server can timestamp actual silence at the speaker
1698
+ // (barge_detected -> playback_stopped = the stop half of barge latency) and, on a
1699
+ // clear, re-truncate its committed text by discarded_ms (audio the user never heard);
1700
+ // barge_seq is echoed so a slow report can't be applied to a later barge. Chained on
1701
+ // audioQueue so in-flight enqueues (dropped by the _responding recheck) settle first.
1702
+ this.audioQueue = this.audioQueue.catch(() => {}).then(() => {
1703
+ const pending = this.player?.pendingMs?.() ?? 0;
1704
+ const device = this.player?.deviceLatencyMs?.() ?? 0;
1705
+ let remaining = pending + device;
1706
+ let discarded = 0;
1707
+ if (event.clear) {
1708
+ this.player?.clear?.(BARGE_CLEAR_FADE_S);
1709
+ const fadeMs = BARGE_CLEAR_FADE_S * 1000;
1710
+ discarded = Math.max(0, pending - fadeMs);
1711
+ remaining = Math.min(pending, fadeMs) + device;
1712
+ }
1713
+ if (this.ws?.readyState === 1) {
1714
+ const report = { type: 'client_event', event: 'playback_stopped',
1715
+ remaining_ms: Math.round(remaining),
1716
+ discarded_ms: Math.round(discarded) };
1717
+ if (typeof event.barge_seq === 'number') report.barge_seq = event.barge_seq;
1718
+ this.ws.send(JSON.stringify(report));
1719
+ }
1720
+ });
1721
+ break;
1722
+ case 'done':
1723
+ this._responding = false;
1724
+ this._ambienceSafe(() => this.ambience.onReplyEnd());
1725
+ // Playback-health report: did this reply's audio starve at the speaker? Sent every
1726
+ // reply (zeroes included) so a smooth session is distinguishable from a silent gap in
1727
+ // telemetry. WS only: over webrtc the browser owns a jitter buffer we cannot observe.
1728
+ if (!webrtc && this.player?.takePlaybackStats) {
1729
+ const stats = this.player.takePlaybackStats();
1730
+ if (this.ws?.readyState === 1) {
1731
+ const report = { type: 'client_event', event: 'playback_report', ...stats };
1732
+ if (event.turn_id) report.turn_id = event.turn_id;
1733
+ this.ws.send(JSON.stringify(report));
1734
+ }
1735
+ }
1736
+ break;
1737
+ case 'working':
1738
+ // Dialt is blocking on a tool result with nothing audible (true) / that wait ended
1739
+ // (false). The thinking sound keys off this; apps can show a "working" state from it.
1740
+ this._ambienceSafe(() => this.ambience.onWorking(!!event.active));
1741
+ break;
1742
+ default:
1743
+ break;
1744
+ }
1745
+ }
1746
+
1747
+ async _message(data) {
1748
+ if (typeof data === 'string') {
1749
+ const event = JSON.parse(data);
1750
+ if (event.type === 'inject_context_ack' && typeof event.message_id === 'string') {
1751
+ const pending = this._pendingInjections.get(event.message_id);
1752
+ if (pending) {
1753
+ this._pendingInjections.delete(event.message_id);
1754
+ clearTimeout(pending.timer);
1755
+ pending.resolve(event);
1756
+ }
1757
+ }
1758
+ if (event.type === 'tool_job_narration') this._applyNarrationAck(event);
1759
+ if (event.type === 'tool_interaction_update_ack'
1760
+ && typeof event.interaction_id === 'string') {
1761
+ const queue = this._pendingInteractionUpdates.get(event.interaction_id);
1762
+ if (queue && queue.length) {
1763
+ // The oldest pending whose REQUESTED state matches the ack's echo: the server
1764
+ // always echoes the requested state, so every live caller's ack state-matches. A
1765
+ // client-side timeout removes its queue entry but not the server's eventual answer
1766
+ // -- that orphaned, unmatched ack is dropped rather than handed to the next caller.
1767
+ const index = queue.findIndex((entry) => entry.state === event.state);
1768
+ if (index !== -1) {
1769
+ const [pending] = queue.splice(index, 1);
1770
+ if (!queue.length) this._pendingInteractionUpdates.delete(event.interaction_id);
1771
+ clearTimeout(pending.timer);
1772
+ pending.resolve(event);
1773
+ }
1774
+ }
1775
+ }
1776
+ this._applyReflex(event);
1777
+ this.dispatchEvent(new CustomEvent(event.type, { detail: event }));
1778
+ this.dispatchEvent(new CustomEvent('event', { detail: event }));
1779
+ return event;
1780
+ }
1781
+ const samples = await binaryToFloat32(data);
1782
+ const detail = { type: 'audio', sr: SAMPLE_RATE, samples };
1783
+ this.dispatchEvent(new CustomEvent('audio', { detail }));
1784
+ this.dispatchEvent(new CustomEvent('event', { detail }));
1785
+ if (this._responding) {
1786
+ this.audioQueue = this.audioQueue
1787
+ .catch(() => {})
1788
+ // Re-check responding AFTER the await: a `canceled` can land (and clear the player) while
1789
+ // this enqueue is in flight; without the recheck the discarded tail would resurrect playback.
1790
+ .then(() => { if (this._responding) return this.player?.enqueue?.(samples); })
1791
+ .catch((err) => {
1792
+ this.dispatchEvent(new CustomEvent('error', { detail: { type: 'error', error: err } }));
1793
+ });
1794
+ await this.audioQueue;
1795
+ }
1796
+ return detail;
1797
+ }
1798
+ }