@jskit-ai/assistant-voice 0.1.13 → 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:
|
|
@@ -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.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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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,
|
|
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,
|
|
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 >=
|
|
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 >
|
|
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
|
-
|
|
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) {
|
package/src/shared/protocol.js
CHANGED
|
@@ -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,
|