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