@olares/lares-core 0.15.30 → 0.15.32
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/i18n/voice.js +2 -0
- package/package.json +2 -1
- package/voice/pcm-player.js +3 -0
- package/voice/speech.js +27 -2
- package/voice/stream-client.js +183 -62
- package/voice/stream-gateway.js +6 -3
- package/voice/tts-settings.js +12 -5
- package/voice/tts-voice.js +53 -12
- package/voice/unified-conversation.js +3 -1
- package/voice/wake-lock.js +52 -0
package/i18n/voice.js
CHANGED
|
@@ -4,6 +4,7 @@ export const ZH = {
|
|
|
4
4
|
"tts.default": "模型默认",
|
|
5
5
|
"tts.loading": "正在加载音色…",
|
|
6
6
|
"tts.loadFailed": "音色加载失败,请重试",
|
|
7
|
+
"tts.stale": "这次没能刷新音色,显示的是上次成功加载的列表。",
|
|
7
8
|
"tts.noPresets": "此模型不提供预设音色,使用模型默认声音。",
|
|
8
9
|
"tts.preview": "试听",
|
|
9
10
|
"tts.stopPreview": "停止试听",
|
|
@@ -314,6 +315,7 @@ export const EN = {
|
|
|
314
315
|
"tts.default": "Model default",
|
|
315
316
|
"tts.loading": "Loading voices…",
|
|
316
317
|
"tts.loadFailed": "Could not load voices. Try again.",
|
|
318
|
+
"tts.stale": "Could not refresh voices; showing the last list that loaded.",
|
|
317
319
|
"tts.noPresets": "This model has no preset voices. Its default voice will be used.",
|
|
318
320
|
"tts.preview": "Preview voice",
|
|
319
321
|
"tts.stopPreview": "Stop preview",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@olares/lares-core",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.32",
|
|
4
4
|
"description": "Lares 共享业务核心:Router、workspace、文件/媒体、Olares 身份(无 UI、无平台壳)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -115,6 +115,7 @@
|
|
|
115
115
|
"./voice/stream-client": "./voice/stream-client.js",
|
|
116
116
|
"./voice/pcm-player": "./voice/pcm-player.js",
|
|
117
117
|
"./voice/pcm-worklet": "./voice/pcm-worklet.js",
|
|
118
|
+
"./voice/wake-lock": "./voice/wake-lock.js",
|
|
118
119
|
"./voice/config": "./voice/config.js",
|
|
119
120
|
"./voice/languages": "./voice/languages.js",
|
|
120
121
|
"./voice/service": "./voice/service.js",
|
package/voice/pcm-player.js
CHANGED
|
@@ -135,6 +135,9 @@ export function createPcmStreamPlayer({ onProgress = () => {}, onMetric = () =>
|
|
|
135
135
|
pause() { paused = true; node?.port.postMessage({ type: "pause" }); },
|
|
136
136
|
resume() { paused = false; node?.port.postMessage({ type: "resume" }); },
|
|
137
137
|
get paused() { return paused; },
|
|
138
|
+
/** The browser's own view; a phone can report "running" while the OS has muted it. */
|
|
139
|
+
contextState: () => context?.state || "missing",
|
|
140
|
+
sampleRate: () => context?.sampleRate || 0,
|
|
138
141
|
playedFrames: () => played,
|
|
139
142
|
stop,
|
|
140
143
|
};
|
package/voice/speech.js
CHANGED
|
@@ -134,19 +134,24 @@ export function matchSpeechEvent(event) {
|
|
|
134
134
|
export function createReplySpeaker() {
|
|
135
135
|
let armed = false;
|
|
136
136
|
let skipKey = null;
|
|
137
|
+
// The answer that was already on screen when a turn opened is history. A new
|
|
138
|
+
// turn can deliver its first text in the same snapshot that reports the turn,
|
|
139
|
+
// and that text must still be read: the old code skipped it by its own key.
|
|
140
|
+
let lastKey = null;
|
|
137
141
|
return {
|
|
138
142
|
/** @returns {Utterance | null} the utterance to follow now, or null */
|
|
139
143
|
observe({ sessionChanged, wasRunning, running, utterance }) {
|
|
140
144
|
if (sessionChanged) {
|
|
141
145
|
armed = Boolean(running);
|
|
142
|
-
skipKey = utterance?.key ?? null;
|
|
146
|
+
skipKey = lastKey = utterance?.key ?? null;
|
|
143
147
|
return null;
|
|
144
148
|
}
|
|
145
149
|
if (!wasRunning && running) {
|
|
146
150
|
armed = true;
|
|
147
|
-
skipKey =
|
|
151
|
+
skipKey = lastKey;
|
|
148
152
|
}
|
|
149
153
|
const follow = armed && utterance && utterance.key !== skipKey ? utterance : null;
|
|
154
|
+
if (utterance?.key) lastKey = utterance.key;
|
|
150
155
|
if (!running) armed = false;
|
|
151
156
|
return follow;
|
|
152
157
|
},
|
|
@@ -324,6 +329,26 @@ export function createSentencePlayer({ send, play, halt, silence, fail = () => {
|
|
|
324
329
|
get canPause() { return current?.controls?.canPause === true; },
|
|
325
330
|
pause() { if (!current?.controls?.canPause) return false; current.controls.pause(); emitOutput({ key: current.key, status: "confirming" }); return true; },
|
|
326
331
|
resume() { if (!current?.controls?.canPause) return false; current.controls.resume(); emitOutput({ key: current.key, status: "playing" }); return true; },
|
|
332
|
+
/**
|
|
333
|
+
* Stop the current audio without marking the answer read. A voice take
|
|
334
|
+
* that is cancelled must be able to bring the same answer back; a take
|
|
335
|
+
* that is submitted consumes it instead.
|
|
336
|
+
*/
|
|
337
|
+
suspend() {
|
|
338
|
+
if (!current) return null;
|
|
339
|
+
const key = current.key;
|
|
340
|
+
const source = current.source;
|
|
341
|
+
// `follow` marks a key as read when it starts. A cancelled voice take
|
|
342
|
+
// must be able to follow the same key again, so undo that mark here.
|
|
343
|
+
if (key != null) consumed.delete(key);
|
|
344
|
+
stop();
|
|
345
|
+
silence(source);
|
|
346
|
+
return key;
|
|
347
|
+
},
|
|
348
|
+
/** Mark an answer read without playing it (a voice take owns it now). */
|
|
349
|
+
consume(key) {
|
|
350
|
+
mark(consumed, key);
|
|
351
|
+
},
|
|
327
352
|
stop() {
|
|
328
353
|
if (!current) return;
|
|
329
354
|
mark(consumed, current.key);
|
package/voice/stream-client.js
CHANGED
|
@@ -30,74 +30,195 @@ function streamMetric(options, event) {
|
|
|
30
30
|
});
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* What a phone-side failure needs next to the frames: a WebView can keep
|
|
35
|
+
* reporting a running context while the app is hidden or the OS muted it, so
|
|
36
|
+
* the page and platform state travel with the metric.
|
|
37
|
+
*/
|
|
38
|
+
function clientFacts() {
|
|
39
|
+
const nav = globalThis.navigator || {};
|
|
40
|
+
return {
|
|
41
|
+
visibility: String(globalThis.document?.visibilityState || "unknown").slice(0, 16),
|
|
42
|
+
hidden: globalThis.document?.hidden === true,
|
|
43
|
+
platform: String(nav.userAgentData?.platform || nav.platform || "").slice(0, 40),
|
|
44
|
+
ua: String(nav.userAgent || "").slice(0, 180),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const RECONNECT_ATTEMPTS = 3;
|
|
49
|
+
const RECONNECT_DELAY_MS = 400;
|
|
50
|
+
|
|
51
|
+
/** A phone can lose the socket (lock screen, app switch, Wi-Fi/cellular) mid-answer. */
|
|
52
|
+
function reconnectable(error) {
|
|
53
|
+
const code = error?.message;
|
|
54
|
+
return code === "voice_disconnected";
|
|
55
|
+
}
|
|
56
|
+
|
|
33
57
|
export async function playAudioStream(options) {
|
|
34
58
|
const started = Date.now();
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
try { connection.send(JSON.stringify({ type: "progress", frames })); }
|
|
45
|
-
catch (error) {
|
|
46
|
-
if (!progressBlocked) { progressBlocked = true; streamMetric(options, { stage: "playback_progress_blocked", code: error?.message || "send_failed", frames }); }
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
if (!progressNoted) { progressNoted = true; streamMetric(options, { stage: "playback_progress_sent", frames }); }
|
|
50
|
-
},
|
|
51
|
-
onMetric: (event) => { if (event.stage === "playback_started") options.onStatus?.("playing"); streamMetric(options, { ...event, durationMs: Date.now() - started }); } });
|
|
52
|
-
let chain = Promise.resolve(), complete = false, ending = false, firstReceived = false, ready = false, paused = false, segmentIndex = -1;
|
|
59
|
+
const attempts = Math.max(1, Number(options.reconnectAttempts) || RECONNECT_ATTEMPTS);
|
|
60
|
+
const reconnectDelay = Number.isFinite(options.reconnectDelayMs) ? Math.max(0, options.reconnectDelayMs) : RECONNECT_DELAY_MS;
|
|
61
|
+
// Frames already rendered before the current socket. A reconnected socket
|
|
62
|
+
// replays the answer from its first segment; dropping this prefix keeps the
|
|
63
|
+
// listener where the dropped connection left off.
|
|
64
|
+
let skipFrames = 0;
|
|
65
|
+
let lastError = null;
|
|
66
|
+
let paused = false;
|
|
67
|
+
const controls = { player: null, connection: null, file: null, ready: false };
|
|
53
68
|
options.controls?.({
|
|
54
|
-
get canPause() { return ready; },
|
|
55
|
-
pause() {
|
|
56
|
-
|
|
69
|
+
get canPause() { return controls.ready; },
|
|
70
|
+
pause() {
|
|
71
|
+
if (!controls.ready || paused) return;
|
|
72
|
+
paused = true; controls.player?.pause(); controls.file?.pause();
|
|
73
|
+
if (controls.connection?.readyState === 1) controls.connection.send(JSON.stringify({ type: "pause" }));
|
|
74
|
+
},
|
|
75
|
+
resume() {
|
|
76
|
+
if (!controls.ready || !paused) return;
|
|
77
|
+
paused = false; controls.player?.resume();
|
|
78
|
+
void controls.file?.resume()?.catch?.(() => controls.connection?.close());
|
|
79
|
+
if (controls.connection?.readyState === 1) controls.connection.send(JSON.stringify({ type: "resume" }));
|
|
80
|
+
},
|
|
57
81
|
});
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
|
|
83
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
84
|
+
if (attempt > 0) {
|
|
85
|
+
if (options.signal?.aborted) throw new Error("voice_cancelled");
|
|
86
|
+
streamMetric(options, { stage: "playback_reconnect", attempt, skipFrames, code: lastError?.message || "voice_disconnected", ...clientFacts() });
|
|
87
|
+
await new Promise((resolve) => setTimeout(resolve, reconnectDelay));
|
|
88
|
+
if (options.signal?.aborted) throw new Error("voice_cancelled");
|
|
89
|
+
}
|
|
90
|
+
const connection = await openAudioStream({ ...options, operation: "tts" });
|
|
91
|
+
if (!connection) throw new Error("voice_stream_failed");
|
|
92
|
+
const file = createBrowserPlayer();
|
|
93
|
+
let progressNoted = false, progressBlocked = false;
|
|
94
|
+
let lastProgressAt = Date.now(), gapReported = false, gapAt = 0, formatNoted = false;
|
|
95
|
+
let chain = Promise.resolve(), complete = false, ending = false, firstReceived = false, ready = false, segmentIndex = -1;
|
|
96
|
+
// The skipped prefix belongs to this new socket and is acknowledged as it
|
|
97
|
+
// arrives, so the host keeps sending it without waiting for a playhead
|
|
98
|
+
// that only starts after the skip.
|
|
99
|
+
let skipBytes = skipFrames * 2;
|
|
100
|
+
let skippedFrames = 0;
|
|
101
|
+
let skipAckAt = 0;
|
|
102
|
+
const player = createPcmStreamPlayer({
|
|
103
|
+
onProgress: (frames) => {
|
|
104
|
+
lastProgressAt = Date.now();
|
|
105
|
+
if (gapReported) { gapReported = false; streamMetric(options, { stage: "playback_gap_end", frames: skipFrames + frames, gapMs: Date.now() - gapAt, ...clientFacts(), context: player.contextState() }); }
|
|
106
|
+
ack(skipFrames + frames);
|
|
107
|
+
},
|
|
108
|
+
onMetric: (event) => {
|
|
109
|
+
if (event.stage === "playback_started") options.onStatus?.("playing");
|
|
110
|
+
streamMetric(options, { ...event, durationMs: Date.now() - started });
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
controls.player = player;
|
|
114
|
+
controls.connection = connection;
|
|
115
|
+
controls.file = file;
|
|
116
|
+
controls.ready = false;
|
|
117
|
+
const ack = (frames) => {
|
|
118
|
+
if (connection.readyState !== 1) {
|
|
119
|
+
if (!progressBlocked) { progressBlocked = true; streamMetric(options, { stage: "playback_progress_blocked", readyState: connection.readyState, frames }); }
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
try { connection.send(JSON.stringify({ type: "progress", frames })); }
|
|
123
|
+
catch (error) {
|
|
124
|
+
if (!progressBlocked) { progressBlocked = true; streamMetric(options, { stage: "playback_progress_blocked", code: error?.message || "send_failed", frames }); }
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (!progressNoted) { progressNoted = true; streamMetric(options, { stage: "playback_progress_sent", frames }); }
|
|
128
|
+
};
|
|
129
|
+
const writeAudio = (bytes) => {
|
|
130
|
+
let chunk = bytes;
|
|
131
|
+
if (skipBytes > 0) {
|
|
132
|
+
const drop = Math.min(skipBytes, chunk.length);
|
|
133
|
+
skipBytes -= drop;
|
|
134
|
+
skippedFrames += drop / 2;
|
|
135
|
+
if (Date.now() - skipAckAt >= 40) { skipAckAt = Date.now(); ack(skippedFrames); }
|
|
136
|
+
chunk = chunk.subarray(drop);
|
|
137
|
+
if (!chunk.length) return;
|
|
138
|
+
}
|
|
139
|
+
player.write(chunk);
|
|
140
|
+
};
|
|
141
|
+
const gapWatch = setInterval(() => {
|
|
142
|
+
if (!ready || paused || complete || gapReported || Date.now() - lastProgressAt < 4000) return;
|
|
143
|
+
gapReported = true;
|
|
144
|
+
gapAt = lastProgressAt;
|
|
145
|
+
streamMetric(options, { stage: "playback_gap", gapMs: Date.now() - lastProgressAt, frames: skipFrames + player.playedFrames(), ...clientFacts(), context: player.contextState(), sampleRate: player.sampleRate() });
|
|
146
|
+
}, 1000);
|
|
147
|
+
gapWatch.unref?.();
|
|
148
|
+
const noteVisibility = () => {
|
|
149
|
+
if (ready && !complete) streamMetric(options, { stage: "playback_visibility", frames: skipFrames + player.playedFrames(), paused, ...clientFacts(), context: player.contextState() });
|
|
150
|
+
};
|
|
151
|
+
if (typeof document !== "undefined" && document.addEventListener) document.addEventListener("visibilitychange", noteVisibility);
|
|
152
|
+
try {
|
|
153
|
+
await new Promise((resolve, reject) => {
|
|
154
|
+
let logged = false;
|
|
155
|
+
const fail = (error) => {
|
|
156
|
+
if (logged) return;
|
|
157
|
+
logged = true;
|
|
158
|
+
reject(error);
|
|
159
|
+
};
|
|
160
|
+
const cancelled = () => { player.stop(); file.stop(); fail(new Error("voice_cancelled")); };
|
|
161
|
+
options.signal?.addEventListener("abort", cancelled, { once: true });
|
|
162
|
+
connection.addEventListener("message", ({ data }) => {
|
|
163
|
+
if (typeof data !== "string" && !firstReceived) { firstReceived = true; streamMetric(options, { stage: "playback_first_received", durationMs: Date.now() - started }); }
|
|
164
|
+
let event = null;
|
|
165
|
+
if (typeof data === "string") {
|
|
166
|
+
try { event = JSON.parse(data); } catch (error) { chain = chain.then(() => { throw error; }).catch(fail); return; }
|
|
167
|
+
if (event?.type === "done") ending = true;
|
|
83
168
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
169
|
+
chain = chain.then(async () => {
|
|
170
|
+
if (options.signal?.aborted) throw new Error("voice_cancelled");
|
|
171
|
+
if (typeof data !== "string") { writeAudio(new Uint8Array(data)); return; }
|
|
172
|
+
if (event.type === "segment") {
|
|
173
|
+
if (event.index !== segmentIndex + 1) throw new Error("voice_reply_sequence");
|
|
174
|
+
segmentIndex = event.index;
|
|
175
|
+
}
|
|
176
|
+
else if (event.type === "segment.end") options.onSegment?.(event);
|
|
177
|
+
else if (event.type === "format") {
|
|
178
|
+
await player.start(event); ready = controls.ready = true; lastProgressAt = Date.now();
|
|
179
|
+
if (paused) {
|
|
180
|
+
player.pause();
|
|
181
|
+
if (connection.readyState === 1) connection.send(JSON.stringify({ type: "pause" }));
|
|
182
|
+
}
|
|
183
|
+
if (!formatNoted) { formatNoted = true; streamMetric(options, { stage: "playback_client", sampleRate: event.sampleRate, context: player.contextState(), ...clientFacts() }); }
|
|
184
|
+
}
|
|
185
|
+
else if (event.type === "file") {
|
|
186
|
+
// A whole-file answer cannot be offset-skipped; a reconnect plays it again.
|
|
187
|
+
ready = controls.ready = true; options.onStatus?.("playing");
|
|
188
|
+
await file.play(wavBlob(event.audio));
|
|
189
|
+
if (connection.readyState === 1) connection.send(JSON.stringify({ type: "file.done" }));
|
|
190
|
+
}
|
|
191
|
+
else if (event.type === "error") throw new Error(event.code || "voice_stream_failed");
|
|
192
|
+
else if (event.type === "done") { await player.finish(); complete = true; resolve(); }
|
|
193
|
+
}).catch(fail);
|
|
194
|
+
});
|
|
195
|
+
const dropped = () => new Error(options.signal?.aborted ? "voice_cancelled" : "voice_disconnected");
|
|
196
|
+
connection.addEventListener("error", () => fail(dropped()), { once: true });
|
|
197
|
+
connection.addEventListener("close", () => {
|
|
198
|
+
options.signal?.removeEventListener("abort", cancelled);
|
|
199
|
+
if (!complete && !ending) fail(dropped());
|
|
200
|
+
}, { once: true });
|
|
201
|
+
if (options.signal?.aborted) cancelled();
|
|
90
202
|
});
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
203
|
+
return true;
|
|
204
|
+
} catch (error) {
|
|
205
|
+
lastError = error;
|
|
206
|
+
if (reconnectable(error) && attempt < attempts - 1) {
|
|
207
|
+
skipFrames += player.playedFrames();
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (error?.message !== "voice_cancelled") {
|
|
211
|
+
streamMetric(options, { stage: "playback_failed", code: error?.message || "voice_playback_failed", durationMs: Date.now() - started });
|
|
212
|
+
}
|
|
213
|
+
throw error;
|
|
214
|
+
} finally {
|
|
215
|
+
clearInterval(gapWatch);
|
|
216
|
+
if (typeof document !== "undefined" && document.removeEventListener) document.removeEventListener("visibilitychange", noteVisibility);
|
|
217
|
+
if (controls.player === player) { controls.player = null; controls.connection = null; controls.file = null; controls.ready = false; }
|
|
218
|
+
player.stop(); file.stop(); connection.close();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
throw lastError || new Error("voice_disconnected");
|
|
101
222
|
}
|
|
102
223
|
|
|
103
224
|
/** A single final ASR result; partials never submit a task or replace the draft. */
|
package/voice/stream-gateway.js
CHANGED
|
@@ -10,7 +10,7 @@ import { routerGatewayUrl, routerAuthHeaders } from "../router/gateway.js";
|
|
|
10
10
|
const fault = (code, status = 409) => new VoiceError(code, status, code);
|
|
11
11
|
|
|
12
12
|
/** Single-use HTTP grants bind raw audio sockets to the authenticated Host scope. */
|
|
13
|
-
export function createAudioStreamGateway({ config, models, replies, synthesize = synthesizeStream, env = process.env, metric = () => {}, playbackStallMs = 45_000 }) {
|
|
13
|
+
export function createAudioStreamGateway({ config, models, replies, synthesize = synthesizeStream, env = process.env, metric = () => {}, playbackStallMs = 45_000, pauseHoldMs = 120_000, lifetimeMs = 300_000 }) {
|
|
14
14
|
const tickets = new Map(), clients = new Set();
|
|
15
15
|
const synthesis = createSynthesisRuns({ synthesize, metric });
|
|
16
16
|
const server = new WebSocketServer({ noServer: true, maxPayload: 96 * 1024, perMessageDeflate: false,
|
|
@@ -66,7 +66,7 @@ export function createAudioStreamGateway({ config, models, replies, synthesize =
|
|
|
66
66
|
if (grant.operation === "tts" && code !== "voice_cancelled") metric({ stage: "playback_stop", voiceId: grant.scope.id, code, sentFrames, playedFrames });
|
|
67
67
|
send({ type: "error", code }); close();
|
|
68
68
|
};
|
|
69
|
-
const lifetime = setTimeout(() => error(fault("voice_timeout")),
|
|
69
|
+
const lifetime = setTimeout(() => error(fault("voice_timeout")), lifetimeMs);
|
|
70
70
|
client.on("close", close); client.on("error", close);
|
|
71
71
|
client.on("message", (raw, binary) => {
|
|
72
72
|
try {
|
|
@@ -149,7 +149,10 @@ export function createAudioStreamGateway({ config, models, replies, synthesize =
|
|
|
149
149
|
// while without the client being gone. Wait for it to come
|
|
150
150
|
// back instead of ending the answer after ten seconds.
|
|
151
151
|
await new Promise((resolve, reject) => {
|
|
152
|
-
|
|
152
|
+
// A locked screen or a device switch is a pause, not a
|
|
153
|
+
// lost client: hold the answer long enough for the page
|
|
154
|
+
// to come back before failing it.
|
|
155
|
+
const limit = pausedAt ? Math.max(1, pauseHoldMs - (Date.now() - pausedAt)) : playbackStallMs;
|
|
153
156
|
const timer = setTimeout(() => { waiter = null; reject(fault(pausedAt ? "voice_interruption_timeout" : "voice_audio_backpressure")); }, limit);
|
|
154
157
|
waiter = () => { clearTimeout(timer); resolve(); };
|
|
155
158
|
});
|
package/voice/tts-settings.js
CHANGED
|
@@ -12,7 +12,10 @@ export function ttsSettingsModel(value) {
|
|
|
12
12
|
/** Framework-neutral settings lifecycle; model switches invalidate both audio and directory results. */
|
|
13
13
|
export function createTtsSettings({ preferences, list = fetchTtsVoices, preview, activity = voiceActivity }) {
|
|
14
14
|
const initialModel = ttsSettingsModel(preferences.getSnapshot().value);
|
|
15
|
-
let state = { model: initialModel, voices: [], selected: ttsVoiceForModel(preferences.getSnapshot().value?.config || {}, initialModel) || "", loading: Boolean(initialModel), error: null, saving: false, playing: false, previewError: null, blocked: activity.getSnapshot(), unavailable: false };
|
|
15
|
+
let state = { model: initialModel, voices: [], selected: ttsVoiceForModel(preferences.getSnapshot().value?.config || {}, initialModel) || "", loading: Boolean(initialModel), error: null, stale: false, saving: false, playing: false, previewError: null, blocked: activity.getSnapshot(), unavailable: false };
|
|
16
|
+
// A transient failure keeps the last directory this page loaded for the model
|
|
17
|
+
// instead of turning a working voice picker into an error screen.
|
|
18
|
+
const cached = new Map();
|
|
16
19
|
const listeners = new Set();
|
|
17
20
|
let generation = 0, listing = null, playing = null, disposed = false;
|
|
18
21
|
const emit = patch => { if (disposed) return; state = { ...state, ...patch }; for (const listener of listeners) listener(); };
|
|
@@ -25,16 +28,20 @@ export function createTtsSettings({ preferences, list = fetchTtsVoices, preview,
|
|
|
25
28
|
const load = async () => {
|
|
26
29
|
const token = ++generation; listing?.abort(); stop();
|
|
27
30
|
const model = ttsSettingsModel(preferences.getSnapshot().value);
|
|
28
|
-
emit({ model, voices: [], loading: Boolean(model), error: null, unavailable: false, previewError: null });
|
|
31
|
+
emit({ model, voices: [], loading: Boolean(model), error: null, stale: false, unavailable: false, previewError: null });
|
|
29
32
|
updateSelection();
|
|
30
33
|
if (!model) return;
|
|
31
34
|
const abort = listing = new AbortController();
|
|
32
35
|
try {
|
|
33
|
-
const voices = await list(model, { signal: AbortSignal.any([abort.signal, AbortSignal.timeout(
|
|
36
|
+
const voices = await list(model, { signal: AbortSignal.any([abort.signal, AbortSignal.timeout(30_000)]) });
|
|
34
37
|
if (token !== generation || disposed) return;
|
|
35
|
-
|
|
38
|
+
cached.set(model, voices);
|
|
39
|
+
emit({ voices, loading: false, error: null, stale: false }); updateSelection();
|
|
36
40
|
} catch (error) {
|
|
37
|
-
if (token
|
|
41
|
+
if (token !== generation || disposed || abort.signal.aborted) return;
|
|
42
|
+
const previous = cached.get(model);
|
|
43
|
+
if (previous) { emit({ voices: previous, loading: false, error: null, stale: true }); updateSelection(); }
|
|
44
|
+
else emit({ loading: false, error: "tts_voices_failed", stale: false });
|
|
38
45
|
}
|
|
39
46
|
};
|
|
40
47
|
let wasLoading = preferences.getSnapshot().loading, catalog = JSON.stringify(preferences.getSnapshot().value?.ttsModels || []);
|
package/voice/tts-voice.js
CHANGED
|
@@ -4,17 +4,58 @@ export function ttsVoiceForModel(config, model) {
|
|
|
4
4
|
return config.ttsModel === model ? config.voice || undefined : undefined;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
7
|
+
// A cold or busy TTS engine answers 5xx until its model is ready. The directory
|
|
8
|
+
// is metadata, so a couple of short retries are enough and keep the settings
|
|
9
|
+
// usable instead of failing on the first probe.
|
|
10
|
+
const ATTEMPT_TIMEOUT_MS = 8_000;
|
|
11
|
+
const RETRY_DELAYS_MS = [500, 1_500];
|
|
12
|
+
const TRANSIENT_STATUS = new Set([429, 500, 502, 503, 504]);
|
|
13
|
+
|
|
14
|
+
function delay(ms, signal) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const timer = setTimeout(() => { cleanup(); resolve(); }, ms);
|
|
17
|
+
const stop = () => { cleanup(); reject(new Error("voice_voices_aborted")); };
|
|
18
|
+
const cleanup = () => { clearTimeout(timer); signal?.removeEventListener("abort", stop); };
|
|
19
|
+
signal?.addEventListener("abort", stop, { once: true });
|
|
20
|
+
if (signal?.aborted) stop();
|
|
19
21
|
});
|
|
20
22
|
}
|
|
23
|
+
|
|
24
|
+
function attemptSignal(signal) {
|
|
25
|
+
const timeout = typeof AbortSignal?.timeout === "function" ? AbortSignal.timeout(ATTEMPT_TIMEOUT_MS) : null;
|
|
26
|
+
if (signal && timeout && typeof AbortSignal.any === "function") {
|
|
27
|
+
try { return AbortSignal.any([signal, timeout]); } catch { /* caller signal only */ }
|
|
28
|
+
}
|
|
29
|
+
return signal ?? timeout ?? undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function fetchTtsVoices(model, { signal, fetcher = fetch, retryDelays = RETRY_DELAYS_MS } = {}) {
|
|
33
|
+
const url = `/llm/v1/audio/voices?model=${encodeURIComponent(model)}`;
|
|
34
|
+
const failure = new Error("tts_voices_failed");
|
|
35
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
36
|
+
try {
|
|
37
|
+
const res = await fetcher(url, { signal: attemptSignal(signal) });
|
|
38
|
+
const body = await res.json().catch(() => null);
|
|
39
|
+
if ((res.status === 404 && /no preset voices/i.test(String(body?.detail || body?.error?.message || ""))) || res.status === 405 || res.status === 501) return [];
|
|
40
|
+
if (!res.ok) {
|
|
41
|
+
if (TRANSIENT_STATUS.has(res.status) && attempt < retryDelays.length && !signal?.aborted) {
|
|
42
|
+
await delay(retryDelays[attempt], signal);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
throw failure;
|
|
46
|
+
}
|
|
47
|
+
if (!Array.isArray(body?.voices)) throw failure;
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
return body.voices.flatMap(row => {
|
|
50
|
+
const id = typeof row === "string" ? row : row?.id;
|
|
51
|
+
if (typeof id !== "string" || !id || seen.has(id)) return [];
|
|
52
|
+
seen.add(id);
|
|
53
|
+
return [{ id, label: typeof row?.name === "string" && row.name.trim() ? row.name : id }];
|
|
54
|
+
});
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error === failure) throw failure;
|
|
57
|
+
if (signal?.aborted || attempt >= retryDelays.length) throw failure;
|
|
58
|
+
await delay(retryDelays[attempt], signal);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -274,7 +274,9 @@ export function createUnifiedConversation({ request, socket: openSocket = browse
|
|
|
274
274
|
lastSessionId = state.sessionId;
|
|
275
275
|
if (lastSessionId) controller.bindSession(lastSessionId);
|
|
276
276
|
if (state.error && state.error !== "voice_cancelled") throw new Error(state.error);
|
|
277
|
-
|
|
277
|
+
// A finished reply is not a running agent: keeping it "running" left the
|
|
278
|
+
// panel on Working forever and blocked the next continuous turn.
|
|
279
|
+
controller.observeAgent(Boolean(state.running), Boolean(reply?.audio));
|
|
278
280
|
};
|
|
279
281
|
const watch = async () => {
|
|
280
282
|
if (disposed || controller.getSnapshot().connection === "closed") return;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A phone that turns its screen off suspends the WebView: the AudioContext is
|
|
3
|
+
* interrupted and the socket is torn down, so a long answer stops after the
|
|
4
|
+
* first sentences. Keeping the screen awake for the length of a readout is the
|
|
5
|
+
* only part of that chain the page itself can control.
|
|
6
|
+
*
|
|
7
|
+
* The lock is only a hint. A WebView without `navigator.wakeLock`, or a request
|
|
8
|
+
* the platform refuses (no gesture, low power mode), must leave playback alone.
|
|
9
|
+
*/
|
|
10
|
+
export function createScreenWakeLock(nav = globalThis.navigator, doc = globalThis.document) {
|
|
11
|
+
let sentinel = null;
|
|
12
|
+
let wanted = false;
|
|
13
|
+
let watching = false;
|
|
14
|
+
const supported = () => typeof nav?.wakeLock?.request === "function";
|
|
15
|
+
|
|
16
|
+
const drop = () => {
|
|
17
|
+
const held = sentinel;
|
|
18
|
+
sentinel = null;
|
|
19
|
+
if (!held) return;
|
|
20
|
+
try { void held.release?.()?.catch?.(() => {}); } catch { /* already released */ }
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const acquire = async () => {
|
|
24
|
+
if (!wanted || sentinel || !supported() || doc?.visibilityState === "hidden") return false;
|
|
25
|
+
try {
|
|
26
|
+
const held = await nav.wakeLock.request("screen");
|
|
27
|
+
// The answer may have ended while the platform was answering the request.
|
|
28
|
+
if (!wanted) { try { void held.release?.()?.catch?.(() => {}); } catch { /* already released */ } return false; }
|
|
29
|
+
sentinel = held;
|
|
30
|
+
held.addEventListener?.("release", () => { if (sentinel === held) sentinel = null; });
|
|
31
|
+
return true;
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// A hidden page loses its lock. Re-acquire through the platform's own rule
|
|
38
|
+
// instead of guessing when the page is allowed to hold one again.
|
|
39
|
+
const watch = () => {
|
|
40
|
+
if (watching || !doc?.addEventListener) return;
|
|
41
|
+
watching = true;
|
|
42
|
+
doc.addEventListener("visibilitychange", () => { if (wanted && doc.visibilityState === "visible") void acquire(); });
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
get supported() { return supported(); },
|
|
47
|
+
get held() { return Boolean(sentinel); },
|
|
48
|
+
/** Ask for the lock; resolves to whether the platform granted it. */
|
|
49
|
+
async hold() { wanted = true; watch(); return acquire(); },
|
|
50
|
+
release() { wanted = false; drop(); },
|
|
51
|
+
};
|
|
52
|
+
}
|