@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,34 @@
1
+ /**
2
+ * `createVoiceModule` — the voice tier as a `bootstrapShell` module.
3
+ *
4
+ * The returned object is structurally a `ChatModule`. It is typed locally
5
+ * rather than imported from `@iloveagents/foundry-web-shell` on purpose: the
6
+ * shell composes modules, so a module importing the shell would invert the
7
+ * dependency and make every voice consumer install the shell — including the
8
+ * ones that build their own runtime. Only the three fields this module
9
+ * actually populates are declared.
10
+ */
11
+ import type { ReactNode } from "react";
12
+ import { type VoiceInstallOptions } from "./install.tsx";
13
+ /** The slice of `foundry-web-shell`'s `ChatModule` contract this module fills. */
14
+ export interface VoiceChatModule {
15
+ name: string;
16
+ layoutExtras?: ReactNode;
17
+ }
18
+ /**
19
+ * ```ts
20
+ * bootstrapShell({
21
+ * modules: [
22
+ * createVoiceModule({
23
+ * connection: { proxyUrl: import.meta.env.VITE_VOICE_PROXY_URL },
24
+ * session: sessionConfig().voice("en-US-AvaMultilingualNeural").build(),
25
+ * }),
26
+ * ],
27
+ * });
28
+ * ```
29
+ *
30
+ * Call it once, at module scope, next to the other modules — the voice
31
+ * adapter is registered as a side effect of the call, before the runtime
32
+ * first mounts.
33
+ */
34
+ export declare function createVoiceModule(options: VoiceInstallOptions): VoiceChatModule;
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { installVoice } from "./install.js";
3
+ /**
4
+ * ```ts
5
+ * bootstrapShell({
6
+ * modules: [
7
+ * createVoiceModule({
8
+ * connection: { proxyUrl: import.meta.env.VITE_VOICE_PROXY_URL },
9
+ * session: sessionConfig().voice("en-US-AvaMultilingualNeural").build(),
10
+ * }),
11
+ * ],
12
+ * });
13
+ * ```
14
+ *
15
+ * Call it once, at module scope, next to the other modules — the voice
16
+ * adapter is registered as a side effect of the call, before the runtime
17
+ * first mounts.
18
+ */
19
+ export function createVoiceModule(options) {
20
+ const { Host } = installVoice(options);
21
+ return {
22
+ name: "foundry-voice",
23
+ layoutExtras: _jsx(Host, {}),
24
+ };
25
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * What you look at while you are talking.
3
+ *
4
+ * A live call replaces the conversation rather than adding to it: the
5
+ * transcript scrolling past is not the thing you are attending to, and the
6
+ * empty-state "How can I help you today?" is actively wrong once the answer
7
+ * to that question is "say it out loud". The thread is not lost — it is
8
+ * behind this, and comes straight back when the call ends. In `relay` the
9
+ * turns are already in it; in `realtime` assistant-ui keeps them there for
10
+ * the life of the session.
11
+ *
12
+ * Everything here is theme tokens and the session's own state, so it
13
+ * inherits an app's palette without being told about it.
14
+ */
15
+ export interface VoiceStageProps {
16
+ /** Overrides the live session's avatar streams (rarely needed). */
17
+ videoStream?: MediaStream | null;
18
+ audioStream?: MediaStream | null;
19
+ }
20
+ export declare function VoiceStage(props?: VoiceStageProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,64 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * What you look at while you are talking.
4
+ *
5
+ * A live call replaces the conversation rather than adding to it: the
6
+ * transcript scrolling past is not the thing you are attending to, and the
7
+ * empty-state "How can I help you today?" is actively wrong once the answer
8
+ * to that question is "say it out loud". The thread is not lost — it is
9
+ * behind this, and comes straight back when the call ends. In `relay` the
10
+ * turns are already in it; in `realtime` assistant-ui keeps them there for
11
+ * the life of the session.
12
+ *
13
+ * Everything here is theme tokens and the session's own state, so it
14
+ * inherits an app's palette without being told about it.
15
+ */
16
+ import { useVoiceState } from "@assistant-ui/react";
17
+ import { VoiceVisualizer } from "./voice-visualizer.js";
18
+ import { VoiceAvatarPanel } from "./voice-avatar.js";
19
+ import { useVoiceUiStore } from "./voice-ui-store.js";
20
+ export function VoiceStage(props = {}) {
21
+ // Read from the store by default, so the stage can be registered as a
22
+ // bare component reference rather than a closure that has to be
23
+ // re-registered every time a stream arrives.
24
+ const liveVideo = useVoiceUiStore((s) => s.videoStream);
25
+ const liveAudio = useVoiceUiStore((s) => s.audioStream);
26
+ // `undefined` means "not specified, use the live session". An explicit
27
+ // `null` is a host saying "show no avatar / play no audio here", which the
28
+ // props contract allows and `??` silently ignored — it treats both the same
29
+ // and handed the live streams back either way.
30
+ const videoStream = props.videoStream === undefined ? liveVideo : props.videoStream;
31
+ const audioStream = props.audioStream === undefined ? liveAudio : props.audioStream;
32
+ const voice = useVoiceState();
33
+ const analyser = useVoiceUiStore((s) => s.analyser);
34
+ const sessionState = useVoiceUiStore((s) => s.sessionState);
35
+ const connectionState = useVoiceUiStore((s) => s.connectionState);
36
+ const userText = useVoiceUiStore((s) => s.userText);
37
+ const assistantText = useVoiceUiStore((s) => s.assistantText);
38
+ const chromaKey = useVoiceUiStore((s) => s.chromaKey);
39
+ const connecting = !voice || voice.status.type === "starting";
40
+ const muted = voice?.isMuted ?? false;
41
+ const status = connecting
42
+ ? "Connecting…"
43
+ : connectionState === "reconnecting"
44
+ ? "Reconnecting…"
45
+ : muted
46
+ ? "Microphone muted"
47
+ : sessionState === "thinking"
48
+ ? "Thinking…"
49
+ : sessionState === "speaking"
50
+ ? "Speaking"
51
+ : "Listening";
52
+ // While the assistant talks the visualiser is showing its voice, so the
53
+ // caption should be its words; otherwise show what was just heard.
54
+ const caption = sessionState === "speaking" ? assistantText : userText;
55
+ return (_jsxs("div", { className: "relative flex h-full min-h-full flex-col", children: [_jsx("div", { className: "relative min-h-0 flex-1 overflow-hidden", children: videoStream ? (
56
+ // Full-bleed and anchored to the top, so the avatar is framed head
57
+ // and torso with the legs running off the lower edge — the framing
58
+ // a person on a call has, rather than a full-length figure floating
59
+ // in the middle of the page. The `!` overrides the SDK canvas's
60
+ // inline `height:auto; object-fit:contain`.
61
+ _jsx(VoiceAvatarPanel, { videoStream: videoStream, audioStream: audioStream, chromaKeyConfig: chromaKey ?? undefined, className: "absolute inset-0", canvasClassName: "h-full! w-full! max-h-none! max-w-none! object-cover! object-top!" })) : (_jsx("div", { className: "absolute inset-0 flex items-center justify-center", children: _jsx(VoiceVisualizer, { analyser: connecting || muted ? null : analyser, label: `Voice conversation — ${status}`, className: "h-full max-h-80 w-full max-w-md" }) })) }), _jsxs("div", { className: "relative shrink-0 px-6 pb-8 pt-4 text-center", children: [_jsx("p", { className: "text-sm text-muted-foreground", role: "status", "aria-live": "polite", children: status }), _jsx("p", { className: "mx-auto mt-2 flex h-14 max-w-xl items-start justify-center overflow-hidden " +
62
+ "text-lg leading-7 " +
63
+ (caption ? "text-foreground/90" : "text-sm leading-7 text-muted-foreground/70"), children: _jsx("span", { className: "line-clamp-2", children: caption || "Say something to get started." }) })] })] }));
64
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * A one-line "what is the session doing right now" strip, rendered in the
3
+ * composer banner slot while a voice session is live.
4
+ *
5
+ * Listening / speaking come from the runtime's own voice state; `thinking`
6
+ * and `reconnecting` have no runtime equivalent and come from the SDK.
7
+ * Without this, the gap between the user finishing a sentence and the
8
+ * assistant starting to speak is indistinguishable from a dead session.
9
+ */
10
+ export declare function VoiceStatusStrip(): import("react/jsx-runtime").JSX.Element | null;
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * A one-line "what is the session doing right now" strip, rendered in the
4
+ * composer banner slot while a voice session is live.
5
+ *
6
+ * Listening / speaking come from the runtime's own voice state; `thinking`
7
+ * and `reconnecting` have no runtime equivalent and come from the SDK.
8
+ * Without this, the gap between the user finishing a sentence and the
9
+ * assistant starting to speak is indistinguishable from a dead session.
10
+ */
11
+ import { useVoiceState } from "@assistant-ui/react";
12
+ import { useVoiceUiStore } from "./voice-ui-store.js";
13
+ export function VoiceStatusStrip() {
14
+ const voice = useVoiceState();
15
+ const sessionState = useVoiceUiStore((s) => s.sessionState);
16
+ const connectionState = useVoiceUiStore((s) => s.connectionState);
17
+ if (!voice || voice.status.type === "ended")
18
+ return null;
19
+ const label = voice.status.type === "starting"
20
+ ? "Connecting…"
21
+ : connectionState === "reconnecting"
22
+ ? "Reconnecting…"
23
+ : voice.isMuted
24
+ ? "Microphone muted"
25
+ : sessionState === "thinking"
26
+ ? "Thinking…"
27
+ : voice.mode === "speaking"
28
+ ? "Speaking…"
29
+ : "Listening…";
30
+ return (_jsxs("div", { className: "flex items-center gap-2 px-4 py-1.5 text-xs text-muted-foreground", role: "status", "aria-live": "polite", children: [_jsx("span", { "aria-hidden": "true", className: `size-1.5 rounded-full bg-primary ${voice.isMuted ? "" : "animate-pulse"}` }), label] }));
31
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * `VoiceSurface` — the render-less host that owns the Voice Live session.
3
+ *
4
+ * `useVoiceLive` is a hook, so the session can only exist inside a mounted
5
+ * component; assistant-ui's `RealtimeVoiceAdapter.connect()` is imperative
6
+ * and may be called at any moment after that. This component is the join:
7
+ * it mounts the hook once (inert — `autoConnect` is off, nothing touches the
8
+ * microphone until the user asks) and publishes its controls to the bridge,
9
+ * which is what the adapter awaits.
10
+ *
11
+ * Every callback handed to the hook is stable and closes over nothing but
12
+ * the bridge, so none of them can go stale for the life of a session.
13
+ */
14
+ import type { VoiceBridge } from "../adapter/voice-bridge.ts";
15
+ import type { VoiceConfig } from "../adapter/types.ts";
16
+ export interface VoiceSurfaceProps {
17
+ bridge: VoiceBridge;
18
+ config: VoiceConfig;
19
+ /** Render the avatar panel when the session config asks for an avatar. @default true */
20
+ avatar?: boolean;
21
+ /**
22
+ * Replace the chat thread with the voice stage while a call is live.
23
+ * @default true
24
+ */
25
+ stage?: boolean;
26
+ }
27
+ export declare function VoiceSurface({ bridge, config, avatar, stage }: VoiceSurfaceProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,289 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * `VoiceSurface` — the render-less host that owns the Voice Live session.
4
+ *
5
+ * `useVoiceLive` is a hook, so the session can only exist inside a mounted
6
+ * component; assistant-ui's `RealtimeVoiceAdapter.connect()` is imperative
7
+ * and may be called at any moment after that. This component is the join:
8
+ * it mounts the hook once (inert — `autoConnect` is off, nothing touches the
9
+ * microphone until the user asks) and publishes its controls to the bridge,
10
+ * which is what the adapter awaits.
11
+ *
12
+ * Every callback handed to the hook is stable and closes over nothing but
13
+ * the bridge, so none of them can go stale for the life of a session.
14
+ */
15
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
16
+ import { useVoiceLive } from "@iloveagents/foundry-voice-live-react";
17
+ import { clientToolRegistry } from "@iloveagents/foundry-agent";
18
+ import { registerChatSlots, submitComposerText, useChatSlotsStore, } from "@iloveagents/foundry-web-ui";
19
+ import { buildVoiceSession } from "../adapter/session-config.js";
20
+ import { createRegistryToolExecutor } from "../adapter/tool-bridge.js";
21
+ import { ToolSync } from "../adapter/tool-sync.js";
22
+ import { HalfDuplexGate } from "../adapter/half-duplex.js";
23
+ import { RelayAnswerWatcher } from "./relay-answer-watcher.js";
24
+ import { VoiceAvatarPanel } from "./voice-avatar.js";
25
+ import { VoiceStage } from "./voice-stage.js";
26
+ import { needsAudioOnlySink } from "./audio-ownership.js";
27
+ import { VoiceAudioSink } from "./voice-audio-sink.js";
28
+ import { useDirectAudioOutput } from "./use-direct-audio-output.js";
29
+ import { resetVoiceSessionState, useVoiceUiStore } from "./voice-ui-store.js";
30
+ export function VoiceSurface({ bridge, config, avatar = true, stage = true }) {
31
+ const mode = config.mode ?? "relay";
32
+ const wantsTools = mode === "realtime" && (config.exposeClientTools ?? true);
33
+ const session = useMemo(() => buildVoiceSession({
34
+ mode,
35
+ overrides: config.session,
36
+ registry: wantsTools ? clientToolRegistry.getState() : undefined,
37
+ }), [mode, config.session, wantsTools]);
38
+ const onEvent = useCallback((event) => bridge.handleServerEvent(event), [bridge]);
39
+ const onTranscript = useCallback((role, text, isFinal) => {
40
+ bridge.handleTranscript(role, text, isFinal);
41
+ // The stage shows what is being said right now; a final empty string
42
+ // is a VAD misfire and would blank the line for no reason.
43
+ if (text.trim() || !isFinal) {
44
+ useVoiceUiStore.setState(role === "user" ? { userText: text } : { assistantText: text });
45
+ }
46
+ }, [bridge]);
47
+ const toolExecutor = useMemo(() => (wantsTools ? createRegistryToolExecutor(clientToolRegistry.getState()) : undefined), [wantsTools]);
48
+ const live = useVoiceLive({
49
+ connection: config.connection,
50
+ session,
51
+ autoConnect: false,
52
+ logLevel: config.logLevel,
53
+ reconnect: config.reconnect,
54
+ onEvent,
55
+ onTranscript,
56
+ toolExecutor,
57
+ });
58
+ // The hook returns a fresh object every render; the controls handed to the
59
+ // bridge must not. Route them through a ref so `attach` happens once and
60
+ // still reaches the current session.
61
+ const liveRef = useRef(live);
62
+ liveRef.current = live;
63
+ const controls = useMemo(() => ({
64
+ connect: () => liveRef.current.connect(),
65
+ disconnect: () => liveRef.current.disconnect(),
66
+ mute: () => {
67
+ if (!liveRef.current.isMuted)
68
+ liveRef.current.toggleMute();
69
+ },
70
+ unmute: () => {
71
+ if (liveRef.current.isMuted)
72
+ liveRef.current.toggleMute();
73
+ },
74
+ sendEvent: (event) => liveRef.current.sendEvent(event),
75
+ cancelResponse: () => liveRef.current.cancelResponse(),
76
+ getAnalyser: () => liveRef.current.audioAnalyser,
77
+ }), []);
78
+ useEffect(() => {
79
+ const detach = bridge.attach(controls);
80
+ return () => {
81
+ detach();
82
+ // Losing the host ends the call. Nothing else can do it from here: the
83
+ // disconnected-state effect below cannot run after unmount, so without
84
+ // this a conditionally-rendered <FoundryVoice/> (or any host removed
85
+ // mid-call) leaves assistant-ui holding a running voice session and the
86
+ // speech queue wedged for whatever mounts next.
87
+ bridge.handleClosed();
88
+ };
89
+ }, [bridge, controls]);
90
+ useEffect(() => {
91
+ bridge.setSubmitText(mode === "relay" ? submitComposerText : null);
92
+ return () => bridge.setSubmitText(null);
93
+ }, [bridge, mode]);
94
+ // Keep the voice model's tool catalogue in step with the page's. Page
95
+ // tools are registered and cleared on navigation, so a catalogue frozen
96
+ // at connect time leaves the model either unable to act on the page the
97
+ // user is looking at, or calling a tool that no longer exists.
98
+ //
99
+ // Keyed on `isReady`, not just mounted: a registry change that happens
100
+ // while the session is still connecting used to be dropped by an
101
+ // is-ready guard and never replayed. Re-running on the ready edge and
102
+ // syncing immediately closes that window, and `ToolSync`'s dedupe makes
103
+ // the extra call free when nothing actually changed.
104
+ const hostToolsKey = JSON.stringify(config.session?.tools ?? null);
105
+ const toolSync = useRef(null);
106
+ toolSync.current ?? (toolSync.current = new ToolSync((tools) => liveRef.current.updateSession({ tools })));
107
+ useEffect(() => {
108
+ const sync = toolSync.current;
109
+ if (!sync)
110
+ return;
111
+ if (!wantsTools || !live.isReady) {
112
+ // The next session sends its own catalogue; forget what this one did.
113
+ sync.reset();
114
+ return;
115
+ }
116
+ const hostTools = config.session?.tools;
117
+ sync.sync(clientToolRegistry.getState(), hostTools);
118
+ return clientToolRegistry.subscribe(() => sync.sync(clientToolRegistry.getState(), hostTools));
119
+ // Keyed on the host tools' CONTENT, not the array's identity: a host that
120
+ // rebuilds its config object every render would otherwise resubscribe on
121
+ // every render for a catalogue that never changed.
122
+ // eslint-disable-next-line react-hooks/exhaustive-deps
123
+ }, [wantsTools, live.isReady, hostToolsKey]);
124
+ // Half-duplex: hold the microphone closed while the assistant speaks, so
125
+ // its own voice cannot trip the service's VAD and start a conversation
126
+ // with itself. The when-to-open/close decision lives in `HalfDuplexGate`
127
+ // (pure, tested) — the two rules that matter, releasing only what the gate
128
+ // took and never overriding the user's own mute, are exactly the ones an
129
+ // inline pair of booleans got wrong.
130
+ const halfDuplexGate = useRef(new HalfDuplexGate());
131
+ useEffect(() => {
132
+ const action = halfDuplexGate.current.next({
133
+ enabled: !!config.halfDuplex,
134
+ speaking: live.sessionState === "speaking",
135
+ sdkMuted: live.isMuted,
136
+ userMuted: bridge.isUserMuted,
137
+ });
138
+ if (action === "mute" && !liveRef.current.isMuted)
139
+ liveRef.current.toggleMute();
140
+ else if (action === "unmute" && liveRef.current.isMuted)
141
+ liveRef.current.toggleMute();
142
+ }, [bridge, config.halfDuplex, live.sessionState, live.isMuted]);
143
+ // Publish session facts the UI reads (mic button, status strip). The
144
+ // bridge is the adapter's channel; this store is the components'.
145
+ const sessionState = live.sessionState;
146
+ useEffect(() => {
147
+ bridge.handleSessionState(sessionState);
148
+ useVoiceUiStore.setState({ sessionState });
149
+ }, [bridge, sessionState]);
150
+ useEffect(() => {
151
+ useVoiceUiStore.setState({
152
+ connectionState: live.connectionState,
153
+ isMuted: live.isMuted,
154
+ isReady: live.isReady,
155
+ analyser: live.audioAnalyser,
156
+ videoStream: live.videoStream,
157
+ audioStream: live.audioStream,
158
+ });
159
+ }, [
160
+ live.connectionState,
161
+ live.isMuted,
162
+ live.isReady,
163
+ live.audioAnalyser,
164
+ live.videoStream,
165
+ live.audioStream,
166
+ ]);
167
+ // A finished session must not leak into the next one: stale captions, a
168
+ // dead analyser, and an outstanding auto-mute are all last-call state.
169
+ useEffect(() => {
170
+ if (live.connectionState === "disconnected") {
171
+ // A fatal error and the disconnect can be published together, and this
172
+ // effect is declared first, so it would end the session as "finished"
173
+ // before the error effect below ever ran — and assistant-ui's `end()` is
174
+ // once-only, so the reason could never be corrected. A consumer would
175
+ // see a clean hang-up where the call had actually failed. Report the
176
+ // error here, on the way past, so whichever arrives wins honestly.
177
+ if (live.error && live.error !== reportedError.current) {
178
+ reportedError.current = live.error;
179
+ bridge.handleError(live.error);
180
+ }
181
+ // End the adapter session too, not just our own UI state. Without this
182
+ // a clean remote close (or exhausted reconnects) left `bridge.connected`
183
+ // true and assistant-ui's voice session `running`: the controls kept
184
+ // showing a live call and relay answers were queued to a dead transport.
185
+ bridge.handleClosed();
186
+ halfDuplexGate.current.reset();
187
+ resetVoiceSessionState();
188
+ }
189
+ }, [bridge, live.connectionState, live.error]);
190
+ // Host chroma-key tuning travels through the store because the stage is
191
+ // registered as a bare component reference, not a closure over config.
192
+ useEffect(() => {
193
+ useVoiceUiStore.setState({ chromaKey: config.chromaKey ?? null });
194
+ }, [config.chromaKey]);
195
+ // Hand the thread over to the stage for the duration of the call, and hand
196
+ // it straight back afterwards. Registering the component IS the takeover,
197
+ // so there is no second "is it active" flag to keep in step.
198
+ const callLive = live.connectionState === "connected" || live.connectionState === "reconnecting";
199
+ useEffect(() => {
200
+ if (!stage)
201
+ return;
202
+ // Last-registration-wins: another module may take the slot while a call is
203
+ // running, so every path that CLEARS has to check we still own it. Guarding
204
+ // only the cleanup was not enough — ending a call re-runs this effect with
205
+ // `callLive` false, and the body cleared the slot on the way past.
206
+ const releaseIfOurs = () => {
207
+ if (useChatSlotsStore.getState().threadSurface === VoiceStage) {
208
+ registerChatSlots({ threadSurface: null });
209
+ }
210
+ };
211
+ if (callLive)
212
+ registerChatSlots({ threadSurface: VoiceStage });
213
+ else
214
+ releaseIfOurs();
215
+ return releaseIfOurs;
216
+ }, [stage, callLive]);
217
+ // Surface a session-fatal error once. `error` stays set until the next
218
+ // connect, so re-reporting it on every render would end the session
219
+ // repeatedly and spam the host's callback.
220
+ const reportedError = useRef(null);
221
+ useEffect(() => {
222
+ if (live.error && live.error !== reportedError.current) {
223
+ reportedError.current = live.error;
224
+ bridge.handleError(live.error);
225
+ }
226
+ else if (!live.error) {
227
+ reportedError.current = null;
228
+ }
229
+ }, [bridge, live.error]);
230
+ // Who plays the audio, and how.
231
+ //
232
+ // Avatar: `VoiceLiveAvatar` owns an element and the audio arrives with the
233
+ // video. WebRTC: the stream is a remote track, which only an element can
234
+ // play. Otherwise the audio is PCM this browser decoded itself, and it
235
+ // goes straight out through Web Audio — see `useDirectAudioOutput` for why
236
+ // that is worth doing rather than attaching the MediaStream.
237
+ // Whether an element is needed follows from what the SESSION negotiated, not
238
+ // from whether we chose to show a face. A host that configures an avatar but
239
+ // hides the panel (`avatar: false`) still receives the assistant's voice as a
240
+ // remote track, and routing that through Web Audio as if it were locally
241
+ // decoded PCM plays nothing at all — the assistant simply goes silent.
242
+ const hasAvatar = !!session.avatar;
243
+ const showAvatar = avatar && hasAvatar;
244
+ const isWebRtc = config.connection.transport === "webrtc";
245
+ const needsElement = hasAvatar || isWebRtc;
246
+ useDirectAudioOutput(!needsElement, live.audioContext, live.audioAnalyser);
247
+ // Whether a sink is needed depends on whether an avatar element is ACTUALLY
248
+ // mounted, not on whether we intended one. With `stage: true` the avatar is
249
+ // drawn by the registered stage, and the chat surface can take that stage
250
+ // down — closing the floating panel does exactly that — while the call
251
+ // deliberately continues. Keyed off intent, the sink stayed absent and the
252
+ // assistant went silent until the panel was reopened.
253
+ const avatarPanels = useVoiceUiStore((s) => s.avatarPanels);
254
+ // Who plays the remote stream while an avatar is appearing or disappearing.
255
+ //
256
+ // A panel registers itself in an effect, so the count cannot answer this on
257
+ // the commit where an avatar mounts — it is still zero while
258
+ // `VoiceLiveAvatar` is mounting its own audio element, and a sink mounted
259
+ // alongside gives the stream two owners and starts it twice. What IS known
260
+ // synchronously is that a panel renders exactly when there is a video
261
+ // stream to draw, so an element can be predicted rather than observed.
262
+ //
263
+ // The prediction can still be wrong in one direction: with `stage: true` the
264
+ // panel is drawn by a registered chat slot, and the surrounding surface may
265
+ // not be on screen at all (a closed bubble). So if nothing has registered by
266
+ // the next tick, the prediction is abandoned and the sink takes the audio
267
+ // back — which is the case that made an ongoing call go silent.
268
+ const avatarElementExpected = showAvatar && !!live.videoStream;
269
+ const [predictionFailed, setPredictionFailed] = useState(false);
270
+ useEffect(() => {
271
+ if (!avatarElementExpected || avatarPanels > 0) {
272
+ setPredictionFailed(false);
273
+ return;
274
+ }
275
+ const settle = setTimeout(() => setPredictionFailed(true), 0);
276
+ return () => clearTimeout(settle);
277
+ }, [avatarElementExpected, avatarPanels]);
278
+ const sinkNeeded = needsAudioOnlySink({
279
+ needsElement,
280
+ avatarPanels,
281
+ avatarElementExpected,
282
+ predictionFailed,
283
+ });
284
+ return (_jsxs(_Fragment, { children: [mode === "relay" ? _jsx(RelayAnswerWatcher, { bridge: bridge }) : null, showAvatar && !stage ? (
285
+ // With the stage on, the avatar is drawn there instead of floating.
286
+ _jsx(VoiceAvatarPanel, { videoStream: live.videoStream, audioStream: live.audioStream,
287
+ // Tuning must not depend on where the avatar happens to be drawn.
288
+ chromaKeyConfig: config.chromaKey ?? undefined })) : sinkNeeded ? (_jsx(VoiceAudioSink, { stream: live.audioStream })) : null] }));
289
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Session facts the voice UI reads.
3
+ *
4
+ * Distinct from the bridge, deliberately: the bridge is the channel to
5
+ * assistant-ui's runtime (transcripts, mode, volume, status), while this is
6
+ * the channel to *our* components (the mic button's tooltip, the status
7
+ * strip's wording). Mixing them would make the mic button re-render on every
8
+ * transcript delta and put UI copy inside the protocol layer.
9
+ *
10
+ * Status the runtime already owns — connected, muted, listening vs speaking —
11
+ * is read from ``useVoiceState()`` / ``useVoiceVolume()``. What lives here is
12
+ * only what the runtime has no concept of: the SDK's finer-grained session
13
+ * state (``thinking``) and its connection lifecycle (``reconnecting``).
14
+ */
15
+ import type { ChromaKeyConfig, ConnectionState, SessionState } from "@iloveagents/foundry-voice-live-react";
16
+ interface VoiceUiState {
17
+ connectionState: ConnectionState;
18
+ sessionState: SessionState;
19
+ isMuted: boolean;
20
+ isReady: boolean;
21
+ /** Live output analyser, for the visualiser. Null between sessions. */
22
+ analyser: AnalyserNode | null;
23
+ /**
24
+ * The last thing each side said, for the voice stage.
25
+ *
26
+ * Only the latest line: the stage is something you glance at while
27
+ * talking, not a transcript to read. The thread is where history lives —
28
+ * in `relay` these turns are already there, and in `realtime` assistant-ui
29
+ * puts them there for the life of the session.
30
+ */
31
+ userText: string;
32
+ assistantText: string;
33
+ /** Avatar streams, when the session was configured with one. */
34
+ videoStream: MediaStream | null;
35
+ audioStream: MediaStream | null;
36
+ /** Host chroma-key tuning, forwarded to the avatar wherever it renders. */
37
+ chromaKey: ChromaKeyConfig | null;
38
+ /**
39
+ * How many `VoiceAvatarPanel`s are mounted right now.
40
+ *
41
+ * Who plays the audio cannot be decided from configuration alone. The stage
42
+ * is registered as a chat slot, so the surrounding chat surface decides
43
+ * whether it is rendered — and a floating panel closing while the call
44
+ * deliberately continues takes the avatar's element down with it. A count
45
+ * kept by the panels themselves is the only thing that knows.
46
+ */
47
+ avatarPanels: number;
48
+ }
49
+ export declare const useVoiceUiStore: import("zustand").UseBoundStore<import("zustand").StoreApi<VoiceUiState>>;
50
+ /**
51
+ * Everything a finished session leaves behind. Without this, the next call
52
+ * opens on the previous call's caption and a dead analyser — stale by
53
+ * definition, and visibly so on the stage.
54
+ */
55
+ export declare function resetVoiceSessionState(): void;
56
+ /** Called by `VoiceAvatarPanel` as it mounts and unmounts. */
57
+ export declare function trackAvatarPanel(delta: 1 | -1): void;
58
+ export {};
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Session facts the voice UI reads.
3
+ *
4
+ * Distinct from the bridge, deliberately: the bridge is the channel to
5
+ * assistant-ui's runtime (transcripts, mode, volume, status), while this is
6
+ * the channel to *our* components (the mic button's tooltip, the status
7
+ * strip's wording). Mixing them would make the mic button re-render on every
8
+ * transcript delta and put UI copy inside the protocol layer.
9
+ *
10
+ * Status the runtime already owns — connected, muted, listening vs speaking —
11
+ * is read from ``useVoiceState()`` / ``useVoiceVolume()``. What lives here is
12
+ * only what the runtime has no concept of: the SDK's finer-grained session
13
+ * state (``thinking``) and its connection lifecycle (``reconnecting``).
14
+ */
15
+ import { create } from "zustand";
16
+ export const useVoiceUiStore = create(() => ({
17
+ connectionState: "disconnected",
18
+ sessionState: "idle",
19
+ isMuted: false,
20
+ isReady: false,
21
+ analyser: null,
22
+ userText: "",
23
+ assistantText: "",
24
+ videoStream: null,
25
+ audioStream: null,
26
+ chromaKey: null,
27
+ avatarPanels: 0,
28
+ }));
29
+ /**
30
+ * Everything a finished session leaves behind. Without this, the next call
31
+ * opens on the previous call's caption and a dead analyser — stale by
32
+ * definition, and visibly so on the stage.
33
+ */
34
+ export function resetVoiceSessionState() {
35
+ useVoiceUiStore.setState({
36
+ userText: "",
37
+ assistantText: "",
38
+ analyser: null,
39
+ videoStream: null,
40
+ audioStream: null,
41
+ });
42
+ }
43
+ /** Called by `VoiceAvatarPanel` as it mounts and unmounts. */
44
+ export function trackAvatarPanel(delta) {
45
+ useVoiceUiStore.setState((s) => ({ avatarPanels: Math.max(0, s.avatarPanels + delta) }));
46
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The listening/speaking indicator: concentric rings breathing with the
3
+ * audio level.
4
+ *
5
+ * Three things it does that a canvas visualiser usually gets wrong:
6
+ *
7
+ * - **It wears the theme, and notices when the theme changes.** Colours are
8
+ * read from the app's CSS custom properties (`--primary`,
9
+ * `--muted-foreground`) rather than baked in. A canvas cannot inherit a CSS
10
+ * variable the way a DOM node does, so it re-reads them: periodically while
11
+ * animating (a theme-layer swap lands as inline style on an ancestor, which
12
+ * no root-element observer can see), and on a root class / `data-theme` /
13
+ * OS-scheme change for the static case, where no loop is running to notice.
14
+ * Opacity comes from `globalAlpha`, not from building `rgba()` strings, so
15
+ * any colour format the design system uses — `oklch`, `hsl`, hex — works
16
+ * untouched.
17
+ * - **It matches the device.** The backing store is sized in device pixels
18
+ * and scaled, so the rings are not soft on a retina display; a
19
+ * `ResizeObserver` keeps that true when the pane is resized.
20
+ * - **It stops.** No analyser, a hidden tab, or `prefers-reduced-motion` and
21
+ * the animation loop is not running at all — a decorative
22
+ * `requestAnimationFrame` that never yields is a battery drain on a page
23
+ * people leave open.
24
+ */
25
+ export interface VoiceVisualizerProps {
26
+ /** Analyser from the live session; `null` renders the idle state. */
27
+ analyser: AnalyserNode | null;
28
+ /**
29
+ * Extra re-read trigger for theme systems the built-in observation cannot
30
+ * see (root attributes and the OS scheme are watched automatically).
31
+ */
32
+ themeKey?: string;
33
+ className?: string;
34
+ /** Accessible description of what the animation represents. */
35
+ label?: string;
36
+ }
37
+ export declare function VoiceVisualizer({ analyser, themeKey, className, label, }: VoiceVisualizerProps): import("react/jsx-runtime").JSX.Element;