@jskit-ai/assistant-voice 0.1.5 → 0.1.6
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.
|
@@ -9,14 +9,14 @@
|
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@fastify/websocket": "^11.2.0",
|
|
12
|
-
"@jskit-ai/assistant-core": "0.1.
|
|
13
|
-
"@jskit-ai/assistant-runtime": "0.1.
|
|
14
|
-
"@jskit-ai/assistant-voice": "0.1.
|
|
15
|
-
"@jskit-ai/connectors-catalog": "0.1.
|
|
16
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
17
|
-
"@jskit-ai/kernel": "0.1.
|
|
18
|
-
"@jskit-ai/realtime": "0.1.
|
|
19
|
-
"@jskit-ai/shell-web": "0.1.
|
|
12
|
+
"@jskit-ai/assistant-core": "0.1.206",
|
|
13
|
+
"@jskit-ai/assistant-runtime": "0.1.199",
|
|
14
|
+
"@jskit-ai/assistant-voice": "0.1.6",
|
|
15
|
+
"@jskit-ai/connectors-catalog": "0.1.45",
|
|
16
|
+
"@jskit-ai/http-runtime": "0.1.226",
|
|
17
|
+
"@jskit-ai/kernel": "0.1.228",
|
|
18
|
+
"@jskit-ai/realtime": "0.1.225",
|
|
19
|
+
"@jskit-ai/shell-web": "0.1.232",
|
|
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.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Duplex voice conversations with application-owned targets, authorization and speech services.",
|
|
6
6
|
"scripts": {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"examples"
|
|
19
19
|
],
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@jskit-ai/assistant-core": "0.1.
|
|
21
|
+
"@jskit-ai/assistant-core": "0.1.206",
|
|
22
22
|
"@mdi/js": "^7.4.47",
|
|
23
23
|
"ws": "^8.20.1"
|
|
24
24
|
},
|
|
@@ -6,6 +6,7 @@ import { useVoiceLauncher } from "./voiceLauncher.js";
|
|
|
6
6
|
const props = defineProps({
|
|
7
7
|
session: { type: Object, required: true },
|
|
8
8
|
disabled: { type: Boolean, default: false },
|
|
9
|
+
speakerDisabled: { type: Boolean, default: false },
|
|
9
10
|
compact: { type: Boolean, default: false },
|
|
10
11
|
iconOnly: { type: Boolean, default: false },
|
|
11
12
|
reviewInTranscript: { type: Boolean, default: false },
|
|
@@ -90,7 +91,8 @@ const gesture = useVoiceLauncher({
|
|
|
90
91
|
<v-btn
|
|
91
92
|
:icon="readAloud ? mdiVolumeHigh : mdiVolumeOff" :aria-pressed="readAloud" :variant="iconOnly ? 'text' : 'tonal'" :color="iconOnly ? 'primary' : undefined" :min-height="iconOnly ? 44 : compact ? 48 : 64" :width="iconOnly ? 40 : undefined" :min-width="iconOnly ? 40 : compact ? 48 : 64" rounded="xl"
|
|
92
93
|
:aria-label="readAloud ? `Turn ${targetLabel} read-aloud off` : `Read ${targetLabel} answers aloud`"
|
|
93
|
-
:title="readAloud ? 'Spoken replies on' : 'Spoken replies off'"
|
|
94
|
+
:title="readAloud ? 'Spoken replies on' : 'Spoken replies off'"
|
|
95
|
+
:disabled="speakerDisabled || session.readAloudChangePending?.value === true" @click="toggleReadAloud"
|
|
94
96
|
>
|
|
95
97
|
<template v-if="iconOnly" #default>
|
|
96
98
|
<span class="assistant-voice-controls__icon-disc"><v-icon size="20" :icon="readAloud ? mdiVolumeHigh : mdiVolumeOff" /></span>
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { computed, onScopeDispose, ref, watch } from "vue";
|
|
2
2
|
import { useVoiceTransport } from "./voiceTransport.js";
|
|
3
3
|
import { takeStreamingSpeech } from "../shared/protocol.js";
|
|
4
|
+
import { createConversationNarrationTracker } from "./conversationNarration.js";
|
|
4
5
|
|
|
5
6
|
/** Created inside the controller's effect scope, independent of a mounted screen. */
|
|
6
7
|
export function useVoiceConversation(binding, { socketUrl, createTransport = useVoiceTransport } = {}) {
|
|
7
8
|
const oneOffTalkMode = computed(() => binding.defaults?.talkMode === "hold" ? "hold" : "tap");
|
|
8
9
|
const targetLabel = computed(() => binding.label || "Assistant");
|
|
9
10
|
const readAloud = ref(binding.defaults?.readAloud === true);
|
|
11
|
+
const readAloudChangePending = computed(() => binding.readAloudChangePending === true);
|
|
10
12
|
const soundPreparing = ref(false);
|
|
11
13
|
let soundRevision = 0;
|
|
12
14
|
const conversationId = binding.conversationId || binding.id;
|
|
@@ -110,6 +112,72 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
110
112
|
let requestedMessageId = "";
|
|
111
113
|
let speechInvitation = "";
|
|
112
114
|
let lastStreamingId = "";
|
|
115
|
+
const narrationTracker = createConversationNarrationTracker();
|
|
116
|
+
const narrationPageVisible = ref(globalThis.document?.visibilityState !== "hidden");
|
|
117
|
+
const hiddenNarrationPage = computed(() => Boolean(binding.narration) && !narrationPageVisible.value);
|
|
118
|
+
let narrationSettleTimer;
|
|
119
|
+
let thinkingMurmurTimer;
|
|
120
|
+
let narrationEpoch = 0;
|
|
121
|
+
|
|
122
|
+
function clearNarrationTimers() {
|
|
123
|
+
++narrationEpoch;
|
|
124
|
+
clearTimeout(narrationSettleTimer);
|
|
125
|
+
clearTimeout(thinkingMurmurTimer);
|
|
126
|
+
}
|
|
127
|
+
function retireNarration(selected = () => true) {
|
|
128
|
+
for (let index = speechQueue.length - 1; index >= 0; --index) {
|
|
129
|
+
const reply = speechQueue[index];
|
|
130
|
+
if (!reply.narrationKind || !selected(reply.narrationKind)) continue;
|
|
131
|
+
cancelReply(reply, "narration-retired");
|
|
132
|
+
speechQueue.splice(index, 1);
|
|
133
|
+
if (reply.turnId && voice.activeSpeechTurnId.value === reply.turnId) voice.stopSpeaking("narration-retired");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function narrationEnabled() {
|
|
137
|
+
const narration = binding.narration;
|
|
138
|
+
return Boolean(narration && narration.eligible && !narration.loading && readAloud.value
|
|
139
|
+
&& binding.state.available !== false && narrationPageVisible.value && !capturing.value
|
|
140
|
+
&& !starting.value && !speechSuppressed && !disposed);
|
|
141
|
+
}
|
|
142
|
+
function queueNarration(kind, text) {
|
|
143
|
+
// Optional activity uses the original queue but never impersonates a canonical output.
|
|
144
|
+
if (!narrationEnabled() || speechQueue.some(reply => !reply.narrationKind)
|
|
145
|
+
|| speechActive.value && !speechQueue.some(reply => reply.narrationKind && reply.turnId === voice.activeSpeechTurnId.value)) return;
|
|
146
|
+
speechQueue.push({ narrationKind: kind, raw: text, consumed: 0, final: true, started: false });
|
|
147
|
+
void drainSpeech();
|
|
148
|
+
}
|
|
149
|
+
function observeNarration(settled = false, enabled = narrationEnabled()) {
|
|
150
|
+
const narration = binding.narration;
|
|
151
|
+
if (!narration) return;
|
|
152
|
+
const entries = narrationTracker.observe(narration.turns, {
|
|
153
|
+
includeFinals: false, settled, enabled,
|
|
154
|
+
vocalizeThinking: narration.vocalizeThinking,
|
|
155
|
+
vocalizeInterimTurns: narration.vocalizeInterimTurns
|
|
156
|
+
});
|
|
157
|
+
for (const entry of entries) queueNarration(entry.kind, entry.text);
|
|
158
|
+
}
|
|
159
|
+
function scheduleThinkingMurmur(epoch) {
|
|
160
|
+
const narration = binding.narration;
|
|
161
|
+
if (!narrationEnabled() || !narration.working || !narration.thinkingSounds) return;
|
|
162
|
+
thinkingMurmurTimer = setTimeout(() => {
|
|
163
|
+
if (epoch !== narrationEpoch || !narrationEnabled() || !binding.narration?.working || !binding.narration.thinkingSounds) return;
|
|
164
|
+
if (!speechQueue.length && !draining && !speechActive.value && voice.state.value === "idle") queueNarration("murmur", "Hmm...");
|
|
165
|
+
scheduleThinkingMurmur(epoch);
|
|
166
|
+
}, 20_000);
|
|
167
|
+
}
|
|
168
|
+
function refreshNarration(settled = false) {
|
|
169
|
+
clearNarrationTimers();
|
|
170
|
+
observeNarration(settled);
|
|
171
|
+
const narration = binding.narration;
|
|
172
|
+
if (!narrationEnabled() || !narration.working) return;
|
|
173
|
+
const epoch = narrationEpoch;
|
|
174
|
+
if (narration.vocalizeThinking || narration.vocalizeInterimTurns) {
|
|
175
|
+
narrationSettleTimer = setTimeout(() => {
|
|
176
|
+
if (epoch === narrationEpoch && narrationEnabled()) observeNarration(true);
|
|
177
|
+
}, 700);
|
|
178
|
+
}
|
|
179
|
+
scheduleThinkingMurmur(epoch);
|
|
180
|
+
}
|
|
113
181
|
|
|
114
182
|
function observe(callback, value) {
|
|
115
183
|
try {
|
|
@@ -122,16 +190,18 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
122
190
|
if (reply.terminal || (phase === "started" && reply.audible)) return;
|
|
123
191
|
if (phase === "started") reply.audible = true;
|
|
124
192
|
else reply.terminal = true;
|
|
193
|
+
if (reply.narrationKind) return;
|
|
125
194
|
observe(binding.onPlayback, {
|
|
126
195
|
conversationId, outputId: reply.outputId, phase, ...(reason ? { reason } : {})
|
|
127
196
|
});
|
|
128
197
|
}
|
|
129
198
|
function receivePlayback(event) {
|
|
130
|
-
const reply = [...replies.values()].find(reply => reply.turnId === event.turnId)
|
|
199
|
+
const reply = [...replies.values()].find(reply => reply.turnId === event.turnId)
|
|
200
|
+
|| speechQueue.find(reply => reply.narrationKind && reply.turnId === event.turnId);
|
|
131
201
|
if (!reply) return;
|
|
132
202
|
if (event.phase === "completed") {
|
|
133
203
|
reply.drained = true;
|
|
134
|
-
if (reply.canonicalFinal) emitReply(reply, "completed");
|
|
204
|
+
if (reply.canonicalFinal || reply.narrationKind) emitReply(reply, "completed");
|
|
135
205
|
} else emitReply(reply, event.phase, event.reason);
|
|
136
206
|
}
|
|
137
207
|
function cancelReply(reply, reason = "cancelled", phase = "interrupted") {
|
|
@@ -140,6 +210,8 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
140
210
|
}
|
|
141
211
|
function stopSpeech() { cancelSpeech(); }
|
|
142
212
|
function cancelSpeech(reason = "cancelled", phase = "interrupted") {
|
|
213
|
+
clearNarrationTimers();
|
|
214
|
+
retireNarration();
|
|
143
215
|
for (const reply of replies.values()) {
|
|
144
216
|
if (!reply.terminal) cancelReply(reply, reason, phase);
|
|
145
217
|
}
|
|
@@ -181,7 +253,11 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
181
253
|
reply.started = true;
|
|
182
254
|
reply.turnId = crypto.randomUUID();
|
|
183
255
|
const accepted = await voice.speak(chunk.text, reply.turnId, { stream: true });
|
|
184
|
-
if (
|
|
256
|
+
if (disposed) return;
|
|
257
|
+
if (reply.cancelled) {
|
|
258
|
+
if (reply.narrationKind) continue;
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
185
261
|
if (accepted === false) {
|
|
186
262
|
cancelReply(reply, "no-audio");
|
|
187
263
|
continue;
|
|
@@ -196,6 +272,7 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
196
272
|
let reply = replies.get(message.id);
|
|
197
273
|
if (!reply) {
|
|
198
274
|
if (!primed || !readAloud.value || speechSuppressed || seen.has(message.id) || (!final && message.autonomous)) return;
|
|
275
|
+
retireNarration();
|
|
199
276
|
reply = {
|
|
200
277
|
id: message.id, outputId: message.outputId || message.id, streamId: message.streamId,
|
|
201
278
|
raw: "", consumed: 0, final: false, canonicalFinal: false, started: false
|
|
@@ -204,6 +281,14 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
204
281
|
speechQueue.push(reply);
|
|
205
282
|
}
|
|
206
283
|
if (reply.cancelled) return;
|
|
284
|
+
if (hiddenNarrationPage.value && !reply.canonicalFinal && (final || !reply.raw)) {
|
|
285
|
+
// Consume hidden outputs without replay; a streaming answer cannot claim
|
|
286
|
+
// completion when its first canonical final was not admitted for speech.
|
|
287
|
+
cancelReply(reply, "page-hidden");
|
|
288
|
+
if (reply.turnId && voice.activeSpeechTurnId.value === reply.turnId) voice.stopSpeaking("page-hidden");
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (final) retireNarration();
|
|
207
292
|
const text = String(message.text || "").slice(0, 4000);
|
|
208
293
|
const spokenPrefix = reply.raw.slice(0, reply.consumed);
|
|
209
294
|
if (!text.startsWith(spokenPrefix) && !(final && text.trimEnd() === spokenPrefix.trimEnd())) {
|
|
@@ -234,9 +319,13 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
234
319
|
}
|
|
235
320
|
}
|
|
236
321
|
async function toggleReadAloud() {
|
|
322
|
+
if (readAloudChangePending.value) return;
|
|
237
323
|
error.value = "";
|
|
238
324
|
readAloud.value = !readAloud.value;
|
|
239
325
|
observe(binding.onReadAloudChange, readAloud.value);
|
|
326
|
+
await applyReadAloud();
|
|
327
|
+
}
|
|
328
|
+
async function applyReadAloud() {
|
|
240
329
|
if (!readAloud.value) {
|
|
241
330
|
++soundRevision;
|
|
242
331
|
soundPreparing.value = false;
|
|
@@ -246,7 +335,13 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
246
335
|
speechSuppressed = false;
|
|
247
336
|
speechInvitation = "";
|
|
248
337
|
await enableSound();
|
|
338
|
+
if (!disposed) refreshNarration();
|
|
249
339
|
}
|
|
340
|
+
watch(() => binding.defaults?.readAloud, (value) => {
|
|
341
|
+
if (disposed || typeof value !== "boolean" || value === readAloud.value) return;
|
|
342
|
+
readAloud.value = value;
|
|
343
|
+
void applyReadAloud();
|
|
344
|
+
});
|
|
250
345
|
async function deliverTranscript() {
|
|
251
346
|
const pending = pendingTranscript.value;
|
|
252
347
|
if (!pending || sending.value) return;
|
|
@@ -300,7 +395,7 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
300
395
|
} catch (cause) { recording = null; error.value = cause.message; }
|
|
301
396
|
}
|
|
302
397
|
async function startRecording(reviewBeforeSend, continuous = false) {
|
|
303
|
-
if (disposed || starting.value || microphoneMuted.value || capturing.value || committing ||
|
|
398
|
+
if (disposed || hiddenNarrationPage.value || starting.value || microphoneMuted.value || capturing.value || committing ||
|
|
304
399
|
(pendingTranscript.value || sending.value) && !(continuous && live.value && (callMode.value === "hands-free" || pushHolding.value))) return;
|
|
305
400
|
error.value = "";
|
|
306
401
|
reviewBeforeSend ||= binding.defaults?.reviewBeforeSend === true;
|
|
@@ -333,7 +428,8 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
333
428
|
} finally { changingCallMode.value = false; }
|
|
334
429
|
}
|
|
335
430
|
async function startPushToTalk(event) {
|
|
336
|
-
if (event?.repeat || event?.isPrimary === false || event?.button !== undefined && event.button !== 0 || pushHolding.value
|
|
431
|
+
if (event?.repeat || event?.isPrimary === false || event?.button !== undefined && event.button !== 0 || pushHolding.value
|
|
432
|
+
|| disposed || hiddenNarrationPage.value || starting.value
|
|
337
433
|
|| capturing.value && !voice.listening.value || committing) return;
|
|
338
434
|
if (event?.pointerId !== undefined) event.currentTarget.setPointerCapture(event.pointerId);
|
|
339
435
|
pushInput = event?.pointerId ?? event?.key ?? null;
|
|
@@ -373,7 +469,7 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
373
469
|
await cancelRecording();
|
|
374
470
|
}
|
|
375
471
|
async function toggleHandsFree() {
|
|
376
|
-
if (disposed || pushHolding.value) return;
|
|
472
|
+
if (disposed || hiddenNarrationPage.value || pushHolding.value) return;
|
|
377
473
|
if (live.value && callMode.value === "hands-free" && voice.listening.value && (pendingTranscript.value || sending.value || committing)) {
|
|
378
474
|
// Pause must finish the newer recording after the existing admission,
|
|
379
475
|
// without replacing the one pending transcript or stopping output.
|
|
@@ -415,7 +511,7 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
415
511
|
await cancelRecording();
|
|
416
512
|
return;
|
|
417
513
|
}
|
|
418
|
-
if (disposed || microphoneMuted.value || capturing.value || committing ||
|
|
514
|
+
if (disposed || hiddenNarrationPage.value || microphoneMuted.value || capturing.value || committing ||
|
|
419
515
|
(pendingTranscript.value || sending.value) && callMode.value !== "hands-free" && !pushHolding.value) return;
|
|
420
516
|
const controller = new AbortController();
|
|
421
517
|
startupAbort = controller;
|
|
@@ -575,6 +671,7 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
575
671
|
if (pendingTranscript.value && messages.some((message) => message.role === "user" && message.id === pendingTranscript.value.messageId && message.receipt !== false)) {
|
|
576
672
|
dismissTranscript();
|
|
577
673
|
}
|
|
674
|
+
let invited = false;
|
|
578
675
|
for (const message of messages) {
|
|
579
676
|
// Unadmitted local records do not acknowledge a request or lift speech silence.
|
|
580
677
|
if (message.role === "user" && message.receipt === false) continue;
|
|
@@ -582,6 +679,7 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
582
679
|
if (message.role === "user" && message.id === speechInvitation) {
|
|
583
680
|
speechSuppressed = false;
|
|
584
681
|
speechInvitation = "";
|
|
682
|
+
invited = true;
|
|
585
683
|
}
|
|
586
684
|
if (message.role === "assistant") queueReply(message, true);
|
|
587
685
|
seen.add(message.id);
|
|
@@ -596,7 +694,7 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
596
694
|
lastStreamingId = reply?.id || "";
|
|
597
695
|
if (reply?.text) {
|
|
598
696
|
queueReply(reply, reply.status === "completed");
|
|
599
|
-
if (!readAloud.value || !primed || reply.autonomous) seen.add(reply.id);
|
|
697
|
+
if (!readAloud.value || !primed || reply.autonomous || hiddenNarrationPage.value) seen.add(reply.id);
|
|
600
698
|
}
|
|
601
699
|
// Keep only the visible history plus active playback receipts in memory.
|
|
602
700
|
const retained = new Set([...messages.map((message) => message.id), reply?.id, ...speechQueue.map((item) => item.id),
|
|
@@ -604,8 +702,31 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
604
702
|
for (const id of seen) if (!retained.has(id)) seen.delete(id);
|
|
605
703
|
for (const id of replies.keys()) if (!retained.has(id)) replies.delete(id);
|
|
606
704
|
primed = true;
|
|
705
|
+
if (invited) refreshNarration();
|
|
607
706
|
void drainSpeech();
|
|
608
707
|
}, { immediate: true });
|
|
708
|
+
watch(() => {
|
|
709
|
+
const narration = binding.narration;
|
|
710
|
+
return {
|
|
711
|
+
id: binding.id, conversationId: binding.conversationId, narration,
|
|
712
|
+
loading: narration?.loading, eligible: narration?.eligible, working: narration?.working,
|
|
713
|
+
thinking: narration?.vocalizeThinking, interim: narration?.vocalizeInterimTurns,
|
|
714
|
+
sounds: narration?.thinkingSounds, turns: narration?.turns, enabled: narrationEnabled()
|
|
715
|
+
};
|
|
716
|
+
}, (current, previous) => {
|
|
717
|
+
clearNarrationTimers();
|
|
718
|
+
if (!current.narration || current.loading || current.id !== previous?.id || current.conversationId !== previous?.conversationId) {
|
|
719
|
+
narrationTracker.reset();
|
|
720
|
+
retireNarration();
|
|
721
|
+
if (!current.narration || current.loading) return;
|
|
722
|
+
}
|
|
723
|
+
if (!current.enabled) retireNarration();
|
|
724
|
+
else retireNarration(kind => kind === "thinking" ? !current.thinking
|
|
725
|
+
: kind === "commentary" ? !current.interim : !current.sounds || !current.working);
|
|
726
|
+
// Retire disabled tails before enabling a flag; settings are not a request to replay history.
|
|
727
|
+
if (previous && (current.enabled !== previous.enabled || current.thinking !== previous.thinking || current.interim !== previous.interim)) observeNarration(true, false);
|
|
728
|
+
refreshNarration(!current.enabled);
|
|
729
|
+
}, { immediate: true, deep: true });
|
|
609
730
|
watch(voice.error, (message) => { if (message) cancelSpeech("playback-error", "failed"); });
|
|
610
731
|
watch(() => error.value || voice.error.value, (message) => { if (message) binding.onError?.(message); });
|
|
611
732
|
watch([voice.activeSpeechTurnId, voice.canAppendSpeech], () => { void drainSpeech(); });
|
|
@@ -674,10 +795,26 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
674
795
|
watch([pendingTranscript, voice.captureState, voice.partialTranscript, voice.endpoint, sending, starting, takingTranscript], publishTranscript, { immediate: true });
|
|
675
796
|
watch(avatarVisual, (visual) => binding.onVisual?.(visual), { immediate: true });
|
|
676
797
|
const page = globalThis.window || globalThis;
|
|
798
|
+
const updateNarrationVisibility = () => {
|
|
799
|
+
narrationPageVisible.value = globalThis.document?.visibilityState !== "hidden";
|
|
800
|
+
if (disposed || !hiddenNarrationPage.value || !(recording || starting.value || committing || capturing.value)) return;
|
|
801
|
+
// This is the original document-hidden capture boundary, not avatar/body
|
|
802
|
+
// visibility. Retire B before cancellation; A's delivery stays untouched.
|
|
803
|
+
pushHolding.value = false;
|
|
804
|
+
pushInput = null;
|
|
805
|
+
if (!pendingTranscript.value) {
|
|
806
|
+
holding.value = false;
|
|
807
|
+
heldReview.value = false;
|
|
808
|
+
}
|
|
809
|
+
void discardRecording();
|
|
810
|
+
voice.stopSpeaking("page-hidden");
|
|
811
|
+
};
|
|
812
|
+
globalThis.document?.addEventListener?.("visibilitychange", updateNarrationVisibility);
|
|
677
813
|
const cancelStartup = () => { if (starting.value) void discardRecording(); else void cancelPushToTalk(); };
|
|
678
814
|
page.addEventListener?.("pagehide", cancelStartup);
|
|
679
815
|
page.addEventListener?.("blur", cancelPushToTalk);
|
|
680
816
|
onScopeDispose(() => {
|
|
817
|
+
globalThis.document?.removeEventListener?.("visibilitychange", updateNarrationVisibility);
|
|
681
818
|
page.removeEventListener?.("pagehide", cancelStartup);
|
|
682
819
|
page.removeEventListener?.("blur", cancelPushToTalk);
|
|
683
820
|
startupAbort?.abort(); startupAbort = null;
|
|
@@ -703,7 +840,7 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
|
|
|
703
840
|
(sending.value || Boolean(pendingTranscript.value) || capturing.value));
|
|
704
841
|
const hasUnsentSpeech = computed(() => capturing.value || starting.value || sending.value || Boolean(pendingTranscript.value));
|
|
705
842
|
return {
|
|
706
|
-
oneOffTalkMode, targetLabel, voice, readAloud, error, pendingTranscript, heldReview, holding, sending,
|
|
843
|
+
oneOffTalkMode, targetLabel, voice, readAloud, readAloudChangePending, error, pendingTranscript, heldReview, holding, sending,
|
|
707
844
|
live, starting, callMode, changingCallMode, pushHolding, microphoneMuted, capturing,
|
|
708
845
|
speechActive, talkLabel, liveLabel, callConnection, callStatus, callDetail, callModeBusy,
|
|
709
846
|
microphoneStatus, avatarVisual, callAudioLevel, status, heldTranscript, voiceWords, voiceAnswer,
|