@olares/lares-core 0.15.29 → 0.15.30

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olares/lares-core",
3
- "version": "0.15.29",
3
+ "version": "0.15.30",
4
4
  "description": "Lares 共享业务核心:Router、workspace、文件/媒体、Olares 身份(无 UI、无平台壳)",
5
5
  "type": "module",
6
6
  "repository": {
@@ -20,6 +20,9 @@ const BARGE_IN_LIMIT_MS = 10_000;
20
20
  // AudioWorklet keeps a bounded queue; sending a whole answer at network speed
21
21
  // overflowed it and dropped audio.
22
22
  const PLAY_AHEAD_MS = 2_000;
23
+ // A temporarily interrupted AudioContext (device switch, lock screen) must not
24
+ // end a reply; only a client that stays silent this long is treated as stuck.
25
+ const PLAYOUT_STALL_MS = 30_000;
23
26
  const TICKET_MS = 30_000;
24
27
  // Usage lives in the child ASR/LLM/TTS requests; the OpenAI shape stays parseable (Router ADR-83).
25
28
  const ZERO_USAGE = Object.freeze({ total_tokens: 0, input_tokens: 0, output_tokens: 0, input_token_details: { text_tokens: 0, audio_tokens: 0, cached_tokens: 0 }, output_token_details: { text_tokens: 0, audio_tokens: 0 } });
@@ -179,7 +182,7 @@ export function createLaresRealtimeGateway({ service, config, transcribe, synthe
179
182
  if (current.abort.signal.aborted || current.done || closed || client.readyState !== 1) return;
180
183
  const heard = played.get(current.itemId) ?? 0;
181
184
  if (heard > lastPlayed) { lastPlayed = heard; playedAt = Date.now(); }
182
- if (Date.now() - playedAt > 10_000) throw new Error("voice_audio_backpressure");
185
+ if (Date.now() - playedAt > PLAYOUT_STALL_MS) throw new Error("voice_audio_backpressure");
183
186
  await new Promise((resolve) => setTimeout(resolve, 20));
184
187
  }
185
188
  if (current.abort.signal.aborted || current.done || closed || client.readyState !== 1) return;
@@ -3,6 +3,21 @@ import { pcmSamples } from "./realtime-audio.js";
3
3
 
4
4
  let primed = null;
5
5
  const worklets = new WeakMap();
6
+ let watchingVisibility = false;
7
+
8
+ /** iOS/macOS suspend the page's context on a call, the lock screen or a device switch. */
9
+ function resumeIfInterrupted(context) {
10
+ if (!context || context.state === "closed" || context.state === "running") return;
11
+ void context.resume?.().catch(() => {});
12
+ }
13
+
14
+ function watchVisibility() {
15
+ if (watchingVisibility || typeof document === "undefined" || !document.addEventListener) return;
16
+ watchingVisibility = true;
17
+ document.addEventListener("visibilitychange", () => {
18
+ if (document.visibilityState === "visible") resumeIfInterrupted(primed);
19
+ });
20
+ }
6
21
 
7
22
  function ensureWorklet(ctx) {
8
23
  const pending = worklets.get(ctx);
@@ -35,7 +50,9 @@ export function createPcmStreamPlayer({ onProgress = () => {}, onMetric = () =>
35
50
  * gets a fresh context instead of a silent failure.
36
51
  */
37
52
  const openContext = async (shared) => {
53
+ watchVisibility();
38
54
  if (shared) {
55
+ shared.addEventListener?.("statechange", () => resumeIfInterrupted(shared));
39
56
  try {
40
57
  await ensureWorklet(shared);
41
58
  await shared.resume?.();
@@ -49,6 +66,7 @@ export function createPcmStreamPlayer({ onProgress = () => {}, onMetric = () =>
49
66
  }
50
67
  const fresh = new Context();
51
68
  owned = true;
69
+ fresh.addEventListener?.("statechange", () => resumeIfInterrupted(fresh));
52
70
  try {
53
71
  await ensureWorklet(fresh);
54
72
  await fresh.resume?.();
@@ -39,17 +39,14 @@ export function voiceCallFailure(view) {
39
39
  }
40
40
 
41
41
  /**
42
- * Playback stays visible at the composer until it ends.
43
- * A hidden readout call and a chat reader both use this one strip:
44
- * status plus the control that stops it. The hold button is not that strip.
42
+ * A voice call that is still playing keeps one strip at the composer until it
43
+ * ends: status plus the control that stops it. The hold button is not that
44
+ * strip. A chat readout stays on the assistant row that is being read.
45
45
  */
46
- export function voicePlaybackChrome(view, readout) {
46
+ export function voicePlaybackChrome(view) {
47
47
  const playback = view?.snapshot?.playback;
48
- if (playback && playback !== "idle") {
49
- return { status: voicePanelPresentation(view).status, stop: "call", stopLabel: "panel.close" };
50
- }
51
- if (!readout) return null;
52
- return { status: `readout.${readout.status}`, stop: "readout", stopLabel: readout.status === "error" ? "readout.dismiss" : "panel.close" };
48
+ if (!playback || playback === "idle") return null;
49
+ return { status: voicePanelPresentation(view).status, stop: "call", stopLabel: "panel.close" };
53
50
  }
54
51
 
55
52
  /** Shared inline labels; clients own focus, positioning and the timer tick. */
@@ -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 = () => {} }) {
13
+ export function createAudioStreamGateway({ config, models, replies, synthesize = synthesizeStream, env = process.env, metric = () => {}, playbackStallMs = 45_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,
@@ -145,7 +145,14 @@ export function createAudioStreamGateway({ config, models, replies, synthesize =
145
145
  for (let offset = 0; offset < event.chunk.length; offset += 16384) {
146
146
  while (pausedAt || sentFrames - playedFrames > rate * 2 || client.bufferedAmount > 128 * 1024) {
147
147
  if (!reportedWait) { reportedWait = true; metric({ stage: "playback_wait", voiceId: grant.scope.id, sentFrames, playedFrames, paused: Boolean(pausedAt) }); }
148
- await new Promise((resolve, reject) => { const timer = setTimeout(() => { waiter = null; reject(fault(pausedAt ? "voice_interruption_timeout" : "voice_audio_backpressure")); }, pausedAt ? Math.max(1, 10000 - (Date.now() - pausedAt)) : 10000); waiter = () => { clearTimeout(timer); resolve(); }; });
148
+ // A device switch or a locked screen can stop playout for a
149
+ // while without the client being gone. Wait for it to come
150
+ // back instead of ending the answer after ten seconds.
151
+ await new Promise((resolve, reject) => {
152
+ const limit = pausedAt ? Math.max(1, 10_000 - (Date.now() - pausedAt)) : playbackStallMs;
153
+ const timer = setTimeout(() => { waiter = null; reject(fault(pausedAt ? "voice_interruption_timeout" : "voice_audio_backpressure")); }, limit);
154
+ waiter = () => { clearTimeout(timer); resolve(); };
155
+ });
149
156
  if (abort.signal.aborted || client.readyState !== 1) throw fault("voice_cancelled");
150
157
  }
151
158
  if (client.readyState !== 1) throw fault("voice_cancelled");