@iloveagents/foundry-web-voice 0.1.11 → 0.1.13

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/README.md CHANGED
@@ -43,6 +43,23 @@ retrieval surface, and the reply is spoken back in the Azure voice you configure
43
43
  that is `turn_detection.create_response: false` plus `pre_generated_assistant_message` — both
44
44
  documented Voice Live features, not tricks.
45
45
 
46
+ For slow app-agent runs, opt into `relayInterimResponse: { text: "One moment, please.",
47
+ delayMs: 15000, skipInitialResponse: true }`. The relay speaks it once while waiting
48
+ for answer text, through the same serialized speech queue. `skipInitialResponse`
49
+ lets the first reply of each call open the conversation without an acknowledgement.
50
+ Answer text (including a buffered short reply), interruption, run completion and
51
+ disconnect cancel a pending acknowledgement. It is presentation feedback, so it
52
+ does not add a message to the persisted conversation. Native Voice Live interim
53
+ responses cannot observe tools executing in the app's separate agent.
54
+
55
+ Optionally provide `describeActivity({ toolName, completed, args })` to return a
56
+ short description of observed tool work, or `null` for unknown activity. The
57
+ watcher reports only tools from the current user turn, preferring pending calls.
58
+ With `intervalMs` (default 18000), changed descriptions can be spoken occasionally,
59
+ never repeating a phrase and capped at three updates per run. Described tool work
60
+ can receive an update during the opening turn; the generic fallback remains
61
+ suppressed there. Curate descriptions rather than speaking raw arguments or results.
62
+
46
63
  **`realtime`** hands the conversation to the Voice Live model for the lowest possible latency. It
47
64
  can still call your client-side tools: the schemas in `clientToolRegistry` are sent with the
48
65
  session, and a call is dispatched through the same registry the typed chat uses, so `ui_navigate`
@@ -0,0 +1,4 @@
1
+ import type { ThreadMessage } from "@assistant-ui/react";
2
+ import type { RelayActivity } from "./types.js";
3
+ /** Only describe work in the current user turn; prefer a call still in progress. */
4
+ export declare function currentRelayActivity(messages: readonly ThreadMessage[]): RelayActivity | null;
@@ -0,0 +1,17 @@
1
+ /** Only describe work in the current user turn; prefer a call still in progress. */
2
+ export function currentRelayActivity(messages) {
3
+ let userIndex = messages.length - 1;
4
+ while (userIndex >= 0 && messages[userIndex]?.role !== "user")
5
+ userIndex--;
6
+ if (userIndex < 0)
7
+ return null;
8
+ const tools = messages
9
+ .slice(userIndex + 1)
10
+ .flatMap((message) => message.role === "assistant"
11
+ ? message.content.filter((part) => part.type === "tool-call")
12
+ : []);
13
+ const tool = [...tools].reverse().find((part) => part.result === undefined) ?? tools[tools.length - 1];
14
+ return tool
15
+ ? { toolName: tool.toolName, completed: tool.result !== undefined, args: tool.args }
16
+ : null;
17
+ }
@@ -31,9 +31,31 @@ export interface VoiceTranscript {
31
31
  text: string;
32
32
  isFinal: boolean;
33
33
  }
34
+ export interface RelayActivity {
35
+ toolName: string;
36
+ completed: boolean;
37
+ args?: Readonly<Record<string, unknown>>;
38
+ }
34
39
  export interface VoiceConfig {
35
40
  /** @default "relay" */
36
41
  mode?: VoiceMode;
42
+ /**
43
+ * Spoken progress when the app's agent takes longer to answer.
44
+ * Relay only: Voice Live cannot observe tools running in the app's agent.
45
+ * Uses the normal speech queue, never adds a message to conversation history.
46
+ * Cancelled on the first answer text, interruption, run end or disconnect.
47
+ */
48
+ relayInterimResponse?: {
49
+ text: string;
50
+ /** @default 15000 */
51
+ delayMs?: number;
52
+ /** Suppress opening-turn filler; described tool work can still receive updates. */
53
+ skipInitialResponse?: boolean;
54
+ /** Describe observed tool activity without speaking raw arguments or reasoning. */
55
+ describeActivity?: (activity: RelayActivity) => string | null;
56
+ /** Minimum spacing for changed activity updates; at most three updates per run. */
57
+ intervalMs?: number;
58
+ };
37
59
  /**
38
60
  * How to reach Voice Live. In production this is `{ proxyUrl }`: a
39
61
  * browser cannot set an `Authorization` header on a WebSocket, so the
@@ -25,7 +25,7 @@
25
25
  */
26
26
  import { type RealtimeVoiceAdapter } from "@assistant-ui/react";
27
27
  import type { VoiceLiveClientEvent, VoiceLiveServerEvent, SessionState } from "@iloveagents/foundry-voice-live-react";
28
- import type { VoiceConfig } from "./types.js";
28
+ import type { RelayActivity, VoiceConfig } from "./types.js";
29
29
  /** What the bridge needs from the live session; supplied by the React host. */
30
30
  export interface VoiceTransportControls {
31
31
  connect: () => Promise<void>;
@@ -74,6 +74,13 @@ export declare class VoiceBridge {
74
74
  * stream, and speak the whole reply again. Forever.
75
75
  */
76
76
  private answerDone;
77
+ private hasSpokenAnswer;
78
+ private relayRunId;
79
+ private interimTimer;
80
+ private interimActivity;
81
+ private interimSuppressed;
82
+ private interimCount;
83
+ private interimTexts;
77
84
  /** The response currently being spoken, once the service has named it. */
78
85
  private activeResponseId;
79
86
  /**
@@ -194,6 +201,11 @@ export declare class VoiceBridge {
194
201
  * the moment the session goes live.
195
202
  */
196
203
  primeAnswer(messageId: string | null): void;
204
+ /** A stable user-turn id while the agent runs; null on completion/cancellation. */
205
+ trackRelayRun(runId: string | null): void;
206
+ trackRelayActivity(activity: RelayActivity | null): void;
207
+ private scheduleInterim;
208
+ private clearInterim;
197
209
  trackAnswer(messageId: string, text: string, isComplete: boolean): void;
198
210
  /**
199
211
  * Speak exact text through Voice Live without invoking its model.
@@ -62,6 +62,13 @@ export class VoiceBridge {
62
62
  * stream, and speak the whole reply again. Forever.
63
63
  */
64
64
  this.answerDone = false;
65
+ this.hasSpokenAnswer = false;
66
+ this.relayRunId = null;
67
+ this.interimTimer = null;
68
+ this.interimActivity = null;
69
+ this.interimSuppressed = false;
70
+ this.interimCount = 0;
71
+ this.interimTexts = new Set();
65
72
  /** The response currently being spoken, once the service has named it. */
66
73
  this.activeResponseId = null;
67
74
  /**
@@ -266,6 +273,8 @@ export class VoiceBridge {
266
273
  handleServerEvent(event) {
267
274
  switch (event.type) {
268
275
  case "input_audio_buffer.speech_started":
276
+ this.interimSuppressed = true;
277
+ this.clearInterim();
269
278
  // Barge-in. Drop everything still queued to be spoken; the wire's
270
279
  // in-flight response is cancelled by the service (`interrupt_response`),
271
280
  // and cancelling locally flushes playback that is already buffered.
@@ -369,6 +378,8 @@ export class VoiceBridge {
369
378
  * is exactly the pair of routes on which the transport dies mid-utterance.
370
379
  */
371
380
  endCall() {
381
+ this.clearInterim();
382
+ this.relayRunId = null;
372
383
  this.connected = false;
373
384
  // Cleared here rather than on each exit path: the cancellation branch left
374
385
  // it set, and a later `handleClosed()` would then act on the controls of a
@@ -384,6 +395,7 @@ export class VoiceBridge {
384
395
  this.sentences.reset();
385
396
  this.answerId = null;
386
397
  this.answerDone = false;
398
+ this.hasSpokenAnswer = false;
387
399
  }
388
400
  // ---- relay: speak the agent's answer --------------------------------------
389
401
  /**
@@ -402,12 +414,83 @@ export class VoiceBridge {
402
414
  primeAnswer(messageId) {
403
415
  this.skipAnswerId = messageId;
404
416
  }
417
+ /** A stable user-turn id while the agent runs; null on completion/cancellation. */
418
+ trackRelayRun(runId) {
419
+ if (runId === this.relayRunId)
420
+ return;
421
+ this.clearInterim();
422
+ this.relayRunId = runId;
423
+ this.interimActivity = null;
424
+ this.interimSuppressed = false;
425
+ this.interimCount = 0;
426
+ this.interimTexts.clear();
427
+ const interim = this.config.relayInterimResponse;
428
+ if (!runId || !interim?.text.trim() || this.mode !== "relay" || !this.isLive)
429
+ return;
430
+ if (interim.skipInitialResponse && !this.hasSpokenAnswer)
431
+ return;
432
+ this.scheduleInterim(interim.delayMs ?? 15000);
433
+ }
434
+ trackRelayActivity(activity) {
435
+ this.interimActivity = activity;
436
+ const config = this.config.relayInterimResponse;
437
+ if (activity &&
438
+ config?.describeActivity &&
439
+ this.interimTimer === null &&
440
+ this.interimCount === 0 &&
441
+ this.relayRunId &&
442
+ !this.interimSuppressed &&
443
+ this.isLive &&
444
+ this.mode === "relay") {
445
+ // Real tool work can merit an update even in the opening turn; greetings cannot.
446
+ if (config.describeActivity(activity)?.trim())
447
+ this.scheduleInterim(config.delayMs ?? 15000);
448
+ }
449
+ }
450
+ scheduleInterim(delay) {
451
+ if (!Number.isFinite(delay) || delay < 0)
452
+ return;
453
+ this.interimTimer = setTimeout(() => {
454
+ this.interimTimer = null;
455
+ const config = this.config.relayInterimResponse;
456
+ if (!config || !this.isLive || !this.relayRunId || this.interimSuppressed)
457
+ return;
458
+ const activityText = this.interimActivity
459
+ ? config.describeActivity?.(this.interimActivity)
460
+ : null;
461
+ const text = activityText?.trim() ||
462
+ (this.interimCount === 0 && !(config.skipInitialResponse && !this.hasSpokenAnswer)
463
+ ? config.text.trim()
464
+ : "");
465
+ if (text &&
466
+ !this.interimTexts.has(text) &&
467
+ !this.speech.isSpeaking &&
468
+ this.speech.pendingCount === 0) {
469
+ this.interimTexts.add(text);
470
+ this.interimCount++;
471
+ this.speech.enqueue(text);
472
+ }
473
+ if (config.describeActivity && this.interimCount < 3)
474
+ this.scheduleInterim(Math.max(10000, config.intervalMs ?? 18000));
475
+ }, delay);
476
+ }
477
+ clearInterim() {
478
+ if (this.interimTimer !== null)
479
+ clearTimeout(this.interimTimer);
480
+ this.interimTimer = null;
481
+ }
405
482
  trackAnswer(messageId, text, isComplete) {
406
483
  // The watcher is mounted for the whole app lifetime, but an answer is
407
484
  // only spoken while a voice session is actually live — otherwise every
408
485
  // typed conversation would queue speech at a disconnected transport.
409
486
  if (this.mode !== "relay" || !this.isLive)
410
487
  return;
488
+ // Short replies are buffered until their sentence/turn completes. Once
489
+ // answer text is arriving, a waiting message must not jump ahead of it.
490
+ if (text.trim()) {
491
+ this.interimSuppressed = true;
492
+ this.clearInterim();
493
+ }
411
494
  if (messageId === this.skipAnswerId)
412
495
  return;
413
496
  if (this.answerId !== messageId) {
@@ -423,13 +506,15 @@ export class VoiceBridge {
423
506
  // bracket, so it must not be held back — holding it at that point drops
424
507
  // it, since `flush()` sees only what was pushed.
425
508
  const speakable = toSpeakableText(text, { final: isComplete });
426
- for (const utterance of this.sentences.push(speakable))
427
- this.speech.enqueue(utterance);
509
+ const utterances = this.sentences.push(speakable);
428
510
  if (isComplete) {
429
- for (const utterance of this.sentences.flush())
430
- this.speech.enqueue(utterance);
511
+ utterances.push(...this.sentences.flush());
431
512
  this.answerDone = true;
432
513
  }
514
+ for (const utterance of utterances) {
515
+ this.hasSpokenAnswer = true;
516
+ this.speech.enqueue(utterance);
517
+ }
433
518
  }
434
519
  /**
435
520
  * Speak exact text through Voice Live without invoking its model.
@@ -441,6 +526,7 @@ export class VoiceBridge {
441
526
  * response gate as every other turn.
442
527
  */
443
528
  sendSpokenText(text) {
529
+ this.clearInterim();
444
530
  this.controls?.sendEvent({
445
531
  type: "response.create",
446
532
  response: {
package/dist/index.d.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  */
14
14
  export { createVoiceModule, type VoiceChatModule } from "./react/voice-module.js";
15
15
  export { FoundryVoice, installVoice, type VoiceInstallOptions, type VoiceInstallation, } from "./react/install.js";
16
- export type { VoiceConfig, VoiceMode, VoiceTranscript } from "./adapter/types.js";
16
+ export type { RelayActivity, VoiceConfig, VoiceMode, VoiceTranscript } from "./adapter/types.js";
17
17
  export { VoiceMicButton } from "./react/voice-mic-button.js";
18
18
  export { VoiceStatusStrip } from "./react/voice-status-strip.js";
19
19
  export { VoiceStage, type VoiceStageProps } from "./react/voice-stage.js";
@@ -12,12 +12,15 @@
12
12
  */
13
13
  import { useEffect, useRef } from "react";
14
14
  import { useAuiState, useVoiceState } from "@assistant-ui/react";
15
+ import { currentRelayActivity } from "../adapter/relay-activity.js";
15
16
  export function RelayAnswerWatcher({ bridge }) {
16
17
  // The array identity changes on every streamed token, which is exactly
17
18
  // the update this component exists to observe. It renders nothing, so the
18
19
  // re-render costs a comparison and a `useEffect`.
19
20
  const messages = useAuiState((s) => s.thread.messages);
20
21
  const status = useVoiceState()?.status.type;
22
+ const running = useAuiState((s) => s.thread.isRunning);
23
+ const userTurnId = [...messages].reverse().find((m) => m.role === "user")?.id ?? null;
21
24
  // Whatever is on screen when the session goes live was answered before the
22
25
  // user asked for voice. Reading it back at them is not a greeting, it is a
23
26
  // non-sequitur.
@@ -29,6 +32,13 @@ export function RelayAnswerWatcher({ bridge }) {
29
32
  const lastAssistant = [...latest.current].reverse().find((m) => m.role === "assistant");
30
33
  bridge.primeAnswer(lastAssistant?.id ?? null);
31
34
  }, [bridge, status]);
35
+ useEffect(() => {
36
+ bridge.trackRelayRun(status === "running" && running ? userTurnId : null);
37
+ return () => bridge.trackRelayRun(null);
38
+ }, [bridge, status, running, userTurnId]);
39
+ useEffect(() => {
40
+ bridge.trackRelayActivity(status === "running" && running ? currentRelayActivity(messages) : null);
41
+ }, [bridge, messages, status, running]);
32
42
  useEffect(() => {
33
43
  const last = messages[messages.length - 1];
34
44
  if (!last || last.role !== "assistant")
@@ -35,6 +35,7 @@ export function VoiceStage(props = {}) {
35
35
  const connectionState = useVoiceUiStore((s) => s.connectionState);
36
36
  const userText = useVoiceUiStore((s) => s.userText);
37
37
  const assistantText = useVoiceUiStore((s) => s.assistantText);
38
+ const transcriptRole = useVoiceUiStore((s) => s.transcriptRole);
38
39
  const chromaKey = useVoiceUiStore((s) => s.chromaKey);
39
40
  const connecting = !voice || voice.status.type === "starting";
40
41
  const muted = voice?.isMuted ?? false;
@@ -49,9 +50,8 @@ export function VoiceStage(props = {}) {
49
50
  : sessionState === "speaking"
50
51
  ? "Speaking"
51
52
  : "Listening";
52
- // While the assistant talks the visualiser is showing its voice, so the
53
- // caption should be its words; otherwise show what was just heard.
54
- const caption = sessionState === "speaking" ? assistantText : userText;
53
+ // Pauses in playback do not change who last spoke.
54
+ const caption = transcriptRole === "assistant" ? assistantText : userText;
55
55
  return (_jsxs("div", { className: "relative flex h-full min-h-full flex-col", children: [_jsx("div", { className: "relative min-h-0 flex-1 overflow-hidden", children: videoStream ? (
56
56
  // Full-bleed and anchored to the top, so the avatar is framed head
57
57
  // and torso with the legs running off the lower edge — the framing
@@ -26,7 +26,7 @@ import { VoiceStage } from "./voice-stage.js";
26
26
  import { needsAudioOnlySink } from "./audio-ownership.js";
27
27
  import { VoiceAudioSink } from "./voice-audio-sink.js";
28
28
  import { useDirectAudioOutput } from "./use-direct-audio-output.js";
29
- import { resetVoiceSessionState, useVoiceUiStore } from "./voice-ui-store.js";
29
+ import { resetVoiceSessionState, updateVoiceCaption, useVoiceUiStore } from "./voice-ui-store.js";
30
30
  export function VoiceSurface({ bridge, config, avatar = true, stage = true }) {
31
31
  const mode = config.mode ?? "relay";
32
32
  const wantsTools = mode === "realtime" && (config.exposeClientTools ?? true);
@@ -38,11 +38,7 @@ export function VoiceSurface({ bridge, config, avatar = true, stage = true }) {
38
38
  const onEvent = useCallback((event) => bridge.handleServerEvent(event), [bridge]);
39
39
  const onTranscript = useCallback((role, text, isFinal) => {
40
40
  bridge.handleTranscript(role, text, isFinal);
41
- // The stage shows what is being said right now; a final empty string
42
- // is a VAD misfire and would blank the line for no reason.
43
- if (text.trim() || !isFinal) {
44
- useVoiceUiStore.setState(role === "user" ? { userText: text } : { assistantText: text });
45
- }
41
+ updateVoiceCaption(role, text);
46
42
  }, [bridge]);
47
43
  const toolExecutor = useMemo(() => (wantsTools ? createRegistryToolExecutor(clientToolRegistry.getState()) : undefined), [wantsTools]);
48
44
  const live = useVoiceLive({
@@ -30,6 +30,8 @@ interface VoiceUiState {
30
30
  */
31
31
  userText: string;
32
32
  assistantText: string;
33
+ /** Latest non-empty transcript's speaker; independent of playback pauses. */
34
+ transcriptRole: "user" | "assistant" | null;
33
35
  /** Avatar streams, when the session was configured with one. */
34
36
  videoStream: MediaStream | null;
35
37
  audioStream: MediaStream | null;
@@ -53,6 +55,8 @@ export declare const useVoiceUiStore: import("zustand").UseBoundStore<import("zu
53
55
  * definition, and visibly so on the stage.
54
56
  */
55
57
  export declare function resetVoiceSessionState(): void;
58
+ /** Keep the last caption until somebody actually says something new. */
59
+ export declare function updateVoiceCaption(role: "user" | "assistant", text: string): void;
56
60
  /** Called by `VoiceAvatarPanel` as it mounts and unmounts. */
57
61
  export declare function trackAvatarPanel(delta: 1 | -1): void;
58
62
  export {};
@@ -21,6 +21,7 @@ export const useVoiceUiStore = create(() => ({
21
21
  analyser: null,
22
22
  userText: "",
23
23
  assistantText: "",
24
+ transcriptRole: null,
24
25
  videoStream: null,
25
26
  audioStream: null,
26
27
  chromaKey: null,
@@ -35,11 +36,21 @@ export function resetVoiceSessionState() {
35
36
  useVoiceUiStore.setState({
36
37
  userText: "",
37
38
  assistantText: "",
39
+ transcriptRole: null,
38
40
  analyser: null,
39
41
  videoStream: null,
40
42
  audioStream: null,
41
43
  });
42
44
  }
45
+ /** Keep the last caption until somebody actually says something new. */
46
+ export function updateVoiceCaption(role, text) {
47
+ if (!text.trim())
48
+ return;
49
+ useVoiceUiStore.setState({
50
+ transcriptRole: role,
51
+ ...(role === "user" ? { userText: text } : { assistantText: text }),
52
+ });
53
+ }
43
54
  /** Called by `VoiceAvatarPanel` as it mounts and unmounts. */
44
55
  export function trackAvatarPanel(delta) {
45
56
  useVoiceUiStore.setState((s) => ({ avatarPanels: Math.max(0, s.avatarPanels + delta) }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iloveagents/foundry-web-voice",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Optional spoken-conversation tier for Foundry UI — Azure Voice Live wired into assistant-ui's realtime voice contract",
5
5
  "keywords": [
6
6
  "foundry",
@@ -38,8 +38,8 @@
38
38
  ],
39
39
  "dependencies": {
40
40
  "@iloveagents/foundry-voice-live-react": "^0.5.0",
41
- "@iloveagents/foundry-agent": "^0.31.0",
42
- "@iloveagents/foundry-web-ui": "^0.31.0"
41
+ "@iloveagents/foundry-agent": "^0.32.2",
42
+ "@iloveagents/foundry-web-ui": "^0.32.2"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "@assistant-ui/react": "^0.15.1",