@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,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;
|