@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.
- package/LICENSE +21 -0
- package/README.md +157 -0
- package/dist/adapter/half-duplex.d.ts +42 -0
- package/dist/adapter/half-duplex.js +65 -0
- package/dist/adapter/session-config.d.ts +55 -0
- package/dist/adapter/session-config.js +148 -0
- package/dist/adapter/speech-queue.d.ts +95 -0
- package/dist/adapter/speech-queue.js +344 -0
- package/dist/adapter/tool-bridge.d.ts +25 -0
- package/dist/adapter/tool-bridge.js +37 -0
- package/dist/adapter/tool-sync.d.ts +46 -0
- package/dist/adapter/tool-sync.js +55 -0
- package/dist/adapter/types.d.ts +91 -0
- package/dist/adapter/types.js +8 -0
- package/dist/adapter/voice-bridge.d.ts +214 -0
- package/dist/adapter/voice-bridge.js +539 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +30 -0
- package/dist/react/audio-ownership.d.ts +41 -0
- package/dist/react/audio-ownership.js +37 -0
- package/dist/react/install.d.ts +62 -0
- package/dist/react/install.js +100 -0
- package/dist/react/relay-answer-watcher.d.ts +16 -0
- package/dist/react/relay-answer-watcher.js +45 -0
- package/dist/react/use-direct-audio-output.d.ts +23 -0
- package/dist/react/use-direct-audio-output.js +44 -0
- package/dist/react/voice-audio-sink.d.ts +21 -0
- package/dist/react/voice-audio-sink.js +55 -0
- package/dist/react/voice-avatar.d.ts +61 -0
- package/dist/react/voice-avatar.js +76 -0
- package/dist/react/voice-launcher-badge.d.ts +14 -0
- package/dist/react/voice-launcher-badge.js +36 -0
- package/dist/react/voice-mic-button.d.ts +19 -0
- package/dist/react/voice-mic-button.js +53 -0
- package/dist/react/voice-module.d.ts +34 -0
- package/dist/react/voice-module.js +25 -0
- package/dist/react/voice-stage.d.ts +20 -0
- package/dist/react/voice-stage.js +64 -0
- package/dist/react/voice-status-strip.d.ts +10 -0
- package/dist/react/voice-status-strip.js +31 -0
- package/dist/react/voice-surface.d.ts +27 -0
- package/dist/react/voice-surface.js +289 -0
- package/dist/react/voice-ui-store.d.ts +58 -0
- package/dist/react/voice-ui-store.js +46 -0
- package/dist/react/voice-visualizer.d.ts +37 -0
- package/dist/react/voice-visualizer.js +222 -0
- 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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|