@iloveagents/foundry-web-voice 0.1.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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +157 -0
  3. package/dist/adapter/half-duplex.d.ts +42 -0
  4. package/dist/adapter/half-duplex.js +65 -0
  5. package/dist/adapter/session-config.d.ts +55 -0
  6. package/dist/adapter/session-config.js +148 -0
  7. package/dist/adapter/speech-queue.d.ts +95 -0
  8. package/dist/adapter/speech-queue.js +344 -0
  9. package/dist/adapter/tool-bridge.d.ts +25 -0
  10. package/dist/adapter/tool-bridge.js +37 -0
  11. package/dist/adapter/tool-sync.d.ts +46 -0
  12. package/dist/adapter/tool-sync.js +55 -0
  13. package/dist/adapter/types.d.ts +91 -0
  14. package/dist/adapter/types.js +8 -0
  15. package/dist/adapter/voice-bridge.d.ts +214 -0
  16. package/dist/adapter/voice-bridge.js +539 -0
  17. package/dist/index.d.ts +28 -0
  18. package/dist/index.js +30 -0
  19. package/dist/react/audio-ownership.d.ts +41 -0
  20. package/dist/react/audio-ownership.js +37 -0
  21. package/dist/react/install.d.ts +62 -0
  22. package/dist/react/install.js +100 -0
  23. package/dist/react/relay-answer-watcher.d.ts +16 -0
  24. package/dist/react/relay-answer-watcher.js +45 -0
  25. package/dist/react/use-direct-audio-output.d.ts +23 -0
  26. package/dist/react/use-direct-audio-output.js +44 -0
  27. package/dist/react/voice-audio-sink.d.ts +21 -0
  28. package/dist/react/voice-audio-sink.js +55 -0
  29. package/dist/react/voice-avatar.d.ts +61 -0
  30. package/dist/react/voice-avatar.js +76 -0
  31. package/dist/react/voice-launcher-badge.d.ts +14 -0
  32. package/dist/react/voice-launcher-badge.js +36 -0
  33. package/dist/react/voice-mic-button.d.ts +19 -0
  34. package/dist/react/voice-mic-button.js +53 -0
  35. package/dist/react/voice-module.d.ts +34 -0
  36. package/dist/react/voice-module.js +25 -0
  37. package/dist/react/voice-stage.d.ts +20 -0
  38. package/dist/react/voice-stage.js +64 -0
  39. package/dist/react/voice-status-strip.d.ts +10 -0
  40. package/dist/react/voice-status-strip.js +31 -0
  41. package/dist/react/voice-surface.d.ts +27 -0
  42. package/dist/react/voice-surface.js +289 -0
  43. package/dist/react/voice-ui-store.d.ts +58 -0
  44. package/dist/react/voice-ui-store.js +46 -0
  45. package/dist/react/voice-visualizer.d.ts +37 -0
  46. package/dist/react/voice-visualizer.js +222 -0
  47. package/package.json +71 -0
@@ -0,0 +1,539 @@
1
+ /**
2
+ * `VoiceBridge` — everything the voice tier does that is not React.
3
+ *
4
+ * It sits between two halves that cannot see each other:
5
+ *
6
+ * - **assistant-ui**, which asks for a `RealtimeVoiceAdapter`: an
7
+ * imperative `connect()` returning a session that emits transcripts,
8
+ * mode, volume and status.
9
+ * - **the Voice Live SDK**, whose entry point is the `useVoiceLive` hook —
10
+ * a React object that only exists while a component is mounted.
11
+ *
12
+ * The join is `createVoiceSession(options, setup)`, whose `setup` is
13
+ * **async**: `connect()` can return a session in the `starting` state
14
+ * immediately and resolve the transport afterwards. So a render-less host
15
+ * component owns the hook and calls `attach()` with its controls, and the
16
+ * bridge's `connect()` simply awaits them. That reuses the SDK whole —
17
+ * WebRTC, avatar, reconnect, the response gate, the tool round-trip —
18
+ * instead of reimplementing a transport to satisfy an interface shape.
19
+ *
20
+ * The bridge holds no React component, hook or rendered state — the only
21
+ * thing it takes from `@assistant-ui/react` is the voice contract itself
22
+ * (`createVoiceSession`, the adapter types). So every rule below is driven
23
+ * and asserted as a plain object against a fake transport, with no tree to
24
+ * render and no session to connect.
25
+ */
26
+ import { createVoiceSession } from "@assistant-ui/react";
27
+ import { SentenceStream, SpeechQueue, toSpeakableText } from "./speech-queue.js";
28
+ /** How often the level meter samples, in ms. 20 Hz is smooth and cheap. */
29
+ const VOLUME_INTERVAL_MS = 50;
30
+ /** How long `connect()` waits for the host component to attach before giving up. */
31
+ const ATTACH_TIMEOUT_MS = 10000;
32
+ export class VoiceBridge {
33
+ constructor(config) {
34
+ this.config = config;
35
+ this.controls = null;
36
+ this.waiters = [];
37
+ this.helpers = null;
38
+ this.connected = false;
39
+ /**
40
+ * The user's mute intent, as expressed through the runtime's controls.
41
+ *
42
+ * Distinct from the SDK's capture-mute state on purpose: half-duplex mutes
43
+ * the SDK on its own behalf while the assistant speaks, and releasing that
44
+ * mute must never override a mute the *user* asked for — that is how a
45
+ * microphone ends up hot behind a UI that says muted.
46
+ */
47
+ this.userMuted = false;
48
+ /** An answer that was already on screen when voice connected — not ours to speak. */
49
+ this.skipAnswerId = null;
50
+ this.volumeTimer = null;
51
+ this.volumeBuffer = null;
52
+ this.sentences = new SentenceStream();
53
+ /** Id of the assistant message currently being spoken, so a new answer resets the stream. */
54
+ this.answerId = null;
55
+ /**
56
+ * Whether `answerId` has been spoken to the end.
57
+ *
58
+ * The watcher re-fires for an answer that is already finished — the
59
+ * thread's message array gets a new identity on any notification, not just
60
+ * on new text. Without a "done" marker, clearing `answerId` on completion
61
+ * made the very next tick look like a brand-new answer, reset the sentence
62
+ * stream, and speak the whole reply again. Forever.
63
+ */
64
+ this.answerDone = false;
65
+ /** The response currently being spoken, once the service has named it. */
66
+ this.activeResponseId = null;
67
+ /**
68
+ * Responses cancelled by barge-in whose `response.done` has not arrived yet.
69
+ *
70
+ * Bounded by the number of interruptions in a call and cleared with it. A
71
+ * cancelled response that never reports simply leaves an unused entry — the
72
+ * alternative, counting completions to swallow, wedges the queue for the
73
+ * rest of the session when the count is wrong.
74
+ */
75
+ this.cancelledResponses = new Set();
76
+ /**
77
+ * A response was cancelled before the service had named it.
78
+ *
79
+ * The SDK's own gate documents the window: between sending `response.create`
80
+ * and receiving `response.created` the conversation looks idle. Interrupting
81
+ * in there leaves nothing to correlate, so the completion that eventually
82
+ * arrives would look like the NEXT answer's and release it early — the same
83
+ * bug, in the gap. The wire preserves order, so the first `response.created`
84
+ * after such a cancel names the response that was cancelled.
85
+ */
86
+ this.cancelledBeforeCreated = false;
87
+ /**
88
+ * The connection attempt currently allowed to become a session.
89
+ *
90
+ * A token per attempt, not a boolean on the bridge. A boolean is shared, so
91
+ * starting a second call reset it — and if the FIRST transport then came up,
92
+ * its continuation saw "not abandoned", marked itself connected, and left a
93
+ * microphone open with no host, while also clearing the newer attempt's
94
+ * pending record. Each attempt compares its own token, so an obsolete
95
+ * continuation can never mistake a later call's state for permission.
96
+ */
97
+ this.attempt = null;
98
+ /**
99
+ * A connection that has been asked for but has not settled.
100
+ *
101
+ * Held so that abandoning a call can take the transport down NOW rather than
102
+ * waiting on a promise that may never resolve — a permission prompt left
103
+ * open, a transport that stalls. `setup` re-checks its attempt token past
104
+ * the await and covers a connection that arrives late; the case it cannot reach
105
+ * is the one that never arrives, which is exactly this.
106
+ */
107
+ this.pendingConnect = null;
108
+ /** Injected so the bridge never imports the React chat surface. */
109
+ this.submitText = null;
110
+ this.mode = config.mode ?? "relay";
111
+ this.speech = new SpeechQueue((text) => this.sendSpokenText(text));
112
+ }
113
+ /**
114
+ * True only once the transport is actually carrying a session.
115
+ *
116
+ * `helpers` exists from the first line of `setup`, which is *before*
117
+ * `controls.connect()` resolves. Speaking in that window sends into a
118
+ * socket that is not open yet: the utterance is lost, and because the
119
+ * queue is now "speaking" it never receives the `response.done` that
120
+ * would release it — the session goes silent for good.
121
+ */
122
+ get isLive() {
123
+ return this.connected && this.helpers !== null && !this.helpers.isDisposed();
124
+ }
125
+ /** The user's mute intent (see `userMuted`). Read by the half-duplex gate. */
126
+ get isUserMuted() {
127
+ return this.userMuted;
128
+ }
129
+ // ---- wiring ---------------------------------------------------------------
130
+ /** The React host publishes its live controls here (and retracts them on unmount). */
131
+ attach(controls) {
132
+ this.controls = controls;
133
+ const waiters = this.waiters;
134
+ this.waiters = [];
135
+ for (const resolve of waiters)
136
+ resolve(controls);
137
+ return () => {
138
+ if (this.controls === controls)
139
+ this.controls = null;
140
+ };
141
+ }
142
+ /** How the transcript reaches the composer in `relay` mode. */
143
+ setSubmitText(submit) {
144
+ this.submitText = submit;
145
+ }
146
+ // ---- the assistant-ui contract --------------------------------------------
147
+ createAdapter() {
148
+ return {
149
+ connect: ({ abortSignal }) => createVoiceSession({ abortSignal }, async (helpers) => {
150
+ this.helpers = helpers;
151
+ const controls = await this.awaitControls(abortSignal);
152
+ // Race the connection against cancellation rather than waiting it
153
+ // out. `controls.connect()` takes no signal and is unbounded — a
154
+ // permission prompt left open, a transport that never settles — and
155
+ // until it resolves the cleanup that owns `controls.disconnect()`
156
+ // does not exist. Awaiting first therefore left a discarded session
157
+ // able to acquire the microphone minutes later with no control able
158
+ // to stop it.
159
+ const attempt = Symbol("voice-connect");
160
+ this.attempt = attempt;
161
+ const connecting = controls.connect();
162
+ this.pendingConnect = { controls };
163
+ if (abortSignal) {
164
+ const cancelled = new Promise((resolve) => {
165
+ if (abortSignal.aborted)
166
+ resolve("cancelled");
167
+ else
168
+ abortSignal.addEventListener("abort", () => resolve("cancelled"), { once: true });
169
+ });
170
+ const winner = await Promise.race([
171
+ connecting.then(() => "connected"),
172
+ cancelled,
173
+ ]);
174
+ if (winner === "cancelled") {
175
+ // Take it down now in case it is already up, and again whenever
176
+ // it finally settles — `disconnect()` on an idle transport is a
177
+ // no-op, a live one left running is not.
178
+ controls.disconnect();
179
+ void connecting.then(() => controls.disconnect(), () => { });
180
+ this.endCall();
181
+ // No `helpers.end()` here, and deliberately so: assistant-ui
182
+ // registers `abort -> session.disconnect() -> cleanup()`, which
183
+ // marks the session disposed, and every helper is a no-op after
184
+ // that. The status stays `starting` for ever by ITS design, not
185
+ // ours. What is ours is releasing the bridge so the next call
186
+ // starts clean, and making sure the transport cannot outlive
187
+ // this one. Throwing settles `setup` so nothing waits on it.
188
+ throw abortSignal.reason instanceof Error
189
+ ? abortSignal.reason
190
+ : new Error("Voice connection cancelled");
191
+ }
192
+ }
193
+ else {
194
+ await connecting;
195
+ }
196
+ if (this.attempt === attempt)
197
+ this.pendingConnect = null;
198
+ if (this.attempt !== attempt) {
199
+ // Abandoned: the host went away, or a newer call superseded this
200
+ // one. Either way nothing remains that would control this
201
+ // transport, so it must not become a live session. `endCall()` is
202
+ // NOT called here — a newer attempt may already own the bridge,
203
+ // and tearing its state down is exactly the confusion a shared
204
+ // flag caused.
205
+ controls.disconnect();
206
+ throw new Error("Voice connection superseded before it completed");
207
+ }
208
+ this.connected = true;
209
+ // `createVoiceSession` starts every session in `starting`; nothing
210
+ // else moves it on, so a session that never says `running` shows
211
+ // as connecting forever.
212
+ helpers.setStatus({ type: "running" });
213
+ this.startVolumeMeter(controls);
214
+ return {
215
+ disconnect: () => {
216
+ this.endCall();
217
+ controls.disconnect();
218
+ },
219
+ mute: () => {
220
+ this.userMuted = true;
221
+ controls.mute();
222
+ },
223
+ unmute: () => {
224
+ this.userMuted = false;
225
+ controls.unmute();
226
+ },
227
+ };
228
+ }),
229
+ };
230
+ }
231
+ // ---- fed by the React host ------------------------------------------------
232
+ /**
233
+ * A transcript line from the SDK (`text` is cumulative for the turn).
234
+ *
235
+ * The modes diverge here, and the divergence is the whole reason a spoken
236
+ * turn in `relay` renders as an ordinary, persisted thread turn:
237
+ *
238
+ * - `realtime` republishes both roles, and assistant-ui materialises them
239
+ * as (session-scoped) thread messages.
240
+ * - `relay` republishes **neither**. The user's words become a real
241
+ * message by going through the composer, and the answer is a real AG-UI
242
+ * turn. Emitting the transcript as well would render each of them
243
+ * twice.
244
+ */
245
+ handleTranscript(role, text, isFinal) {
246
+ this.config.onTranscript?.({ role, text, isFinal });
247
+ if (this.mode === "realtime") {
248
+ // A VAD misfire (room noise, the assistant's own tail) produces a
249
+ // final user transcript with nothing in it. Publishing it puts an
250
+ // empty bubble in the thread and makes the run look like a turn the
251
+ // user took.
252
+ if (!(role === "user" && isFinal && !text.trim())) {
253
+ this.helpers?.emitTranscript({ role, text, isFinal });
254
+ }
255
+ return;
256
+ }
257
+ if (role === "user" && isFinal) {
258
+ const spoken = text.trim();
259
+ // A VAD false-positive produces an empty final transcript. Submitting
260
+ // it would post a blank user turn and run the agent on nothing.
261
+ if (spoken)
262
+ this.submitText?.(spoken);
263
+ }
264
+ }
265
+ /** Raw server events — only the few that drive turn-taking are read. */
266
+ handleServerEvent(event) {
267
+ switch (event.type) {
268
+ case "input_audio_buffer.speech_started":
269
+ // Barge-in. Drop everything still queued to be spoken; the wire's
270
+ // in-flight response is cancelled by the service (`interrupt_response`),
271
+ // and cancelling locally flushes playback that is already buffered.
272
+ if (this.speech.isSpeaking || this.speech.pendingCount > 0) {
273
+ // Remember what was in flight. Cancelling cannot unsend what is
274
+ // already on the wire, so that response still reports `response.done`
275
+ // — usually after the NEXT answer has started speaking. Released
276
+ // then, it frees an utterance while the current one is still going
277
+ // and the two run together.
278
+ if (this.activeResponseId) {
279
+ this.cancelledResponses.add(this.activeResponseId);
280
+ }
281
+ else if (this.speech.isSpeaking) {
282
+ // Sent, not yet acknowledged: claim the id when it arrives.
283
+ this.cancelledBeforeCreated = true;
284
+ }
285
+ this.activeResponseId = null;
286
+ this.speech.cancel();
287
+ this.controls?.cancelResponse();
288
+ }
289
+ this.sentences.reset();
290
+ this.answerDone = true;
291
+ break;
292
+ case "response.created": {
293
+ const created = responseIdOf(event);
294
+ if (this.cancelledBeforeCreated) {
295
+ this.cancelledBeforeCreated = false;
296
+ if (created)
297
+ this.cancelledResponses.add(created);
298
+ break;
299
+ }
300
+ this.activeResponseId = created;
301
+ break;
302
+ }
303
+ case "response.done": {
304
+ const id = responseIdOf(event);
305
+ // Only the response we are actually waiting on releases the queue.
306
+ if (id && this.cancelledResponses.delete(id))
307
+ break;
308
+ if (id && this.activeResponseId === id)
309
+ this.activeResponseId = null;
310
+ this.speech.onResponseDone();
311
+ break;
312
+ }
313
+ default:
314
+ break;
315
+ }
316
+ }
317
+ /** SDK session state → assistant-ui's two-value mode. */
318
+ handleSessionState(state) {
319
+ this.helpers?.emitMode(state === "speaking" ? "speaking" : "listening");
320
+ }
321
+ /** A session-fatal error: report it, and end the session so the UI leaves the live state. */
322
+ handleError(message) {
323
+ this.endCall();
324
+ this.config.onError?.(message);
325
+ this.helpers?.end("error", new Error(message));
326
+ }
327
+ /**
328
+ * The transport reached a terminal closed state.
329
+ *
330
+ * Idempotent by the `connected` check, because every route out of a call
331
+ * ends here: an explicit `disconnect()`, a failure through `handleError`,
332
+ * and the surface observing `connectionState === "disconnected"` all arrive,
333
+ * and ending the session twice would be a second `end()` on a session that
334
+ * already finished.
335
+ */
336
+ handleClosed() {
337
+ // `connected` is only true once `connect()` resolves, so it cannot tell
338
+ // "no call yet" from "a call still coming up" — and only the second of
339
+ // those has anything to tear down.
340
+ this.attempt = null;
341
+ // A connection may still be on its way up with nothing left to control it.
342
+ // Take it down now; one that resolves LATER is caught inside `setup`,
343
+ // which re-checks `abandoned` past its await. The case that path cannot
344
+ // reach is the one that never resolves at all, which is this.
345
+ const pending = this.pendingConnect;
346
+ if (pending) {
347
+ this.pendingConnect = null;
348
+ pending.controls.disconnect();
349
+ }
350
+ // Everything below runs for an abandoned setup too. Stopping at the
351
+ // transport left the session in `starting` for ever and the speech queue
352
+ // holding whatever it held, which then swallowed the next call — the very
353
+ // failure `endCall()` exists to prevent, reintroduced by an early return.
354
+ if (!this.connected && !pending)
355
+ return;
356
+ this.endCall();
357
+ this.helpers?.end("finished");
358
+ }
359
+ /**
360
+ * Everything a finished call must let go of, whichever way it ended.
361
+ *
362
+ * This bridge is installed once for the life of the app, so its state
363
+ * outlives any single call. The speech queue is the dangerous part: it
364
+ * releases on `response.done`, and a transport that has died will never send
365
+ * one. Left `speaking`, it swallows every utterance of every LATER call —
366
+ * the session connects, the agent answers, and nothing is ever heard again.
367
+ *
368
+ * `disconnect()` always did this; an error or a remote close did not, which
369
+ * is exactly the pair of routes on which the transport dies mid-utterance.
370
+ */
371
+ endCall() {
372
+ this.connected = false;
373
+ // Cleared here rather than on each exit path: the cancellation branch left
374
+ // it set, and a later `handleClosed()` would then act on the controls of a
375
+ // call that ended long ago — disconnecting a transport it does not own and
376
+ // ending a session it did not start.
377
+ this.pendingConnect = null;
378
+ this.activeResponseId = null;
379
+ this.cancelledResponses.clear();
380
+ this.cancelledBeforeCreated = false;
381
+ this.userMuted = false;
382
+ this.stopVolumeMeter();
383
+ this.speech.cancel();
384
+ this.sentences.reset();
385
+ this.answerId = null;
386
+ this.answerDone = false;
387
+ }
388
+ // ---- relay: speak the agent's answer --------------------------------------
389
+ /**
390
+ * Track the assistant answer as it streams and speak it sentence by
391
+ * sentence. `relay` only — in `realtime` the model is already speaking.
392
+ *
393
+ * @param messageId identity of the answer; a change resets the stream
394
+ * @param text cumulative answer text so far (markdown)
395
+ * @param isComplete the turn has settled
396
+ */
397
+ /**
398
+ * Mark an answer as already-delivered, so connecting voice does not read
399
+ * the last thing on screen back to the user. Called by the relay watcher
400
+ * the moment the session goes live.
401
+ */
402
+ primeAnswer(messageId) {
403
+ this.skipAnswerId = messageId;
404
+ }
405
+ trackAnswer(messageId, text, isComplete) {
406
+ // The watcher is mounted for the whole app lifetime, but an answer is
407
+ // only spoken while a voice session is actually live — otherwise every
408
+ // typed conversation would queue speech at a disconnected transport.
409
+ if (this.mode !== "relay" || !this.isLive)
410
+ return;
411
+ if (messageId === this.skipAnswerId)
412
+ return;
413
+ if (this.answerId !== messageId) {
414
+ this.answerId = messageId;
415
+ this.answerDone = false;
416
+ this.sentences.reset();
417
+ }
418
+ else if (this.answerDone) {
419
+ // Already spoken in full; later ticks for it carry no new words.
420
+ return;
421
+ }
422
+ // Once the answer has settled, nothing can still change a trailing
423
+ // bracket, so it must not be held back — holding it at that point drops
424
+ // it, since `flush()` sees only what was pushed.
425
+ const speakable = toSpeakableText(text, { final: isComplete });
426
+ for (const utterance of this.sentences.push(speakable))
427
+ this.speech.enqueue(utterance);
428
+ if (isComplete) {
429
+ for (const utterance of this.sentences.flush())
430
+ this.speech.enqueue(utterance);
431
+ this.answerDone = true;
432
+ }
433
+ }
434
+ /**
435
+ * Speak exact text through Voice Live without invoking its model.
436
+ *
437
+ * `pre_generated_assistant_message` is the documented way to do this, and
438
+ * it is what keeps the avatar's lip-sync and the chosen Azure voice while
439
+ * the words come from somewhere else entirely. Sent through the SDK's
440
+ * `sendEvent`, which routes a raw `response.create` through the same
441
+ * response gate as every other turn.
442
+ */
443
+ sendSpokenText(text) {
444
+ this.controls?.sendEvent({
445
+ type: "response.create",
446
+ response: {
447
+ pre_generated_assistant_message: {
448
+ type: "message",
449
+ role: "assistant",
450
+ content: [{ type: "text", text }],
451
+ },
452
+ },
453
+ });
454
+ }
455
+ // ---- internals ------------------------------------------------------------
456
+ awaitControls(abortSignal) {
457
+ if (this.controls)
458
+ return Promise.resolve(this.controls);
459
+ return new Promise((resolve, reject) => {
460
+ const timer = setTimeout(() => {
461
+ remove();
462
+ reject(new Error("Voice session host is not mounted. Render <FoundryVoice /> inside the assistant runtime, or register the module with bootstrapShell."));
463
+ }, ATTACH_TIMEOUT_MS);
464
+ const onAbort = () => {
465
+ remove();
466
+ reject(new Error("Voice connection aborted."));
467
+ };
468
+ const settle = (controls) => {
469
+ remove();
470
+ resolve(controls);
471
+ };
472
+ const remove = () => {
473
+ clearTimeout(timer);
474
+ abortSignal?.removeEventListener("abort", onAbort);
475
+ this.waiters = this.waiters.filter((w) => w !== settle);
476
+ };
477
+ abortSignal?.addEventListener("abort", onAbort, { once: true });
478
+ this.waiters.push(settle);
479
+ });
480
+ }
481
+ startVolumeMeter(controls) {
482
+ this.stopVolumeMeter();
483
+ this.volumeTimer = setInterval(() => {
484
+ const helpers = this.helpers;
485
+ if (!helpers || helpers.isDisposed()) {
486
+ this.stopVolumeMeter();
487
+ return;
488
+ }
489
+ helpers.emitVolume(readLevel(controls.getAnalyser(), (size) => this.ensureVolumeBuffer(size)));
490
+ }, VOLUME_INTERVAL_MS);
491
+ }
492
+ ensureVolumeBuffer(size) {
493
+ // Reused across ticks: allocating a fresh array 20× a second for the
494
+ // life of a call is pure garbage-collector pressure. Sized from the
495
+ // analyser rather than a constant of our own — the SDK pins fftSize to
496
+ // 256 today, and a buffer that quietly disagreed with it would read only
497
+ // the oldest slice of each window and under-report the level, with
498
+ // nothing failing to make that visible.
499
+ if (this.volumeBuffer === null || this.volumeBuffer.length !== size) {
500
+ this.volumeBuffer = new Uint8Array(new ArrayBuffer(size));
501
+ }
502
+ return this.volumeBuffer;
503
+ }
504
+ stopVolumeMeter() {
505
+ if (this.volumeTimer === null)
506
+ return;
507
+ clearInterval(this.volumeTimer);
508
+ this.volumeTimer = null;
509
+ }
510
+ }
511
+ /**
512
+ * Peak deviation from silence across the analyser's time-domain samples,
513
+ * normalised to 0…1.
514
+ *
515
+ * The analyser the SDK exposes is on the **output** graph, so this is the
516
+ * assistant's speaking level, not the microphone's — the honest meaning of
517
+ * the number a consumer reads from `useVoiceVolume()`.
518
+ */
519
+ /** The response id on a `response.created` / `response.done` event, if present. */
520
+ function responseIdOf(event) {
521
+ const response = event.response;
522
+ return typeof response?.id === "string" ? response.id : null;
523
+ }
524
+ export function readLevel(analyser, getBuffer) {
525
+ if (!analyser)
526
+ return 0;
527
+ const buffer = getBuffer(analyser.fftSize);
528
+ analyser.getByteTimeDomainData(buffer);
529
+ // A provider may still hand back something larger; the tail of an oversized
530
+ // buffer holds samples the analyser did not overwrite.
531
+ const samples = Math.min(buffer.length, analyser.fftSize);
532
+ let peak = 0;
533
+ for (let i = 0; i < samples; i++) {
534
+ const deviation = Math.abs(buffer[i] - 128);
535
+ if (deviation > peak)
536
+ peak = deviation;
537
+ }
538
+ return Math.min(1, peak / 128);
539
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `@iloveagents/foundry-web-voice` — spoken conversation for Foundry UI.
3
+ *
4
+ * Optional by construction: nothing in `@iloveagents/foundry-web-ui` imports
5
+ * this package, and an app that never installs it has an unchanged composer
6
+ * and an unchanged runtime.
7
+ *
8
+ * ```ts
9
+ * bootstrapShell({
10
+ * modules: [createVoiceModule({ connection: { proxyUrl: VOICE_PROXY_URL } })],
11
+ * });
12
+ * ```
13
+ */
14
+ export { createVoiceModule, type VoiceChatModule } from "./react/voice-module.tsx";
15
+ export { FoundryVoice, installVoice, type VoiceInstallOptions, type VoiceInstallation, } from "./react/install.tsx";
16
+ export type { VoiceConfig, VoiceMode, VoiceTranscript } from "./adapter/types.ts";
17
+ export { VoiceMicButton } from "./react/voice-mic-button.tsx";
18
+ export { VoiceStatusStrip } from "./react/voice-status-strip.tsx";
19
+ export { VoiceStage, type VoiceStageProps } from "./react/voice-stage.tsx";
20
+ export { VoiceVisualizer, type VoiceVisualizerProps } from "./react/voice-visualizer.tsx";
21
+ export { VoiceAvatarPanel, type VoiceAvatarPanelProps } from "./react/voice-avatar.tsx";
22
+ export { useVoiceUiStore } from "./react/voice-ui-store.ts";
23
+ export { VoiceBridge, type VoiceTransportControls } from "./adapter/voice-bridge.ts";
24
+ export { buildVoiceSession, toVoiceLiveTools } from "./adapter/session-config.ts";
25
+ export { createRegistryToolExecutor } from "./adapter/tool-bridge.ts";
26
+ export { ToolSync } from "./adapter/tool-sync.ts";
27
+ export { SentenceStream, SpeechQueue, toSpeakableText } from "./adapter/speech-queue.ts";
28
+ export { HalfDuplexGate, type HalfDuplexAction, type HalfDuplexInputs, } from "./adapter/half-duplex.ts";
package/dist/index.js ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * `@iloveagents/foundry-web-voice` — spoken conversation for Foundry UI.
3
+ *
4
+ * Optional by construction: nothing in `@iloveagents/foundry-web-ui` imports
5
+ * this package, and an app that never installs it has an unchanged composer
6
+ * and an unchanged runtime.
7
+ *
8
+ * ```ts
9
+ * bootstrapShell({
10
+ * modules: [createVoiceModule({ connection: { proxyUrl: VOICE_PROXY_URL } })],
11
+ * });
12
+ * ```
13
+ */
14
+ // --- installation ---
15
+ export { createVoiceModule } from "./react/voice-module.js";
16
+ export { FoundryVoice, installVoice, } from "./react/install.js";
17
+ // --- chrome (place these yourself with `chrome: false`) ---
18
+ export { VoiceMicButton } from "./react/voice-mic-button.js";
19
+ export { VoiceStatusStrip } from "./react/voice-status-strip.js";
20
+ export { VoiceStage } from "./react/voice-stage.js";
21
+ export { VoiceVisualizer } from "./react/voice-visualizer.js";
22
+ export { VoiceAvatarPanel } from "./react/voice-avatar.js";
23
+ export { useVoiceUiStore } from "./react/voice-ui-store.js";
24
+ // --- the pieces, for hosts that assemble their own ---
25
+ export { VoiceBridge } from "./adapter/voice-bridge.js";
26
+ export { buildVoiceSession, toVoiceLiveTools } from "./adapter/session-config.js";
27
+ export { createRegistryToolExecutor } from "./adapter/tool-bridge.js";
28
+ export { ToolSync } from "./adapter/tool-sync.js";
29
+ export { SentenceStream, SpeechQueue, toSpeakableText } from "./adapter/speech-queue.js";
30
+ export { HalfDuplexGate, } from "./adapter/half-duplex.js";
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Who plays the assistant's audio.
3
+ *
4
+ * A pure decision rather than a chain of conditions inside the surface,
5
+ * because this one question has been got wrong three times, each time in a
6
+ * state nobody thought to enumerate:
7
+ *
8
+ * 1. Keyed on whether the host CONFIGURED an avatar, so hiding the panel
9
+ * (`avatar: false`) routed a remote track through Web Audio as if it were
10
+ * locally-decoded PCM and the assistant simply went silent.
11
+ * 2. Keyed on a count the panels keep, which cannot answer on the commit where
12
+ * an avatar mounts — the count is still zero while `VoiceLiveAvatar` mounts
13
+ * its own audio element, so both owned the stream and it started twice.
14
+ * 3. Guarded with a settled flag that only delayed the REVERSE handoff, so the
15
+ * mounting case above was left exactly as it was.
16
+ *
17
+ * The shape that works: predict, then verify. A panel renders exactly when
18
+ * there is a video stream to draw, so an incoming element is known
19
+ * synchronously and the sink can stand down before it arrives. The prediction
20
+ * is wrong in one direction only — with `stage: true` the panel belongs to a
21
+ * registered chat slot, and the surrounding surface may not be on screen (a
22
+ * closed bubble) — so when nothing registers, the caller reports the
23
+ * prediction failed and the sink takes the audio back.
24
+ */
25
+ export interface AudioOwnership {
26
+ /** An element is required at all: a remote track (WebRTC, or an avatar). */
27
+ needsElement: boolean;
28
+ /** How many `VoiceAvatarPanel`s are mounted right now. */
29
+ avatarPanels: number;
30
+ /** A panel is about to render: the avatar is shown and its video has arrived. */
31
+ avatarElementExpected: boolean;
32
+ /** Nothing registered after that prediction, so no panel is going to appear. */
33
+ predictionFailed: boolean;
34
+ }
35
+ /**
36
+ * Whether the surface must mount a bare `<audio>` sink of its own.
37
+ *
38
+ * False whenever an avatar element already exists or is about to, since that
39
+ * element plays the same stream.
40
+ */
41
+ export declare function needsAudioOnlySink({ needsElement, avatarPanels, avatarElementExpected, predictionFailed, }: AudioOwnership): boolean;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Who plays the assistant's audio.
3
+ *
4
+ * A pure decision rather than a chain of conditions inside the surface,
5
+ * because this one question has been got wrong three times, each time in a
6
+ * state nobody thought to enumerate:
7
+ *
8
+ * 1. Keyed on whether the host CONFIGURED an avatar, so hiding the panel
9
+ * (`avatar: false`) routed a remote track through Web Audio as if it were
10
+ * locally-decoded PCM and the assistant simply went silent.
11
+ * 2. Keyed on a count the panels keep, which cannot answer on the commit where
12
+ * an avatar mounts — the count is still zero while `VoiceLiveAvatar` mounts
13
+ * its own audio element, so both owned the stream and it started twice.
14
+ * 3. Guarded with a settled flag that only delayed the REVERSE handoff, so the
15
+ * mounting case above was left exactly as it was.
16
+ *
17
+ * The shape that works: predict, then verify. A panel renders exactly when
18
+ * there is a video stream to draw, so an incoming element is known
19
+ * synchronously and the sink can stand down before it arrives. The prediction
20
+ * is wrong in one direction only — with `stage: true` the panel belongs to a
21
+ * registered chat slot, and the surrounding surface may not be on screen (a
22
+ * closed bubble) — so when nothing registers, the caller reports the
23
+ * prediction failed and the sink takes the audio back.
24
+ */
25
+ /**
26
+ * Whether the surface must mount a bare `<audio>` sink of its own.
27
+ *
28
+ * False whenever an avatar element already exists or is about to, since that
29
+ * element plays the same stream.
30
+ */
31
+ export function needsAudioOnlySink({ needsElement, avatarPanels, avatarElementExpected, predictionFailed, }) {
32
+ if (!needsElement)
33
+ return false;
34
+ if (avatarPanels > 0)
35
+ return false;
36
+ return !avatarElementExpected || predictionFailed;
37
+ }