@jskit-ai/assistant-voice 0.1.3 → 0.1.5

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.
@@ -29,13 +29,14 @@ function resolveWebSocketUrl(value) {
29
29
  return url.toString();
30
30
  }
31
31
 
32
- function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnListen = false, autoReconnect = false, voiceId = "" } = {}) {
32
+ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnListen = false, autoReconnect = false, voiceId = "", onPlayback } = {}) {
33
33
  const availableVoices = ref([]);
34
34
  const selectedVoice = ref(voiceId);
35
35
  const connectionState = ref("idle");
36
36
  const captureState = ref("idle");
37
37
  const speechState = ref("idle");
38
38
  const error = ref("");
39
+ const playbackBlocked = ref(false);
39
40
  const transcript = ref("");
40
41
  const endpoint = ref(null);
41
42
  const completedUtterance = ref(null);
@@ -71,8 +72,10 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
71
72
  let playbackGain = null;
72
73
  let playbackAnalyser = null;
73
74
  let playbackFrame = 0;
75
+ let playbackPreparation = 0;
74
76
  let nextPlaybackTime = 0;
75
77
  let playbackEpoch = 0;
78
+ let playbackReceipt = null;
76
79
  let playbackQueue = Promise.resolve();
77
80
  let queuedPlaybackFrames = 0;
78
81
  let speechStreamEnded = false;
@@ -102,12 +105,31 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
102
105
  const canAppendSpeech = computed(() => Boolean(activeSpeechTurnId.value) && !speechInputEnded.value &&
103
106
  synthesisPending.value < 2 && bufferedSeconds.value < 4);
104
107
 
105
- function setError(message = "") {
108
+ function emitPlayback(phase, reason) {
109
+ const receipt = playbackReceipt;
110
+ if (!receipt || receipt.terminal || (phase === "started" && receipt.started)) return;
111
+ if (phase === "started") receipt.started = true;
112
+ else receipt.terminal = true;
113
+ try {
114
+ Promise.resolve(onPlayback?.({
115
+ turnId: receipt.turnId, phase, ...(reason ? { reason } : {})
116
+ })).catch(() => {});
117
+ } catch {
118
+ // Observers cannot take ownership of playback or cleanup.
119
+ }
120
+ }
121
+
122
+ function observePlaybackStart(source) {
123
+ if (!source.voiceCancelled && playbackContext.state === "running" &&
124
+ playbackContext.currentTime >= source.voiceStartsAt) emitPlayback("started");
125
+ }
126
+
127
+ function setError(message = "", reason = "playback-error") {
106
128
  pendingListenTurnId = "";
107
129
  endpoint.value = null;
108
130
  activeListenTurnId.value = "";
109
131
  void closeMicrophone().catch(() => null);
110
- stopPlayback();
132
+ stopPlayback("failed", reason);
111
133
  error.value = String(message || "Voice could not continue.");
112
134
  captureState.value = "idle";
113
135
  connectionState.value = "idle";
@@ -129,7 +151,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
129
151
  ? "Voice connection was interrupted. Your unfinished recording needs review; use Talk to resume."
130
152
  : "Voice connection was interrupted. The answer remains in chat; use Talk to resume.";
131
153
  const wasActive = Boolean(activeListenTurnId.value || pendingListenTurnId || activeSpeechTurnId.value || reconnecting.value);
132
- setError(message);
154
+ setError(message, "disconnected");
133
155
  disconnect({ preserveError: true });
134
156
  recoveryWanted = autoReconnect && wasActive;
135
157
  scheduleRecovery();
@@ -190,6 +212,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
190
212
  return total / Math.max(1, end - start);
191
213
  };
192
214
  const update = () => {
215
+ for (const source of playbackSources) observePlaybackStart(source);
193
216
  bufferedSeconds.value = Math.max(0, nextPlaybackTime - playbackContext.currentTime);
194
217
  playbackAnalyser.getFloatTimeDomainData(samples);
195
218
  playbackAnalyser.getByteFrequencyData(spectrum);
@@ -235,6 +258,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
235
258
  async function preparePlayback() {
236
259
  const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
237
260
  if (!AudioContextCtor) {
261
+ playbackBlocked.value = true;
238
262
  throw new Error("This browser does not support streamed audio playback.");
239
263
  }
240
264
  if (!playbackContext || playbackContext.state === "closed") {
@@ -247,13 +271,29 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
247
271
  playbackAnalyser.connect(playbackContext.destination);
248
272
  }
249
273
  playbackGain.gain.value = speechOutputEnabled.value ? 1 : 0;
250
- await playbackContext.resume();
274
+ const context = playbackContext;
275
+ const preparation = ++playbackPreparation;
276
+ try {
277
+ const resumed = context.resume();
278
+ if (preparation === playbackPreparation && context.state !== "running") playbackBlocked.value = true;
279
+ await resumed;
280
+ if (context.state !== "running") throw new Error("Sound is blocked by this browser.");
281
+ if (preparation === playbackPreparation && !unmounted) playbackBlocked.value = false;
282
+ } catch (cause) {
283
+ // A newer unlock of this same context supersedes an older resume failure.
284
+ if (preparation !== playbackPreparation && context === playbackContext &&
285
+ context.state === "running" && !unmounted) return;
286
+ if (preparation === playbackPreparation && !unmounted) playbackBlocked.value = true;
287
+ throw cause;
288
+ }
251
289
  }
252
290
 
253
291
  function finishPlaybackWhenDrained() {
254
292
  if (!activeSpeechTurnId.value || !speechStreamEnded || playbackSources.size || queuedPlaybackFrames) {
255
293
  return;
256
294
  }
295
+ if (playbackReceipt?.started) emitPlayback("completed");
296
+ else emitPlayback("failed", "playback-error");
257
297
  activeSpeechTurnId.value = "";
258
298
  speechState.value = "idle";
259
299
  if (speechOwner.value === speechClaim) speechOwner.value = null;
@@ -312,6 +352,8 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
312
352
  source.onended = () => {
313
353
  source.disconnect();
314
354
  if (epoch !== playbackEpoch) return;
355
+ // A natural end also observes very short sounds between animation frames.
356
+ if (!source.voiceCancelled && playbackContext.state === "running") emitPlayback("started");
315
357
  playbackSources.delete(source);
316
358
  bufferedSeconds.value = playbackSources.size ? Math.max(0, nextPlaybackTime - playbackContext.currentTime) : 0;
317
359
  if (!playbackSources.size) speechState.value = "thinking";
@@ -337,7 +379,8 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
337
379
  });
338
380
  }
339
381
 
340
- function stopPlayback() {
382
+ function stopPlayback(phase = "interrupted", reason = "cancelled") {
383
+ emitPlayback(phase, reason);
341
384
  playbackEpoch += 1;
342
385
  activeSpeechTurnId.value = "";
343
386
  speechState.value = "idle";
@@ -347,6 +390,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
347
390
  queuedPlaybackFrames = 0;
348
391
  for (const source of playbackSources) {
349
392
  try {
393
+ source.voiceCancelled = true;
350
394
  source.stop();
351
395
  } catch {
352
396
  // A source that already ended needs no further cleanup.
@@ -681,6 +725,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
681
725
  }
682
726
  stopPlayback();
683
727
  const epoch = playbackEpoch;
728
+ playbackReceipt = { turnId, started: false, terminal: false };
684
729
  activeSpeechTurnId.value = turnId;
685
730
  speechState.value = "thinking";
686
731
  try {
@@ -708,7 +753,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
708
753
  return true;
709
754
  } catch (speechError) {
710
755
  if (epoch !== playbackEpoch) return false;
711
- stopPlayback();
756
+ stopPlayback("failed", "playback-error");
712
757
  speechState.value = "idle";
713
758
  throw speechError;
714
759
  }
@@ -732,6 +777,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
732
777
 
733
778
  function finishCurrentPhrase() {
734
779
  if (!activeSpeechTurnId.value) return;
780
+ emitPlayback("interrupted", "cancelled");
735
781
  const now = playbackContext?.currentTime || 0;
736
782
  const current = [...playbackSources].find(source => source.voiceEndsAt > now);
737
783
  if (!current?.voiceSegment) { stopSpeaking(); return; }
@@ -739,6 +785,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
739
785
  speechInputEnded.value = true;
740
786
  for (const source of [...playbackSources]) {
741
787
  if (source.voiceSegment?.index <= stopAfterSegment) continue;
788
+ source.voiceCancelled = true;
742
789
  source.stop();
743
790
  playbackSources.delete(source);
744
791
  }
@@ -747,10 +794,10 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
747
794
  sendControl({ type: "speak.stop-after", turnId: activeSpeechTurnId.value, segmentIndex: stopAfterSegment });
748
795
  }
749
796
 
750
- function stopSpeaking() {
797
+ function stopSpeaking(reason = "cancelled") {
751
798
  const turnId = activeSpeechTurnId.value;
752
799
  const pending = turnId && !ready.value && !activeListenTurnId.value && !pendingListenTurnId;
753
- stopPlayback();
800
+ stopPlayback("interrupted", reason);
754
801
  if (turnId && socket?.readyState === WEBSOCKET_OPEN) sendControl({ turnId, type: "cancel" });
755
802
  if (pending) disconnect();
756
803
  }
@@ -807,7 +854,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
807
854
  playbackGain.gain.value = enabled ? 1 : 0;
808
855
  }
809
856
  if (!enabled && activeSpeechTurnId.value) {
810
- stopSpeaking();
857
+ stopSpeaking("muted");
811
858
  }
812
859
  }, { flush: "sync" });
813
860
  watch(selectedSocketPath, () => {
@@ -869,6 +916,7 @@ function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnL
869
916
  microphoneMuted,
870
917
  muted,
871
918
  partialTranscript,
919
+ playbackBlocked,
872
920
  preparePlayback,
873
921
  ready,
874
922
  reconnecting,