@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 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.30",
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",
@@ -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 = utterance?.key ?? null;
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);
@@ -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 connection = await openAudioStream({ ...options, operation: "tts" });
36
- if (!connection) throw new Error("voice_stream_failed");
37
- const file = createBrowserPlayer();
38
- let progressNoted = false, progressBlocked = false;
39
- const player = createPcmStreamPlayer({ onProgress: (frames) => {
40
- if (connection.readyState !== 1) {
41
- if (!progressBlocked) { progressBlocked = true; streamMetric(options, { stage: "playback_progress_blocked", readyState: connection.readyState, frames }); }
42
- return;
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() { if (!ready || paused) return; paused = true; player.pause(); file.pause(); if (connection.readyState === 1) connection.send(JSON.stringify({ type: "pause" })); },
56
- resume() { if (!ready || !paused) return; paused = false; player.resume(); void file.resume()?.catch(() => connection.close()); if (connection.readyState === 1) connection.send(JSON.stringify({ type: "resume" })); },
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
- try {
59
- await new Promise((resolve, reject) => {
60
- let failureLogged = false;
61
- const noteFailure = (error) => {
62
- const code = error?.message || "voice_playback_failed";
63
- if (failureLogged || code === "voice_cancelled") return;
64
- failureLogged = true;
65
- streamMetric(options, { stage: "playback_failed", code, durationMs: Date.now() - started });
66
- };
67
- const fail = (error) => { noteFailure(error); reject(error); connection.close(); };
68
- const cancelled = () => { player.stop(); file.stop(); fail(new Error("voice_cancelled")); };
69
- options.signal?.addEventListener("abort", cancelled, { once: true });
70
- connection.addEventListener("message", ({ data }) => {
71
- if (typeof data !== "string" && !firstReceived) { firstReceived = true; streamMetric(options, { stage: "playback_first_received", durationMs: Date.now() - started }); }
72
- let event = null;
73
- if (typeof data === "string") {
74
- try { event = JSON.parse(data); } catch (error) { chain = chain.then(() => { throw error; }).catch(fail); return; }
75
- if (event?.type === "done") ending = true;
76
- }
77
- chain = chain.then(async () => {
78
- if (options.signal?.aborted) throw new Error("voice_cancelled");
79
- if (typeof data !== "string") { player.write(new Uint8Array(data)); return; }
80
- if (event.type === "segment") {
81
- if (event.index !== segmentIndex + 1) throw new Error("voice_reply_sequence");
82
- segmentIndex = event.index;
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
- else if (event.type === "segment.end") options.onSegment?.(event);
85
- else if (event.type === "format") { await player.start(event); ready = true; if (paused) player.pause(); }
86
- else if (event.type === "file") { ready = true; options.onStatus?.("playing"); await file.play(wavBlob(event.audio)); if (connection.readyState === 1) connection.send(JSON.stringify({ type: "file.done" })); }
87
- else if (event.type === "error") throw new Error(event.code || "voice_stream_failed");
88
- else if (event.type === "done") { await player.finish(); complete = true; resolve(); }
89
- }).catch(fail);
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
- const dropped = () => new Error(options.signal?.aborted ? "voice_cancelled" : "voice_disconnected");
92
- connection.addEventListener("error", () => fail(dropped()), { once: true });
93
- connection.addEventListener("close", () => {
94
- options.signal?.removeEventListener("abort", cancelled);
95
- if (!complete && !ending) { const error = dropped(); noteFailure(error); reject(error); }
96
- }, { once: true });
97
- if (options.signal?.aborted) cancelled();
98
- });
99
- return true;
100
- } finally { player.stop(); file.stop(); connection.close(); }
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. */
@@ -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")), 180000);
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
- const limit = pausedAt ? Math.max(1, 10_000 - (Date.now() - pausedAt)) : playbackStallMs;
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
  });
@@ -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(10000)]) });
36
+ const voices = await list(model, { signal: AbortSignal.any([abort.signal, AbortSignal.timeout(30_000)]) });
34
37
  if (token !== generation || disposed) return;
35
- emit({ voices, loading: false }); updateSelection();
38
+ cached.set(model, voices);
39
+ emit({ voices, loading: false, error: null, stale: false }); updateSelection();
36
40
  } catch (error) {
37
- if (token === generation && !disposed && !abort.signal.aborted) emit({ loading: false, error: "tts_voices_failed" });
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 || []);
@@ -4,17 +4,58 @@ export function ttsVoiceForModel(config, model) {
4
4
  return config.ttsModel === model ? config.voice || undefined : undefined;
5
5
  }
6
6
 
7
- export async function fetchTtsVoices(model, { signal, fetcher = fetch } = {}) {
8
- const res = await fetcher(`/llm/v1/audio/voices?model=${encodeURIComponent(model)}`, { signal: signal ?? AbortSignal.timeout(10000) });
9
- const body = await res.json().catch(() => null);
10
- if ((res.status === 404 && /no preset voices/i.test(String(body?.detail || body?.error?.message || ""))) || res.status === 405 || res.status === 501) return [];
11
- if (!res.ok) throw new Error("tts_voices_failed");
12
- if (!Array.isArray(body?.voices)) throw new Error("tts_voices_failed");
13
- const seen = new Set();
14
- return body.voices.flatMap(row => {
15
- const id = typeof row === "string" ? row : row?.id;
16
- if (typeof id !== "string" || !id || seen.has(id)) return [];
17
- seen.add(id);
18
- return [{ id, label: typeof row?.name === "string" && row.name.trim() ? row.name : id }];
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
- controller.observeAgent(Boolean(state.running) || Boolean(reply), Boolean(reply?.audio));
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
+ }