@jskit-ai/assistant-voice 0.1.13 → 0.1.15

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:
@@ -9,14 +9,14 @@
9
9
  },
10
10
  "dependencies": {
11
11
  "@fastify/websocket": "^11.2.0",
12
- "@jskit-ai/assistant-core": "0.1.213",
13
- "@jskit-ai/assistant-runtime": "0.1.206",
14
- "@jskit-ai/assistant-voice": "0.1.13",
15
- "@jskit-ai/connectors-catalog": "0.1.52",
16
- "@jskit-ai/http-runtime": "0.1.233",
17
- "@jskit-ai/kernel": "0.1.235",
18
- "@jskit-ai/realtime": "0.1.232",
19
- "@jskit-ai/shell-web": "0.1.239",
12
+ "@jskit-ai/assistant-core": "0.1.215",
13
+ "@jskit-ai/assistant-runtime": "0.1.208",
14
+ "@jskit-ai/assistant-voice": "0.1.15",
15
+ "@jskit-ai/connectors-catalog": "0.1.54",
16
+ "@jskit-ai/http-runtime": "0.1.235",
17
+ "@jskit-ai/kernel": "0.1.237",
18
+ "@jskit-ai/realtime": "0.1.234",
19
+ "@jskit-ai/shell-web": "0.1.241",
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.13",
3
+ "version": "0.1.15",
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.213",
21
+ "@jskit-ai/assistant-core": "0.1.215",
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() {
@@ -649,6 +672,7 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
649
672
  const finished = recording;
650
673
  recording = null;
651
674
  if (voice.error.value) {
675
+ if (committing?.messageId === finished.messageId) committing = null;
652
676
  live.value = false;
653
677
  const text = voice.partialTranscript.value.trim() || voice.transcript.value.trim();
654
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,