@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.
- package/README.md +38 -2
- package/examples/conversation/App.vue +2 -1
- package/examples/conversation/package.json +8 -8
- package/package.json +2 -2
- package/src/client/ConversationDialog.vue +23 -12
- package/src/client/VoiceConversation.vue +41 -15
- package/src/client/VoiceConversationControls.vue +102 -40
- package/src/client/VoiceConversationHost.vue +32 -31
- package/src/client/VoiceConversationSettings.vue +52 -0
- package/src/client/index.js +1 -0
- package/src/client/voiceConversation.js +223 -65
- package/src/client/voiceTransport.js +58 -10
|
@@ -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
|
|
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
|
-
|
|
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,
|