@jskit-ai/assistant-voice 0.0.0-stage → 0.1.2

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.
Files changed (37) hide show
  1. package/README.md +169 -2
  2. package/examples/conversation/App.vue +94 -0
  3. package/examples/conversation/Conversation.vue +28 -0
  4. package/examples/conversation/README.md +181 -0
  5. package/examples/conversation/config.js +15 -0
  6. package/examples/conversation/index.html +1 -0
  7. package/examples/conversation/integrations.example.json +16 -0
  8. package/examples/conversation/main.js +58 -0
  9. package/examples/conversation/package.json +32 -0
  10. package/examples/conversation/server.js +105 -0
  11. package/examples/conversation/src/error.js +1 -0
  12. package/examples/conversation/src/placement.js +1 -0
  13. package/examples/conversation/src/placementTopology.js +1 -0
  14. package/examples/conversation/vite.config.js +9 -0
  15. package/package.json +67 -4
  16. package/src/client/ConversationDialog.vue +67 -0
  17. package/src/client/VoiceAvatar.vue +18 -0
  18. package/src/client/VoiceConversation.vue +117 -0
  19. package/src/client/VoiceConversationHost.vue +78 -0
  20. package/src/client/audioCapture.js +198 -0
  21. package/src/client/conversationNarration.js +108 -0
  22. package/src/client/conversationVoiceState.js +18 -0
  23. package/src/client/index.js +9 -0
  24. package/src/client/voiceAvatar.js +234 -0
  25. package/src/client/voiceController.js +124 -0
  26. package/src/client/voiceConversation.js +557 -0
  27. package/src/client/voiceLauncher.js +63 -0
  28. package/src/client/voicePlayback.js +58 -0
  29. package/src/client/voiceTransport.js +890 -0
  30. package/src/server/index.js +5 -0
  31. package/src/server/voiceAccessToken.js +79 -0
  32. package/src/server/voiceDaemon.js +599 -0
  33. package/src/server/voiceProxy.js +199 -0
  34. package/src/server/voiceProxyConfig.js +70 -0
  35. package/src/server/voiceSocketClose.js +31 -0
  36. package/src/shared/protocol.js +439 -0
  37. package/src/shared/visemes.js +130 -0
@@ -0,0 +1,557 @@
1
+ import { computed, onScopeDispose, ref, watch } from "vue";
2
+ import { useVoiceTransport } from "./voiceTransport.js";
3
+ import { takeStreamingSpeech } from "../shared/protocol.js";
4
+
5
+ /** Created inside the controller's effect scope, independent of a mounted screen. */
6
+ export function useVoiceConversation(binding, { socketUrl, createTransport = useVoiceTransport } = {}) {
7
+ const oneOffTalkMode = computed(() => binding.defaults?.talkMode === "hold" ? "hold" : "tap");
8
+ const targetLabel = computed(() => binding.label || "Assistant");
9
+ const readAloud = ref(false);
10
+ const error = ref("");
11
+ const pendingTranscript = ref(null);
12
+ const heldReview = ref(false);
13
+ const holding = ref(false);
14
+ const sending = ref(false);
15
+ const live = ref(false);
16
+ const starting = ref(false);
17
+ const callMode = ref(binding.defaults?.mode === "hands-free" ? "hands-free" : "push-to-talk");
18
+ const changingCallMode = ref(false);
19
+ const pushHolding = ref(false);
20
+ let pushInput = null;
21
+ let startupAbort = null;
22
+ let decidedCandidate = "";
23
+ let stopTimer;
24
+ let committing = null;
25
+ const voice = createTransport({
26
+ socketUrl,
27
+ voiceId: binding.defaults?.voiceId || "",
28
+ speechEnabled: readAloud,
29
+ interruptSpeechOnListen: false,
30
+ autoReconnect: true
31
+ });
32
+ watch([() => binding.defaults?.voiceId, voice.availableVoices], ([voiceId, voices]) => {
33
+ if (voiceId === undefined) return;
34
+ voice.selectedVoice.value = !voices.length || voices.some(voice => voice.id === voiceId) ? voiceId : "";
35
+ });
36
+ const microphoneMuted = voice.microphoneMuted;
37
+ const capturing = computed(() => voice.captureState.value !== "idle");
38
+ const speechActive = computed(() => Boolean(voice.activeSpeechTurnId.value));
39
+ const talkLabel = computed(() => voice.listening.value ? "Send recording"
40
+ : capturing.value ? "Cancel recording" : `Talk to ${targetLabel.value}`);
41
+ const liveLabel = computed(() => starting.value ? "Cancel voice startup" : live.value ? "End voice" : "Start voice");
42
+ const callConnection = computed(() => {
43
+ if (voice.reconnecting.value) return "Voice interrupted";
44
+ if (error.value || voice.error.value) return "Voice needs attention";
45
+ if (starting.value) return "Starting voice…";
46
+ if (live.value) return "Voice active";
47
+ return "Voice off";
48
+ });
49
+ const callStatus = computed(() => {
50
+ if (voice.reconnecting.value) return "Reconnecting…";
51
+ if (starting.value) return "Starting voice…";
52
+ if (live.value && voice.captureState.value === "connecting") return "Connecting…";
53
+ if (live.value && voice.captureState.value === "opening") return "Opening microphone…";
54
+ if (error.value || voice.error.value) return "Voice needs attention";
55
+ if (!live.value) return "";
56
+ if (pushHolding.value && voice.listening.value) return "Listening to you";
57
+ if (voice.speaking.value) return `${targetLabel.value} is speaking`;
58
+ if (speechActive.value) return "Preparing speech…";
59
+ if (binding.state.status === "working") return "Thinking";
60
+ if (sending.value) return "Sending your message…";
61
+ if (pendingTranscript.value) return "Review your message below";
62
+ if (microphoneMuted.value) return "Microphone muted";
63
+ if (voice.captureState.value === "transcribing") return "Transcribing…";
64
+ if (voice.listening.value) return "Listening to you";
65
+ return callMode.value === "push-to-talk" ? "Hold to speak" : "Preparing microphone…";
66
+ });
67
+ const callDetail = computed(() => {
68
+ if (voice.reconnecting.value) return "Waiting for the voice connection. Your unfinished words remain available for review.";
69
+ if (error.value || voice.error.value) return "Review your message below or try voice again.";
70
+ if (starting.value || live.value && ["connecting", "opening"].includes(voice.captureState.value)) return "Allow microphone access if your browser asks.";
71
+ if (!live.value) return "";
72
+ if (microphoneMuted.value) return "Unmute when you want to speak. You can still listen.";
73
+ if (!readAloud.value) return "Sound is off. Answers still appear in chat.";
74
+ if (callMode.value === "push-to-talk") return "Hold to speak. Release to send. The conversation stays open.";
75
+ return "You can speak naturally, including while I reply.";
76
+ });
77
+ const callModeBusy = computed(() => starting.value || changingCallMode.value || pushHolding.value || sending.value || Boolean(pendingTranscript.value)
78
+ || Boolean(voice.partialTranscript.value.trim()) || capturing.value && !voice.listening.value);
79
+ const microphoneStatus = computed(() => microphoneMuted.value ? "Microphone muted" : voice.listening.value ? "Microphone on" : "Microphone off");
80
+ const avatarVisual = computed(() => ({
81
+ avatar: binding.avatar,
82
+ state: voice.speaking.value ? "speaking" : pushHolding.value && voice.listening.value ? "listening" : binding.state.status === "working" ? "thinking"
83
+ : voice.listening.value && !microphoneMuted.value ? "listening" : "idle",
84
+ mouthLevel: voice.mouthLevel.value,
85
+ mouthPose: voice.mouthPose.value
86
+ }));
87
+ const callAudioLevel = computed(() => Math.max(0, Math.min(1, voice.speaking.value ? voice.mouthLevel.value
88
+ : voice.listening.value && !microphoneMuted.value ? voice.inputLevel.value : 0)));
89
+ const status = computed(() => {
90
+ const capture = { connecting: "Connecting…", opening: "Opening mic…", listening: "Listening", transcribing: "Transcribing…" }[voice.captureState.value];
91
+ return [starting.value ? "Starting voice…" : "", voice.reconnecting.value ? "Reconnecting…" : "", live.value ? "Live conversation" : "", microphoneMuted.value ? "Microphone muted" : capture, voice.speaking.value ? "Speaking" : speechActive.value ? "Preparing speech…" : ""].filter(Boolean).join(" · ");
92
+ });
93
+ const heldTranscript = computed(() => pendingTranscript.value?.text ||
94
+ (["listening", "transcribing"].includes(voice.captureState.value) ? voice.partialTranscript.value : ""));
95
+ const voiceWords = computed(() => heldTranscript.value || binding.state.messages?.findLast(message => message.role === "user")?.text || "");
96
+ const voiceAnswer = computed(() => binding.state.streamingReply?.text || binding.state.messages?.findLast(message => message.role === "assistant")?.text || "");
97
+ let recording = null;
98
+ let primed = false;
99
+ let draining = false;
100
+ let disposed = false;
101
+ const seen = new Set();
102
+ const speechQueue = [];
103
+ const replies = new Map();
104
+ let speechSuppressed = false;
105
+ let requestedMessageId = "";
106
+ let speechInvitation = "";
107
+ let lastStreamingId = "";
108
+
109
+ function stopSpeech() {
110
+ for (const reply of speechQueue) reply.cancelled = true;
111
+ speechQueue.length = 0;
112
+ speechSuppressed = true;
113
+ speechInvitation = "";
114
+ voice.stopSpeaking();
115
+ }
116
+ function inviteSpeech(messageId) {
117
+ // Only a new request from this page can lift silence. A delayed admission,
118
+ // history refresh or retry of the same request is not a new invitation.
119
+ if (!messageId || messageId === requestedMessageId || seen.has(messageId)) return;
120
+ requestedMessageId = messageId;
121
+ speechInvitation = messageId;
122
+ }
123
+ async function drainSpeech() {
124
+ if (draining || disposed || !readAloud.value || speechSuppressed) return;
125
+ draining = true;
126
+ try {
127
+ while (speechQueue.length) {
128
+ const reply = speechQueue[0];
129
+ if (reply.cancelled) { speechQueue.shift(); continue; }
130
+ if (reply.started && !speechActive.value) { speechQueue.shift(); continue; }
131
+ if (speechActive.value && !reply.started) return;
132
+ if (reply.started && !voice.canAppendSpeech.value) return;
133
+ const chunk = takeStreamingSpeech(reply.raw.slice(reply.consumed), reply.final, reply.started ? 140 : 64);
134
+ if (!chunk) {
135
+ if (reply.final && reply.started) voice.endSpeech();
136
+ else if (reply.final) { speechQueue.shift(); continue; }
137
+ return;
138
+ }
139
+ reply.consumed += chunk.consumed;
140
+ if (!chunk.text) continue;
141
+ if (!reply.started) {
142
+ reply.started = true;
143
+ await voice.speak(chunk.text, crypto.randomUUID(), { stream: true });
144
+ if (reply.cancelled || disposed) return;
145
+ } else if (!voice.appendSpeech(chunk.text)) return;
146
+ if (reply.final && reply.consumed >= reply.raw.length) { voice.endSpeech(); return; }
147
+ }
148
+ } catch (cause) { error.value = cause.message; stopSpeech(); }
149
+ finally { draining = false; }
150
+ }
151
+ function queueReply(message, final) {
152
+ let reply = replies.get(message.id);
153
+ if (!reply) {
154
+ if (!primed || !readAloud.value || speechSuppressed || seen.has(message.id) || (!final && message.autonomous)) return;
155
+ reply = { id: message.id, streamId: message.streamId, raw: "", consumed: 0, final: false, started: false };
156
+ replies.set(message.id, reply);
157
+ speechQueue.push(reply);
158
+ }
159
+ if (reply.cancelled) return;
160
+ const text = String(message.text || "").slice(0, 4000);
161
+ const spokenPrefix = reply.raw.slice(0, reply.consumed);
162
+ if (!text.startsWith(spokenPrefix) && !(final && text.trimEnd() === spokenPrefix.trimEnd())) {
163
+ // A model revision cannot unsay words. Retire that projection; never splice
164
+ // its old prefix onto a different answer or replay it as a second answer.
165
+ reply.cancelled = true;
166
+ if (speechQueue[0] === reply) voice.stopSpeaking();
167
+ return;
168
+ }
169
+ reply.consumed = Math.min(reply.consumed, text.length);
170
+ reply.raw = text;
171
+ reply.final = final || text.length >= 4000;
172
+ }
173
+ async function enableSpeech() {
174
+ speechSuppressed = false;
175
+ speechInvitation = "";
176
+ await voice.preparePlayback();
177
+ readAloud.value = true;
178
+ }
179
+ async function toggleReadAloud() {
180
+ error.value = "";
181
+ if (readAloud.value) { readAloud.value = false; stopSpeech(); return; }
182
+ try { await enableSpeech(); }
183
+ catch (cause) { error.value = cause.message; }
184
+ }
185
+ async function deliverTranscript() {
186
+ const pending = pendingTranscript.value;
187
+ if (!pending || sending.value) return;
188
+ sending.value = true;
189
+ error.value = "";
190
+ try {
191
+ inviteSpeech(pending.messageId);
192
+ const receipt = await binding.submitText(pending.text, { messageId: pending.messageId, context: pending.focus });
193
+ if (receipt === false || receipt?.ok === false) throw new Error(receipt?.message || "The conversation did not accept this message. Review it before retrying.");
194
+ if (pendingTranscript.value === pending) dismissTranscript();
195
+ } catch (cause) { if (pendingTranscript.value === pending) error.value = cause.message; }
196
+ finally { sending.value = false; }
197
+ }
198
+ async function cancelRecording() {
199
+ startupAbort?.abort();
200
+ startupAbort = null;
201
+ starting.value = false;
202
+ recording = null;
203
+ await voice.cancelListening();
204
+ }
205
+ async function discardRecording() {
206
+ live.value = false;
207
+ committing = null;
208
+ clearTimeout(stopTimer);
209
+ await cancelRecording();
210
+ }
211
+ function dismissTranscript() {
212
+ pendingTranscript.value = null;
213
+ heldReview.value = false;
214
+ holding.value = false;
215
+ error.value = "";
216
+ }
217
+ async function talk() {
218
+ error.value = "";
219
+ try {
220
+ if (voice.listening.value) { await voice.stopListening(); return; }
221
+ if (capturing.value) { await discardRecording(); return; }
222
+ if (pendingTranscript.value) return;
223
+ await startRecording(false);
224
+ } catch (cause) { recording = null; error.value = cause.message; }
225
+ }
226
+ async function startRecording(reviewBeforeSend, continuous = false) {
227
+ if (disposed || starting.value || microphoneMuted.value || capturing.value || pendingTranscript.value || sending.value) return;
228
+ error.value = "";
229
+ reviewBeforeSend ||= binding.defaults?.reviewBeforeSend === true;
230
+ heldReview.value = reviewBeforeSend;
231
+ holding.value = reviewBeforeSend;
232
+ // Keep the starting target even if navigation changes during this utterance.
233
+ const next = { focus: { ...(binding.captureContext?.() || {}) }, messageId: crypto.randomUUID(), started: false, reviewBeforeSend, continuous };
234
+ recording = next;
235
+ if (!reviewBeforeSend && !live.value) {
236
+ readAloud.value = true;
237
+ void voice.preparePlayback().catch((cause) => { error.value = cause.message; });
238
+ }
239
+ try {
240
+ const started = await voice.startListening({ continuous });
241
+ if (recording === next) { next.started = started; if (!started) recording = null; }
242
+ } catch (cause) { if (recording === next) { recording = null; error.value = cause.message; } }
243
+ }
244
+ async function toggleMicrophoneMuted() {
245
+ voice.setMicrophoneMuted(!microphoneMuted.value);
246
+ clearTimeout(stopTimer);
247
+ if (microphoneMuted.value && starting.value) await cancelRecording();
248
+ else if (!microphoneMuted.value && live.value && callMode.value === "hands-free" && !capturing.value && !pendingTranscript.value && !sending.value) await startRecording(false, true);
249
+ }
250
+ async function changeCallMode(mode) {
251
+ if (!["push-to-talk", "hands-free"].includes(mode) || mode === callMode.value || callModeBusy.value || committing) return;
252
+ changingCallMode.value = true;
253
+ callMode.value = mode;
254
+ try {
255
+ if (capturing.value) await cancelRecording();
256
+ if (live.value && mode === "hands-free" && !microphoneMuted.value) await startRecording(false, true);
257
+ } finally { changingCallMode.value = false; }
258
+ }
259
+ async function startPushToTalk(event) {
260
+ if (event?.repeat || event?.isPrimary === false || event?.button !== undefined && event.button !== 0 || pushHolding.value || disposed || starting.value
261
+ || capturing.value && !voice.listening.value || pendingTranscript.value || sending.value || committing) return;
262
+ if (event?.pointerId !== undefined) event.currentTarget.setPointerCapture(event.pointerId);
263
+ pushInput = event?.pointerId ?? event?.key ?? null;
264
+ pushHolding.value = true;
265
+ callMode.value = "push-to-talk";
266
+ voice.setMicrophoneMuted(false);
267
+ retireAnswer(true);
268
+ if (!live.value) await toggleLive();
269
+ if (!pushHolding.value || !live.value || disposed) return;
270
+ // A hold can take over an open hands-free microphone without throwing away
271
+ // its current words. Release then finalizes that same recording once.
272
+ if (recording && voice.listening.value) {
273
+ recording.continuous = false;
274
+ return;
275
+ }
276
+ await startRecording(false);
277
+ if (!recording) pushHolding.value = false;
278
+ }
279
+ async function finishPushToTalk(event) {
280
+ if (!pushHolding.value || event && (event.pointerId ?? event.key ?? null) !== pushInput) return;
281
+ pushHolding.value = false;
282
+ pushInput = null;
283
+ try {
284
+ if (voice.listening.value) await voice.stopListening();
285
+ else await cancelRecording();
286
+ } catch (cause) { await cancelRecording(); error.value = cause.message; }
287
+ }
288
+ async function cancelPushToTalk(event) {
289
+ if (!pushHolding.value || event?.pointerId !== undefined && event.pointerId !== pushInput) return;
290
+ pushHolding.value = false;
291
+ pushInput = null;
292
+ await cancelRecording();
293
+ }
294
+ async function toggleHandsFree() {
295
+ if (disposed || pushHolding.value || pendingTranscript.value || sending.value || committing) return;
296
+ if (starting.value) { await cancelRecording(); return; }
297
+ if (capturing.value && !voice.listening.value) return;
298
+ if (live.value && callMode.value === "hands-free") {
299
+ if (!microphoneMuted.value && voice.listening.value) {
300
+ // Pause is an explicit utterance boundary. Flush before muting so the
301
+ // final sound is included; resuming starts a fresh recording.
302
+ await voice.stopListening();
303
+ voice.setMicrophoneMuted(true);
304
+ } else await toggleMicrophoneMuted();
305
+ return;
306
+ }
307
+ if (callModeBusy.value) return;
308
+ voice.setMicrophoneMuted(false);
309
+ await changeCallMode("hands-free");
310
+ if (!live.value) await toggleLive();
311
+ }
312
+ async function toggleLive() {
313
+ error.value = "";
314
+ if (starting.value) { await cancelRecording(); return; }
315
+ if (live.value) {
316
+ live.value = false;
317
+ pushHolding.value = false;
318
+ committing = null;
319
+ clearTimeout(stopTimer);
320
+ await cancelRecording();
321
+ stopSpeech();
322
+ readAloud.value = false;
323
+ void voice.close().catch(cause => { if (!live.value) error.value = cause.message; });
324
+ return;
325
+ }
326
+ if (disposed || microphoneMuted.value || capturing.value || pendingTranscript.value || sending.value) return;
327
+ const controller = new AbortController();
328
+ startupAbort = controller;
329
+ starting.value = true;
330
+ const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(10000)]);
331
+ try {
332
+ // Unlock playback in the click gesture. Live capture uses the same model
333
+ // admission as typed chat and does not need a separate Helper connection.
334
+ await voice.preparePlayback();
335
+ signal.throwIfAborted();
336
+ await voice.connect();
337
+ signal.throwIfAborted();
338
+ if (disposed) return;
339
+ starting.value = false;
340
+ live.value = true;
341
+ readAloud.value = true;
342
+ if (callMode.value === "hands-free") {
343
+ await startRecording(false, true);
344
+ if (startupAbort === controller && !recording?.started) live.value = false;
345
+ }
346
+ } catch (cause) {
347
+ if (startupAbort === controller && !controller.signal.aborted) {
348
+ live.value = false;
349
+ error.value = signal.aborted ? "Voice setup took too long. Try Talk again." : cause.message;
350
+ }
351
+ } finally {
352
+ if (startupAbort === controller) { startupAbort = null; starting.value = false; }
353
+ }
354
+ }
355
+ function retireAnswer(smooth = false) {
356
+ for (const reply of speechQueue) reply.cancelled = true;
357
+ speechQueue.length = 0;
358
+ if (smooth) voice.finishCurrentPhrase();
359
+ else voice.stopSpeaking();
360
+ }
361
+ function currentSpokenText() {
362
+ const reply = speechQueue[0];
363
+ return reply ? reply.raw.slice(0, reply.consumed).slice(-2000) : "";
364
+ }
365
+ function explicitSilence(text) {
366
+ return /^(?:please )?(?:st[oa]p (?:talking|speaking)|be quiet|wait[, ]+let me (?:explain|finish)|let me finish)[.!]?$/iu.test(text.trim());
367
+ }
368
+ function decideTurn() {
369
+ const candidate = voice.endpoint.value;
370
+ if (!live.value || callMode.value !== "hands-free" || !voice.listening.value || microphoneMuted.value || pendingTranscript.value || sending.value || !candidate) return;
371
+ const key = `${candidate.turnId}:${candidate.revision}`;
372
+ if (decidedCandidate === key) return;
373
+ decidedCandidate = key;
374
+ const captured = { focus: { ...(recording?.focus || (binding.captureContext?.() || {})) }, messageId: recording?.messageId || crypto.randomUUID(), text: candidate.text };
375
+ try {
376
+ const text = candidate.text.trim();
377
+ const spoken = currentSpokenText();
378
+ if (explicitSilence(text)) {
379
+ if (!spoken.toLowerCase().includes(text.toLowerCase())) stopSpeech();
380
+ voice.finishUtterance(candidate, { discard: true });
381
+ return;
382
+ }
383
+ // Keep hesitation in the current utterance. Never drop short answers such
384
+ // as yes/no or corrections; the conversation model interprets them.
385
+ if (/(?:^|\s)(?:u+m+|u+h+|e+r+|h+m+)[.!?,…]*$/iu.test(text)) return;
386
+ const acknowledgement = speechActive.value && !spoken.includes("?") && /^(?:m+[- ]?h*m+|u+h[- ]?huh)[.!?,]*$/iu.test(text);
387
+ const words = text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
388
+ const spokenWords = spoken.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
389
+ const echo = speechActive.value && words.split(" ").length >= 4 && ` ${spokenWords} `.includes(` ${words} `);
390
+ if (!words || /^\[(?:cough|sneeze|noise|silence)\]$/iu.test(text) || acknowledgement || echo) {
391
+ voice.finishUtterance(candidate, { discard: true });
392
+ return;
393
+ }
394
+ committing = { ...captured, turnId: candidate.turnId, revision: candidate.revision };
395
+ if (!voice.finishUtterance(candidate)) { committing = null; return; }
396
+ // The daemon rechecks the endpoint revision before returning final text.
397
+ // Continue the current phrase while the ordinary request steers the conversation.
398
+ retireAnswer(true);
399
+ } catch (cause) {
400
+ if (live.value && candidate === voice.endpoint.value) {
401
+ live.value = false;
402
+ if (recording) recording.reviewBeforeSend = true;
403
+ error.value = `${cause.message} Tap the microphone to finish and review this recording.`;
404
+ }
405
+ }
406
+ }
407
+ watch([voice.endpoint, pendingTranscript, sending], decideTurn);
408
+ watch(voice.completedUtterance, (utterance) => {
409
+ if (!utterance || !live.value || !committing || committing.turnId !== utterance.turnId || committing.revision !== utterance.revision) return;
410
+ pendingTranscript.value = { ...committing, text: utterance.text };
411
+ committing = null;
412
+ void deliverTranscript();
413
+ });
414
+ watch(voice.utteranceReset, (reset) => {
415
+ // A discard can lose a race with resumed speech. Keep this utterance's
416
+ // identity and destination until the daemon acknowledges the boundary.
417
+ if (reset?.turnId !== voice.activeListenTurnId.value || !live.value || !recording) return;
418
+ recording = { focus: { ...(binding.captureContext?.() || {}) }, messageId: crypto.randomUUID(), started: true, continuous: true };
419
+ });
420
+ watch(voice.partialTranscript, (text, previous) => {
421
+ clearTimeout(stopTimer);
422
+ if (!live.value || microphoneMuted.value) return;
423
+ if (text && !previous && recording) recording.focus = { ...(binding.captureContext?.() || {}) };
424
+ if (!explicitSilence(text) || currentSpokenText().toLowerCase().includes(text.toLowerCase())) return;
425
+ stopTimer = setTimeout(() => {
426
+ if (live.value && !microphoneMuted.value && voice.partialTranscript.value === text) stopSpeech();
427
+ }, 120);
428
+ });
429
+ watch([pendingTranscript, sending], () => {
430
+ if (live.value && callMode.value === "hands-free" && !changingCallMode.value && !microphoneMuted.value && !capturing.value && !pendingTranscript.value && !sending.value && !voice.error.value) void startRecording(false, true);
431
+ });
432
+ async function startHeldRecording() { await startRecording(true); }
433
+ async function finishHeldRecording() {
434
+ holding.value = false;
435
+ if (!recording?.reviewBeforeSend) return;
436
+ try {
437
+ if (voice.listening.value) await voice.stopListening();
438
+ else {
439
+ await cancelRecording();
440
+ error.value = "The microphone was not ready. Hold the avatar again to record.";
441
+ }
442
+ } catch (cause) { await cancelRecording(); error.value = cause.message; }
443
+ }
444
+ async function discardHeldRecording() {
445
+ if (sending.value) return;
446
+ await cancelRecording();
447
+ dismissTranscript();
448
+ }
449
+ watch([voice.captureState, voice.transcript, voice.error], () => {
450
+ if (!recording?.started || capturing.value) return;
451
+ const finished = recording;
452
+ recording = null;
453
+ if (voice.error.value) {
454
+ live.value = false;
455
+ const text = voice.partialTranscript.value.trim() || voice.transcript.value.trim();
456
+ if (text) {
457
+ pendingTranscript.value = { ...finished, text, reviewBeforeSend: true };
458
+ holding.value = false;
459
+ error.value = "The connection interrupted this recording. These words may be incomplete. Review before sending, or discard and record again.";
460
+ }
461
+ return;
462
+ }
463
+ const text = voice.transcript.value.trim();
464
+ if (!text) {
465
+ if (!finished.continuous) error.value = "No speech was recognized. Try again.";
466
+ return;
467
+ }
468
+ if (live.value && !finished.reviewBeforeSend && explicitSilence(text)) {
469
+ stopSpeech();
470
+ return;
471
+ }
472
+ pendingTranscript.value = { ...finished, text };
473
+ if (!finished.reviewBeforeSend) void deliverTranscript();
474
+ else holding.value = false;
475
+ });
476
+ watch(() => ({ messages: binding.state.messages, reply: binding.state.streamingReply,
477
+ text: binding.state.streamingReply?.text }), ({ messages = [], reply }) => {
478
+ if (!binding.id) return;
479
+ // Reconcile an uncertain send by identity, never by equal transcript text.
480
+ if (pendingTranscript.value && messages.some((message) => message.role === "user" && message.id === pendingTranscript.value.messageId)) {
481
+ dismissTranscript();
482
+ }
483
+ for (const message of messages) {
484
+ if (seen.has(message.id)) continue;
485
+ if (message.role === "user" && message.id === speechInvitation) {
486
+ speechSuppressed = false;
487
+ speechInvitation = "";
488
+ }
489
+ if (message.role === "assistant") queueReply(message, true);
490
+ seen.add(message.id);
491
+ }
492
+ if (lastStreamingId && lastStreamingId !== reply?.id && !messages.some(message => message.id === lastStreamingId)) {
493
+ const retired = replies.get(lastStreamingId);
494
+ if (retired && !retired.final && !retired.cancelled) {
495
+ retired.cancelled = true;
496
+ if (speechQueue[0] === retired) voice.finishCurrentPhrase();
497
+ }
498
+ }
499
+ lastStreamingId = reply?.id || "";
500
+ if (reply?.text) {
501
+ queueReply(reply, reply.status === "completed");
502
+ if (!readAloud.value || !primed || reply.autonomous) seen.add(reply.id);
503
+ }
504
+ // Keep only the visible history plus active playback receipts in memory.
505
+ const retained = new Set([...messages.map((message) => message.id), reply?.id, ...speechQueue.map((item) => item.id)]);
506
+ for (const id of seen) if (!retained.has(id)) seen.delete(id);
507
+ for (const id of replies.keys()) if (!retained.has(id)) replies.delete(id);
508
+ primed = true;
509
+ void drainSpeech();
510
+ }, { immediate: true });
511
+ watch(voice.error, (message) => { if (message) stopSpeech(); });
512
+ watch(() => error.value || voice.error.value, (message) => { if (message) binding.onError?.(message); });
513
+ watch([voice.activeSpeechTurnId, voice.canAppendSpeech], () => { void drainSpeech(); });
514
+ watch([pendingTranscript, voice.captureState, voice.partialTranscript], () => {
515
+ const pending = pendingTranscript.value;
516
+ const transcript = pending ? { id: pending.messageId, text: pending.text }
517
+ : recording && ["listening", "transcribing"].includes(voice.captureState.value) && voice.partialTranscript.value
518
+ ? { id: recording.messageId, text: voice.partialTranscript.value } : null;
519
+ binding.onTranscript?.(transcript);
520
+ }, { immediate: true });
521
+ watch(avatarVisual, (visual) => binding.onVisual?.(visual), { immediate: true });
522
+ const page = globalThis.window || globalThis;
523
+ const cancelStartup = () => { if (starting.value) void discardRecording(); else void cancelPushToTalk(); };
524
+ page.addEventListener?.("pagehide", cancelStartup);
525
+ page.addEventListener?.("blur", cancelPushToTalk);
526
+ onScopeDispose(() => {
527
+ page.removeEventListener?.("pagehide", cancelStartup);
528
+ page.removeEventListener?.("blur", cancelPushToTalk);
529
+ startupAbort?.abort(); startupAbort = null;
530
+ binding.onTranscript?.(null); binding.onVisual?.(null); disposed = true; live.value = false; clearTimeout(stopTimer); recording = null; speechQueue.length = 0;
531
+ });
532
+
533
+ async function close() {
534
+ startupAbort?.abort();
535
+ disposed = true;
536
+ live.value = false;
537
+ clearTimeout(stopTimer);
538
+ recording = null;
539
+ committing = null;
540
+ stopSpeech();
541
+ await voice.close();
542
+ }
543
+ function editTranscript(text) {
544
+ if (pendingTranscript.value && !sending.value) pendingTranscript.value = { ...pendingTranscript.value, text: String(text) };
545
+ }
546
+ const hasUnsentSpeech = computed(() => capturing.value || starting.value || sending.value || Boolean(pendingTranscript.value));
547
+ return {
548
+ oneOffTalkMode, targetLabel, voice, readAloud, error, pendingTranscript, heldReview, holding, sending,
549
+ live, starting, callMode, changingCallMode, pushHolding, microphoneMuted, capturing,
550
+ speechActive, talkLabel, liveLabel, callConnection, callStatus, callDetail, callModeBusy,
551
+ microphoneStatus, avatarVisual, callAudioLevel, status, heldTranscript, voiceWords, voiceAnswer,
552
+ stopSpeech, inviteSpeech, toggleReadAloud, deliverTranscript, cancelRecording,
553
+ discardRecording, dismissTranscript, talk, toggleMicrophoneMuted, changeCallMode,
554
+ startPushToTalk, finishPushToTalk, cancelPushToTalk, toggleLive, toggleHandsFree, startHeldRecording,
555
+ finishHeldRecording, discardHeldRecording, close, editTranscript, hasUnsentSpeech
556
+ };
557
+ }
@@ -0,0 +1,63 @@
1
+ import { onScopeDispose, ref, toValue } from "vue";
2
+
3
+ /** Optional hold gesture for a launcher; the shared host still owns capture. */
4
+ export function useVoiceLauncher({ open, enabled = true, onTap = null, onError = () => {} }) {
5
+ const holding = ref(false);
6
+ let input = null;
7
+ let timer;
8
+ let suppressClick = false;
9
+ let revision = 0;
10
+ let session;
11
+ function report(operation) { Promise.resolve(operation).catch(onError); }
12
+ function begin(identity, delay) {
13
+ suppressClick = false;
14
+ input = identity;
15
+ const expected = ++revision;
16
+ timer = setTimeout(() => {
17
+ holding.value = true;
18
+ report((async () => {
19
+ const opened = await open();
20
+ if (expected !== revision || !holding.value || !opened) return;
21
+ session = opened;
22
+ await session.startHeldRecording();
23
+ })());
24
+ }, delay);
25
+ }
26
+ function pointerDown(event) {
27
+ if (!toValue(enabled) || event.button !== 0 || event.isPrimary === false || input !== null) return;
28
+ event.currentTarget.setPointerCapture(event.pointerId);
29
+ begin(event.pointerId, 350);
30
+ }
31
+ function keyDown(event) {
32
+ if (!toValue(enabled) || event.repeat || input !== null || ![" ", "Enter"].includes(event.key)) return;
33
+ begin(event.key, onTap ? 350 : 0);
34
+ }
35
+ function finish(discard) {
36
+ clearTimeout(timer);
37
+ const keyboard = typeof input === "string";
38
+ input = null;
39
+ ++revision;
40
+ if (!holding.value) {
41
+ if (!discard && keyboard && onTap) report(onTap());
42
+ return;
43
+ }
44
+ holding.value = false;
45
+ suppressClick = true;
46
+ if (session) report(discard ? session.discardHeldRecording() : session.finishHeldRecording());
47
+ session = null;
48
+ }
49
+ function pointerUp(event) { if (event.pointerId === input) finish(false); }
50
+ function keyUp(event) { if (event.key === input) finish(false); }
51
+ function cancel(event) {
52
+ if (event?.pointerId !== undefined && event.pointerId !== input) return;
53
+ finish(true);
54
+ }
55
+ function click(event) {
56
+ const suppressed = suppressClick && event.detail !== 0;
57
+ suppressClick = false;
58
+ if (suppressed) { event.preventDefault(); event.stopImmediatePropagation(); return; }
59
+ if (toValue(enabled) && onTap) report(onTap());
60
+ }
61
+ onScopeDispose(cancel);
62
+ return { holding, pointerDown, pointerUp, keyDown, keyUp, cancel, click };
63
+ }
@@ -0,0 +1,58 @@
1
+ const MOUTH_POSES = new Set(["closed", "open", "round", "small", "wide"]);
2
+
3
+ function pcm16ArrayBufferToFloat32(buffer) {
4
+ const source = new DataView(buffer);
5
+ const samples = new Float32Array(Math.floor(source.byteLength / 2));
6
+ for (let index = 0; index < samples.length; index += 1) {
7
+ samples[index] = source.getInt16(index * 2, true) / 32768;
8
+ }
9
+ return samples;
10
+ }
11
+
12
+ function normalizeSpeechSegment(message = {}, sampleRate = 22_050) {
13
+ const sampleCount = Math.max(0, Math.floor(Number(message.sampleCount) || 0));
14
+ const durationMs = sampleCount
15
+ ? (sampleCount / Math.max(1, Number(sampleRate) || 22_050)) * 1_000
16
+ : Math.max(0, Number(message.durationMs) || 0);
17
+ const cues = (Array.isArray(message.cues) ? message.cues : [])
18
+ .map((cue) => ({
19
+ atMs: Math.min(durationMs, Math.max(0, Number(cue?.atMs) || 0)),
20
+ level: Math.min(1, Math.max(0, Number(cue?.level) || 0)),
21
+ pose: MOUTH_POSES.has(String(cue?.pose || "")) ? String(cue.pose) : "closed"
22
+ }))
23
+ .sort((left, right) => left.atMs - right.atMs);
24
+ return {
25
+ cues,
26
+ durationMs,
27
+ remainingSamples: sampleCount,
28
+ sampleCount,
29
+ startedAt: null
30
+ };
31
+ }
32
+
33
+ function visemeCueAtTime(segments = [], currentTime = 0) {
34
+ const segment = segments.find((entry) => (
35
+ Number.isFinite(entry?.startedAt) &&
36
+ currentTime >= entry.startedAt &&
37
+ currentTime <= entry.startedAt + (entry.durationMs / 1_000)
38
+ ));
39
+ if (!segment) {
40
+ return null;
41
+ }
42
+ const elapsedMs = Math.max(0, (currentTime - segment.startedAt) * 1_000);
43
+ let active = null;
44
+ for (const cue of segment.cues) {
45
+ if (cue.atMs > elapsedMs) {
46
+ break;
47
+ }
48
+ active = cue;
49
+ }
50
+ return active;
51
+ }
52
+
53
+ export {
54
+ MOUTH_POSES,
55
+ normalizeSpeechSegment,
56
+ pcm16ArrayBufferToFloat32,
57
+ visemeCueAtTime
58
+ };