@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,222 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* The listening/speaking indicator: concentric rings breathing with the
|
|
4
|
+
* audio level.
|
|
5
|
+
*
|
|
6
|
+
* Three things it does that a canvas visualiser usually gets wrong:
|
|
7
|
+
*
|
|
8
|
+
* - **It wears the theme, and notices when the theme changes.** Colours are
|
|
9
|
+
* read from the app's CSS custom properties (`--primary`,
|
|
10
|
+
* `--muted-foreground`) rather than baked in. A canvas cannot inherit a CSS
|
|
11
|
+
* variable the way a DOM node does, so it re-reads them: periodically while
|
|
12
|
+
* animating (a theme-layer swap lands as inline style on an ancestor, which
|
|
13
|
+
* no root-element observer can see), and on a root class / `data-theme` /
|
|
14
|
+
* OS-scheme change for the static case, where no loop is running to notice.
|
|
15
|
+
* Opacity comes from `globalAlpha`, not from building `rgba()` strings, so
|
|
16
|
+
* any colour format the design system uses — `oklch`, `hsl`, hex — works
|
|
17
|
+
* untouched.
|
|
18
|
+
* - **It matches the device.** The backing store is sized in device pixels
|
|
19
|
+
* and scaled, so the rings are not soft on a retina display; a
|
|
20
|
+
* `ResizeObserver` keeps that true when the pane is resized.
|
|
21
|
+
* - **It stops.** No analyser, a hidden tab, or `prefers-reduced-motion` and
|
|
22
|
+
* the animation loop is not running at all — a decorative
|
|
23
|
+
* `requestAnimationFrame` that never yields is a battery drain on a page
|
|
24
|
+
* people leave open.
|
|
25
|
+
*/
|
|
26
|
+
import { useEffect, useRef } from "react";
|
|
27
|
+
/**
|
|
28
|
+
* How often, in frames, to re-read the theme while animating.
|
|
29
|
+
*
|
|
30
|
+
* `getComputedStyle` forces a style recalc, so this cannot run per frame —
|
|
31
|
+
* but it must run at all: foundry applies its tokens as inline style on a
|
|
32
|
+
* `ThemeScope` div, so a theme-layer change is invisible to an observer
|
|
33
|
+
* watching the root element. Twice a second is imperceptible to the user
|
|
34
|
+
* and negligible to the browser.
|
|
35
|
+
*/
|
|
36
|
+
const COLOR_REFRESH_FRAMES = 30;
|
|
37
|
+
/** Rings drawn outward from the core. */
|
|
38
|
+
const RING_COUNT = 5;
|
|
39
|
+
const RING_GAP = 22;
|
|
40
|
+
const CORE_RADIUS = 20;
|
|
41
|
+
/**
|
|
42
|
+
* Level smoothing, asymmetric on purpose.
|
|
43
|
+
*
|
|
44
|
+
* A single factor tracks speech syllable-for-syllable and the rings punch in
|
|
45
|
+
* and out on every one — a thud rather than a presence. Rising quickly and
|
|
46
|
+
* falling slowly turns the same signal into a swell that follows the shape
|
|
47
|
+
* of a phrase instead of its consonants.
|
|
48
|
+
*/
|
|
49
|
+
const ATTACK = 0.09;
|
|
50
|
+
const RELEASE = 0.035;
|
|
51
|
+
/**
|
|
52
|
+
* How far the rings travel at full level, in px.
|
|
53
|
+
*
|
|
54
|
+
* Deliberately small. The eye reads a gentle change as "alive"; a large one
|
|
55
|
+
* as an alarm, and at speech rates it is simply hectic.
|
|
56
|
+
*/
|
|
57
|
+
const SWELL = 14;
|
|
58
|
+
function readColors(el) {
|
|
59
|
+
const style = getComputedStyle(el);
|
|
60
|
+
const primary = style.getPropertyValue("--primary").trim();
|
|
61
|
+
const muted = style.getPropertyValue("--muted-foreground").trim();
|
|
62
|
+
return {
|
|
63
|
+
// A theme that defines neither still gets something visible.
|
|
64
|
+
active: primary || "currentColor",
|
|
65
|
+
idle: muted || "currentColor",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export function VoiceVisualizer({ analyser, themeKey, className, label = "Voice activity", }) {
|
|
69
|
+
const canvasRef = useRef(null);
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const canvas = canvasRef.current;
|
|
72
|
+
const ctx = canvas?.getContext("2d");
|
|
73
|
+
if (!canvas || !ctx)
|
|
74
|
+
return;
|
|
75
|
+
// Live, not a mount-time snapshot. Someone who turns reduced motion on
|
|
76
|
+
// mid-call is asking for the animation to stop now, and a voice session can
|
|
77
|
+
// run for many minutes — leaving the loop going for the rest of it is the
|
|
78
|
+
// whole of what they were trying to avoid.
|
|
79
|
+
const motionQuery = window.matchMedia?.("(prefers-reduced-motion: reduce)") ?? null;
|
|
80
|
+
let reducedMotion = motionQuery?.matches ?? false;
|
|
81
|
+
let colors = readColors(canvas);
|
|
82
|
+
let level = 0;
|
|
83
|
+
let frame = null;
|
|
84
|
+
let sinceColorRead = 0;
|
|
85
|
+
// The CSS-pixel size draw() works in. Cached here rather than measured per
|
|
86
|
+
// frame: getBoundingClientRect() forces layout, and at 60fps for the length
|
|
87
|
+
// of a call that is a self-inflicted reflow on every single frame. The only
|
|
88
|
+
// thing that can change it is a resize, and that has its own observer.
|
|
89
|
+
let cssWidth = 0;
|
|
90
|
+
let cssHeight = 0;
|
|
91
|
+
// Device pixels, so the strokes are crisp rather than upscaled.
|
|
92
|
+
const resize = () => {
|
|
93
|
+
const dpr = window.devicePixelRatio || 1;
|
|
94
|
+
const { width, height } = canvas.getBoundingClientRect();
|
|
95
|
+
if (!width || !height)
|
|
96
|
+
return;
|
|
97
|
+
cssWidth = width;
|
|
98
|
+
cssHeight = height;
|
|
99
|
+
canvas.width = Math.round(width * dpr);
|
|
100
|
+
canvas.height = Math.round(height * dpr);
|
|
101
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
102
|
+
};
|
|
103
|
+
resize();
|
|
104
|
+
// Assigning canvas.width/height clears the backing store. With a loop
|
|
105
|
+
// running the next frame repaints it, but the static path (no analyser,
|
|
106
|
+
// or reduced motion) has no next frame — so resizing the window or the
|
|
107
|
+
// surrounding pane simply erased the idle indicator until something else
|
|
108
|
+
// happened to redraw.
|
|
109
|
+
const observer = new ResizeObserver(() => {
|
|
110
|
+
resize();
|
|
111
|
+
if (frame === null)
|
|
112
|
+
draw();
|
|
113
|
+
});
|
|
114
|
+
observer.observe(canvas);
|
|
115
|
+
const buffer = analyser ? new Uint8Array(new ArrayBuffer(analyser.frequencyBinCount)) : null;
|
|
116
|
+
const draw = () => {
|
|
117
|
+
if (++sinceColorRead >= COLOR_REFRESH_FRAMES) {
|
|
118
|
+
sinceColorRead = 0;
|
|
119
|
+
colors = readColors(canvas);
|
|
120
|
+
}
|
|
121
|
+
const width = cssWidth;
|
|
122
|
+
const height = cssHeight;
|
|
123
|
+
const cx = width / 2;
|
|
124
|
+
const cy = height / 2;
|
|
125
|
+
if (analyser && buffer) {
|
|
126
|
+
analyser.getByteFrequencyData(buffer);
|
|
127
|
+
let sum = 0;
|
|
128
|
+
for (let i = 0; i < buffer.length; i++)
|
|
129
|
+
sum += buffer[i];
|
|
130
|
+
const next = sum / buffer.length / 255;
|
|
131
|
+
level += (next - level) * (next > level ? ATTACK : RELEASE);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
level += (0 - level) * RELEASE;
|
|
135
|
+
}
|
|
136
|
+
// Transparent: the surface behind decides the background, which is what
|
|
137
|
+
// makes this usable on a card, a panel, or a full-bleed stage.
|
|
138
|
+
ctx.clearRect(0, 0, width, height);
|
|
139
|
+
ctx.strokeStyle = analyser ? colors.active : colors.idle;
|
|
140
|
+
ctx.fillStyle = analyser ? colors.active : colors.idle;
|
|
141
|
+
for (let i = 0; i < RING_COUNT; i++) {
|
|
142
|
+
const radius = CORE_RADIUS + i * RING_GAP + level * SWELL;
|
|
143
|
+
const alpha = Math.max(0, 0.45 - i * 0.09);
|
|
144
|
+
if (alpha <= 0)
|
|
145
|
+
continue;
|
|
146
|
+
ctx.globalAlpha = alpha;
|
|
147
|
+
ctx.lineWidth = 1.5 + level * 1.2;
|
|
148
|
+
ctx.beginPath();
|
|
149
|
+
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
|
150
|
+
ctx.stroke();
|
|
151
|
+
}
|
|
152
|
+
ctx.globalAlpha = 0.32 + level * 0.3;
|
|
153
|
+
ctx.beginPath();
|
|
154
|
+
ctx.arc(cx, cy, CORE_RADIUS * (0.9 + level * 0.2), 0, Math.PI * 2);
|
|
155
|
+
ctx.fill();
|
|
156
|
+
ctx.globalAlpha = 1;
|
|
157
|
+
};
|
|
158
|
+
const loop = () => {
|
|
159
|
+
draw();
|
|
160
|
+
frame = requestAnimationFrame(loop);
|
|
161
|
+
};
|
|
162
|
+
/** Run only when there is something to animate and nobody objecting. */
|
|
163
|
+
const sync = () => {
|
|
164
|
+
const shouldAnimate = !reducedMotion && !!analyser && !document.hidden;
|
|
165
|
+
if (shouldAnimate && frame === null) {
|
|
166
|
+
loop();
|
|
167
|
+
}
|
|
168
|
+
else if (!shouldAnimate && frame !== null) {
|
|
169
|
+
cancelAnimationFrame(frame);
|
|
170
|
+
frame = null;
|
|
171
|
+
// One static frame: the shape is still meaningful as a state icon.
|
|
172
|
+
draw();
|
|
173
|
+
}
|
|
174
|
+
else if (!shouldAnimate) {
|
|
175
|
+
draw();
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
sync();
|
|
179
|
+
// A background tab throttles rAF to a crawl; stop rather than limp.
|
|
180
|
+
const onVisibility = sync;
|
|
181
|
+
const onMotionChange = (e) => {
|
|
182
|
+
reducedMotion = e.matches;
|
|
183
|
+
sync();
|
|
184
|
+
};
|
|
185
|
+
motionQuery?.addEventListener?.("change", onMotionChange);
|
|
186
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
187
|
+
// The theme can change without this component re-rendering: a class or
|
|
188
|
+
// data-theme flip, the OS scheme changing while the app is on "system",
|
|
189
|
+
// or a runtime theme layer being applied. Painted pixels do not follow a
|
|
190
|
+
// CSS variable, so watch for all three and repaint the static frame.
|
|
191
|
+
const refreshColors = () => {
|
|
192
|
+
colors = readColors(canvas);
|
|
193
|
+
if (frame === null)
|
|
194
|
+
draw();
|
|
195
|
+
};
|
|
196
|
+
const scheme = window.matchMedia?.("(prefers-color-scheme: dark)");
|
|
197
|
+
scheme?.addEventListener?.("change", refreshColors);
|
|
198
|
+
// Every ancestor, not just <html>: foundry's ThemeScope sets its runtime
|
|
199
|
+
// variables as an inline style on a wrapper div somewhere between the
|
|
200
|
+
// canvas and the root, and a change there is invisible to an observer
|
|
201
|
+
// watching only the document element. The animating path re-reads on a
|
|
202
|
+
// timer anyway; this is what keeps the STATIC path honest.
|
|
203
|
+
const themeObserver = new MutationObserver(refreshColors);
|
|
204
|
+
for (let node = canvas; node; node = node.parentElement) {
|
|
205
|
+
themeObserver.observe(node, {
|
|
206
|
+
attributes: true,
|
|
207
|
+
attributeFilter: ["class", "data-theme", "style"],
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
refreshColors();
|
|
211
|
+
return () => {
|
|
212
|
+
if (frame !== null)
|
|
213
|
+
cancelAnimationFrame(frame);
|
|
214
|
+
observer.disconnect();
|
|
215
|
+
themeObserver.disconnect();
|
|
216
|
+
scheme?.removeEventListener?.("change", refreshColors);
|
|
217
|
+
motionQuery?.removeEventListener?.("change", onMotionChange);
|
|
218
|
+
document.removeEventListener("visibilitychange", onVisibility);
|
|
219
|
+
};
|
|
220
|
+
}, [analyser, themeKey]);
|
|
221
|
+
return (_jsx("canvas", { ref: canvasRef, role: "img", "aria-label": label, className: className ?? "h-48 w-full max-w-md" }));
|
|
222
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@iloveagents/foundry-web-voice",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Optional spoken-conversation tier for Foundry UI — Azure Voice Live wired into assistant-ui's realtime voice contract",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"foundry",
|
|
7
|
+
"voice-live",
|
|
8
|
+
"assistant-ui",
|
|
9
|
+
"ag-ui",
|
|
10
|
+
"azure",
|
|
11
|
+
"voice",
|
|
12
|
+
"realtime",
|
|
13
|
+
"avatar"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "iLoveAgents",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/iLoveAgents/foundry-ui.git",
|
|
20
|
+
"directory": "packages/web-voice"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/iLoveAgents/foundry-ui/tree/main/packages/web-voice#readme",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/iLoveAgents/foundry-ui/issues"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"import": "./dist/index.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"README.md",
|
|
37
|
+
"LICENSE"
|
|
38
|
+
],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsc -p tsconfig.build.json && node ../../scripts/fix-dts-extensions.mjs dist",
|
|
41
|
+
"test:unit": "vitest run",
|
|
42
|
+
"typecheck": "tsc --noEmit"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@iloveagents/foundry-agent": "workspace:^",
|
|
46
|
+
"@iloveagents/foundry-web-ui": "workspace:^",
|
|
47
|
+
"@iloveagents/foundry-voice-live-react": "^0.5.0"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"@assistant-ui/react": "^0.15.1",
|
|
51
|
+
"lucide-react": ">=0.400.0",
|
|
52
|
+
"react": "^19.0.0",
|
|
53
|
+
"react-dom": "^19.0.0",
|
|
54
|
+
"zustand": "^5.0.0"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@assistant-ui/react": "^0.15.1",
|
|
58
|
+
"@types/react": "^19.2.2",
|
|
59
|
+
"@types/react-dom": "^19.2.2",
|
|
60
|
+
"jsdom": "^28.1.0",
|
|
61
|
+
"lucide-react": ">=0.400.0",
|
|
62
|
+
"react": "^19.0.0",
|
|
63
|
+
"react-dom": "^19.0.0",
|
|
64
|
+
"typescript": "~5.9.3",
|
|
65
|
+
"vitest": "^4.1.4",
|
|
66
|
+
"zustand": "^5.0.0"
|
|
67
|
+
},
|
|
68
|
+
"publishConfig": {
|
|
69
|
+
"access": "public"
|
|
70
|
+
}
|
|
71
|
+
}
|