@jskit-ai/assistant-voice 0.1.2 → 0.1.4

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.
@@ -2,12 +2,15 @@
2
2
  import { inject, nextTick, onScopeDispose, ref } from "vue";
3
3
  import { createVoiceConversationController, projectConversationVoiceState, VoiceConversationHost } from "@jskit-ai/assistant-voice/client";
4
4
  import { ShellErrorHost } from "@jskit-ai/shell-web/client";
5
+ import { useAssistantConversationFactory } from "@jskit-ai/assistant-runtime/client";
5
6
  import Conversation from "./Conversation.vue";
6
7
 
7
8
  const example = inject("example.application");
8
9
  const closed = ref([]);
9
10
  const views = new Map();
10
11
  const error = ref("");
12
+ const avatarSize = ref("compact");
13
+ const acquireConversation = useAssistantConversationFactory();
11
14
  const controller = createVoiceConversationController({ connectSpeech: binding => binding.socketUrl });
12
15
  async function openText(id) {
13
16
  closed.value = closed.value.filter(value => value !== id);
@@ -18,12 +21,22 @@ function voiceBinding(runtime, label) {
18
21
  const identity = runtime.identity;
19
22
  let retained;
20
23
  return {
21
- id: JSON.stringify([identity.actorKey, identity.conversationId]), label,
24
+ id: JSON.stringify([identity.actorKey, identity.conversationId]), conversationId: identity.conversationId, label,
25
+ get adapter() { return retained?.adapter.value; },
22
26
  socketUrl: `/api/conversations/${encodeURIComponent(identity.conversationId)}/voice`,
23
27
  get state() { return projectConversationVoiceState({ turns: runtime.turns.value, status: runtime.snapshot.value?.status }); },
24
28
  get available() { return runtime.available.value; },
25
29
  captureContext: () => ({ ...identity }),
26
- retain() { retained = runtime.retain(); runtime = retained.runtime; },
30
+ retain() {
31
+ retained = acquireConversation({ conversationId: identity.conversationId, endpoint: identity.endpoint,
32
+ surfaceId: identity.targetSurfaceId, hostSurfaceId: identity.hostSurfaceId,
33
+ actorKey: identity.actorKey, workspaceSlug: identity.workspaceSlug,
34
+ presentation: { assistantLabel: label, layout: "compact" } });
35
+ if (retained.runtime.value !== runtime) {
36
+ retained.release(); retained = null;
37
+ throw new Error("Voice must retain the original conversation runtime.");
38
+ }
39
+ },
27
40
  release() { retained?.release(); retained = null; },
28
41
  submitText(text, { messageId, context } = {}) {
29
42
  if (context?.actorKey !== identity.actorKey || context?.conversationId !== identity.conversationId) {
@@ -78,7 +91,7 @@ onScopeDispose(() => { void controller.dispose(); });
78
91
  </section>
79
92
  </div>
80
93
  </main>
81
- <VoiceConversationHost :controller="controller" />
94
+ <VoiceConversationHost v-model:avatar-size="avatarSize" :controller="controller" />
82
95
  <ShellErrorHost />
83
96
  </v-app>
84
97
  </template>
@@ -9,14 +9,14 @@
9
9
  },
10
10
  "dependencies": {
11
11
  "@fastify/websocket": "^11.2.0",
12
- "@jskit-ai/assistant-core": "0.1.202",
13
- "@jskit-ai/assistant-runtime": "0.1.195",
14
- "@jskit-ai/assistant-voice": "0.1.2",
15
- "@jskit-ai/connectors-catalog": "0.1.41",
16
- "@jskit-ai/http-runtime": "0.1.222",
17
- "@jskit-ai/kernel": "0.1.224",
18
- "@jskit-ai/realtime": "0.1.221",
19
- "@jskit-ai/shell-web": "0.1.228",
12
+ "@jskit-ai/assistant-core": "0.1.204",
13
+ "@jskit-ai/assistant-runtime": "0.1.197",
14
+ "@jskit-ai/assistant-voice": "0.1.4",
15
+ "@jskit-ai/connectors-catalog": "0.1.43",
16
+ "@jskit-ai/http-runtime": "0.1.224",
17
+ "@jskit-ai/kernel": "0.1.226",
18
+ "@jskit-ai/realtime": "0.1.223",
19
+ "@jskit-ai/shell-web": "0.1.230",
20
20
  "@mdi/js": "^7.4.47",
21
21
  "@tanstack/vue-query": "^5.90.5",
22
22
  "fastify": "^5.8.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-voice",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "description": "Duplex voice conversations with application-owned targets, authorization and speech services.",
6
6
  "scripts": {
@@ -18,6 +18,7 @@
18
18
  "examples"
19
19
  ],
20
20
  "dependencies": {
21
+ "@jskit-ai/assistant-core": "0.1.204",
21
22
  "@mdi/js": "^7.4.47",
22
23
  "ws": "^8.20.1"
23
24
  },
@@ -1,28 +1,23 @@
1
1
  <script setup>
2
- import { computed, ref, useId, watch } from "vue";
3
- import { mdiMicrophone, mdiMicrophoneOff, mdiStop, mdiVolumeHigh, mdiVolumeOff } from "@mdi/js";
2
+ import { computed, ref, watch } from "vue";
3
+ import { AssistantConversationElement } from "@jskit-ai/assistant-core/client/conversation";
4
4
  import VoiceAvatar from "./VoiceAvatar.vue";
5
- import { useVoiceLauncher } from "./voiceLauncher.js";
5
+ import VoiceConversationControls from "./VoiceConversationControls.vue";
6
6
 
7
- const props = defineProps({ session: { type: Object, required: true }, disabled: { type: Boolean, default: false } });
8
- const {
9
- targetLabel, voice, error, microphoneMuted, live, starting, callMode,
10
- callStatus, callAudioLevel, avatarVisual, speechActive, heldTranscript,
11
- capturing, pendingTranscript, voiceWords, voiceAnswer, pushHolding, sending,
12
- stopSpeech, startPushToTalk, finishPushToTalk, cancelPushToTalk, toggleHandsFree, readAloud, toggleReadAloud,
13
- deliverTranscript, discardHeldRecording, editTranscript
14
- } = props.session;
15
- const handsFreeListening = computed(() => live.value && callMode.value === "hands-free" && !microphoneMuted.value);
16
- const canTalk = computed(() => !props.disabled && !sending.value && !pendingTranscript.value &&
17
- (!capturing.value || voice.listening.value || pushHolding.value || starting.value));
18
- const talkLabel = computed(() => starting.value ? "Connecting…" : pushHolding.value ? "Release to send" : handsFreeListening.value ? "Pause" : "Talk");
19
- const gestureHint = useId();
20
- const gesture = useVoiceLauncher({
21
- enabled: canTalk,
22
- open: () => ({ startHeldRecording: startPushToTalk, finishHeldRecording: finishPushToTalk, discardHeldRecording: cancelPushToTalk }),
23
- onTap: toggleHandsFree,
24
- onError: cause => { error.value = cause.message; }
7
+ const props = defineProps({
8
+ session: { type: Object, required: true }, disabled: { type: Boolean, default: false },
9
+ adapter: { type: Object, default: null },
10
+ showAvatar: { type: Boolean, default: true },
11
+ avatarSize: { type: String, default: "compact" }
25
12
  });
13
+ defineEmits(["update:avatarSize"]);
14
+ const { targetLabel, voice, microphoneMuted, heldTranscript, pendingTranscript, sending,
15
+ avatarVisual, callAudioLevel, voiceWords, voiceAnswer } = props.session;
16
+ const presentationAdapter = computed(() => props.adapter && ({
17
+ ...props.adapter,
18
+ composer: { ...props.adapter.composer, canSend: props.adapter.composer?.canSend && !props.session.hasUnsentSpeech.value }
19
+ }));
20
+ const toolsTarget = ref(null);
26
21
  const voiceWordsTarget = ref(null);
27
22
  const voiceAnswerTarget = ref(null);
28
23
  const followWords = ref(true);
@@ -36,18 +31,30 @@ for (const [text, target, following] of [[voiceWords, voiceWordsTarget, followWo
36
31
  </script>
37
32
 
38
33
  <template>
39
- <fieldset class="assistant-voice" :disabled="disabled">
40
- <p v-if="error || voice.error.value" class="assistant-voice__error" role="alert">{{ error || voice.error.value }}</p>
41
- <section class="assistant-voice__call" :aria-label="`${targetLabel} voice chat controls`">
42
- <div class="assistant-voice__call-body">
34
+ <fieldset v-if="adapter" class="assistant-voice-conversation" :disabled="disabled">
35
+ <AssistantConversationElement :adapter="presentationAdapter" :avatar-size="avatarSize" @update:avatar-size="$emit('update:avatarSize', $event)">
36
+ <template v-if="showAvatar" #avatar="size">
37
+ <slot name="avatar" v-bind="avatarVisual" :size="size.size" :height="size.height">
38
+ <VoiceAvatar v-bind="avatarVisual" />
39
+ </slot>
40
+ </template>
41
+ <template #composer-tools><div ref="toolsTarget" class="assistant-voice-conversation__tools" /></template>
42
+ <template #composer-feedback>
43
+ <VoiceConversationControls :session="session" :disabled="disabled" compact :tools-target="toolsTarget">
44
+ <template #work-control><slot name="work-control" /></template>
45
+ </VoiceConversationControls>
46
+ </template>
47
+ </AssistantConversationElement>
48
+ </fieldset>
49
+ <fieldset v-else class="assistant-voice" :disabled="disabled">
50
+ <section
51
+ class="assistant-voice__call" :class="{ 'assistant-voice__call--without-avatar': !showAvatar }"
52
+ :aria-label="`${targetLabel} voice chat controls`"
53
+ >
54
+ <div v-if="showAvatar" class="assistant-voice__call-body">
43
55
  <div class="assistant-voice__call-portrait" :style="{ '--call-audio-scale': 1 + callAudioLevel * .16 }" aria-hidden="true">
44
56
  <slot name="avatar" v-bind="avatarVisual"><VoiceAvatar v-bind="avatarVisual" /></slot>
45
57
  </div>
46
- <div class="assistant-voice__call-status-row">
47
- <p class="text-title-medium" role="status">{{ callStatus }}</p>
48
- <v-btn v-if="speechActive" :icon="mdiStop" variant="text" min-width="48" min-height="48" :aria-label="`Stop ${targetLabel} speaking`" title="Stop speaking" @click="stopSpeech" />
49
- <slot name="work-control" />
50
- </div>
51
58
  </div>
52
59
  <div class="assistant-voice__captions" aria-label="Voice conversation text">
53
60
  <section ref="voiceWordsTarget" class="assistant-voice__caption" aria-label="Recognized words" tabindex="0" @scroll="followWords = isAtBottom($event.currentTarget)">
@@ -59,59 +66,29 @@ for (const [text, target, following] of [[voiceWords, voiceWordsTarget, followWo
59
66
  <p class="assistant-voice__caption-text text-body-medium">{{ voiceAnswer }}</p>
60
67
  </section>
61
68
  </div>
62
- <section v-if="pendingTranscript && (pendingTranscript.reviewBeforeSend || !sending)" class="assistant-voice__review" aria-label="Review voice message">
63
- <v-textarea :model-value="pendingTranscript.text" label="Review your message" :disabled="sending" rows="2" max-rows="3" auto-grow hide-details @update:model-value="editTranscript" />
64
- <div class="assistant-voice__review-actions">
65
- <v-btn :disabled="sending" min-height="48" @click="discardHeldRecording">Discard</v-btn>
66
- <v-btn :disabled="sending || !pendingTranscript.text.trim()" min-height="48" color="primary" @click="deliverTranscript">{{ sending ? 'Sending…' : 'Send' }}</v-btn>
67
- </div>
68
- </section>
69
- <div class="assistant-voice__talk-action">
70
- <v-btn
71
- class="assistant-voice__talk" color="primary" :variant="handsFreeListening ? 'tonal' : 'flat'" min-height="64" rounded="xl"
72
- :prepend-icon="voice.listening.value && !microphoneMuted ? mdiMicrophone : mdiMicrophoneOff" :aria-pressed="handsFreeListening || pushHolding" :aria-describedby="gestureHint"
73
- :disabled="!canTalk"
74
- @pointerdown="gesture.pointerDown" @pointerup="gesture.pointerUp" @pointercancel="gesture.cancel" @lostpointercapture="gesture.cancel"
75
- @keydown.space.prevent="gesture.keyDown" @keyup.space.prevent="gesture.keyUp"
76
- @keydown.enter.prevent="gesture.keyDown" @keyup.enter.prevent="gesture.keyUp" @blur="gesture.cancel" @contextmenu.prevent @click="gesture.click"
77
- >
78
- {{ talkLabel }}
79
- </v-btn>
80
- <v-btn
81
- :icon="readAloud ? mdiVolumeHigh : mdiVolumeOff" :aria-pressed="readAloud" variant="tonal" min-height="64" min-width="64" rounded="xl"
82
- :aria-label="readAloud ? `Turn ${targetLabel} read-aloud off` : `Read ${targetLabel} answers aloud`"
83
- :title="readAloud ? 'Spoken replies on' : 'Spoken replies off'" @click="toggleReadAloud"
84
- />
85
- <p :id="gestureHint" class="text-body-small text-medium-emphasis">{{ handsFreeListening ? 'Tap to pause · hold to speak' : 'Tap for hands-free · hold to speak' }}</p>
86
- </div>
69
+ <VoiceConversationControls :session="session" :disabled="disabled"><template #work-control><slot name="work-control" /></template></VoiceConversationControls>
87
70
  </section>
88
71
  </fieldset>
89
72
  </template>
90
73
 
91
74
  <style scoped>
75
+ .assistant-voice-conversation { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; min-width: 0; height: 100%; border: 0; padding: 0; margin: 0; }
76
+ .assistant-voice-conversation__tools { display: flex; flex-wrap: wrap; gap: 4px; }
92
77
  .assistant-voice { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; border: 0; padding: 0; margin: 0; min-width: 0; }
93
78
  .assistant-voice__call { width: 100%; flex: 1; min-height: 0; display: grid; grid-template-rows: minmax(0, 1fr) minmax(144px, 30%) auto; }
79
+ .assistant-voice__call--without-avatar { grid-template-rows: minmax(144px, 1fr) auto; }
94
80
  .assistant-voice__call-body { min-height: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 16px 16px 0; text-align: center; }
95
81
  .assistant-voice__call-portrait { position: relative; height: min(240px, calc(100% - 64px)); aspect-ratio: 1; max-width: 100%; margin: 8px auto; isolation: isolate; }
96
82
  .assistant-voice__call-portrait::before { content: ''; position: absolute; inset: -12px; border-radius: 50%; background: rgba(var(--v-theme-primary), .12); transform: scale(var(--call-audio-scale)); transition: transform 80ms linear; z-index: -1; }
97
83
  .assistant-voice__call-portrait :deep(svg) { width: 100%; height: 100%; }
98
- .assistant-voice__call-status-row { display: flex; flex: 0 0 48px; justify-content: center; align-items: center; gap: 8px; height: 48px; }
99
- .assistant-voice__call-status-row p { margin: 0; max-height: 48px; overflow: hidden; }
100
84
  .assistant-voice__captions { min-height: 0; display: grid; grid-template-rows: repeat(2, minmax(0, 1fr)); gap: 8px; padding: 0 16px; }
101
85
  .assistant-voice__caption { min-height: 0; padding: 8px 12px; border-radius: 12px; background: rgba(var(--v-theme-on-surface), .04); overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; touch-action: pan-y; }
102
86
  .assistant-voice__caption > strong { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
103
87
  .assistant-voice__caption-text { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
104
- .assistant-voice__talk-action { justify-self: center; width: min(288px, 100%); display: grid; grid-template-columns: minmax(0, 1fr) 64px; gap: 6px 8px; padding: 16px; }
105
- .assistant-voice__talk { touch-action: none; user-select: none; -webkit-touch-callout: none; }
106
- .assistant-voice__talk-action p { grid-column: 1 / -1; margin: 0; text-align: center; white-space: nowrap; }
107
- .assistant-voice__review { flex: 0 0 auto; max-height: 160px; overflow-y: auto; padding: 8px 16px; }
108
- .assistant-voice__review-actions { display: flex; justify-content: flex-end; gap: 8px; }
109
- .assistant-voice__error { padding: 8px 16px; color: rgb(var(--v-theme-error)); }
110
88
  @media (max-height: 650px) {
111
89
  .assistant-voice__call-body { padding-top: 0; }
112
90
  .assistant-voice__captions { gap: 4px; }
113
91
  .assistant-voice__caption { padding-block: 4px; }
114
- .assistant-voice__talk-action { padding-top: 8px; }
115
92
  }
116
93
  @media (prefers-reduced-motion: reduce) { .assistant-voice__call-portrait::before { transition: none; transform: none; } }
117
94
  </style>
@@ -0,0 +1,101 @@
1
+ <script setup>
2
+ import { computed, useId } from "vue";
3
+ import { mdiMicrophone, mdiMicrophoneOff, mdiStop, mdiVolumeHigh, mdiVolumeOff } from "@mdi/js";
4
+ import { useVoiceLauncher } from "./voiceLauncher.js";
5
+
6
+ const props = defineProps({
7
+ session: { type: Object, required: true },
8
+ disabled: { type: Boolean, default: false },
9
+ compact: { type: Boolean, default: false },
10
+ toolsTarget: { type: Object, default: null }
11
+ });
12
+ const {
13
+ targetLabel, voice, error, microphoneMuted, live, starting, callMode,
14
+ callStatus, speechActive, heldTranscript,
15
+ capturing, pendingTranscript, pushHolding, sending,
16
+ stopSpeech, startPushToTalk, finishPushToTalk, cancelPushToTalk, toggleHandsFree, readAloud, toggleReadAloud, enableSound,
17
+ deliverTranscript, discardHeldRecording, editTranscript
18
+ } = props.session;
19
+ const controlStatus = computed(() => {
20
+ if (!props.compact || !props.session.hasUnsentSpeech.value) return callStatus.value;
21
+ if (pendingTranscript.value) return "Send or discard speech to send text.";
22
+ return voice.listening.value ? "Recording · finish speech to send text." : "Finish speech to send text.";
23
+ });
24
+ const handsFreeListening = computed(() => live.value && callMode.value === "hands-free" && !microphoneMuted.value);
25
+ const canTalk = computed(() => !props.disabled && !sending.value && !pendingTranscript.value &&
26
+ (!capturing.value || voice.listening.value || pushHolding.value || starting.value));
27
+ const talkLabel = computed(() => starting.value ? "Connecting…" : pushHolding.value ? "Release to send" : handsFreeListening.value ? "Pause" : "Talk");
28
+ const gestureHint = useId();
29
+ const gesture = useVoiceLauncher({
30
+ enabled: canTalk,
31
+ open: () => ({ startHeldRecording: startPushToTalk, finishHeldRecording: finishPushToTalk, discardHeldRecording: cancelPushToTalk }),
32
+ onTap: toggleHandsFree,
33
+ onError: cause => { error.value = cause.message; }
34
+ });
35
+ </script>
36
+
37
+ <template>
38
+ <fieldset class="assistant-voice-controls" :class="{ 'assistant-voice-controls--compact': compact }" :disabled="disabled">
39
+ <p v-if="error || voice.error.value" class="assistant-voice__error" role="alert">{{ error || voice.error.value }}</p>
40
+ <p v-if="controlStatus" class="assistant-voice-controls__status text-body-small" role="status" :title="callStatus">{{ controlStatus }}</p>
41
+ <p v-if="compact && heldTranscript && !pendingTranscript" class="assistant-voice-controls__provisional" role="status" aria-label="Recognized words">Not sent · {{ heldTranscript }}</p>
42
+ <p v-if="readAloud && voice.playbackBlocked?.value" class="assistant-voice-controls__status" role="status">Sound is blocked. Enable sound to hear future replies.</p>
43
+ <v-btn v-if="readAloud && voice.playbackBlocked?.value" min-height="48" @click="enableSound">Enable sound</v-btn>
44
+ <section v-if="pendingTranscript && (pendingTranscript.reviewBeforeSend || !sending)" class="assistant-voice__review" aria-label="Review voice message">
45
+ <v-textarea
46
+ :model-value="pendingTranscript.text" label="Review your message" :disabled="sending"
47
+ :density="compact ? 'compact' : 'default'" :rows="compact ? 1 : 2" :max-rows="compact ? 2 : 3"
48
+ auto-grow hide-details @update:model-value="editTranscript"
49
+ />
50
+ <div class="assistant-voice__review-actions">
51
+ <v-btn :disabled="sending" min-height="48" @click="discardHeldRecording">Discard</v-btn>
52
+ <v-btn :disabled="sending || !pendingTranscript.text.trim()" min-height="48" color="primary" @click="deliverTranscript">{{ sending ? 'Sending…' : 'Send' }}</v-btn>
53
+ </div>
54
+ </section>
55
+ <Teleport :to="toolsTarget" :disabled="!toolsTarget">
56
+ <fieldset class="assistant-voice-controls__tools" :class="{ 'assistant-voice-controls__tools--compact': compact }" :disabled="disabled" :aria-label="`${targetLabel} voice chat controls`">
57
+ <v-btn v-if="speechActive" :icon="mdiStop" variant="text" min-width="48" min-height="48" :aria-label="`Stop ${targetLabel} speaking`" title="Stop speaking" @click="stopSpeech" />
58
+ <slot name="work-control" />
59
+ <div class="assistant-voice__talk-action">
60
+ <v-btn
61
+ class="assistant-voice__talk" color="primary" :variant="handsFreeListening ? 'tonal' : 'flat'" :min-height="compact ? 48 : 64" rounded="xl"
62
+ :prepend-icon="voice.listening.value && !microphoneMuted ? mdiMicrophone : mdiMicrophoneOff" :aria-pressed="handsFreeListening || pushHolding" :aria-describedby="gestureHint"
63
+ :disabled="!canTalk"
64
+ @pointerdown="gesture.pointerDown" @pointerup="gesture.pointerUp" @pointercancel="gesture.cancel" @lostpointercapture="gesture.cancel"
65
+ @keydown.space.prevent="gesture.keyDown" @keyup.space.prevent="gesture.keyUp"
66
+ @keydown.enter.prevent="gesture.keyDown" @keyup.enter.prevent="gesture.keyUp" @blur="gesture.cancel" @contextmenu.prevent @click="gesture.click"
67
+ >
68
+ {{ talkLabel }}
69
+ </v-btn>
70
+ <v-btn
71
+ :icon="readAloud ? mdiVolumeHigh : mdiVolumeOff" :aria-pressed="readAloud" variant="tonal" :min-height="compact ? 48 : 64" :min-width="compact ? 48 : 64" rounded="xl"
72
+ :aria-label="readAloud ? `Turn ${targetLabel} read-aloud off` : `Read ${targetLabel} answers aloud`"
73
+ :title="readAloud ? 'Spoken replies on' : 'Spoken replies off'" @click="toggleReadAloud"
74
+ />
75
+ <p :id="gestureHint" class="text-body-small text-medium-emphasis">{{ handsFreeListening ? 'Tap to pause · hold to speak' : 'Tap for hands-free · hold to speak' }}</p>
76
+ </div>
77
+ </fieldset>
78
+ </Teleport>
79
+ </fieldset>
80
+ </template>
81
+
82
+ <style scoped>
83
+ .assistant-voice-controls, .assistant-voice-controls__tools { border: 0; padding: 0; margin: 0; min-width: 0; }
84
+ .assistant-voice-controls { flex: 0 0 auto; }
85
+ .assistant-voice-controls--compact { flex: 1 1 100%; width: 100%; }
86
+ .assistant-voice-controls__tools { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; }
87
+ .assistant-voice-controls__status { text-align: center; margin: 0; }
88
+ .assistant-voice-controls__provisional { margin: 0; max-height: 3rem; overflow-y: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
89
+ .assistant-voice__talk-action { width: min(288px, 100%); display: grid; grid-template-columns: minmax(0, 1fr) 64px; gap: 6px 8px; padding: 16px; }
90
+ .assistant-voice__talk { touch-action: none; user-select: none; -webkit-touch-callout: none; }
91
+ .assistant-voice__talk-action p { grid-column: 1 / -1; margin: 0; text-align: center; white-space: nowrap; }
92
+ .assistant-voice__review { flex: 0 0 auto; max-height: 160px; overflow-y: auto; padding: 8px 16px; }
93
+ .assistant-voice__review-actions { display: flex; justify-content: flex-end; gap: 8px; }
94
+ .assistant-voice__error { padding: 8px 16px; color: rgb(var(--v-theme-error)); }
95
+ .assistant-voice-controls__tools--compact { justify-content: flex-start; gap: 4px; }
96
+ .assistant-voice-controls__tools--compact .assistant-voice__talk-action { display: flex; padding: 0; width: auto; gap: 4px; }
97
+ .assistant-voice-controls__tools--compact .assistant-voice__talk-action p { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
98
+ .assistant-voice-controls--compact .assistant-voice__review { max-height: 128px; padding: 0; }
99
+ .assistant-voice-controls--compact .assistant-voice__error { margin: 0; padding: 0; }
100
+ @media (max-height: 650px) { .assistant-voice__talk-action { padding-top: 8px; } }
101
+ </style>
@@ -3,7 +3,8 @@ import { mdiCog, mdiHeadset, mdiStop } from "@mdi/js";
3
3
  import ConversationDialog from "./ConversationDialog.vue";
4
4
  import VoiceConversation from "./VoiceConversation.vue";
5
5
 
6
- const props = defineProps({ controller: { type: Object, required: true }, activator: { type: Object, default: null } });
6
+ const props = defineProps({ controller: { type: Object, required: true }, activator: { type: Object, default: null }, avatarSize: { type: String, default: "compact" } });
7
+ defineEmits(["update:avatarSize"]);
7
8
  const state = props.controller.state;
8
9
  async function invoke(operation) {
9
10
  try { await operation(); }
@@ -19,7 +20,9 @@ async function openText() {
19
20
 
20
21
  <template>
21
22
  <ConversationDialog
22
- v-if="state.session" :model-value="state.visible" :activator="activator" :title="state.binding.label || 'Assistant'" mode="talk" :show-modes="Boolean(state.binding.openText)"
23
+ v-if="state.session" :model-value="state.visible" :activator="activator"
24
+ :title="state.binding.label || 'Assistant'" mode="talk"
25
+ :show-modes="Boolean(state.binding.openText && !state.binding.adapter)"
23
26
  minimizable close-label="Close voice chat" @minimize="controller.minimize()" @update:model-value="invoke(() => controller.end({ discard: true }))"
24
27
  @update:mode="value => value === 'text' && invoke(openText)"
25
28
  >
@@ -53,12 +56,18 @@ async function openText() {
53
56
  </div>
54
57
  </v-alert>
55
58
  <div class="voice-host__body">
56
- <VoiceConversation :key="state.binding.id" :session="state.session" :disabled="state.busy">
57
- <template v-if="$slots.avatar" #avatar="visual"><slot name="avatar" v-bind="visual" :binding="state.binding" /></template>
58
- <template #work-control>
59
- <v-btn v-if="state.binding.cancelWork && state.binding.state.status === 'working'" :disabled="state.busy" :icon="mdiStop" variant="text" aria-label="Stop agent work" title="Stop agent work" @click="invoke(state.binding.cancelWork)" />
60
- </template>
61
- </VoiceConversation>
59
+ <slot name="conversation" :binding="state.binding" :session="state.session" :disabled="state.busy">
60
+ <VoiceConversation
61
+ :key="state.binding.id" :session="state.session" :disabled="state.busy"
62
+ :adapter="state.binding.adapter || null" :show-avatar="state.binding.showAvatar !== false"
63
+ :avatar-size="avatarSize" @update:avatar-size="$emit('update:avatarSize', $event)"
64
+ >
65
+ <template v-if="$slots.avatar" #avatar="visual"><slot name="avatar" v-bind="visual" :binding="state.binding" /></template>
66
+ <template #work-control>
67
+ <v-btn v-if="state.binding.cancelWork && state.binding.state.status === 'working'" :disabled="state.busy" :icon="mdiStop" variant="text" aria-label="Stop agent work" title="Stop agent work" @click="invoke(state.binding.cancelWork)" />
68
+ </template>
69
+ </VoiceConversation>
70
+ </slot>
62
71
  </div>
63
72
  </ConversationDialog>
64
73
  <template v-if="state.session && !state.visible">
@@ -3,6 +3,7 @@ export { createVoiceConversationController } from "./voiceController.js";
3
3
  export { default as VoiceConversationHost } from "./VoiceConversationHost.vue";
4
4
  export { default as ConversationDialog } from "./ConversationDialog.vue";
5
5
  export { default as VoiceConversation } from "./VoiceConversation.vue";
6
+ export { default as VoiceConversationControls } from "./VoiceConversationControls.vue";
6
7
 
7
8
  export { useVoiceLauncher } from "./voiceLauncher.js";
8
9
  export { useVoiceAvatar } from "./voiceAvatar.js";
@@ -6,7 +6,10 @@ import { takeStreamingSpeech } from "../shared/protocol.js";
6
6
  export function useVoiceConversation(binding, { socketUrl, createTransport = useVoiceTransport } = {}) {
7
7
  const oneOffTalkMode = computed(() => binding.defaults?.talkMode === "hold" ? "hold" : "tap");
8
8
  const targetLabel = computed(() => binding.label || "Assistant");
9
- const readAloud = ref(false);
9
+ const readAloud = ref(binding.defaults?.readAloud === true);
10
+ const soundPreparing = ref(false);
11
+ let soundRevision = 0;
12
+ const conversationId = binding.conversationId || binding.id;
10
13
  const error = ref("");
11
14
  const pendingTranscript = ref(null);
12
15
  const heldReview = ref(false);
@@ -27,7 +30,8 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
27
30
  voiceId: binding.defaults?.voiceId || "",
28
31
  speechEnabled: readAloud,
29
32
  interruptSpeechOnListen: false,
30
- autoReconnect: true
33
+ autoReconnect: true,
34
+ onPlayback: receivePlayback
31
35
  });
32
36
  watch([() => binding.defaults?.voiceId, voice.availableVoices], ([voiceId, voices]) => {
33
37
  if (voiceId === undefined) return;
@@ -106,12 +110,42 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
106
110
  let speechInvitation = "";
107
111
  let lastStreamingId = "";
108
112
 
109
- function stopSpeech() {
110
- for (const reply of speechQueue) reply.cancelled = true;
113
+ function observe(callback, value) {
114
+ try {
115
+ Promise.resolve(callback?.(value)).catch(() => {});
116
+ } catch {
117
+ // Observers cannot take ownership of scheduling or cleanup.
118
+ }
119
+ }
120
+ function emitReply(reply, phase, reason) {
121
+ if (reply.terminal || (phase === "started" && reply.audible)) return;
122
+ if (phase === "started") reply.audible = true;
123
+ else reply.terminal = true;
124
+ observe(binding.onPlayback, {
125
+ conversationId, outputId: reply.outputId, phase, ...(reason ? { reason } : {})
126
+ });
127
+ }
128
+ function receivePlayback(event) {
129
+ const reply = [...replies.values()].find(reply => reply.turnId === event.turnId);
130
+ if (!reply) return;
131
+ if (event.phase === "completed") {
132
+ reply.drained = true;
133
+ if (reply.canonicalFinal) emitReply(reply, "completed");
134
+ } else emitReply(reply, event.phase, event.reason);
135
+ }
136
+ function cancelReply(reply, reason = "cancelled", phase = "interrupted") {
137
+ reply.cancelled = true;
138
+ emitReply(reply, phase, reason);
139
+ }
140
+ function stopSpeech() { cancelSpeech(); }
141
+ function cancelSpeech(reason = "cancelled", phase = "interrupted") {
142
+ for (const reply of replies.values()) {
143
+ if (!reply.terminal) cancelReply(reply, reason, phase);
144
+ }
111
145
  speechQueue.length = 0;
112
146
  speechSuppressed = true;
113
147
  speechInvitation = "";
114
- voice.stopSpeaking();
148
+ voice.stopSpeaking(reason);
115
149
  }
116
150
  function inviteSpeech(messageId) {
117
151
  // Only a new request from this page can lift silence. A delayed admission,
@@ -121,7 +155,7 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
121
155
  speechInvitation = messageId;
122
156
  }
123
157
  async function drainSpeech() {
124
- if (draining || disposed || !readAloud.value || speechSuppressed) return;
158
+ if (draining || disposed || !readAloud.value || speechSuppressed || soundPreparing.value || voice.playbackBlocked?.value) return;
125
159
  draining = true;
126
160
  try {
127
161
  while (speechQueue.length) {
@@ -133,15 +167,24 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
133
167
  const chunk = takeStreamingSpeech(reply.raw.slice(reply.consumed), reply.final, reply.started ? 140 : 64);
134
168
  if (!chunk) {
135
169
  if (reply.final && reply.started) voice.endSpeech();
136
- else if (reply.final) { speechQueue.shift(); continue; }
170
+ else if (reply.final) {
171
+ cancelReply(reply, "no-audio");
172
+ speechQueue.shift();
173
+ continue;
174
+ }
137
175
  return;
138
176
  }
139
177
  reply.consumed += chunk.consumed;
140
178
  if (!chunk.text) continue;
141
179
  if (!reply.started) {
142
180
  reply.started = true;
143
- await voice.speak(chunk.text, crypto.randomUUID(), { stream: true });
181
+ reply.turnId = crypto.randomUUID();
182
+ const accepted = await voice.speak(chunk.text, reply.turnId, { stream: true });
144
183
  if (reply.cancelled || disposed) return;
184
+ if (accepted === false) {
185
+ cancelReply(reply, "no-audio");
186
+ continue;
187
+ }
145
188
  } else if (!voice.appendSpeech(chunk.text)) return;
146
189
  if (reply.final && reply.consumed >= reply.raw.length) { voice.endSpeech(); return; }
147
190
  }
@@ -152,7 +195,10 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
152
195
  let reply = replies.get(message.id);
153
196
  if (!reply) {
154
197
  if (!primed || !readAloud.value || speechSuppressed || seen.has(message.id) || (!final && message.autonomous)) return;
155
- reply = { id: message.id, streamId: message.streamId, raw: "", consumed: 0, final: false, started: false };
198
+ reply = {
199
+ id: message.id, outputId: message.outputId || message.id, streamId: message.streamId,
200
+ raw: "", consumed: 0, final: false, canonicalFinal: false, started: false
201
+ };
156
202
  replies.set(message.id, reply);
157
203
  speechQueue.push(reply);
158
204
  }
@@ -162,25 +208,43 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
162
208
  if (!text.startsWith(spokenPrefix) && !(final && text.trimEnd() === spokenPrefix.trimEnd())) {
163
209
  // A model revision cannot unsay words. Retire that projection; never splice
164
210
  // its old prefix onto a different answer or replay it as a second answer.
165
- reply.cancelled = true;
211
+ cancelReply(reply);
166
212
  if (speechQueue[0] === reply) voice.stopSpeaking();
167
213
  return;
168
214
  }
169
215
  reply.consumed = Math.min(reply.consumed, text.length);
170
216
  reply.raw = text;
217
+ reply.canonicalFinal ||= final;
171
218
  reply.final = final || text.length >= 4000;
219
+ if (reply.canonicalFinal && reply.drained) emitReply(reply, "completed");
172
220
  }
173
- async function enableSpeech() {
174
- speechSuppressed = false;
175
- speechInvitation = "";
176
- await voice.preparePlayback();
177
- readAloud.value = true;
221
+ async function enableSound() {
222
+ const revision = ++soundRevision;
223
+ soundPreparing.value = true;
224
+ try {
225
+ await voice.preparePlayback();
226
+ } catch {
227
+ // Requested sound stays on; its recovery control remains available.
228
+ } finally {
229
+ if (revision === soundRevision && !disposed) {
230
+ soundPreparing.value = false;
231
+ void drainSpeech();
232
+ }
233
+ }
178
234
  }
179
235
  async function toggleReadAloud() {
180
236
  error.value = "";
181
- if (readAloud.value) { readAloud.value = false; stopSpeech(); return; }
182
- try { await enableSpeech(); }
183
- catch (cause) { error.value = cause.message; }
237
+ readAloud.value = !readAloud.value;
238
+ observe(binding.onReadAloudChange, readAloud.value);
239
+ if (!readAloud.value) {
240
+ ++soundRevision;
241
+ soundPreparing.value = false;
242
+ cancelSpeech("muted");
243
+ return;
244
+ }
245
+ speechSuppressed = false;
246
+ speechInvitation = "";
247
+ await enableSound();
184
248
  }
185
249
  async function deliverTranscript() {
186
250
  const pending = pendingTranscript.value;
@@ -232,10 +296,6 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
232
296
  // Keep the starting target even if navigation changes during this utterance.
233
297
  const next = { focus: { ...(binding.captureContext?.() || {}) }, messageId: crypto.randomUUID(), started: false, reviewBeforeSend, continuous };
234
298
  recording = next;
235
- if (!reviewBeforeSend && !live.value) {
236
- readAloud.value = true;
237
- void voice.preparePlayback().catch((cause) => { error.value = cause.message; });
238
- }
239
299
  try {
240
300
  const started = await voice.startListening({ continuous });
241
301
  if (recording === next) { next.started = started; if (!started) recording = null; }
@@ -264,7 +324,6 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
264
324
  pushHolding.value = true;
265
325
  callMode.value = "push-to-talk";
266
326
  voice.setMicrophoneMuted(false);
267
- retireAnswer(true);
268
327
  if (!live.value) await toggleLive();
269
328
  if (!pushHolding.value || !live.value || disposed) return;
270
329
  // A hold can take over an open hands-free microphone without throwing away
@@ -318,9 +377,6 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
318
377
  committing = null;
319
378
  clearTimeout(stopTimer);
320
379
  await cancelRecording();
321
- stopSpeech();
322
- readAloud.value = false;
323
- void voice.close().catch(cause => { if (!live.value) error.value = cause.message; });
324
380
  return;
325
381
  }
326
382
  if (disposed || microphoneMuted.value || capturing.value || pendingTranscript.value || sending.value) return;
@@ -329,16 +385,14 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
329
385
  starting.value = true;
330
386
  const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(10000)]);
331
387
  try {
332
- // Unlock playback in the click gesture. Live capture uses the same model
333
- // admission as typed chat and does not need a separate Helper connection.
334
- await voice.preparePlayback();
388
+ // Unlock requested sound in the gesture without delaying microphone startup.
389
+ if (readAloud.value) void enableSound();
335
390
  signal.throwIfAborted();
336
391
  await voice.connect();
337
392
  signal.throwIfAborted();
338
393
  if (disposed) return;
339
394
  starting.value = false;
340
395
  live.value = true;
341
- readAloud.value = true;
342
396
  if (callMode.value === "hands-free") {
343
397
  await startRecording(false, true);
344
398
  if (startupAbort === controller && !recording?.started) live.value = false;
@@ -352,12 +406,6 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
352
406
  if (startupAbort === controller) { startupAbort = null; starting.value = false; }
353
407
  }
354
408
  }
355
- function retireAnswer(smooth = false) {
356
- for (const reply of speechQueue) reply.cancelled = true;
357
- speechQueue.length = 0;
358
- if (smooth) voice.finishCurrentPhrase();
359
- else voice.stopSpeaking();
360
- }
361
409
  function currentSpokenText() {
362
410
  const reply = speechQueue[0];
363
411
  return reply ? reply.raw.slice(0, reply.consumed).slice(-2000) : "";
@@ -394,8 +442,7 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
394
442
  committing = { ...captured, turnId: candidate.turnId, revision: candidate.revision };
395
443
  if (!voice.finishUtterance(candidate)) { committing = null; return; }
396
444
  // The daemon rechecks the endpoint revision before returning final text.
397
- // Continue the current phrase while the ordinary request steers the conversation.
398
- retireAnswer(true);
445
+ // Ordinary steering leaves the current answer and queued audio intact.
399
446
  } catch (cause) {
400
447
  if (live.value && candidate === voice.endpoint.value) {
401
448
  live.value = false;
@@ -491,8 +538,8 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
491
538
  }
492
539
  if (lastStreamingId && lastStreamingId !== reply?.id && !messages.some(message => message.id === lastStreamingId)) {
493
540
  const retired = replies.get(lastStreamingId);
494
- if (retired && !retired.final && !retired.cancelled) {
495
- retired.cancelled = true;
541
+ if (retired && !retired.canonicalFinal && !retired.cancelled) {
542
+ cancelReply(retired);
496
543
  if (speechQueue[0] === retired) voice.finishCurrentPhrase();
497
544
  }
498
545
  }
@@ -502,13 +549,14 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
502
549
  if (!readAloud.value || !primed || reply.autonomous) seen.add(reply.id);
503
550
  }
504
551
  // Keep only the visible history plus active playback receipts in memory.
505
- const retained = new Set([...messages.map((message) => message.id), reply?.id, ...speechQueue.map((item) => item.id)]);
552
+ const retained = new Set([...messages.map((message) => message.id), reply?.id, ...speechQueue.map((item) => item.id),
553
+ ...[...replies.values()].filter(item => !item.terminal).map(item => item.id)]);
506
554
  for (const id of seen) if (!retained.has(id)) seen.delete(id);
507
555
  for (const id of replies.keys()) if (!retained.has(id)) replies.delete(id);
508
556
  primed = true;
509
557
  void drainSpeech();
510
558
  }, { immediate: true });
511
- watch(voice.error, (message) => { if (message) stopSpeech(); });
559
+ watch(voice.error, (message) => { if (message) cancelSpeech("playback-error", "failed"); });
512
560
  watch(() => error.value || voice.error.value, (message) => { if (message) binding.onError?.(message); });
513
561
  watch([voice.activeSpeechTurnId, voice.canAppendSpeech], () => { void drainSpeech(); });
514
562
  watch([pendingTranscript, voice.captureState, voice.partialTranscript], () => {
@@ -527,6 +575,7 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
527
575
  page.removeEventListener?.("pagehide", cancelStartup);
528
576
  page.removeEventListener?.("blur", cancelPushToTalk);
529
577
  startupAbort?.abort(); startupAbort = null;
578
+ stopSpeech();
530
579
  binding.onTranscript?.(null); binding.onVisual?.(null); disposed = true; live.value = false; clearTimeout(stopTimer); recording = null; speechQueue.length = 0;
531
580
  });
532
581
 
@@ -549,7 +598,7 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
549
598
  live, starting, callMode, changingCallMode, pushHolding, microphoneMuted, capturing,
550
599
  speechActive, talkLabel, liveLabel, callConnection, callStatus, callDetail, callModeBusy,
551
600
  microphoneStatus, avatarVisual, callAudioLevel, status, heldTranscript, voiceWords, voiceAnswer,
552
- stopSpeech, inviteSpeech, toggleReadAloud, deliverTranscript, cancelRecording,
601
+ stopSpeech, inviteSpeech, toggleReadAloud, enableSound, soundPreparing, deliverTranscript, cancelRecording,
553
602
  discardRecording, dismissTranscript, talk, toggleMicrophoneMuted, changeCallMode,
554
603
  startPushToTalk, finishPushToTalk, cancelPushToTalk, toggleLive, toggleHandsFree, startHeldRecording,
555
604
  finishHeldRecording, discardHeldRecording, close, editTranscript, hasUnsentSpeech
@@ -29,13 +29,14 @@ function resolveWebSocketUrl(value) {
29
29
  return url.toString();
30
30
  }
31
31
 
32
- function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnListen = false, autoReconnect = false, voiceId = "" } = {}) {
32
+ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnListen = false, autoReconnect = false, voiceId = "", onPlayback } = {}) {
33
33
  const availableVoices = ref([]);
34
34
  const selectedVoice = ref(voiceId);
35
35
  const connectionState = ref("idle");
36
36
  const captureState = ref("idle");
37
37
  const speechState = ref("idle");
38
38
  const error = ref("");
39
+ const playbackBlocked = ref(false);
39
40
  const transcript = ref("");
40
41
  const endpoint = ref(null);
41
42
  const completedUtterance = ref(null);
@@ -71,8 +72,10 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
71
72
  let playbackGain = null;
72
73
  let playbackAnalyser = null;
73
74
  let playbackFrame = 0;
75
+ let playbackPreparation = 0;
74
76
  let nextPlaybackTime = 0;
75
77
  let playbackEpoch = 0;
78
+ let playbackReceipt = null;
76
79
  let playbackQueue = Promise.resolve();
77
80
  let queuedPlaybackFrames = 0;
78
81
  let speechStreamEnded = false;
@@ -102,12 +105,31 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
102
105
  const canAppendSpeech = computed(() => Boolean(activeSpeechTurnId.value) && !speechInputEnded.value &&
103
106
  synthesisPending.value < 2 && bufferedSeconds.value < 4);
104
107
 
105
- function setError(message = "") {
108
+ function emitPlayback(phase, reason) {
109
+ const receipt = playbackReceipt;
110
+ if (!receipt || receipt.terminal || (phase === "started" && receipt.started)) return;
111
+ if (phase === "started") receipt.started = true;
112
+ else receipt.terminal = true;
113
+ try {
114
+ Promise.resolve(onPlayback?.({
115
+ turnId: receipt.turnId, phase, ...(reason ? { reason } : {})
116
+ })).catch(() => {});
117
+ } catch {
118
+ // Observers cannot take ownership of playback or cleanup.
119
+ }
120
+ }
121
+
122
+ function observePlaybackStart(source) {
123
+ if (!source.voiceCancelled && playbackContext.state === "running" &&
124
+ playbackContext.currentTime >= source.voiceStartsAt) emitPlayback("started");
125
+ }
126
+
127
+ function setError(message = "", reason = "playback-error") {
106
128
  pendingListenTurnId = "";
107
129
  endpoint.value = null;
108
130
  activeListenTurnId.value = "";
109
131
  void closeMicrophone().catch(() => null);
110
- stopPlayback();
132
+ stopPlayback("failed", reason);
111
133
  error.value = String(message || "Voice could not continue.");
112
134
  captureState.value = "idle";
113
135
  connectionState.value = "idle";
@@ -129,7 +151,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
129
151
  ? "Voice connection was interrupted. Your unfinished recording needs review; use Talk to resume."
130
152
  : "Voice connection was interrupted. The answer remains in chat; use Talk to resume.";
131
153
  const wasActive = Boolean(activeListenTurnId.value || pendingListenTurnId || activeSpeechTurnId.value || reconnecting.value);
132
- setError(message);
154
+ setError(message, "disconnected");
133
155
  disconnect({ preserveError: true });
134
156
  recoveryWanted = autoReconnect && wasActive;
135
157
  scheduleRecovery();
@@ -190,6 +212,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
190
212
  return total / Math.max(1, end - start);
191
213
  };
192
214
  const update = () => {
215
+ for (const source of playbackSources) observePlaybackStart(source);
193
216
  bufferedSeconds.value = Math.max(0, nextPlaybackTime - playbackContext.currentTime);
194
217
  playbackAnalyser.getFloatTimeDomainData(samples);
195
218
  playbackAnalyser.getByteFrequencyData(spectrum);
@@ -235,6 +258,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
235
258
  async function preparePlayback() {
236
259
  const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
237
260
  if (!AudioContextCtor) {
261
+ playbackBlocked.value = true;
238
262
  throw new Error("This browser does not support streamed audio playback.");
239
263
  }
240
264
  if (!playbackContext || playbackContext.state === "closed") {
@@ -247,13 +271,29 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
247
271
  playbackAnalyser.connect(playbackContext.destination);
248
272
  }
249
273
  playbackGain.gain.value = speechOutputEnabled.value ? 1 : 0;
250
- await playbackContext.resume();
274
+ const context = playbackContext;
275
+ const preparation = ++playbackPreparation;
276
+ try {
277
+ const resumed = context.resume();
278
+ if (preparation === playbackPreparation && context.state !== "running") playbackBlocked.value = true;
279
+ await resumed;
280
+ if (context.state !== "running") throw new Error("Sound is blocked by this browser.");
281
+ if (preparation === playbackPreparation && !unmounted) playbackBlocked.value = false;
282
+ } catch (cause) {
283
+ // A newer unlock of this same context supersedes an older resume failure.
284
+ if (preparation !== playbackPreparation && context === playbackContext &&
285
+ context.state === "running" && !unmounted) return;
286
+ if (preparation === playbackPreparation && !unmounted) playbackBlocked.value = true;
287
+ throw cause;
288
+ }
251
289
  }
252
290
 
253
291
  function finishPlaybackWhenDrained() {
254
292
  if (!activeSpeechTurnId.value || !speechStreamEnded || playbackSources.size || queuedPlaybackFrames) {
255
293
  return;
256
294
  }
295
+ if (playbackReceipt?.started) emitPlayback("completed");
296
+ else emitPlayback("failed", "playback-error");
257
297
  activeSpeechTurnId.value = "";
258
298
  speechState.value = "idle";
259
299
  if (speechOwner.value === speechClaim) speechOwner.value = null;
@@ -312,6 +352,8 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
312
352
  source.onended = () => {
313
353
  source.disconnect();
314
354
  if (epoch !== playbackEpoch) return;
355
+ // A natural end also observes very short sounds between animation frames.
356
+ if (!source.voiceCancelled && playbackContext.state === "running") emitPlayback("started");
315
357
  playbackSources.delete(source);
316
358
  bufferedSeconds.value = playbackSources.size ? Math.max(0, nextPlaybackTime - playbackContext.currentTime) : 0;
317
359
  if (!playbackSources.size) speechState.value = "thinking";
@@ -337,7 +379,8 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
337
379
  });
338
380
  }
339
381
 
340
- function stopPlayback() {
382
+ function stopPlayback(phase = "interrupted", reason = "cancelled") {
383
+ emitPlayback(phase, reason);
341
384
  playbackEpoch += 1;
342
385
  activeSpeechTurnId.value = "";
343
386
  speechState.value = "idle";
@@ -347,6 +390,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
347
390
  queuedPlaybackFrames = 0;
348
391
  for (const source of playbackSources) {
349
392
  try {
393
+ source.voiceCancelled = true;
350
394
  source.stop();
351
395
  } catch {
352
396
  // A source that already ended needs no further cleanup.
@@ -681,6 +725,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
681
725
  }
682
726
  stopPlayback();
683
727
  const epoch = playbackEpoch;
728
+ playbackReceipt = { turnId, started: false, terminal: false };
684
729
  activeSpeechTurnId.value = turnId;
685
730
  speechState.value = "thinking";
686
731
  try {
@@ -708,7 +753,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
708
753
  return true;
709
754
  } catch (speechError) {
710
755
  if (epoch !== playbackEpoch) return false;
711
- stopPlayback();
756
+ stopPlayback("failed", "playback-error");
712
757
  speechState.value = "idle";
713
758
  throw speechError;
714
759
  }
@@ -732,6 +777,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
732
777
 
733
778
  function finishCurrentPhrase() {
734
779
  if (!activeSpeechTurnId.value) return;
780
+ emitPlayback("interrupted", "cancelled");
735
781
  const now = playbackContext?.currentTime || 0;
736
782
  const current = [...playbackSources].find(source => source.voiceEndsAt > now);
737
783
  if (!current?.voiceSegment) { stopSpeaking(); return; }
@@ -739,6 +785,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
739
785
  speechInputEnded.value = true;
740
786
  for (const source of [...playbackSources]) {
741
787
  if (source.voiceSegment?.index <= stopAfterSegment) continue;
788
+ source.voiceCancelled = true;
742
789
  source.stop();
743
790
  playbackSources.delete(source);
744
791
  }
@@ -747,10 +794,10 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
747
794
  sendControl({ type: "speak.stop-after", turnId: activeSpeechTurnId.value, segmentIndex: stopAfterSegment });
748
795
  }
749
796
 
750
- function stopSpeaking() {
797
+ function stopSpeaking(reason = "cancelled") {
751
798
  const turnId = activeSpeechTurnId.value;
752
799
  const pending = turnId && !ready.value && !activeListenTurnId.value && !pendingListenTurnId;
753
- stopPlayback();
800
+ stopPlayback("interrupted", reason);
754
801
  if (turnId && socket?.readyState === WEBSOCKET_OPEN) sendControl({ turnId, type: "cancel" });
755
802
  if (pending) disconnect();
756
803
  }
@@ -807,7 +854,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
807
854
  playbackGain.gain.value = enabled ? 1 : 0;
808
855
  }
809
856
  if (!enabled && activeSpeechTurnId.value) {
810
- stopSpeaking();
857
+ stopSpeaking("muted");
811
858
  }
812
859
  }, { flush: "sync" });
813
860
  watch(selectedSocketPath, () => {
@@ -869,6 +916,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
869
916
  microphoneMuted,
870
917
  muted,
871
918
  partialTranscript,
919
+ playbackBlocked,
872
920
  preparePlayback,
873
921
  ready,
874
922
  reconnecting,