@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,214 @@
|
|
|
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 { type RealtimeVoiceAdapter } from "@assistant-ui/react";
|
|
27
|
+
import type { VoiceLiveClientEvent, VoiceLiveServerEvent, SessionState } from "@iloveagents/foundry-voice-live-react";
|
|
28
|
+
import type { VoiceConfig } from "./types.ts";
|
|
29
|
+
/** What the bridge needs from the live session; supplied by the React host. */
|
|
30
|
+
export interface VoiceTransportControls {
|
|
31
|
+
connect: () => Promise<void>;
|
|
32
|
+
disconnect: () => void;
|
|
33
|
+
mute: () => void;
|
|
34
|
+
unmute: () => void;
|
|
35
|
+
/** Put a client event on the wire. `response.create` is serialized by the SDK's response gate. */
|
|
36
|
+
sendEvent: (event: VoiceLiveClientEvent) => void;
|
|
37
|
+
/** Cancel the in-flight response and flush local playback (barge-in). */
|
|
38
|
+
cancelResponse: () => void;
|
|
39
|
+
/** Output analyser for a level meter, when the session exposes one. */
|
|
40
|
+
getAnalyser: () => AnalyserNode | null;
|
|
41
|
+
}
|
|
42
|
+
type Unsubscribe = () => void;
|
|
43
|
+
export declare class VoiceBridge {
|
|
44
|
+
private readonly config;
|
|
45
|
+
private readonly mode;
|
|
46
|
+
private controls;
|
|
47
|
+
private waiters;
|
|
48
|
+
private helpers;
|
|
49
|
+
private connected;
|
|
50
|
+
/**
|
|
51
|
+
* The user's mute intent, as expressed through the runtime's controls.
|
|
52
|
+
*
|
|
53
|
+
* Distinct from the SDK's capture-mute state on purpose: half-duplex mutes
|
|
54
|
+
* the SDK on its own behalf while the assistant speaks, and releasing that
|
|
55
|
+
* mute must never override a mute the *user* asked for — that is how a
|
|
56
|
+
* microphone ends up hot behind a UI that says muted.
|
|
57
|
+
*/
|
|
58
|
+
private userMuted;
|
|
59
|
+
/** An answer that was already on screen when voice connected — not ours to speak. */
|
|
60
|
+
private skipAnswerId;
|
|
61
|
+
private volumeTimer;
|
|
62
|
+
private volumeBuffer;
|
|
63
|
+
private readonly speech;
|
|
64
|
+
private readonly sentences;
|
|
65
|
+
/** Id of the assistant message currently being spoken, so a new answer resets the stream. */
|
|
66
|
+
private answerId;
|
|
67
|
+
/**
|
|
68
|
+
* Whether `answerId` has been spoken to the end.
|
|
69
|
+
*
|
|
70
|
+
* The watcher re-fires for an answer that is already finished — the
|
|
71
|
+
* thread's message array gets a new identity on any notification, not just
|
|
72
|
+
* on new text. Without a "done" marker, clearing `answerId` on completion
|
|
73
|
+
* made the very next tick look like a brand-new answer, reset the sentence
|
|
74
|
+
* stream, and speak the whole reply again. Forever.
|
|
75
|
+
*/
|
|
76
|
+
private answerDone;
|
|
77
|
+
/** The response currently being spoken, once the service has named it. */
|
|
78
|
+
private activeResponseId;
|
|
79
|
+
/**
|
|
80
|
+
* Responses cancelled by barge-in whose `response.done` has not arrived yet.
|
|
81
|
+
*
|
|
82
|
+
* Bounded by the number of interruptions in a call and cleared with it. A
|
|
83
|
+
* cancelled response that never reports simply leaves an unused entry — the
|
|
84
|
+
* alternative, counting completions to swallow, wedges the queue for the
|
|
85
|
+
* rest of the session when the count is wrong.
|
|
86
|
+
*/
|
|
87
|
+
private readonly cancelledResponses;
|
|
88
|
+
/**
|
|
89
|
+
* A response was cancelled before the service had named it.
|
|
90
|
+
*
|
|
91
|
+
* The SDK's own gate documents the window: between sending `response.create`
|
|
92
|
+
* and receiving `response.created` the conversation looks idle. Interrupting
|
|
93
|
+
* in there leaves nothing to correlate, so the completion that eventually
|
|
94
|
+
* arrives would look like the NEXT answer's and release it early — the same
|
|
95
|
+
* bug, in the gap. The wire preserves order, so the first `response.created`
|
|
96
|
+
* after such a cancel names the response that was cancelled.
|
|
97
|
+
*/
|
|
98
|
+
private cancelledBeforeCreated;
|
|
99
|
+
/**
|
|
100
|
+
* The connection attempt currently allowed to become a session.
|
|
101
|
+
*
|
|
102
|
+
* A token per attempt, not a boolean on the bridge. A boolean is shared, so
|
|
103
|
+
* starting a second call reset it — and if the FIRST transport then came up,
|
|
104
|
+
* its continuation saw "not abandoned", marked itself connected, and left a
|
|
105
|
+
* microphone open with no host, while also clearing the newer attempt's
|
|
106
|
+
* pending record. Each attempt compares its own token, so an obsolete
|
|
107
|
+
* continuation can never mistake a later call's state for permission.
|
|
108
|
+
*/
|
|
109
|
+
private attempt;
|
|
110
|
+
/**
|
|
111
|
+
* A connection that has been asked for but has not settled.
|
|
112
|
+
*
|
|
113
|
+
* Held so that abandoning a call can take the transport down NOW rather than
|
|
114
|
+
* waiting on a promise that may never resolve — a permission prompt left
|
|
115
|
+
* open, a transport that stalls. `setup` re-checks its attempt token past
|
|
116
|
+
* the await and covers a connection that arrives late; the case it cannot reach
|
|
117
|
+
* is the one that never arrives, which is exactly this.
|
|
118
|
+
*/
|
|
119
|
+
private pendingConnect;
|
|
120
|
+
/** Injected so the bridge never imports the React chat surface. */
|
|
121
|
+
private submitText;
|
|
122
|
+
constructor(config: VoiceConfig);
|
|
123
|
+
/**
|
|
124
|
+
* True only once the transport is actually carrying a session.
|
|
125
|
+
*
|
|
126
|
+
* `helpers` exists from the first line of `setup`, which is *before*
|
|
127
|
+
* `controls.connect()` resolves. Speaking in that window sends into a
|
|
128
|
+
* socket that is not open yet: the utterance is lost, and because the
|
|
129
|
+
* queue is now "speaking" it never receives the `response.done` that
|
|
130
|
+
* would release it — the session goes silent for good.
|
|
131
|
+
*/
|
|
132
|
+
private get isLive();
|
|
133
|
+
/** The user's mute intent (see `userMuted`). Read by the half-duplex gate. */
|
|
134
|
+
get isUserMuted(): boolean;
|
|
135
|
+
/** The React host publishes its live controls here (and retracts them on unmount). */
|
|
136
|
+
attach(controls: VoiceTransportControls): Unsubscribe;
|
|
137
|
+
/** How the transcript reaches the composer in `relay` mode. */
|
|
138
|
+
setSubmitText(submit: ((text: string) => void) | null): void;
|
|
139
|
+
createAdapter(): RealtimeVoiceAdapter;
|
|
140
|
+
/**
|
|
141
|
+
* A transcript line from the SDK (`text` is cumulative for the turn).
|
|
142
|
+
*
|
|
143
|
+
* The modes diverge here, and the divergence is the whole reason a spoken
|
|
144
|
+
* turn in `relay` renders as an ordinary, persisted thread turn:
|
|
145
|
+
*
|
|
146
|
+
* - `realtime` republishes both roles, and assistant-ui materialises them
|
|
147
|
+
* as (session-scoped) thread messages.
|
|
148
|
+
* - `relay` republishes **neither**. The user's words become a real
|
|
149
|
+
* message by going through the composer, and the answer is a real AG-UI
|
|
150
|
+
* turn. Emitting the transcript as well would render each of them
|
|
151
|
+
* twice.
|
|
152
|
+
*/
|
|
153
|
+
handleTranscript(role: "user" | "assistant", text: string, isFinal: boolean): void;
|
|
154
|
+
/** Raw server events — only the few that drive turn-taking are read. */
|
|
155
|
+
handleServerEvent(event: VoiceLiveServerEvent): void;
|
|
156
|
+
/** SDK session state → assistant-ui's two-value mode. */
|
|
157
|
+
handleSessionState(state: SessionState): void;
|
|
158
|
+
/** A session-fatal error: report it, and end the session so the UI leaves the live state. */
|
|
159
|
+
handleError(message: string): void;
|
|
160
|
+
/**
|
|
161
|
+
* The transport reached a terminal closed state.
|
|
162
|
+
*
|
|
163
|
+
* Idempotent by the `connected` check, because every route out of a call
|
|
164
|
+
* ends here: an explicit `disconnect()`, a failure through `handleError`,
|
|
165
|
+
* and the surface observing `connectionState === "disconnected"` all arrive,
|
|
166
|
+
* and ending the session twice would be a second `end()` on a session that
|
|
167
|
+
* already finished.
|
|
168
|
+
*/
|
|
169
|
+
handleClosed(): void;
|
|
170
|
+
/**
|
|
171
|
+
* Everything a finished call must let go of, whichever way it ended.
|
|
172
|
+
*
|
|
173
|
+
* This bridge is installed once for the life of the app, so its state
|
|
174
|
+
* outlives any single call. The speech queue is the dangerous part: it
|
|
175
|
+
* releases on `response.done`, and a transport that has died will never send
|
|
176
|
+
* one. Left `speaking`, it swallows every utterance of every LATER call —
|
|
177
|
+
* the session connects, the agent answers, and nothing is ever heard again.
|
|
178
|
+
*
|
|
179
|
+
* `disconnect()` always did this; an error or a remote close did not, which
|
|
180
|
+
* is exactly the pair of routes on which the transport dies mid-utterance.
|
|
181
|
+
*/
|
|
182
|
+
private endCall;
|
|
183
|
+
/**
|
|
184
|
+
* Track the assistant answer as it streams and speak it sentence by
|
|
185
|
+
* sentence. `relay` only — in `realtime` the model is already speaking.
|
|
186
|
+
*
|
|
187
|
+
* @param messageId identity of the answer; a change resets the stream
|
|
188
|
+
* @param text cumulative answer text so far (markdown)
|
|
189
|
+
* @param isComplete the turn has settled
|
|
190
|
+
*/
|
|
191
|
+
/**
|
|
192
|
+
* Mark an answer as already-delivered, so connecting voice does not read
|
|
193
|
+
* the last thing on screen back to the user. Called by the relay watcher
|
|
194
|
+
* the moment the session goes live.
|
|
195
|
+
*/
|
|
196
|
+
primeAnswer(messageId: string | null): void;
|
|
197
|
+
trackAnswer(messageId: string, text: string, isComplete: boolean): void;
|
|
198
|
+
/**
|
|
199
|
+
* Speak exact text through Voice Live without invoking its model.
|
|
200
|
+
*
|
|
201
|
+
* `pre_generated_assistant_message` is the documented way to do this, and
|
|
202
|
+
* it is what keeps the avatar's lip-sync and the chosen Azure voice while
|
|
203
|
+
* the words come from somewhere else entirely. Sent through the SDK's
|
|
204
|
+
* `sendEvent`, which routes a raw `response.create` through the same
|
|
205
|
+
* response gate as every other turn.
|
|
206
|
+
*/
|
|
207
|
+
private sendSpokenText;
|
|
208
|
+
private awaitControls;
|
|
209
|
+
private startVolumeMeter;
|
|
210
|
+
private ensureVolumeBuffer;
|
|
211
|
+
private stopVolumeMeter;
|
|
212
|
+
}
|
|
213
|
+
export declare function readLevel(analyser: AnalyserNode | null, getBuffer: (size: number) => Uint8Array<ArrayBuffer>): number;
|
|
214
|
+
export {};
|