@jskit-ai/assistant-voice 0.1.9 → 0.1.11

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
@@ -82,13 +82,18 @@ The host's `avatar` slot receives state, mouthLevel, mouthPose, avatar and bindi
82
82
 
83
83
  Opening a target does not start capture. Tap Talk for hands-free, or hold it for
84
84
  push-to-talk and release to send; connection setup is automatic.
85
- Pause finishes the current utterance, flushing the audio tail before muting.
85
+ Pause stops new microphone input immediately and retains its buffered audio for
86
+ the original final flush before recognition finishes the current utterance.
86
87
  Completed words follow the captured review policy. Pending delivery does not disable
87
88
  the microphone: tap can resume the existing continuous capture or start another
88
89
  one while preserving the prior pending transcript. A busy Pause or hold release
89
- mutes newer capture and finishes it through the original owner after that prior
90
- admission settles. Resuming first keeps the capture open. No pending words are
90
+ mutes newer capture without discarding its buffered tail and finishes it through
91
+ the original owner after that prior admission settles. Resuming first keeps the
92
+ capture and its retained tail open. No pending words are
91
93
  replaced or appended. The icon reflects actual unmuted capture, not readiness.
94
+ When the daemon rejects a stale endpoint commit, its exact turn and revision
95
+ release only that commit. A waiting Pause can then finalize the same recording;
96
+ an obsolete acknowledgement cannot release a newer commit or submit provisional words.
92
97
  Automatic sends keep the call layout fixed. A failed send exposes the retained
93
98
  transcript for review and retry with the same message identity. An application
94
99
  can call `beginTranscriptEdit(messageId)` for its exact unsent preview; this
@@ -9,14 +9,14 @@
9
9
  },
10
10
  "dependencies": {
11
11
  "@fastify/websocket": "^11.2.0",
12
- "@jskit-ai/assistant-core": "0.1.209",
13
- "@jskit-ai/assistant-runtime": "0.1.202",
14
- "@jskit-ai/assistant-voice": "0.1.9",
15
- "@jskit-ai/connectors-catalog": "0.1.48",
16
- "@jskit-ai/http-runtime": "0.1.229",
17
- "@jskit-ai/kernel": "0.1.231",
18
- "@jskit-ai/realtime": "0.1.228",
19
- "@jskit-ai/shell-web": "0.1.235",
12
+ "@jskit-ai/assistant-core": "0.1.211",
13
+ "@jskit-ai/assistant-runtime": "0.1.204",
14
+ "@jskit-ai/assistant-voice": "0.1.11",
15
+ "@jskit-ai/connectors-catalog": "0.1.50",
16
+ "@jskit-ai/http-runtime": "0.1.231",
17
+ "@jskit-ai/kernel": "0.1.233",
18
+ "@jskit-ai/realtime": "0.1.230",
19
+ "@jskit-ai/shell-web": "0.1.237",
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.9",
3
+ "version": "0.1.11",
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.209",
21
+ "@jskit-ai/assistant-core": "0.1.211",
22
22
  "@mdi/js": "^7.4.47",
23
23
  "ws": "^8.20.1"
24
24
  },
@@ -12,8 +12,10 @@ class AssistantVoiceCapture extends AudioWorkletProcessor {
12
12
  this.port.onmessage = (event) => {
13
13
  if (event.data?.type === "mute") {
14
14
  this.muted = event.data.muted;
15
- this.frames = [];
16
- this.length = 0;
15
+ if (!event.data.preserveBuffered) {
16
+ this.frames = [];
17
+ this.length = 0;
18
+ }
17
19
  return;
18
20
  }
19
21
  if (event.data?.type !== "flush") return;
@@ -108,12 +110,13 @@ async function createMicrophoneCapture({ onLevel, onPcm, targetSampleRate = 16_0
108
110
  silence.gain.value = 0;
109
111
  let finishFlush = null;
110
112
  let muted = false;
113
+ let preserveBuffered = false;
111
114
  capture.port.onmessage = (event) => {
112
115
  if (event.data?.type === "flushed") {
113
116
  finishFlush?.();
114
117
  return;
115
118
  }
116
- if (muted) return;
119
+ if (muted && !preserveBuffered) return;
117
120
  const samples = event.data instanceof Float32Array
118
121
  ? event.data
119
122
  : new Float32Array(event.data);
@@ -154,11 +157,14 @@ async function createMicrophoneCapture({ onLevel, onPcm, targetSampleRate = 16_0
154
157
  }
155
158
  });
156
159
  return Object.freeze({
157
- setMuted(value) {
158
- if (muted === Boolean(value)) return;
160
+ setMuted(value, { preserveBuffered: retainTail = false } = {}) {
161
+ const nextPreserveBuffered = Boolean(value) && retainTail;
162
+ if (muted === Boolean(value) && preserveBuffered === nextPreserveBuffered) return;
163
+ const keepBuffered = nextPreserveBuffered || (!value && preserveBuffered);
159
164
  muted = Boolean(value);
165
+ preserveBuffered = nextPreserveBuffered;
160
166
  for (const track of stream.getTracks()) track.enabled = !muted;
161
- capture.port.postMessage({ type: "mute", muted });
167
+ capture.port.postMessage({ type: "mute", muted, ...(keepBuffered ? { preserveBuffered: true } : {}) });
162
168
  if (muted) onLevel?.(0);
163
169
  },
164
170
  close() {
@@ -409,10 +409,10 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
409
409
  if (recording === next) { next.started = started; if (!started) recording = null; }
410
410
  } catch (cause) { if (recording === next) { recording = null; error.value = cause.message; } }
411
411
  }
412
- async function toggleMicrophoneMuted() {
412
+ async function toggleMicrophoneMuted({ preserveBuffered = false } = {}) {
413
413
  // A person's explicit microphone choice supersedes an Edit-owned pause.
414
414
  if (pendingTranscript.value) delete pendingTranscript.value.editPausedCaptureId;
415
- voice.setMicrophoneMuted(!microphoneMuted.value);
415
+ voice.setMicrophoneMuted(!microphoneMuted.value, { preserveBuffered });
416
416
  if (!microphoneMuted.value && recording) delete recording.finishAfterPending;
417
417
  clearTimeout(stopTimer);
418
418
  if (microphoneMuted.value && starting.value) await cancelRecording();
@@ -455,7 +455,7 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
455
455
  try {
456
456
  if (voice.listening.value && (pendingTranscript.value || sending.value || committing)) {
457
457
  if (recording) recording.finishAfterPending = true;
458
- voice.setMicrophoneMuted(true);
458
+ voice.setMicrophoneMuted(true, { preserveBuffered: true });
459
459
  return;
460
460
  }
461
461
  if (voice.listening.value) await voice.stopListening();
@@ -474,7 +474,7 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
474
474
  // Pause must finish the newer recording after the existing admission,
475
475
  // without replacing the one pending transcript or stopping output.
476
476
  if (!microphoneMuted.value && recording) recording.finishAfterPending = true;
477
- await toggleMicrophoneMuted();
477
+ await toggleMicrophoneMuted({ preserveBuffered: true });
478
478
  return;
479
479
  }
480
480
  if (committing) return;
@@ -482,10 +482,10 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
482
482
  if (capturing.value && !voice.listening.value) return;
483
483
  if (live.value && callMode.value === "hands-free") {
484
484
  if (!microphoneMuted.value && voice.listening.value) {
485
- // Pause is an explicit utterance boundary. Flush before muting so the
486
- // final sound is included; resuming starts a fresh recording.
485
+ // Stop new input immediately, retaining the captured tail for the
486
+ // original Stop drain. Resuming starts a fresh recording.
487
+ voice.setMicrophoneMuted(true, { preserveBuffered: true });
487
488
  await voice.stopListening();
488
- voice.setMicrophoneMuted(true);
489
489
  } else if (microphoneMuted.value) await toggleMicrophoneMuted();
490
490
  else await startRecording(false, true);
491
491
  return;
@@ -608,7 +608,11 @@ export function useVoiceConversation(binding, { socketUrl, createTransport = use
608
608
  if (live.value && !microphoneMuted.value && voice.partialTranscript.value === text) stopSpeech();
609
609
  }, 120);
610
610
  });
611
- watch([pendingTranscript, sending, voice.utteranceReset], () => {
611
+ watch([pendingTranscript, sending, voice.utteranceReset, voice.utteranceStale], () => {
612
+ const stale = voice.utteranceStale.value;
613
+ if (committing && stale?.turnId === committing.turnId && stale.revision === committing.revision) {
614
+ committing = null;
615
+ }
612
616
  if (!disposed && live.value && !pushHolding.value && !changingCallMode.value &&
613
617
  !pendingTranscript.value && !sending.value && !committing && microphoneMuted.value &&
614
618
  recording?.finishAfterPending && voice.listening.value) {
@@ -41,6 +41,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
41
41
  const endpoint = ref(null);
42
42
  const completedUtterance = ref(null);
43
43
  const utteranceReset = ref(null);
44
+ const utteranceStale = ref(null);
44
45
  const partialTranscript = ref("");
45
46
  const muted = ref(false);
46
47
  const microphoneMuted = ref(false);
@@ -63,6 +64,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
63
64
  let lastPongAt = 0;
64
65
  let lastSpeechProgressAt = 0;
65
66
  let microphone = null;
67
+ let preserveBufferedMicrophone = false;
66
68
  const openingMicrophones = new Set();
67
69
  let microphoneClaim = null;
68
70
  const speechClaim = Symbol("voice playback");
@@ -444,6 +446,8 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
444
446
  utteranceReset.value = { turnId: message.turnId, revision: message.revision };
445
447
  transcript.value = "";
446
448
  partialTranscript.value = "";
449
+ } else {
450
+ utteranceStale.value = { turnId: message.turnId, revision: message.revision };
447
451
  }
448
452
  return;
449
453
  }
@@ -629,6 +633,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
629
633
  }
630
634
  microphoneClaim = Symbol("voice microphone");
631
635
  microphoneOwner = microphoneClaim;
636
+ preserveBufferedMicrophone = false;
632
637
  const turnId = voiceTurnId("listen");
633
638
  pendingListenTurnId = turnId;
634
639
  if (interruptSpeechOnListen) stopSpeaking();
@@ -647,7 +652,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
647
652
  }
648
653
  },
649
654
  onPcm(frame) {
650
- if (!microphoneMuted.value && socket?.readyState === WEBSOCKET_OPEN && activeListenTurnId.value === turnId) {
655
+ if ((!microphoneMuted.value || preserveBufferedMicrophone) && socket?.readyState === WEBSOCKET_OPEN && activeListenTurnId.value === turnId) {
651
656
  if (socket.bufferedAmount > 32 * 1024) {
652
657
  connectionLost("The microphone connection fell behind. Review the unfinished recording before sending.");
653
658
  return;
@@ -843,9 +848,10 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
843
848
  muted.value = !muted.value;
844
849
  }
845
850
 
846
- function setMicrophoneMuted(value) {
851
+ function setMicrophoneMuted(value, { preserveBuffered = false } = {}) {
847
852
  microphoneMuted.value = Boolean(value);
848
- microphone?.setMuted(microphoneMuted.value);
853
+ preserveBufferedMicrophone = microphoneMuted.value && preserveBuffered;
854
+ microphone?.setMuted(microphoneMuted.value, { preserveBuffered });
849
855
  if (microphoneMuted.value) inputLevel.value = 0;
850
856
  }
851
857
 
@@ -900,6 +906,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
900
906
  captureState,
901
907
  completedUtterance,
902
908
  utteranceReset,
909
+ utteranceStale,
903
910
  endpoint,
904
911
  finishUtterance,
905
912
  finishCurrentPhrase,