@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.
- package/README.md +169 -2
- package/examples/conversation/App.vue +94 -0
- package/examples/conversation/Conversation.vue +28 -0
- package/examples/conversation/README.md +181 -0
- package/examples/conversation/config.js +15 -0
- package/examples/conversation/index.html +1 -0
- package/examples/conversation/integrations.example.json +16 -0
- package/examples/conversation/main.js +58 -0
- package/examples/conversation/package.json +32 -0
- package/examples/conversation/server.js +105 -0
- package/examples/conversation/src/error.js +1 -0
- package/examples/conversation/src/placement.js +1 -0
- package/examples/conversation/src/placementTopology.js +1 -0
- package/examples/conversation/vite.config.js +9 -0
- package/package.json +67 -4
- package/src/client/ConversationDialog.vue +67 -0
- package/src/client/VoiceAvatar.vue +18 -0
- package/src/client/VoiceConversation.vue +117 -0
- package/src/client/VoiceConversationHost.vue +78 -0
- package/src/client/audioCapture.js +198 -0
- package/src/client/conversationNarration.js +108 -0
- package/src/client/conversationVoiceState.js +18 -0
- package/src/client/index.js +9 -0
- package/src/client/voiceAvatar.js +234 -0
- package/src/client/voiceController.js +124 -0
- package/src/client/voiceConversation.js +557 -0
- package/src/client/voiceLauncher.js +63 -0
- package/src/client/voicePlayback.js +58 -0
- package/src/client/voiceTransport.js +890 -0
- package/src/server/index.js +5 -0
- package/src/server/voiceAccessToken.js +79 -0
- package/src/server/voiceDaemon.js +599 -0
- package/src/server/voiceProxy.js +199 -0
- package/src/server/voiceProxyConfig.js +70 -0
- package/src/server/voiceSocketClose.js +31 -0
- package/src/shared/protocol.js +439 -0
- package/src/shared/visemes.js +130 -0
|
@@ -0,0 +1,890 @@
|
|
|
1
|
+
import { computed, onScopeDispose, ref, watch } from "vue";
|
|
2
|
+
|
|
3
|
+
import { speechTextFromAssistant } from "../shared/protocol.js";
|
|
4
|
+
import { createMicrophoneCapture } from "./audioCapture.js";
|
|
5
|
+
import {
|
|
6
|
+
normalizeSpeechSegment,
|
|
7
|
+
pcm16ArrayBufferToFloat32,
|
|
8
|
+
visemeCueAtTime
|
|
9
|
+
} from "./voicePlayback.js";
|
|
10
|
+
|
|
11
|
+
const INPUT_SAMPLE_RATE = 16_000;
|
|
12
|
+
const WEBSOCKET_OPEN = 1;
|
|
13
|
+
let microphoneOwner = null;
|
|
14
|
+
const speechOwner = ref(null);
|
|
15
|
+
|
|
16
|
+
function voiceTurnId(prefix = "turn") {
|
|
17
|
+
const id = globalThis.crypto?.randomUUID?.() ||
|
|
18
|
+
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
19
|
+
return `${prefix}-${id}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resolveWebSocketUrl(value) {
|
|
23
|
+
const url = new URL(value, globalThis.location?.href);
|
|
24
|
+
if (url.protocol === "http:") url.protocol = "ws:";
|
|
25
|
+
if (url.protocol === "https:") url.protocol = "wss:";
|
|
26
|
+
if (!["ws:", "wss:"].includes(url.protocol) || url.username || url.password || url.hash) {
|
|
27
|
+
throw new TypeError("Voice requires a ws:// or wss:// endpoint without embedded credentials.");
|
|
28
|
+
}
|
|
29
|
+
return url.toString();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function useVoiceTransport({ socketUrl, speechEnabled = true, interruptSpeechOnListen = false, autoReconnect = false, voiceId = "" } = {}) {
|
|
33
|
+
const availableVoices = ref([]);
|
|
34
|
+
const selectedVoice = ref(voiceId);
|
|
35
|
+
const connectionState = ref("idle");
|
|
36
|
+
const captureState = ref("idle");
|
|
37
|
+
const speechState = ref("idle");
|
|
38
|
+
const error = ref("");
|
|
39
|
+
const transcript = ref("");
|
|
40
|
+
const endpoint = ref(null);
|
|
41
|
+
const completedUtterance = ref(null);
|
|
42
|
+
const utteranceReset = ref(null);
|
|
43
|
+
const partialTranscript = ref("");
|
|
44
|
+
const muted = ref(false);
|
|
45
|
+
const microphoneMuted = ref(false);
|
|
46
|
+
const inputLevel = ref(0);
|
|
47
|
+
const mouthLevel = ref(0);
|
|
48
|
+
const mouthPose = ref("closed");
|
|
49
|
+
const ready = ref(false);
|
|
50
|
+
const reconnecting = ref(false);
|
|
51
|
+
const bufferedSeconds = ref(0);
|
|
52
|
+
const synthesisPending = ref(0);
|
|
53
|
+
const activeListenTurnId = ref("");
|
|
54
|
+
const activeSpeechTurnId = ref("");
|
|
55
|
+
let socket = null;
|
|
56
|
+
let connectedSocketPath = "";
|
|
57
|
+
let connectPromise = null;
|
|
58
|
+
let recoveryTimer;
|
|
59
|
+
let recoveryAttempt = 0;
|
|
60
|
+
let recoveryWanted = false;
|
|
61
|
+
let heartbeatTimer;
|
|
62
|
+
let lastPongAt = 0;
|
|
63
|
+
let lastSpeechProgressAt = 0;
|
|
64
|
+
let microphone = null;
|
|
65
|
+
const openingMicrophones = new Set();
|
|
66
|
+
let microphoneClaim = null;
|
|
67
|
+
const speechClaim = Symbol("voice playback");
|
|
68
|
+
let pendingListenTurnId = "";
|
|
69
|
+
let unmounted = false;
|
|
70
|
+
let playbackContext = null;
|
|
71
|
+
let playbackGain = null;
|
|
72
|
+
let playbackAnalyser = null;
|
|
73
|
+
let playbackFrame = 0;
|
|
74
|
+
let nextPlaybackTime = 0;
|
|
75
|
+
let playbackEpoch = 0;
|
|
76
|
+
let playbackQueue = Promise.resolve();
|
|
77
|
+
let queuedPlaybackFrames = 0;
|
|
78
|
+
let speechStreamEnded = false;
|
|
79
|
+
let speechSampleRate = 22_050;
|
|
80
|
+
let receivingTurnId = "";
|
|
81
|
+
let receivingSegment = null;
|
|
82
|
+
const speechInputEnded = ref(true);
|
|
83
|
+
let stopAfterSegment = Infinity;
|
|
84
|
+
const streamedSegments = new Map();
|
|
85
|
+
let pendingVisemeSegments = [];
|
|
86
|
+
let scheduledVisemeSegments = [];
|
|
87
|
+
const playbackSources = new Set();
|
|
88
|
+
|
|
89
|
+
const speechOutputEnabled = computed(() => {
|
|
90
|
+
const enabled = typeof speechEnabled === "function"
|
|
91
|
+
? speechEnabled()
|
|
92
|
+
: speechEnabled?.value ?? speechEnabled;
|
|
93
|
+
return enabled !== false && !muted.value;
|
|
94
|
+
});
|
|
95
|
+
const selectedSocketPath = computed(() => typeof socketUrl === "function" ? socketUrl() : socketUrl?.value || socketUrl || "");
|
|
96
|
+
const state = computed(() => error.value ? "error" : captureState.value !== "idle" ? captureState.value
|
|
97
|
+
: speechState.value !== "idle" ? speechState.value : connectionState.value);
|
|
98
|
+
const listening = computed(() => captureState.value === "listening");
|
|
99
|
+
const speaking = computed(() => speechState.value === "speaking");
|
|
100
|
+
const busy = computed(() => captureState.value !== "idle" || connectionState.value === "connecting" || speechState.value === "thinking");
|
|
101
|
+
|
|
102
|
+
const canAppendSpeech = computed(() => Boolean(activeSpeechTurnId.value) && !speechInputEnded.value &&
|
|
103
|
+
synthesisPending.value < 2 && bufferedSeconds.value < 4);
|
|
104
|
+
|
|
105
|
+
function setError(message = "") {
|
|
106
|
+
pendingListenTurnId = "";
|
|
107
|
+
endpoint.value = null;
|
|
108
|
+
activeListenTurnId.value = "";
|
|
109
|
+
void closeMicrophone().catch(() => null);
|
|
110
|
+
stopPlayback();
|
|
111
|
+
error.value = String(message || "Voice could not continue.");
|
|
112
|
+
captureState.value = "idle";
|
|
113
|
+
connectionState.value = "idle";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function scheduleRecovery() {
|
|
117
|
+
if (!autoReconnect || unmounted || !recoveryWanted || recoveryTimer) return;
|
|
118
|
+
if (recoveryAttempt >= 5) { reconnecting.value = false; return; }
|
|
119
|
+
reconnecting.value = true;
|
|
120
|
+
recoveryTimer = setTimeout(() => {
|
|
121
|
+
recoveryTimer = null;
|
|
122
|
+
recoveryAttempt += 1;
|
|
123
|
+
void connect({ recovery: true }).catch(() => scheduleRecovery());
|
|
124
|
+
}, Math.min(8000, 750 * 2 ** recoveryAttempt) + Math.floor(Math.random() * 250));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function connectionLost(message) {
|
|
128
|
+
message ||= activeListenTurnId.value || pendingListenTurnId
|
|
129
|
+
? "Voice connection was interrupted. Your unfinished recording needs review; use Talk to resume."
|
|
130
|
+
: "Voice connection was interrupted. The answer remains in chat; use Talk to resume.";
|
|
131
|
+
const wasActive = Boolean(activeListenTurnId.value || pendingListenTurnId || activeSpeechTurnId.value || reconnecting.value);
|
|
132
|
+
setError(message);
|
|
133
|
+
disconnect({ preserveError: true });
|
|
134
|
+
recoveryWanted = autoReconnect && wasActive;
|
|
135
|
+
scheduleRecovery();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function heartbeat() {
|
|
139
|
+
if (!activeListenTurnId.value && !activeSpeechTurnId.value) { lastPongAt = Date.now(); return; }
|
|
140
|
+
if (Date.now() - lastPongAt > 12000) {
|
|
141
|
+
connectionLost();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (activeSpeechTurnId.value && synthesisPending.value && Date.now() - lastSpeechProgressAt > 20000) {
|
|
145
|
+
connectionLost("Speech generation stalled. The answer remains in chat; use Talk to resume.");
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (socket?.readyState === WEBSOCKET_OPEN) sendControl({ type: "ping", turnId: "heartbeat" });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function sendControl(payload) {
|
|
152
|
+
if (socket?.readyState !== WEBSOCKET_OPEN) {
|
|
153
|
+
throw new Error("Voice is not connected.");
|
|
154
|
+
}
|
|
155
|
+
socket.send(JSON.stringify(payload));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function closeMicrophone() {
|
|
159
|
+
const current = microphone;
|
|
160
|
+
const claim = microphoneClaim;
|
|
161
|
+
microphone = null;
|
|
162
|
+
inputLevel.value = 0;
|
|
163
|
+
try { await current?.close?.(); }
|
|
164
|
+
finally { if (microphoneOwner === claim) microphoneOwner = null; }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function stopMouthMeter() {
|
|
168
|
+
if (playbackFrame) {
|
|
169
|
+
cancelAnimationFrame(playbackFrame);
|
|
170
|
+
playbackFrame = 0;
|
|
171
|
+
}
|
|
172
|
+
mouthLevel.value = 0;
|
|
173
|
+
mouthPose.value = "closed";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function startMouthMeter() {
|
|
177
|
+
if (playbackFrame || !playbackAnalyser) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const samples = new Float32Array(playbackAnalyser.fftSize);
|
|
181
|
+
const spectrum = new Uint8Array(playbackAnalyser.frequencyBinCount);
|
|
182
|
+
const averageBand = (fromHz, toHz) => {
|
|
183
|
+
const binWidth = playbackContext.sampleRate / playbackAnalyser.fftSize;
|
|
184
|
+
const start = Math.max(0, Math.floor(fromHz / binWidth));
|
|
185
|
+
const end = Math.min(spectrum.length, Math.ceil(toHz / binWidth));
|
|
186
|
+
let total = 0;
|
|
187
|
+
for (let index = start; index < end; index += 1) {
|
|
188
|
+
total += spectrum[index];
|
|
189
|
+
}
|
|
190
|
+
return total / Math.max(1, end - start);
|
|
191
|
+
};
|
|
192
|
+
const update = () => {
|
|
193
|
+
bufferedSeconds.value = Math.max(0, nextPlaybackTime - playbackContext.currentTime);
|
|
194
|
+
playbackAnalyser.getFloatTimeDomainData(samples);
|
|
195
|
+
playbackAnalyser.getByteFrequencyData(spectrum);
|
|
196
|
+
let sum = 0;
|
|
197
|
+
for (const sample of samples) {
|
|
198
|
+
sum += sample * sample;
|
|
199
|
+
}
|
|
200
|
+
const level = Math.min(1, Math.sqrt(sum / samples.length) * 7);
|
|
201
|
+
scheduledVisemeSegments = scheduledVisemeSegments.filter((segment) => (
|
|
202
|
+
playbackContext.currentTime <= segment.startedAt + (segment.durationMs / 1_000) + 0.5
|
|
203
|
+
));
|
|
204
|
+
const viseme = visemeCueAtTime(scheduledVisemeSegments, playbackContext.currentTime);
|
|
205
|
+
mouthLevel.value = viseme
|
|
206
|
+
? Math.max(level, viseme.level)
|
|
207
|
+
: level;
|
|
208
|
+
if (viseme?.pose === "closed" || (!viseme && level < 0.035)) {
|
|
209
|
+
mouthPose.value = "closed";
|
|
210
|
+
} else if (viseme) {
|
|
211
|
+
mouthPose.value = viseme.pose;
|
|
212
|
+
} else {
|
|
213
|
+
const low = averageBand(120, 700);
|
|
214
|
+
const middle = averageBand(700, 2_100);
|
|
215
|
+
const high = averageBand(2_100, 4_800);
|
|
216
|
+
if (level > 0.63) {
|
|
217
|
+
mouthPose.value = "open";
|
|
218
|
+
} else if (low > (middle + high) * 0.72) {
|
|
219
|
+
mouthPose.value = "round";
|
|
220
|
+
} else if (middle + high > low * 1.55) {
|
|
221
|
+
mouthPose.value = "wide";
|
|
222
|
+
} else {
|
|
223
|
+
mouthPose.value = "small";
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (speaking.value || playbackSources.size) {
|
|
227
|
+
playbackFrame = requestAnimationFrame(update);
|
|
228
|
+
} else {
|
|
229
|
+
stopMouthMeter();
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
playbackFrame = requestAnimationFrame(update);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function preparePlayback() {
|
|
236
|
+
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
|
|
237
|
+
if (!AudioContextCtor) {
|
|
238
|
+
throw new Error("This browser does not support streamed audio playback.");
|
|
239
|
+
}
|
|
240
|
+
if (!playbackContext || playbackContext.state === "closed") {
|
|
241
|
+
playbackContext = new AudioContextCtor({ latencyHint: "interactive" });
|
|
242
|
+
playbackGain = playbackContext.createGain();
|
|
243
|
+
playbackAnalyser = playbackContext.createAnalyser();
|
|
244
|
+
playbackAnalyser.fftSize = 1_024;
|
|
245
|
+
playbackAnalyser.smoothingTimeConstant = 0.58;
|
|
246
|
+
playbackGain.connect(playbackAnalyser);
|
|
247
|
+
playbackAnalyser.connect(playbackContext.destination);
|
|
248
|
+
}
|
|
249
|
+
playbackGain.gain.value = speechOutputEnabled.value ? 1 : 0;
|
|
250
|
+
await playbackContext.resume();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function finishPlaybackWhenDrained() {
|
|
254
|
+
if (!activeSpeechTurnId.value || !speechStreamEnded || playbackSources.size || queuedPlaybackFrames) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
activeSpeechTurnId.value = "";
|
|
258
|
+
speechState.value = "idle";
|
|
259
|
+
if (speechOwner.value === speechClaim) speechOwner.value = null;
|
|
260
|
+
stopMouthMeter();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function claimVisemeTiming(sampleCount, startsAt) {
|
|
264
|
+
let remaining = sampleCount;
|
|
265
|
+
let offsetSamples = 0;
|
|
266
|
+
while (remaining > 0 && pendingVisemeSegments.length) {
|
|
267
|
+
const segment = pendingVisemeSegments[0];
|
|
268
|
+
if (!Number.isFinite(segment.startedAt)) {
|
|
269
|
+
segment.startedAt = startsAt + (offsetSamples / speechSampleRate);
|
|
270
|
+
scheduledVisemeSegments.push(segment);
|
|
271
|
+
}
|
|
272
|
+
const consumed = Math.min(remaining, segment.remainingSamples);
|
|
273
|
+
segment.remainingSamples -= consumed;
|
|
274
|
+
remaining -= consumed;
|
|
275
|
+
offsetSamples += consumed;
|
|
276
|
+
if (segment.remainingSamples <= 0) {
|
|
277
|
+
pendingVisemeSegments.shift();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function schedulePcm(buffer, epoch = playbackEpoch, segment = null) {
|
|
283
|
+
if (epoch !== playbackEpoch || (segment && segment.index > stopAfterSegment)) return;
|
|
284
|
+
await preparePlayback();
|
|
285
|
+
if (epoch !== playbackEpoch) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (segment && segment.index > stopAfterSegment) return;
|
|
289
|
+
const samples = pcm16ArrayBufferToFloat32(buffer);
|
|
290
|
+
if (!samples.length) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const audioBuffer = playbackContext.createBuffer(1, samples.length, speechSampleRate);
|
|
294
|
+
audioBuffer.copyToChannel(samples, 0);
|
|
295
|
+
const source = playbackContext.createBufferSource();
|
|
296
|
+
source.buffer = audioBuffer;
|
|
297
|
+
source.voiceSegment = segment;
|
|
298
|
+
source.connect(playbackGain);
|
|
299
|
+
const startsAt = Math.max(playbackContext.currentTime + 0.025, nextPlaybackTime);
|
|
300
|
+
nextPlaybackTime = startsAt + audioBuffer.duration;
|
|
301
|
+
source.voiceStartsAt = startsAt;
|
|
302
|
+
source.voiceEndsAt = nextPlaybackTime;
|
|
303
|
+
if (segment) {
|
|
304
|
+
if (!Number.isFinite(segment.startedAt)) {
|
|
305
|
+
segment.startedAt = startsAt;
|
|
306
|
+
if (segment.metadata) scheduledVisemeSegments.push({ ...segment.metadata, startedAt: startsAt });
|
|
307
|
+
}
|
|
308
|
+
} else claimVisemeTiming(samples.length, startsAt);
|
|
309
|
+
bufferedSeconds.value = Math.max(0, nextPlaybackTime - playbackContext.currentTime);
|
|
310
|
+
playbackSources.add(source);
|
|
311
|
+
speechState.value = "speaking";
|
|
312
|
+
source.onended = () => {
|
|
313
|
+
source.disconnect();
|
|
314
|
+
if (epoch !== playbackEpoch) return;
|
|
315
|
+
playbackSources.delete(source);
|
|
316
|
+
bufferedSeconds.value = playbackSources.size ? Math.max(0, nextPlaybackTime - playbackContext.currentTime) : 0;
|
|
317
|
+
if (!playbackSources.size) speechState.value = "thinking";
|
|
318
|
+
finishPlaybackWhenDrained();
|
|
319
|
+
};
|
|
320
|
+
source.start(startsAt);
|
|
321
|
+
startMouthMeter();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function enqueuePcm(buffer) {
|
|
325
|
+
const epoch = playbackEpoch;
|
|
326
|
+
const segment = receivingSegment;
|
|
327
|
+
queuedPlaybackFrames += 1;
|
|
328
|
+
playbackQueue = playbackQueue
|
|
329
|
+
.then(() => schedulePcm(buffer, epoch, segment))
|
|
330
|
+
.catch((playbackError) => {
|
|
331
|
+
if (epoch === playbackEpoch) setError(playbackError?.message || playbackError);
|
|
332
|
+
})
|
|
333
|
+
.finally(() => {
|
|
334
|
+
if (epoch !== playbackEpoch) return;
|
|
335
|
+
queuedPlaybackFrames -= 1;
|
|
336
|
+
finishPlaybackWhenDrained();
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function stopPlayback() {
|
|
341
|
+
playbackEpoch += 1;
|
|
342
|
+
activeSpeechTurnId.value = "";
|
|
343
|
+
speechState.value = "idle";
|
|
344
|
+
if (speechOwner.value === speechClaim) speechOwner.value = null;
|
|
345
|
+
// Retired continuations keep their old epoch, not ownership of this queue.
|
|
346
|
+
playbackQueue = Promise.resolve();
|
|
347
|
+
queuedPlaybackFrames = 0;
|
|
348
|
+
for (const source of playbackSources) {
|
|
349
|
+
try {
|
|
350
|
+
source.stop();
|
|
351
|
+
} catch {
|
|
352
|
+
// A source that already ended needs no further cleanup.
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
playbackSources.clear();
|
|
356
|
+
nextPlaybackTime = 0;
|
|
357
|
+
speechStreamEnded = true;
|
|
358
|
+
speechInputEnded.value = true;
|
|
359
|
+
stopAfterSegment = Infinity;
|
|
360
|
+
bufferedSeconds.value = 0;
|
|
361
|
+
synthesisPending.value = 0;
|
|
362
|
+
receivingTurnId = "";
|
|
363
|
+
receivingSegment = null;
|
|
364
|
+
streamedSegments.clear();
|
|
365
|
+
pendingVisemeSegments = [];
|
|
366
|
+
scheduledVisemeSegments = [];
|
|
367
|
+
stopMouthMeter();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function handleControlMessage(message = {}) {
|
|
371
|
+
const type = String(message.type || "");
|
|
372
|
+
if (type === "pong") { lastPongAt = Date.now(); return; }
|
|
373
|
+
if (type === "voice.ready") {
|
|
374
|
+
availableVoices.value = Array.isArray(message.voices) ? message.voices : [];
|
|
375
|
+
if (!availableVoices.value.some(voice => voice.id === selectedVoice.value)) selectedVoice.value = message.defaultVoice || "";
|
|
376
|
+
lastPongAt = Date.now();
|
|
377
|
+
clearInterval(heartbeatTimer);
|
|
378
|
+
heartbeatTimer = setInterval(heartbeat, 4000);
|
|
379
|
+
reconnecting.value = false;
|
|
380
|
+
recoveryWanted = false;
|
|
381
|
+
recoveryAttempt = 0;
|
|
382
|
+
ready.value = true;
|
|
383
|
+
error.value = "";
|
|
384
|
+
connectionState.value = "idle";
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (type === "transcript.partial" && message.turnId === activeListenTurnId.value) {
|
|
388
|
+
endpoint.value = null;
|
|
389
|
+
partialTranscript.value = String(message.text || "");
|
|
390
|
+
transcript.value = partialTranscript.value;
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (type === "transcript.endpoint" && message.turnId === activeListenTurnId.value) {
|
|
394
|
+
endpoint.value = { turnId: message.turnId, revision: message.revision, text: String(message.text || "") };
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (["transcript.reset", "transcript.stale"].includes(type) && message.turnId === activeListenTurnId.value) {
|
|
398
|
+
endpoint.value = null;
|
|
399
|
+
if (type === "transcript.reset") {
|
|
400
|
+
utteranceReset.value = { turnId: message.turnId, revision: message.revision };
|
|
401
|
+
transcript.value = "";
|
|
402
|
+
partialTranscript.value = "";
|
|
403
|
+
}
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (type === "transcript.final" && message.turnId === activeListenTurnId.value) {
|
|
407
|
+
inputLevel.value = 0;
|
|
408
|
+
transcript.value = String(message.text || "");
|
|
409
|
+
partialTranscript.value = "";
|
|
410
|
+
endpoint.value = null;
|
|
411
|
+
if (message.continuous) {
|
|
412
|
+
completedUtterance.value = { text: transcript.value, turnId: message.turnId, revision: message.revision };
|
|
413
|
+
} else {
|
|
414
|
+
activeListenTurnId.value = "";
|
|
415
|
+
captureState.value = "idle";
|
|
416
|
+
}
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (type === "speech.start") {
|
|
420
|
+
receivingTurnId = String(message.turnId || "");
|
|
421
|
+
receivingSegment = null;
|
|
422
|
+
if (!message.turnId || message.turnId !== activeSpeechTurnId.value) {
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
speechSampleRate = Number(message.sampleRate) || 22_050;
|
|
426
|
+
speechStreamEnded = false;
|
|
427
|
+
speechState.value = "thinking";
|
|
428
|
+
nextPlaybackTime = 0;
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (type === "speech.segment.start" && message.turnId === activeSpeechTurnId.value) {
|
|
432
|
+
receivingSegment = { index: message.segmentIndex };
|
|
433
|
+
streamedSegments.set(message.segmentIndex, receivingSegment);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (type === "speech.chunk.end" && message.turnId === activeSpeechTurnId.value) {
|
|
437
|
+
lastSpeechProgressAt = Date.now();
|
|
438
|
+
synthesisPending.value = Math.max(0, synthesisPending.value - 1);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (type === "speech.segment" && message.turnId === activeSpeechTurnId.value && !speechStreamEnded) {
|
|
442
|
+
const segment = normalizeSpeechSegment(message, speechSampleRate);
|
|
443
|
+
const streamed = streamedSegments.get(message.segmentIndex);
|
|
444
|
+
if (streamed) {
|
|
445
|
+
streamed.metadata = segment;
|
|
446
|
+
if (Number.isFinite(streamed.startedAt)) scheduledVisemeSegments.push({ ...segment, startedAt: streamed.startedAt });
|
|
447
|
+
streamedSegments.delete(message.segmentIndex);
|
|
448
|
+
} else if (segment.sampleCount) pendingVisemeSegments.push(segment);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (type === "speech.end" && message.turnId === activeSpeechTurnId.value) {
|
|
452
|
+
speechStreamEnded = true;
|
|
453
|
+
finishPlaybackWhenDrained();
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (type === "busy" || type === "error") {
|
|
457
|
+
const turnId = String(message.turnId || "");
|
|
458
|
+
if (turnId && turnId !== activeListenTurnId.value && turnId !== activeSpeechTurnId.value) {
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
setError(String(message.message || (type === "busy"
|
|
462
|
+
? "Voice is busy. Try again shortly."
|
|
463
|
+
: "Voice service could not continue.")));
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function handleSocketMessage(event) {
|
|
468
|
+
if (event.data instanceof ArrayBuffer) {
|
|
469
|
+
if (speechOutputEnabled.value && activeSpeechTurnId.value && receivingTurnId === activeSpeechTurnId.value && !speechStreamEnded) {
|
|
470
|
+
lastSpeechProgressAt = Date.now();
|
|
471
|
+
enqueuePcm(event.data);
|
|
472
|
+
}
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
let message;
|
|
476
|
+
try {
|
|
477
|
+
message = JSON.parse(String(event.data || ""));
|
|
478
|
+
} catch {
|
|
479
|
+
setError("Voice service sent an invalid response.");
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
handleControlMessage(message);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function disconnect({ preserveError = false } = {}) {
|
|
486
|
+
clearInterval(heartbeatTimer);
|
|
487
|
+
const current = socket;
|
|
488
|
+
socket = null;
|
|
489
|
+
connectedSocketPath = "";
|
|
490
|
+
connectPromise = null;
|
|
491
|
+
ready.value = false;
|
|
492
|
+
current?.close?.(1000, "Voice client closed");
|
|
493
|
+
connectionState.value = "idle";
|
|
494
|
+
if (!preserveError) error.value = "";
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async function connect({ recovery = false } = {}) {
|
|
498
|
+
if (!recovery) {
|
|
499
|
+
clearTimeout(recoveryTimer); recoveryTimer = null;
|
|
500
|
+
recoveryAttempt = 0; recoveryWanted = false; reconnecting.value = false;
|
|
501
|
+
}
|
|
502
|
+
const path = selectedSocketPath.value;
|
|
503
|
+
if (!path) {
|
|
504
|
+
throw new Error("Voice requires an available conversation.");
|
|
505
|
+
}
|
|
506
|
+
if (connectedSocketPath === path && socket?.readyState === WEBSOCKET_OPEN && ready.value) {
|
|
507
|
+
return true;
|
|
508
|
+
}
|
|
509
|
+
if (connectedSocketPath === path && connectPromise) {
|
|
510
|
+
return connectPromise;
|
|
511
|
+
}
|
|
512
|
+
disconnect({ preserveError: recovery });
|
|
513
|
+
connectionState.value = "connecting";
|
|
514
|
+
if (!recovery) error.value = "";
|
|
515
|
+
ready.value = false;
|
|
516
|
+
connectedSocketPath = path;
|
|
517
|
+
const nextSocket = new WebSocket(resolveWebSocketUrl(path));
|
|
518
|
+
socket = nextSocket;
|
|
519
|
+
nextSocket.binaryType = "arraybuffer";
|
|
520
|
+
connectPromise = new Promise((resolve, reject) => {
|
|
521
|
+
const timeout = setTimeout(() => {
|
|
522
|
+
reject(new Error("Voice service took too long to connect."));
|
|
523
|
+
nextSocket.close();
|
|
524
|
+
}, 8_000);
|
|
525
|
+
const onReady = (event) => {
|
|
526
|
+
if (event.data instanceof ArrayBuffer) {
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
try {
|
|
530
|
+
const message = JSON.parse(String(event.data || ""));
|
|
531
|
+
if (!["voice.ready", "error", "busy"].includes(message.type)) {
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
clearTimeout(timeout);
|
|
535
|
+
nextSocket.removeEventListener("message", onReady);
|
|
536
|
+
if (message.type === "voice.ready") resolve(true);
|
|
537
|
+
else reject(new Error(message.message || "Voice could not connect. Try again."));
|
|
538
|
+
} catch {
|
|
539
|
+
// The regular message handler will surface malformed responses.
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
nextSocket.addEventListener("message", onReady);
|
|
543
|
+
nextSocket.addEventListener("error", () => {
|
|
544
|
+
clearTimeout(timeout);
|
|
545
|
+
reject(new Error("Voice service could not be reached."));
|
|
546
|
+
}, { once: true });
|
|
547
|
+
nextSocket.addEventListener("close", () => {
|
|
548
|
+
clearTimeout(timeout);
|
|
549
|
+
reject(new Error("Voice service closed while connecting."));
|
|
550
|
+
}, { once: true });
|
|
551
|
+
});
|
|
552
|
+
nextSocket.addEventListener("message", (event) => {
|
|
553
|
+
if (socket === nextSocket) handleSocketMessage(event);
|
|
554
|
+
});
|
|
555
|
+
nextSocket.addEventListener("close", () => {
|
|
556
|
+
if (socket !== nextSocket) {
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
socket = null;
|
|
560
|
+
connectPromise = null;
|
|
561
|
+
ready.value = false;
|
|
562
|
+
clearInterval(heartbeatTimer);
|
|
563
|
+
if (pendingListenTurnId || !["idle", "error"].includes(state.value)) {
|
|
564
|
+
connectionLost();
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
try {
|
|
568
|
+
return await connectPromise;
|
|
569
|
+
} catch (connectionError) {
|
|
570
|
+
if (socket === nextSocket) {
|
|
571
|
+
if (!recovery) setError(connectionError?.message || connectionError);
|
|
572
|
+
disconnect({ preserveError: true });
|
|
573
|
+
}
|
|
574
|
+
throw connectionError;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function startListening({ continuous = false } = {}) {
|
|
579
|
+
if (unmounted || pendingListenTurnId || activeListenTurnId.value) {
|
|
580
|
+
return false;
|
|
581
|
+
}
|
|
582
|
+
if (microphoneOwner && microphoneOwner !== microphoneClaim) {
|
|
583
|
+
error.value = "Finish the recording in the other conversation before starting this microphone.";
|
|
584
|
+
return false;
|
|
585
|
+
}
|
|
586
|
+
microphoneClaim = Symbol("voice microphone");
|
|
587
|
+
microphoneOwner = microphoneClaim;
|
|
588
|
+
const turnId = voiceTurnId("listen");
|
|
589
|
+
pendingListenTurnId = turnId;
|
|
590
|
+
if (interruptSpeechOnListen) stopSpeaking();
|
|
591
|
+
captureState.value = "connecting";
|
|
592
|
+
error.value = "";
|
|
593
|
+
try {
|
|
594
|
+
await connect();
|
|
595
|
+
if (pendingListenTurnId !== turnId) return false;
|
|
596
|
+
captureState.value = "opening";
|
|
597
|
+
let capture;
|
|
598
|
+
try {
|
|
599
|
+
const opening = createMicrophoneCapture({
|
|
600
|
+
onLevel(level) {
|
|
601
|
+
if (activeListenTurnId.value === turnId && !microphoneMuted.value) {
|
|
602
|
+
inputLevel.value = Number(level) || 0;
|
|
603
|
+
}
|
|
604
|
+
},
|
|
605
|
+
onPcm(frame) {
|
|
606
|
+
if (!microphoneMuted.value && socket?.readyState === WEBSOCKET_OPEN && activeListenTurnId.value === turnId) {
|
|
607
|
+
if (socket.bufferedAmount > 32 * 1024) {
|
|
608
|
+
connectionLost("The microphone connection fell behind. Review the unfinished recording before sending.");
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
socket.send(frame);
|
|
612
|
+
}
|
|
613
|
+
},
|
|
614
|
+
targetSampleRate: INPUT_SAMPLE_RATE
|
|
615
|
+
});
|
|
616
|
+
openingMicrophones.add(opening);
|
|
617
|
+
try { capture = await opening; }
|
|
618
|
+
finally { openingMicrophones.delete(opening); }
|
|
619
|
+
} catch (microphoneError) {
|
|
620
|
+
if (pendingListenTurnId !== turnId) return false;
|
|
621
|
+
const denied = ["NotAllowedError", "PermissionDeniedError"].includes(microphoneError?.name);
|
|
622
|
+
setError(denied
|
|
623
|
+
? "Microphone access was denied. Allow it in your browser to talk to the assistant."
|
|
624
|
+
: "The microphone could not be opened on this device.");
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
if (pendingListenTurnId !== turnId) {
|
|
628
|
+
await capture.close();
|
|
629
|
+
return false;
|
|
630
|
+
}
|
|
631
|
+
microphone = capture;
|
|
632
|
+
microphone.setMuted(microphoneMuted.value);
|
|
633
|
+
activeListenTurnId.value = turnId;
|
|
634
|
+
transcript.value = "";
|
|
635
|
+
partialTranscript.value = "";
|
|
636
|
+
try {
|
|
637
|
+
endpoint.value = null;
|
|
638
|
+
completedUtterance.value = null;
|
|
639
|
+
sendControl({ sampleRate: INPUT_SAMPLE_RATE, turnId, type: "listen.start", ...(continuous ? { continuous: true } : {}) });
|
|
640
|
+
captureState.value = "listening";
|
|
641
|
+
} catch (controlError) {
|
|
642
|
+
activeListenTurnId.value = "";
|
|
643
|
+
await closeMicrophone();
|
|
644
|
+
throw controlError;
|
|
645
|
+
}
|
|
646
|
+
return true;
|
|
647
|
+
} catch (startError) {
|
|
648
|
+
if (pendingListenTurnId !== turnId) return false;
|
|
649
|
+
setError(startError?.message || startError);
|
|
650
|
+
throw startError;
|
|
651
|
+
} finally {
|
|
652
|
+
if (pendingListenTurnId === turnId) pendingListenTurnId = "";
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function finishUtterance(candidate, { discard = false } = {}) {
|
|
657
|
+
if (candidate !== endpoint.value || candidate?.turnId !== activeListenTurnId.value) return false;
|
|
658
|
+
sendControl({ turnId: candidate.turnId, revision: candidate.revision, type: discard ? "listen.discard" : "listen.commit" });
|
|
659
|
+
endpoint.value = null;
|
|
660
|
+
return true;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async function stopListening() {
|
|
664
|
+
if (!activeListenTurnId.value || !microphone) {
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
const turnId = activeListenTurnId.value;
|
|
668
|
+
captureState.value = "transcribing";
|
|
669
|
+
await closeMicrophone();
|
|
670
|
+
if (activeListenTurnId.value !== turnId) return;
|
|
671
|
+
sendControl({ turnId, type: "listen.stop" });
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
async function speak(text, turnId = voiceTurnId("speak"), { stream = false } = {}) {
|
|
675
|
+
const speechText = speechTextFromAssistant(text);
|
|
676
|
+
if (unmounted || !speechOutputEnabled.value || !speechText) {
|
|
677
|
+
return false;
|
|
678
|
+
}
|
|
679
|
+
if (activeSpeechTurnId.value && socket?.readyState === WEBSOCKET_OPEN) {
|
|
680
|
+
sendControl({ turnId: activeSpeechTurnId.value, type: "cancel" });
|
|
681
|
+
}
|
|
682
|
+
stopPlayback();
|
|
683
|
+
const epoch = playbackEpoch;
|
|
684
|
+
activeSpeechTurnId.value = turnId;
|
|
685
|
+
speechState.value = "thinking";
|
|
686
|
+
try {
|
|
687
|
+
await preparePlayback();
|
|
688
|
+
if (epoch !== playbackEpoch) return false;
|
|
689
|
+
await connect();
|
|
690
|
+
if (epoch !== playbackEpoch) return false;
|
|
691
|
+
if (speechOwner.value) {
|
|
692
|
+
await new Promise((resolve) => {
|
|
693
|
+
const unwatch = watch([speechOwner, activeSpeechTurnId], () => {
|
|
694
|
+
if (epoch !== playbackEpoch || !speechOwner.value) {
|
|
695
|
+
if (epoch === playbackEpoch) speechOwner.value = speechClaim;
|
|
696
|
+
unwatch();
|
|
697
|
+
resolve();
|
|
698
|
+
}
|
|
699
|
+
}, { flush: "sync" });
|
|
700
|
+
});
|
|
701
|
+
} else speechOwner.value = speechClaim;
|
|
702
|
+
if (epoch !== playbackEpoch) return false;
|
|
703
|
+
speechState.value = "thinking";
|
|
704
|
+
speechInputEnded.value = !stream;
|
|
705
|
+
synthesisPending.value = 1;
|
|
706
|
+
lastSpeechProgressAt = Date.now();
|
|
707
|
+
sendControl({ text: speechText, turnId, type: "speak.start", ...(stream ? { stream: true } : {}), ...(selectedVoice.value ? { voiceId: selectedVoice.value } : {}) });
|
|
708
|
+
return true;
|
|
709
|
+
} catch (speechError) {
|
|
710
|
+
if (epoch !== playbackEpoch) return false;
|
|
711
|
+
stopPlayback();
|
|
712
|
+
speechState.value = "idle";
|
|
713
|
+
throw speechError;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function appendSpeech(text) {
|
|
718
|
+
if (!canAppendSpeech.value) return false;
|
|
719
|
+
const speechText = speechTextFromAssistant(text);
|
|
720
|
+
if (!speechText) return false;
|
|
721
|
+
if (!synthesisPending.value) lastSpeechProgressAt = Date.now();
|
|
722
|
+
synthesisPending.value += 1;
|
|
723
|
+
sendControl({ text: speechText, turnId: activeSpeechTurnId.value, type: "speak.append" });
|
|
724
|
+
return true;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function endSpeech() {
|
|
728
|
+
if (!activeSpeechTurnId.value || speechInputEnded.value) return;
|
|
729
|
+
speechInputEnded.value = true;
|
|
730
|
+
sendControl({ turnId: activeSpeechTurnId.value, type: "speak.end" });
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function finishCurrentPhrase() {
|
|
734
|
+
if (!activeSpeechTurnId.value) return;
|
|
735
|
+
const now = playbackContext?.currentTime || 0;
|
|
736
|
+
const current = [...playbackSources].find(source => source.voiceEndsAt > now);
|
|
737
|
+
if (!current?.voiceSegment) { stopSpeaking(); return; }
|
|
738
|
+
stopAfterSegment = current.voiceSegment.index;
|
|
739
|
+
speechInputEnded.value = true;
|
|
740
|
+
for (const source of [...playbackSources]) {
|
|
741
|
+
if (source.voiceSegment?.index <= stopAfterSegment) continue;
|
|
742
|
+
source.stop();
|
|
743
|
+
playbackSources.delete(source);
|
|
744
|
+
}
|
|
745
|
+
nextPlaybackTime = Math.max(now, ...[...playbackSources].map(source => source.voiceEndsAt));
|
|
746
|
+
bufferedSeconds.value = Math.max(0, nextPlaybackTime - now);
|
|
747
|
+
sendControl({ type: "speak.stop-after", turnId: activeSpeechTurnId.value, segmentIndex: stopAfterSegment });
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function stopSpeaking() {
|
|
751
|
+
const turnId = activeSpeechTurnId.value;
|
|
752
|
+
const pending = turnId && !ready.value && !activeListenTurnId.value && !pendingListenTurnId;
|
|
753
|
+
stopPlayback();
|
|
754
|
+
if (turnId && socket?.readyState === WEBSOCKET_OPEN) sendControl({ turnId, type: "cancel" });
|
|
755
|
+
if (pending) disconnect();
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
async function cancelListening() {
|
|
759
|
+
const pending = pendingListenTurnId && !activeSpeechTurnId.value && !ready.value;
|
|
760
|
+
pendingListenTurnId = "";
|
|
761
|
+
const turnId = activeListenTurnId.value;
|
|
762
|
+
endpoint.value = null;
|
|
763
|
+
activeListenTurnId.value = "";
|
|
764
|
+
captureState.value = "idle";
|
|
765
|
+
partialTranscript.value = "";
|
|
766
|
+
transcript.value = "";
|
|
767
|
+
try {
|
|
768
|
+
if (turnId && socket?.readyState === WEBSOCKET_OPEN) sendControl({ turnId, type: "cancel" });
|
|
769
|
+
} finally {
|
|
770
|
+
const closing = closeMicrophone();
|
|
771
|
+
if (pending) disconnect();
|
|
772
|
+
await closing;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
async function cancel() {
|
|
777
|
+
const closing = cancelListening();
|
|
778
|
+
stopSpeaking();
|
|
779
|
+
await closing;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
async function close() {
|
|
783
|
+
recoveryWanted = false; reconnecting.value = false;
|
|
784
|
+
clearTimeout(recoveryTimer); recoveryTimer = null;
|
|
785
|
+
const closing = cancel();
|
|
786
|
+
disconnect();
|
|
787
|
+
// Browser permission prompts cannot be cancelled. A new target must wait
|
|
788
|
+
// until any late grant has been closed, even though ordinary Cancel is fast.
|
|
789
|
+
await Promise.all([
|
|
790
|
+
closing,
|
|
791
|
+
...[...openingMicrophones].map(opening => opening.then(capture => capture.close(), () => {}))
|
|
792
|
+
]);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function toggleMuted() {
|
|
796
|
+
muted.value = !muted.value;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function setMicrophoneMuted(value) {
|
|
800
|
+
microphoneMuted.value = Boolean(value);
|
|
801
|
+
microphone?.setMuted(microphoneMuted.value);
|
|
802
|
+
if (microphoneMuted.value) inputLevel.value = 0;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
watch(speechOutputEnabled, (enabled) => {
|
|
806
|
+
if (playbackGain) {
|
|
807
|
+
playbackGain.gain.value = enabled ? 1 : 0;
|
|
808
|
+
}
|
|
809
|
+
if (!enabled && activeSpeechTurnId.value) {
|
|
810
|
+
stopSpeaking();
|
|
811
|
+
}
|
|
812
|
+
}, { flush: "sync" });
|
|
813
|
+
watch(selectedSocketPath, () => {
|
|
814
|
+
transcript.value = "";
|
|
815
|
+
partialTranscript.value = "";
|
|
816
|
+
void close().catch(() => null);
|
|
817
|
+
}, { flush: "sync" });
|
|
818
|
+
function pauseForPageExit() {
|
|
819
|
+
if (activeListenTurnId.value || pendingListenTurnId || activeSpeechTurnId.value) {
|
|
820
|
+
setError("Voice paused when this page was suspended. Review any unfinished words and use Talk to resume.");
|
|
821
|
+
}
|
|
822
|
+
recoveryWanted = false;
|
|
823
|
+
reconnecting.value = false;
|
|
824
|
+
clearTimeout(recoveryTimer);
|
|
825
|
+
recoveryTimer = null;
|
|
826
|
+
disconnect({ preserveError: true });
|
|
827
|
+
}
|
|
828
|
+
const page = globalThis.window || globalThis;
|
|
829
|
+
page.addEventListener?.("pagehide", pauseForPageExit);
|
|
830
|
+
onScopeDispose(() => {
|
|
831
|
+
page.removeEventListener?.("pagehide", pauseForPageExit);
|
|
832
|
+
unmounted = true;
|
|
833
|
+
clearTimeout(recoveryTimer);
|
|
834
|
+
clearInterval(heartbeatTimer);
|
|
835
|
+
pendingListenTurnId = "";
|
|
836
|
+
activeListenTurnId.value = "";
|
|
837
|
+
void closeMicrophone().catch(() => null);
|
|
838
|
+
stopPlayback();
|
|
839
|
+
disconnect();
|
|
840
|
+
void playbackContext?.close?.().catch(() => null);
|
|
841
|
+
playbackContext = null;
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
return {
|
|
845
|
+
availableVoices,
|
|
846
|
+
selectedVoice,
|
|
847
|
+
activeListenTurnId,
|
|
848
|
+
activeSpeechTurnId,
|
|
849
|
+
appendSpeech,
|
|
850
|
+
bufferedSeconds,
|
|
851
|
+
canAppendSpeech,
|
|
852
|
+
endSpeech,
|
|
853
|
+
captureState,
|
|
854
|
+
completedUtterance,
|
|
855
|
+
utteranceReset,
|
|
856
|
+
endpoint,
|
|
857
|
+
finishUtterance,
|
|
858
|
+
finishCurrentPhrase,
|
|
859
|
+
busy,
|
|
860
|
+
cancel,
|
|
861
|
+
cancelListening,
|
|
862
|
+
close,
|
|
863
|
+
connect,
|
|
864
|
+
error,
|
|
865
|
+
inputLevel,
|
|
866
|
+
listening,
|
|
867
|
+
mouthLevel,
|
|
868
|
+
mouthPose,
|
|
869
|
+
microphoneMuted,
|
|
870
|
+
muted,
|
|
871
|
+
partialTranscript,
|
|
872
|
+
preparePlayback,
|
|
873
|
+
ready,
|
|
874
|
+
reconnecting,
|
|
875
|
+
speak,
|
|
876
|
+
speaking,
|
|
877
|
+
startListening,
|
|
878
|
+
setMicrophoneMuted,
|
|
879
|
+
state,
|
|
880
|
+
stopListening,
|
|
881
|
+
stopSpeaking,
|
|
882
|
+
toggleMuted,
|
|
883
|
+
transcript
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
export {
|
|
888
|
+
useVoiceTransport,
|
|
889
|
+
voiceTurnId
|
|
890
|
+
};
|