@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,62 @@
1
+ /**
2
+ * Installing the voice tier: one bridge, one adapter registration, one
3
+ * (optional) set of chat-chrome slots.
4
+ *
5
+ * Two ways in, one implementation:
6
+ *
7
+ * - `createVoiceModule(config)` for `bootstrapShell` apps — the module's
8
+ * `layoutExtras` mounts the session host.
9
+ * - `<FoundryVoice {...config} />` for apps that compose the runtime
10
+ * themselves and never use the shell.
11
+ *
12
+ * Registration happens when the voice tier is *installed*, not when its host
13
+ * mounts. `useLocalRuntime` re-applies its options on every render, so a
14
+ * later registration would also be picked up — but installing first means
15
+ * the adapter is already there when the runtime first mounts, and the order
16
+ * never has to be reasoned about.
17
+ */
18
+ import type { ComponentType, ReactNode } from "react";
19
+ import { VoiceBridge } from "../adapter/voice-bridge.ts";
20
+ import type { VoiceConfig } from "../adapter/types.ts";
21
+ export interface VoiceInstallOptions extends VoiceConfig {
22
+ /**
23
+ * Register the mic button and status strip into the chat composer.
24
+ * Turn off to place `VoiceMicButton` / `VoiceStatusStrip` yourself.
25
+ * @default true
26
+ */
27
+ chrome?: boolean;
28
+ /** Render the avatar panel when the session config asks for an avatar. @default true */
29
+ avatar?: boolean;
30
+ /**
31
+ * Replace the chat thread with the voice stage while a call is live.
32
+ * Turn off to keep the transcript visible and show the avatar floating.
33
+ * @default true
34
+ */
35
+ stage?: boolean;
36
+ }
37
+ export interface VoiceInstallation {
38
+ bridge: VoiceBridge;
39
+ /** Mount this inside the assistant runtime — it owns the session. */
40
+ Host: ComponentType;
41
+ /** Undo the registrations (adapter and, if claimed, the chat slots). */
42
+ dispose: () => void;
43
+ }
44
+ export declare function installVoice(options: VoiceInstallOptions): VoiceInstallation;
45
+ /**
46
+ * Drop-in voice for an app that composes its own runtime.
47
+ *
48
+ * Render it anywhere inside `AGUIRuntimeProvider`. Installation happens in
49
+ * an effect, never during render: `installVoice` writes to zustand stores,
50
+ * and a store write during render is exactly what StrictMode's double-
51
+ * invoked initializers exist to catch — two bridges get built, the adapter
52
+ * from the *discarded* one stays registered, the mounted host owns the
53
+ * other, and the first connect waits on a bridge nobody attached to until
54
+ * the 10s timeout errors it out.
55
+ *
56
+ * Registering after mount is safe because both seams are reactive: the
57
+ * runtime provider re-reads the adapter store on every render, and the chat
58
+ * slots are subscriptions. The installation is created once per mount, so a
59
+ * caller passing inline config objects cannot churn the session; config
60
+ * changes need a remount (`key`), same as the module path.
61
+ */
62
+ export declare function FoundryVoice(props: VoiceInstallOptions): ReactNode;
@@ -0,0 +1,100 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { registerChatSlots, registerVoiceAdapter, useChatSlotsStore, useVoiceAdapterStore, } from "@iloveagents/foundry-web-ui";
4
+ import { VoiceBridge } from "../adapter/voice-bridge.js";
5
+ import { VoiceMicButton } from "./voice-mic-button.js";
6
+ import { VoiceStatusStrip } from "./voice-status-strip.js";
7
+ import { VoiceLauncherBadge } from "./voice-launcher-badge.js";
8
+ import { VoiceStage } from "./voice-stage.js";
9
+ import { VoiceSurface } from "./voice-surface.js";
10
+ /**
11
+ * Which installation last claimed the chat slots.
12
+ *
13
+ * Module-level because the question is about installations, not components:
14
+ * every installation registers the same `VoiceMicButton`, so identity alone
15
+ * cannot answer "is this still mine?".
16
+ */
17
+ let slotOwner = null;
18
+ export function installVoice(options) {
19
+ const { chrome = true, avatar = true, stage = true, ...config } = options;
20
+ const bridge = new VoiceBridge(config);
21
+ // Kept so disposal can tell "still mine" from "someone replaced me".
22
+ const adapter = bridge.createAdapter();
23
+ registerVoiceAdapter(adapter);
24
+ // The slots need a token rather than component identity: two installations
25
+ // register the SAME `VoiceMicButton`, so comparing the component cannot tell
26
+ // them apart and the older one would clear the newer one's controls.
27
+ const token = Symbol("foundry-voice-install");
28
+ if (chrome)
29
+ slotOwner = token;
30
+ if (chrome) {
31
+ registerChatSlots({
32
+ composerActions: VoiceMicButton,
33
+ composerBanner: VoiceStatusStrip,
34
+ // A call outlives the panel that started it, so the collapsed launcher
35
+ // has to show one is running and offer a way out. Renders nothing while
36
+ // idle, so a chat with no call looks exactly as it did.
37
+ launcherBadge: VoiceLauncherBadge,
38
+ // Nothing typed means Send has nothing to do; the corner is
39
+ // better spent on the other way of asking.
40
+ composerActionsReplaceSend: true,
41
+ });
42
+ }
43
+ const Host = () => (_jsx(VoiceSurface, { bridge: bridge, config: config, avatar: avatar, stage: stage }));
44
+ Host.displayName = "FoundryVoiceHost";
45
+ return {
46
+ bridge,
47
+ Host,
48
+ dispose: () => {
49
+ // Both registries are last-registration-wins, so a later installation
50
+ // legitimately owns these slots. Clearing unconditionally would strip
51
+ // the live one when an OLDER host unmounts — swapping a conditional
52
+ // <FoundryVoice/> would leave the replacement with no adapter and a
53
+ // composer with no controls.
54
+ if (useVoiceAdapterStore.getState().voice === adapter)
55
+ registerVoiceAdapter(null);
56
+ if (chrome && slotOwner === token) {
57
+ slotOwner = null;
58
+ const slots = useChatSlotsStore.getState();
59
+ registerChatSlots({
60
+ // Still identity-checked as well, so a host that put its OWN control
61
+ // in the slot keeps it.
62
+ composerActions: slots.composerActions === VoiceMicButton ? null : undefined,
63
+ composerBanner: slots.composerBanner === VoiceStatusStrip ? null : undefined,
64
+ launcherBadge: slots.launcherBadge === VoiceLauncherBadge ? null : undefined,
65
+ composerActionsReplaceSend: slots.composerActions === VoiceMicButton ? false : undefined,
66
+ threadSurface: slots.threadSurface === VoiceStage ? null : undefined,
67
+ });
68
+ }
69
+ },
70
+ };
71
+ }
72
+ /**
73
+ * Drop-in voice for an app that composes its own runtime.
74
+ *
75
+ * Render it anywhere inside `AGUIRuntimeProvider`. Installation happens in
76
+ * an effect, never during render: `installVoice` writes to zustand stores,
77
+ * and a store write during render is exactly what StrictMode's double-
78
+ * invoked initializers exist to catch — two bridges get built, the adapter
79
+ * from the *discarded* one stays registered, the mounted host owns the
80
+ * other, and the first connect waits on a bridge nobody attached to until
81
+ * the 10s timeout errors it out.
82
+ *
83
+ * Registering after mount is safe because both seams are reactive: the
84
+ * runtime provider re-reads the adapter store on every render, and the chat
85
+ * slots are subscriptions. The installation is created once per mount, so a
86
+ * caller passing inline config objects cannot churn the session; config
87
+ * changes need a remount (`key`), same as the module path.
88
+ */
89
+ export function FoundryVoice(props) {
90
+ const [installation, setInstallation] = useState(null);
91
+ // Captured at install time, deliberately — see the docstring.
92
+ const [initialProps] = useState(props);
93
+ useEffect(() => {
94
+ const inst = installVoice(initialProps);
95
+ setInstallation(inst);
96
+ return () => inst.dispose();
97
+ }, [initialProps]);
98
+ const Host = installation?.Host;
99
+ return Host ? _jsx(Host, {}) : null;
100
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * `RelayAnswerWatcher` — speaks the agent's answer, in `relay` mode.
3
+ *
4
+ * Render-less, mounted inside the assistant runtime (the same shape as
5
+ * foundry-web-ui's `ComposerSubmitBridge`). It watches the last assistant
6
+ * message as it streams and hands the prose to the bridge, which cuts it
7
+ * into sentences and speaks them one at a time.
8
+ *
9
+ * Only **text** parts are spoken. Reasoning parts are the model thinking out
10
+ * loud on the way to an answer, and tool calls are machinery — reading
11
+ * either aloud would be worse than saying nothing.
12
+ */
13
+ import type { VoiceBridge } from "../adapter/voice-bridge.ts";
14
+ export declare function RelayAnswerWatcher({ bridge }: {
15
+ bridge: VoiceBridge;
16
+ }): null;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `RelayAnswerWatcher` — speaks the agent's answer, in `relay` mode.
3
+ *
4
+ * Render-less, mounted inside the assistant runtime (the same shape as
5
+ * foundry-web-ui's `ComposerSubmitBridge`). It watches the last assistant
6
+ * message as it streams and hands the prose to the bridge, which cuts it
7
+ * into sentences and speaks them one at a time.
8
+ *
9
+ * Only **text** parts are spoken. Reasoning parts are the model thinking out
10
+ * loud on the way to an answer, and tool calls are machinery — reading
11
+ * either aloud would be worse than saying nothing.
12
+ */
13
+ import { useEffect, useRef } from "react";
14
+ import { useAuiState, useVoiceState } from "@assistant-ui/react";
15
+ export function RelayAnswerWatcher({ bridge }) {
16
+ // The array identity changes on every streamed token, which is exactly
17
+ // the update this component exists to observe. It renders nothing, so the
18
+ // re-render costs a comparison and a `useEffect`.
19
+ const messages = useAuiState((s) => s.thread.messages);
20
+ const status = useVoiceState()?.status.type;
21
+ // Whatever is on screen when the session goes live was answered before the
22
+ // user asked for voice. Reading it back at them is not a greeting, it is a
23
+ // non-sequitur.
24
+ const latest = useRef(messages);
25
+ latest.current = messages;
26
+ useEffect(() => {
27
+ if (status !== "running")
28
+ return;
29
+ const lastAssistant = [...latest.current].reverse().find((m) => m.role === "assistant");
30
+ bridge.primeAnswer(lastAssistant?.id ?? null);
31
+ }, [bridge, status]);
32
+ useEffect(() => {
33
+ const last = messages[messages.length - 1];
34
+ if (!last || last.role !== "assistant")
35
+ return;
36
+ const text = last.content
37
+ .filter((part) => part.type === "text")
38
+ .map((part) => part.text)
39
+ .join("\n");
40
+ if (!text)
41
+ return;
42
+ bridge.trackAnswer(last.id, text, last.status?.type !== "running");
43
+ }, [bridge, messages]);
44
+ return null;
45
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Play the assistant straight through Web Audio, instead of via a MediaStream
3
+ * and an `<audio>` element.
4
+ *
5
+ * The SDK's WebSocket path decodes PCM in an AudioWorklet and routes it
6
+ * `gain → MediaStreamDestination`, exposing the result as `audioStream` for a
7
+ * consumer to attach. Attaching it works, but it hands the audio to the
8
+ * element's *playout* path: a jitter buffer that rate-adapts to keep itself
9
+ * from draining. At the start of a stream that buffer is empty and still
10
+ * converging, which is audible on the first reply of a session as a crackle
11
+ * or a slight pitch bend — and then never again, because the element keeps
12
+ * running and stays converged.
13
+ *
14
+ * The analyser is already in the signal path (`gain → analyser`), and an
15
+ * `AnalyserNode` passes audio through untouched, so connecting it to the
16
+ * context's destination gives the same signal with one resampling stage and
17
+ * one buffer fewer between the worklet and the speakers.
18
+ *
19
+ * Only for locally-decoded PCM. A WebRTC session's audio is a remote track
20
+ * that the browser must play through an element; connecting the analyser
21
+ * there would play it a second time.
22
+ */
23
+ export declare function useDirectAudioOutput(enabled: boolean, context: AudioContext | null, analyser: AnalyserNode | null): void;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Play the assistant straight through Web Audio, instead of via a MediaStream
3
+ * and an `<audio>` element.
4
+ *
5
+ * The SDK's WebSocket path decodes PCM in an AudioWorklet and routes it
6
+ * `gain → MediaStreamDestination`, exposing the result as `audioStream` for a
7
+ * consumer to attach. Attaching it works, but it hands the audio to the
8
+ * element's *playout* path: a jitter buffer that rate-adapts to keep itself
9
+ * from draining. At the start of a stream that buffer is empty and still
10
+ * converging, which is audible on the first reply of a session as a crackle
11
+ * or a slight pitch bend — and then never again, because the element keeps
12
+ * running and stays converged.
13
+ *
14
+ * The analyser is already in the signal path (`gain → analyser`), and an
15
+ * `AnalyserNode` passes audio through untouched, so connecting it to the
16
+ * context's destination gives the same signal with one resampling stage and
17
+ * one buffer fewer between the worklet and the speakers.
18
+ *
19
+ * Only for locally-decoded PCM. A WebRTC session's audio is a remote track
20
+ * that the browser must play through an element; connecting the analyser
21
+ * there would play it a second time.
22
+ */
23
+ import { useEffect } from "react";
24
+ export function useDirectAudioOutput(enabled, context, analyser) {
25
+ useEffect(() => {
26
+ if (!enabled || !context || !analyser)
27
+ return;
28
+ try {
29
+ analyser.connect(context.destination);
30
+ }
31
+ catch {
32
+ // A closed context throws; there is nothing to play through anyway.
33
+ return;
34
+ }
35
+ return () => {
36
+ try {
37
+ analyser.disconnect(context.destination);
38
+ }
39
+ catch {
40
+ // Already torn down with the context.
41
+ }
42
+ };
43
+ }, [enabled, context, analyser]);
44
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Plays the assistant's audio when no avatar is doing it.
3
+ *
4
+ * The SDK routes playback into a ``MediaStreamAudioDestinationNode`` exposed
5
+ * as ``audioStream`` rather than to the speakers directly, so *something*
6
+ * must attach it to an element. ``VoiceLiveAvatar`` does that itself; in
7
+ * voice-only mode nothing does, and the session is silent — connected,
8
+ * transcribing, and inaudible.
9
+ *
10
+ * It also stands down the instant an avatar panel exists, without waiting to
11
+ * be unmounted. `needsAudioOnlySink` keeps the two apart in every state it can
12
+ * see, but it cannot see the commit in which a panel mounts: the panel
13
+ * registers itself in an effect, and this element is already playing by then.
14
+ * That is one frame of the same stream through two elements — the same audio
15
+ * at double amplitude, which is a click rather than an echo. Subscribing to
16
+ * the count pauses this element in the same effect flush that registers the
17
+ * panel, so the handoff is silent in both directions.
18
+ */
19
+ export declare function VoiceAudioSink({ stream }: {
20
+ stream: MediaStream | null;
21
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,55 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * Plays the assistant's audio when no avatar is doing it.
4
+ *
5
+ * The SDK routes playback into a ``MediaStreamAudioDestinationNode`` exposed
6
+ * as ``audioStream`` rather than to the speakers directly, so *something*
7
+ * must attach it to an element. ``VoiceLiveAvatar`` does that itself; in
8
+ * voice-only mode nothing does, and the session is silent — connected,
9
+ * transcribing, and inaudible.
10
+ *
11
+ * It also stands down the instant an avatar panel exists, without waiting to
12
+ * be unmounted. `needsAudioOnlySink` keeps the two apart in every state it can
13
+ * see, but it cannot see the commit in which a panel mounts: the panel
14
+ * registers itself in an effect, and this element is already playing by then.
15
+ * That is one frame of the same stream through two elements — the same audio
16
+ * at double amplitude, which is a click rather than an echo. Subscribing to
17
+ * the count pauses this element in the same effect flush that registers the
18
+ * panel, so the handoff is silent in both directions.
19
+ */
20
+ import { useEffect, useRef } from "react";
21
+ import { useVoiceUiStore } from "./voice-ui-store.js";
22
+ export function VoiceAudioSink({ stream }) {
23
+ const ref = useRef(null);
24
+ useEffect(() => {
25
+ const el = ref.current;
26
+ if (!el)
27
+ return;
28
+ el.srcObject = stream;
29
+ if (!stream)
30
+ return;
31
+ // Autoplay is allowed here: a voice session only ever starts from a
32
+ // click on the mic, which is the user gesture the policy wants.
33
+ void el.play().catch(() => {
34
+ /* a blocked play surfaces as silence, not an unhandled rejection */
35
+ });
36
+ }, [stream]);
37
+ useEffect(() => {
38
+ const apply = (panels) => {
39
+ const el = ref.current;
40
+ if (!el)
41
+ return;
42
+ if (panels > 0) {
43
+ el.pause();
44
+ }
45
+ else if (el.srcObject) {
46
+ void el.play().catch(() => { });
47
+ }
48
+ };
49
+ apply(useVoiceUiStore.getState().avatarPanels);
50
+ // Imperative, not a re-render: a re-render lands a frame later, which is
51
+ // exactly the frame being closed here.
52
+ return useVoiceUiStore.subscribe((s) => apply(s.avatarPanels));
53
+ }, []);
54
+ return _jsx("audio", { ref: ref, autoPlay: true, hidden: true, "aria-hidden": "true" });
55
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The avatar surface.
3
+ *
4
+ * `VoiceLiveAvatar` does the real work — WebRTC video, chroma-key removal of
5
+ * the green screen, and the hidden `<audio>` element that makes the session
6
+ * audible. This wrapper only decides where it sits, and renders nothing
7
+ * until a video track actually arrives, so a session configured without an
8
+ * avatar (or one still negotiating) leaves the page untouched.
9
+ *
10
+ * Centred, not tucked into a corner: while an avatar is on screen it is the
11
+ * party being spoken to, and a thumbnail in the periphery reads as a webcam
12
+ * preview instead. The thread behind stays scrollable and selectable — the
13
+ * positioning layer takes no pointer events, only the card does.
14
+ */
15
+ import type { ChromaKeyConfig } from "@iloveagents/foundry-voice-live-react";
16
+ export interface VoiceAvatarPanelProps {
17
+ videoStream: MediaStream | null;
18
+ audioStream: MediaStream | null;
19
+ /** Replaces the default centred placement entirely. */
20
+ className?: string;
21
+ /**
22
+ * Classes for the video canvas itself.
23
+ *
24
+ * The SDK sets `height:auto; object-fit:contain` as an inline style, which
25
+ * a plain class cannot beat — pass Tailwind's important modifier
26
+ * (`object-cover!`) to override it.
27
+ */
28
+ canvasClassName?: string;
29
+ /** Chroma-key tuning; defaults to a tighter edge than the SDK's (see `AVATAR_CHROMA_KEY`). */
30
+ chromaKeyConfig?: ChromaKeyConfig;
31
+ }
32
+ /**
33
+ * Keying tuned against the SDK's actual shader rather than by eye.
34
+ *
35
+ * That shader is `alpha = smoothstep(similarity, similarity + smoothness, d)`,
36
+ * where `d` is the distance from the key colour in UV (chrominance) space. The
37
+ * decisive numbers: a green screen sits at `d = 0`, edge pixels smeared by
38
+ * 4:2:0 chroma subsampling land around `0.17`–`0.47`, and the SUBJECT is only
39
+ * as far out as `d ≈ 0.53` — white and light grey are the closest, skin around
40
+ * `0.58`. Subject and fringe are much nearer each other than they look.
41
+ *
42
+ * So `similarity + smoothness` is a hard ceiling: put it above ~0.52 and the
43
+ * upper end of the smoothstep starts inside the subject, which does not read
44
+ * as a soft edge — it turns the whole figure semi-transparent. An earlier
45
+ * attempt to soften the fringe with `0.42 / 0.25` did exactly that: a grey
46
+ * t-shirt came out 46% opaque, white 43%, skin ~70%, and the avatar rendered
47
+ * as a washed-out ghost with a halo.
48
+ *
49
+ * The fringe wants the opposite move. Widening `smoothness` never removed
50
+ * green — fringe pixels sit below `similarity` and were already fully cut —
51
+ * it only ate the subject. Raising `similarity` while keeping the band narrow
52
+ * is what tightens the edge: at `0.44 / 0.08` an 80%-subject edge pixel drops
53
+ * to alpha 0.24 (the SDK default leaves it at 0.73, still visibly green) while
54
+ * every subject colour stays fully opaque.
55
+ *
56
+ * Hosts with different lighting override via `chromaKeyConfig` (plumbed from
57
+ * `VoiceConfig.chromaKey`). `chroma-key.test.ts` re-derives all of the above
58
+ * from the shader, so a future tweak cannot quietly reintroduce the ghost.
59
+ */
60
+ export declare const AVATAR_CHROMA_KEY: ChromaKeyConfig;
61
+ export declare function VoiceAvatarPanel({ videoStream, audioStream, className, canvasClassName, chromaKeyConfig, }: VoiceAvatarPanelProps): import("react/jsx-runtime").JSX.Element | null;
@@ -0,0 +1,76 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * The avatar surface.
4
+ *
5
+ * `VoiceLiveAvatar` does the real work — WebRTC video, chroma-key removal of
6
+ * the green screen, and the hidden `<audio>` element that makes the session
7
+ * audible. This wrapper only decides where it sits, and renders nothing
8
+ * until a video track actually arrives, so a session configured without an
9
+ * avatar (or one still negotiating) leaves the page untouched.
10
+ *
11
+ * Centred, not tucked into a corner: while an avatar is on screen it is the
12
+ * party being spoken to, and a thumbnail in the periphery reads as a webcam
13
+ * preview instead. The thread behind stays scrollable and selectable — the
14
+ * positioning layer takes no pointer events, only the card does.
15
+ */
16
+ import { useEffect } from "react";
17
+ import { VoiceLiveAvatar } from "@iloveagents/foundry-voice-live-react";
18
+ import { trackAvatarPanel } from "./voice-ui-store.js";
19
+ const LAYER = "pointer-events-none fixed inset-x-0 top-1/2 z-40 flex -translate-y-1/2 justify-center px-4";
20
+ /**
21
+ * No background, no border. The avatar arrives chroma-keyed to a cut-out, so
22
+ * anything drawn behind it is the rectangle the keying just removed.
23
+ */
24
+ const CARD = "pointer-events-auto w-[26rem] max-w-[80vw]";
25
+ /**
26
+ * Keying tuned against the SDK's actual shader rather than by eye.
27
+ *
28
+ * That shader is `alpha = smoothstep(similarity, similarity + smoothness, d)`,
29
+ * where `d` is the distance from the key colour in UV (chrominance) space. The
30
+ * decisive numbers: a green screen sits at `d = 0`, edge pixels smeared by
31
+ * 4:2:0 chroma subsampling land around `0.17`–`0.47`, and the SUBJECT is only
32
+ * as far out as `d ≈ 0.53` — white and light grey are the closest, skin around
33
+ * `0.58`. Subject and fringe are much nearer each other than they look.
34
+ *
35
+ * So `similarity + smoothness` is a hard ceiling: put it above ~0.52 and the
36
+ * upper end of the smoothstep starts inside the subject, which does not read
37
+ * as a soft edge — it turns the whole figure semi-transparent. An earlier
38
+ * attempt to soften the fringe with `0.42 / 0.25` did exactly that: a grey
39
+ * t-shirt came out 46% opaque, white 43%, skin ~70%, and the avatar rendered
40
+ * as a washed-out ghost with a halo.
41
+ *
42
+ * The fringe wants the opposite move. Widening `smoothness` never removed
43
+ * green — fringe pixels sit below `similarity` and were already fully cut —
44
+ * it only ate the subject. Raising `similarity` while keeping the band narrow
45
+ * is what tightens the edge: at `0.44 / 0.08` an 80%-subject edge pixel drops
46
+ * to alpha 0.24 (the SDK default leaves it at 0.73, still visibly green) while
47
+ * every subject colour stays fully opaque.
48
+ *
49
+ * Hosts with different lighting override via `chromaKeyConfig` (plumbed from
50
+ * `VoiceConfig.chromaKey`). `chroma-key.test.ts` re-derives all of the above
51
+ * from the shader, so a future tweak cannot quietly reintroduce the ghost.
52
+ */
53
+ export const AVATAR_CHROMA_KEY = {
54
+ keyColor: [0, 1, 0],
55
+ similarity: 0.44,
56
+ smoothness: 0.08,
57
+ };
58
+ export function VoiceAvatarPanel({ videoStream, audioStream, className, canvasClassName, chromaKeyConfig = AVATAR_CHROMA_KEY, }) {
59
+ // Announce that an avatar element exists, so the surface knows it does not
60
+ // also need a bare audio sink. Configuration cannot answer that question:
61
+ // this panel may be rendered by the stage, and the chat surface decides
62
+ // whether the stage is on screen at all.
63
+ const rendering = !!videoStream;
64
+ useEffect(() => {
65
+ if (!rendering)
66
+ return;
67
+ trackAvatarPanel(1);
68
+ return () => trackAvatarPanel(-1);
69
+ }, [rendering]);
70
+ if (!videoStream)
71
+ return null;
72
+ const avatar = (_jsx(VoiceLiveAvatar, { videoStream: videoStream, audioStream: audioStream, canvasClassName: canvasClassName, chromaKeyConfig: chromaKeyConfig }));
73
+ if (className)
74
+ return _jsx("div", { className: className, children: avatar });
75
+ return (_jsx("div", { className: LAYER, children: _jsx("div", { className: CARD, children: avatar }) }));
76
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Shown over the collapsed chat launcher while a call is live.
3
+ *
4
+ * Closing the floating panel does not end the call: `VoiceSurface` is mounted
5
+ * as a layout extra, so the session — and the microphone — outlive the panel
6
+ * that started it, while every control goes away with it. A launcher that
7
+ * looked idle while the microphone was still capturing is the problem this
8
+ * solves. The dot says a call is running; the button ends it without making
9
+ * the user guess that reopening chat is the way out.
10
+ *
11
+ * Reads assistant-ui state only, like the composer button, so it stays true
12
+ * for any registered `RealtimeVoiceAdapter`.
13
+ */
14
+ export declare function VoiceLauncherBadge(): import("react/jsx-runtime").JSX.Element | null;
@@ -0,0 +1,36 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Shown over the collapsed chat launcher while a call is live.
4
+ *
5
+ * Closing the floating panel does not end the call: `VoiceSurface` is mounted
6
+ * as a layout extra, so the session — and the microphone — outlive the panel
7
+ * that started it, while every control goes away with it. A launcher that
8
+ * looked idle while the microphone was still capturing is the problem this
9
+ * solves. The dot says a call is running; the button ends it without making
10
+ * the user guess that reopening chat is the way out.
11
+ *
12
+ * Reads assistant-ui state only, like the composer button, so it stays true
13
+ * for any registered `RealtimeVoiceAdapter`.
14
+ */
15
+ import { useVoiceControls, useVoiceState } from "@assistant-ui/react";
16
+ import { PhoneOff } from "lucide-react";
17
+ export function VoiceLauncherBadge() {
18
+ const voice = useVoiceState();
19
+ const controls = useVoiceControls();
20
+ if (!voice || voice.status.type === "ended")
21
+ return null;
22
+ const connecting = voice.status.type === "starting";
23
+ return (_jsxs("span", {
24
+ // The launcher wrapper is a positioned `group`, which is the anchor
25
+ // contract this slot is documented against.
26
+ className: "pointer-events-none absolute -right-1 -top-1 z-10 flex items-center", children: [_jsx("span", { "aria-hidden": "true", className: `size-3 rounded-full bg-primary ring-2 ring-background ${connecting ? "animate-pulse" : "animate-[pulse_1.6s_ease-in-out_infinite]"}` }), _jsxs("button", { type: "button", onClick: (e) => {
27
+ // The launcher underneath opens the chat; ending the call must not
28
+ // also reopen the panel the user deliberately closed.
29
+ e.stopPropagation();
30
+ controls.disconnect();
31
+ }, "aria-label": "End voice conversation", className: "pointer-events-none ml-1 flex items-center gap-1 rounded-full bg-background/95 " +
32
+ "px-2 py-1 text-xs text-foreground opacity-0 shadow ring-1 ring-border " +
33
+ "transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 " +
34
+ "group-focus-within:pointer-events-auto group-focus-within:opacity-100 " +
35
+ "focus-visible:pointer-events-auto focus-visible:opacity-100", children: [_jsx(PhoneOff, { className: "size-3.5" }), "End call"] }), _jsx("span", { className: "sr-only", children: "Voice conversation in progress" })] }));
36
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The composer's voice control.
3
+ *
4
+ * Every piece of state it reads is assistant-ui's own — `useVoiceState`,
5
+ * `useVoiceControls`, `useVoiceVolume`, and the runtime's `voice` capability
6
+ * flag. Nothing here talks to Voice Live, which is why the same button works
7
+ * for any registered `RealtimeVoiceAdapter`.
8
+ *
9
+ * It renders nothing at all when no adapter is registered, so the composer
10
+ * of an app without voice is unchanged.
11
+ *
12
+ * Shape follows the convention every voice-capable composer has converged
13
+ * on: a filled circular button carrying a waveform, sitting beside Send —
14
+ * and standing down while the user is typing, because at that moment Send is
15
+ * the action and a second filled circle is just noise. Once a session is
16
+ * live it stays put regardless, since ending the call must always be one
17
+ * click away.
18
+ */
19
+ export declare function VoiceMicButton(): import("react/jsx-runtime").JSX.Element | null;
@@ -0,0 +1,53 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The composer's voice control.
4
+ *
5
+ * Every piece of state it reads is assistant-ui's own — `useVoiceState`,
6
+ * `useVoiceControls`, `useVoiceVolume`, and the runtime's `voice` capability
7
+ * flag. Nothing here talks to Voice Live, which is why the same button works
8
+ * for any registered `RealtimeVoiceAdapter`.
9
+ *
10
+ * It renders nothing at all when no adapter is registered, so the composer
11
+ * of an app without voice is unchanged.
12
+ *
13
+ * Shape follows the convention every voice-capable composer has converged
14
+ * on: a filled circular button carrying a waveform, sitting beside Send —
15
+ * and standing down while the user is typing, because at that moment Send is
16
+ * the action and a second filled circle is just noise. Once a session is
17
+ * live it stays put regardless, since ending the call must always be one
18
+ * click away.
19
+ */
20
+ import { useAuiState, useVoiceControls, useVoiceState, useVoiceVolume } from "@assistant-ui/react";
21
+ import { TooltipIconButton } from "@iloveagents/foundry-web-ui";
22
+ import { AudioLines, Loader2, Mic, MicOff, X } from "lucide-react";
23
+ import { useVoiceUiStore } from "./voice-ui-store.js";
24
+ /** Volume below which the ring is not drawn — room noise shouldn't make it shimmer. */
25
+ const RING_FLOOR = 0.05;
26
+ /**
27
+ * Exactly Send's footprint, because it stands in that position: the composer
28
+ * is a query container, and Send is `size-8` in the bubble and `size-10`
29
+ * expanded.
30
+ */
31
+ const CIRCLE = "size-8 @md:size-10 rounded-full shrink-0";
32
+ export function VoiceMicButton() {
33
+ const canVoice = useAuiState((s) => s.optional.thread?.capabilities.voice ?? false);
34
+ const composerEmpty = useAuiState((s) => s.optional.composer?.isEmpty ?? true);
35
+ const voice = useVoiceState();
36
+ const controls = useVoiceControls();
37
+ const volume = useVoiceVolume();
38
+ const connectionState = useVoiceUiStore((s) => s.connectionState);
39
+ if (!canVoice)
40
+ return null;
41
+ const idle = !voice || voice.status.type === "ended";
42
+ if (idle) {
43
+ // Typing is a commitment to the keyboard; let Send have the corner.
44
+ if (!composerEmpty)
45
+ return null;
46
+ return (_jsx(TooltipIconButton, { tooltip: "Start voice conversation", variant: "default", className: CIRCLE, onClick: () => controls.connect(), children: _jsx(AudioLines, { className: "size-5" }) }));
47
+ }
48
+ if (voice.status.type === "starting") {
49
+ return (_jsx(TooltipIconButton, { tooltip: "Connecting\u2026", variant: "default", className: CIRCLE, onClick: () => controls.disconnect(), children: _jsx(Loader2, { className: "size-5 animate-spin" }) }));
50
+ }
51
+ const reconnecting = connectionState === "reconnecting";
52
+ return (_jsxs("span", { className: "flex items-center gap-1", children: [_jsx(TooltipIconButton, { tooltip: voice.isMuted ? "Unmute microphone" : "Mute microphone", variant: "ghost", className: "size-8 @md:size-9 shrink-0 rounded-full", onClick: () => (voice.isMuted ? controls.unmute() : controls.mute()), children: voice.isMuted ? (_jsx(MicOff, { className: "size-5 text-muted-foreground" })) : (_jsx(Mic, { className: "size-5" })) }), _jsxs("span", { className: "relative flex items-center justify-center", children: [_jsx("span", { "aria-hidden": "true", className: "pointer-events-none absolute size-8 @md:size-10 rounded-full bg-primary/25 transition-transform duration-75", style: { transform: `scale(${volume > RING_FLOOR ? 1 + volume * 0.4 : 0})` } }), _jsx(TooltipIconButton, { tooltip: reconnecting ? "Reconnecting — end voice conversation" : "End voice conversation", variant: "default", className: `${CIRCLE} relative ${reconnecting ? "animate-pulse" : ""}`, onClick: () => controls.disconnect(), children: _jsx(X, { className: "size-5" }) })] })] }));
53
+ }