@jskit-ai/assistant-voice 0.1.12 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -149,6 +149,15 @@ replay history or automatically restart capture. If a service advertises several
149
149
  voices, the Voice settings cog offers Speaking voice for the next reply's speaker; an in-flight reply keeps
150
150
  its original speaker through all streamed phrases.
151
151
 
152
+ Canonical replies retain the original bounded Markdown cleanup: inspect at most
153
+ 8,000 raw characters, then speak at most 4,000 normalized characters. A long code
154
+ block inside that window does not consume the spoken allowance or hide the answer
155
+ after it. Streaming keeps raw source offsets for revision checks; the existing
156
+ queue counts normalized consumed prefixes and limits appended text. Reaching the
157
+ speech limit ends synthesis, but a completed playback receipt still waits for
158
+ the canonical final and actual audio drain. Optional activity narration keeps its
159
+ separate original observed-delta limit.
160
+
152
161
  ## Server connection
153
162
 
154
163
  Register an authenticated WebSocket route with Fastify's WebSocket plugin:
@@ -209,3 +218,12 @@ through an application-authorized route; credentials stay on the server. The
209
218
  result contains `voices` (IDs, labels and optional languages) and `defaultVoice`,
210
219
  matching the WebSocket greeting. Listing voices does not synthesize or load
211
220
  additional voice models.
221
+
222
+
223
+ `binding.defaults.reviewBeforeSend` is captured when each recording starts. An
224
+ explicit true value holds completed speech for the existing review/Send flow on
225
+ both manual finalization and automatic hands-free endpoint completion. A daemon
226
+ reset within the same continuous capture retains that choice while creating a
227
+ separate message identity; it does not append words to an earlier queued message.
228
+ Fresh capture reads fresh defaults. Missing/false retains automatic completed
229
+ speech delivery. This flag does not change playback or input ownership.
@@ -9,14 +9,14 @@
9
9
  },
10
10
  "dependencies": {
11
11
  "@fastify/websocket": "^11.2.0",
12
- "@jskit-ai/assistant-core": "0.1.212",
13
- "@jskit-ai/assistant-runtime": "0.1.205",
14
- "@jskit-ai/assistant-voice": "0.1.12",
15
- "@jskit-ai/connectors-catalog": "0.1.51",
16
- "@jskit-ai/http-runtime": "0.1.232",
17
- "@jskit-ai/kernel": "0.1.234",
18
- "@jskit-ai/realtime": "0.1.231",
19
- "@jskit-ai/shell-web": "0.1.238",
12
+ "@jskit-ai/assistant-core": "0.1.214",
13
+ "@jskit-ai/assistant-runtime": "0.1.207",
14
+ "@jskit-ai/assistant-voice": "0.1.14",
15
+ "@jskit-ai/connectors-catalog": "0.1.53",
16
+ "@jskit-ai/http-runtime": "0.1.234",
17
+ "@jskit-ai/kernel": "0.1.236",
18
+ "@jskit-ai/realtime": "0.1.233",
19
+ "@jskit-ai/shell-web": "0.1.240",
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.12",
3
+ "version": "0.1.14",
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.212",
21
+ "@jskit-ai/assistant-core": "0.1.214",
22
22
  "@mdi/js": "^7.4.47",
23
23
  "ws": "^8.20.1"
24
24
  },
@@ -1,6 +1,6 @@
1
1
  import { computed, onScopeDispose, ref, watch } from "vue";
2
2
  import { useVoiceTransport } from "./voiceTransport.js";
3
- import { takeStreamingSpeech } from "../shared/protocol.js";
3
+ import { speechTextFromAssistant, takeStreamingSpeech, VOICE_MAX_SPEECH_TEXT_CHARACTERS } from "../shared/protocol.js";
4
4
  import { createConversationNarrationTracker } from "./conversationNarration.js";
5
5
 
6
6
  /** Created inside the controller's effect scope, independent of a mounted screen. */
@@ -82,7 +82,7 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
82
82
  return "You can speak naturally, including while I reply.";
83
83
  });
84
84
  const callModeBusy = computed(() => starting.value || changingCallMode.value || pushHolding.value || sending.value || Boolean(pendingTranscript.value)
85
- || Boolean(voice.partialTranscript.value.trim()) || capturing.value && !voice.listening.value);
85
+ || capturing.value && Boolean(voice.partialTranscript.value.trim()) || capturing.value && !voice.listening.value);
86
86
  const microphoneStatus = computed(() => microphoneMuted.value ? "Microphone muted" : voice.listening.value ? "Microphone on" : "Microphone off");
87
87
  const avatarVisual = computed(() => ({
88
88
  avatar: binding.avatar,
@@ -237,7 +237,13 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
237
237
  if (reply.started && !speechActive.value) { speechQueue.shift(); continue; }
238
238
  if (speechActive.value && !reply.started) return;
239
239
  if (reply.started && !voice.canAppendSpeech.value) return;
240
- const chunk = takeStreamingSpeech(reply.raw.slice(reply.consumed), reply.final, reply.started ? 140 : 64);
240
+ const previousSpeech = reply.narrationKind ? "" : speechTextFromAssistant(reply.raw.slice(0, reply.consumed));
241
+ const maximumCharacters = reply.narrationKind ? (reply.started ? 140 : 64) : Math.max(1, Math.min(
242
+ reply.started ? 140 : 64,
243
+ VOICE_MAX_SPEECH_TEXT_CHARACTERS - previousSpeech.length,
244
+ VOICE_MAX_SPEECH_TEXT_CHARACTERS - reply.spokenCharacters
245
+ ));
246
+ const chunk = reply.speechCapped ? null : takeStreamingSpeech(reply.raw.slice(reply.consumed), reply.final, maximumCharacters);
241
247
  if (!chunk) {
242
248
  if (reply.final && reply.started) voice.endSpeech();
243
249
  else if (reply.final) {
@@ -248,11 +254,27 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
248
254
  return;
249
255
  }
250
256
  reply.consumed += chunk.consumed;
251
- if (!chunk.text) continue;
257
+ let text = chunk.text;
258
+ if (!reply.narrationKind) {
259
+ // Keep offsets in raw Markdown. The original limit applies after its
260
+ // cleanup, including whitespace between separately streamed phrases.
261
+ const speech = speechTextFromAssistant(reply.raw.slice(0, reply.consumed));
262
+ const boundary = speech.startsWith(previousSpeech)
263
+ ? speech.slice(previousSpeech.length).match(/^\s*/u)[0].length : 0;
264
+ text = text.slice(0, Math.max(0, Math.min(
265
+ VOICE_MAX_SPEECH_TEXT_CHARACTERS - previousSpeech.length - boundary,
266
+ VOICE_MAX_SPEECH_TEXT_CHARACTERS - reply.spokenCharacters
267
+ )));
268
+ reply.spokenCharacters += text.length;
269
+ reply.speechCapped = speech.length >= VOICE_MAX_SPEECH_TEXT_CHARACTERS
270
+ || reply.spokenCharacters >= VOICE_MAX_SPEECH_TEXT_CHARACTERS;
271
+ reply.final ||= reply.speechCapped;
272
+ }
273
+ if (!text) continue;
252
274
  if (!reply.started) {
253
275
  reply.started = true;
254
276
  reply.turnId = crypto.randomUUID();
255
- const accepted = await voice.speak(chunk.text, reply.turnId, { stream: true });
277
+ const accepted = await voice.speak(text, reply.turnId, { stream: true });
256
278
  if (disposed) return;
257
279
  if (reply.cancelled) {
258
280
  if (reply.narrationKind) continue;
@@ -262,8 +284,8 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
262
284
  cancelReply(reply, "no-audio");
263
285
  continue;
264
286
  }
265
- } else if (!voice.appendSpeech(chunk.text)) return;
266
- if (reply.final && reply.consumed >= reply.raw.length) { voice.endSpeech(); return; }
287
+ } else if (!voice.appendSpeech(text)) return;
288
+ if (reply.speechCapped || (reply.final && reply.consumed >= reply.raw.length)) { voice.endSpeech(); return; }
267
289
  }
268
290
  } catch (cause) { error.value = cause.message; stopSpeech(); }
269
291
  finally { draining = false; }
@@ -275,7 +297,8 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
275
297
  retireNarration();
276
298
  reply = {
277
299
  id: message.id, outputId: message.outputId || message.id, streamId: message.streamId,
278
- raw: "", consumed: 0, final: false, canonicalFinal: false, started: false
300
+ raw: "", consumed: 0, spokenCharacters: 0, speechCapped: false,
301
+ final: false, canonicalFinal: false, started: false
279
302
  };
280
303
  replies.set(message.id, reply);
281
304
  speechQueue.push(reply);
@@ -289,7 +312,7 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
289
312
  return;
290
313
  }
291
314
  if (final) retireNarration();
292
- const text = String(message.text || "").slice(0, 4000);
315
+ const text = String(message.text || "").slice(0, VOICE_MAX_SPEECH_TEXT_CHARACTERS * 2);
293
316
  const spokenPrefix = reply.raw.slice(0, reply.consumed);
294
317
  if (!text.startsWith(spokenPrefix) && !(final && text.trimEnd() === spokenPrefix.trimEnd())) {
295
318
  // A model revision cannot unsay words. Retire that projection; never splice
@@ -301,7 +324,7 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
301
324
  reply.consumed = Math.min(reply.consumed, text.length);
302
325
  reply.raw = text;
303
326
  reply.canonicalFinal ||= final;
304
- reply.final = final || text.length >= 4000;
327
+ reply.final = final || reply.speechCapped || text.length >= VOICE_MAX_SPEECH_TEXT_CHARACTERS * 2;
305
328
  if (reply.canonicalFinal && reply.drained) emitReply(reply, "completed");
306
329
  }
307
330
  async function enableSound() {
@@ -552,7 +575,8 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
552
575
  const key = `${candidate.turnId}:${candidate.revision}`;
553
576
  if (decidedCandidate === key) return;
554
577
  decidedCandidate = key;
555
- const captured = { focus: { ...(recording?.focus || (binding.captureContext?.() || {})) }, messageId: recording?.messageId || crypto.randomUUID(), text: candidate.text };
578
+ const captured = { focus: { ...(recording?.focus || (binding.captureContext?.() || {})) }, messageId: recording?.messageId || crypto.randomUUID(), text: candidate.text,
579
+ ...(recording?.reviewBeforeSend ? { reviewBeforeSend: true } : {}) };
556
580
  try {
557
581
  const text = candidate.text.trim();
558
582
  const spoken = currentSpokenText();
@@ -590,13 +614,15 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
590
614
  if (!utterance || !live.value || !committing || committing.turnId !== utterance.turnId || committing.revision !== utterance.revision) return;
591
615
  pendingTranscript.value = { ...committing, text: utterance.text };
592
616
  committing = null;
593
- void deliverTranscript();
617
+ if (!pendingTranscript.value.reviewBeforeSend) void deliverTranscript();
618
+ else holding.value = false;
594
619
  });
595
620
  watch(voice.utteranceReset, (reset) => {
596
621
  // A discard can lose a race with resumed speech. Keep this utterance's
597
622
  // identity and destination until the daemon acknowledges the boundary.
598
623
  if (reset?.turnId !== voice.activeListenTurnId.value || !live.value || !recording) return;
599
624
  recording = { focus: { ...(binding.captureContext?.() || {}) }, messageId: crypto.randomUUID(), started: true, continuous: true,
625
+ ...(recording.reviewBeforeSend ? { reviewBeforeSend: true } : {}),
600
626
  ...(recording.finishAfterPending ? { finishAfterPending: true } : {}) };
601
627
  });
602
628
  watch(voice.partialTranscript, (text, previous) => {
@@ -646,6 +672,7 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
646
672
  const finished = recording;
647
673
  recording = null;
648
674
  if (voice.error.value) {
675
+ if (committing?.messageId === finished.messageId) committing = null;
649
676
  live.value = false;
650
677
  const text = voice.partialTranscript.value.trim() || voice.transcript.value.trim();
651
678
  if (text) {
@@ -1,6 +1,6 @@
1
1
  import { computed, onScopeDispose, ref, watch } from "vue";
2
2
 
3
- import { speechTextFromAssistant } from "../shared/protocol.js";
3
+ import { VOICE_MAX_SYNTHESIS_TIMEOUT_MS, speechTextFromAssistant } from "../shared/protocol.js";
4
4
  import { createMicrophoneCapture } from "./audioCapture.js";
5
5
  import {
6
6
  normalizeSpeechSegment,
@@ -63,6 +63,8 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
63
63
  let heartbeatTimer;
64
64
  let lastPongAt = 0;
65
65
  let lastSpeechProgressAt = 0;
66
+ let synthesisTimeoutMs = 20_000;
67
+ let lastStartedSegmentIndex = -1;
66
68
  let microphone = null;
67
69
  let preserveBufferedMicrophone = false;
68
70
  const openingMicrophones = new Set();
@@ -165,7 +167,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
165
167
  connectionLost();
166
168
  return;
167
169
  }
168
- if (activeSpeechTurnId.value && synthesisPending.value && Date.now() - lastSpeechProgressAt > 20000) {
170
+ if (activeSpeechTurnId.value && synthesisPending.value && Date.now() - lastSpeechProgressAt > synthesisTimeoutMs) {
169
171
  connectionLost("Speech generation stalled. The answer remains in chat; use Talk to resume.");
170
172
  return;
171
173
  }
@@ -405,6 +407,8 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
405
407
  stopAfterSegment = Infinity;
406
408
  bufferedSeconds.value = 0;
407
409
  synthesisPending.value = 0;
410
+ synthesisTimeoutMs = 20_000;
411
+ lastStartedSegmentIndex = -1;
408
412
  receivingTurnId = "";
409
413
  receivingSegment = null;
410
414
  streamedSegments.clear();
@@ -477,11 +481,24 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
477
481
  return;
478
482
  }
479
483
  if (type === "speech.segment.start" && message.turnId === activeSpeechTurnId.value) {
484
+ if (!speechStreamEnded && Number.isSafeInteger(message.segmentIndex) &&
485
+ message.segmentIndex > lastStartedSegmentIndex) {
486
+ lastStartedSegmentIndex = message.segmentIndex;
487
+ synthesisTimeoutMs = 20_000;
488
+ if (Number.isSafeInteger(message.synthesisTimeoutMs) && message.synthesisTimeoutMs > 20_000 &&
489
+ message.synthesisTimeoutMs <= VOICE_MAX_SYNTHESIS_TIMEOUT_MS) {
490
+ // The engine acquired its queue slot. Allow its bounded cold job until
491
+ // first PCM; repeated metadata and pongs are not synthesis progress.
492
+ synthesisTimeoutMs = message.synthesisTimeoutMs;
493
+ lastSpeechProgressAt = Date.now();
494
+ }
495
+ }
480
496
  receivingSegment = { index: message.segmentIndex };
481
497
  streamedSegments.set(message.segmentIndex, receivingSegment);
482
498
  return;
483
499
  }
484
500
  if (type === "speech.chunk.end" && message.turnId === activeSpeechTurnId.value) {
501
+ synthesisTimeoutMs = 20_000;
485
502
  lastSpeechProgressAt = Date.now();
486
503
  synthesisPending.value = Math.max(0, synthesisPending.value - 1);
487
504
  return;
@@ -515,6 +532,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
515
532
  function handleSocketMessage(event) {
516
533
  if (event.data instanceof ArrayBuffer) {
517
534
  if (speechOutputEnabled.value && activeSpeechTurnId.value && receivingTurnId === activeSpeechTurnId.value && !speechStreamEnded) {
535
+ synthesisTimeoutMs = 20_000;
518
536
  lastSpeechProgressAt = Date.now();
519
537
  enqueuePcm(event.data);
520
538
  }
@@ -8,6 +8,7 @@ import {
8
8
  VOICE_MAX_AUDIO_FRAME_BYTES,
9
9
  VOICE_MAX_CONTROL_BYTES,
10
10
  VOICE_MAX_UTTERANCE_SECONDS,
11
+ VOICE_MAX_SYNTHESIS_TIMEOUT_MS,
11
12
  VOICE_PROTOCOL_VERSION,
12
13
  assertVoiceAudioFrame,
13
14
  parseVoiceClientControl,
@@ -356,7 +357,10 @@ function createVoiceDaemon({
356
357
  sendJson(socket, "speech.start", { sampleRate: Number(engine.sampleRate), turnId });
357
358
  }
358
359
  const segmentIndex = active.segmentIndex++;
359
- sendJson(socket, "speech.segment.start", { segmentIndex, turnId });
360
+ const synthesisTimeoutMs = engine.synthesisTimeoutMs;
361
+ sendJson(socket, "speech.segment.start", { segmentIndex, turnId,
362
+ ...(Number.isSafeInteger(synthesisTimeoutMs) && synthesisTimeoutMs > 20_000 &&
363
+ synthesisTimeoutMs <= VOICE_MAX_SYNTHESIS_TIMEOUT_MS ? { synthesisTimeoutMs } : {}) });
360
364
  let sampleCount = 0;
361
365
  const result = text.trim() ? await engine.synthesize(text.trim(), {
362
366
  onAudio(frame) {
@@ -5,6 +5,7 @@ const VOICE_OUTPUT_SAMPLE_RATE = 22_050;
5
5
  const VOICE_MAX_CONTROL_BYTES = 16 * 1024;
6
6
  const VOICE_MAX_AUDIO_FRAME_BYTES = 64 * 1024;
7
7
  const VOICE_MAX_UTTERANCE_SECONDS = 60;
8
+ const VOICE_MAX_SYNTHESIS_TIMEOUT_MS = 120_000;
8
9
  const VOICE_MAX_SPEECH_TEXT_CHARACTERS = 4_000;
9
10
  const VOICE_MAX_SPEECH_CHUNK_CHARACTERS = 280;
10
11
  const VOICE_SPEECH_EM_DASH_PAUSE_MS = 140;
@@ -421,6 +422,7 @@ export {
421
422
  VOICE_MAX_SPEECH_CHUNK_CHARACTERS,
422
423
  VOICE_MAX_SPEECH_TEXT_CHARACTERS,
423
424
  VOICE_MAX_UTTERANCE_SECONDS,
425
+ VOICE_MAX_SYNTHESIS_TIMEOUT_MS,
424
426
  VOICE_OUTPUT_SAMPLE_RATE,
425
427
  VOICE_PROTOCOL_VERSION,
426
428
  assertVoiceAudioFrame,