@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
|
@@ -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 >
|
|
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;
|
package/voice/pcm-player.js
CHANGED
|
@@ -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?.();
|
package/voice/presentation.js
CHANGED
|
@@ -39,17 +39,14 @@ export function voiceCallFailure(view) {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
|
46
|
+
export function voicePlaybackChrome(view) {
|
|
47
47
|
const playback = view?.snapshot?.playback;
|
|
48
|
-
if (playback
|
|
49
|
-
|
|
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. */
|
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 = () => {} }) {
|
|
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
|
-
|
|
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");
|