@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,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
|
+
}
|