@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.205",
13
- "@jskit-ai/assistant-runtime": "0.1.198",
14
- "@jskit-ai/assistant-voice": "0.1.5",
15
- "@jskit-ai/connectors-catalog": "0.1.44",
16
- "@jskit-ai/http-runtime": "0.1.225",
17
- "@jskit-ai/kernel": "0.1.227",
18
- "@jskit-ai/realtime": "0.1.224",
19
- "@jskit-ai/shell-web": "0.1.231",
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.5",
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.205",
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'" @click="toggleReadAloud"
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 (reply.cancelled || disposed) return;
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 || disposed || starting.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,