@olares/lares-core 0.15.31 → 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/package.json +1 -1
- package/voice/speech.js +27 -2
- package/voice/stream-client.js +167 -85
- package/voice/unified-conversation.js +3 -1
package/package.json
CHANGED
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
|
@@ -45,98 +45,180 @@ function clientFacts() {
|
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
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
|
+
|
|
48
57
|
export async function playAudioStream(options) {
|
|
49
58
|
const started = Date.now();
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
let
|
|
58
|
-
const
|
|
59
|
-
lastProgressAt = Date.now();
|
|
60
|
-
if (gapReported) { gapReported = false; streamMetric(options, { stage: "playback_gap_end", frames, gapMs: Date.now() - gapAt, ...clientFacts(), context: player.contextState() }); }
|
|
61
|
-
if (connection.readyState !== 1) {
|
|
62
|
-
if (!progressBlocked) { progressBlocked = true; streamMetric(options, { stage: "playback_progress_blocked", readyState: connection.readyState, frames }); }
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
try { connection.send(JSON.stringify({ type: "progress", frames })); }
|
|
66
|
-
catch (error) {
|
|
67
|
-
if (!progressBlocked) { progressBlocked = true; streamMetric(options, { stage: "playback_progress_blocked", code: error?.message || "send_failed", frames }); }
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
if (!progressNoted) { progressNoted = true; streamMetric(options, { stage: "playback_progress_sent", frames }); }
|
|
71
|
-
},
|
|
72
|
-
onMetric: (event) => { if (event.stage === "playback_started") options.onStatus?.("playing"); streamMetric(options, { ...event, durationMs: Date.now() - started }); } });
|
|
73
|
-
let chain = Promise.resolve(), complete = false, ending = false, firstReceived = false, ready = false, paused = false, segmentIndex = -1;
|
|
74
|
-
const gapWatch = setInterval(() => {
|
|
75
|
-
if (!ready || paused || complete || gapReported || Date.now() - lastProgressAt < 4000) return;
|
|
76
|
-
gapReported = true;
|
|
77
|
-
gapAt = lastProgressAt;
|
|
78
|
-
streamMetric(options, { stage: "playback_gap", gapMs: Date.now() - lastProgressAt, frames: player.playedFrames(), ...clientFacts(), context: player.contextState(), sampleRate: player.sampleRate() });
|
|
79
|
-
}, 1000);
|
|
80
|
-
gapWatch.unref?.();
|
|
81
|
-
const noteVisibility = () => {
|
|
82
|
-
if (ready && !complete) streamMetric(options, { stage: "playback_visibility", frames: player.playedFrames(), paused, ...clientFacts(), context: player.contextState() });
|
|
83
|
-
};
|
|
84
|
-
if (typeof document !== "undefined" && document.addEventListener) document.addEventListener("visibilitychange", noteVisibility);
|
|
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 };
|
|
85
68
|
options.controls?.({
|
|
86
|
-
get canPause() { return ready; },
|
|
87
|
-
pause() {
|
|
88
|
-
|
|
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
|
+
},
|
|
89
81
|
});
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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;
|
|
120
168
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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();
|
|
125
202
|
});
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
+
}
|
|
139
220
|
}
|
|
221
|
+
throw lastError || new Error("voice_disconnected");
|
|
140
222
|
}
|
|
141
223
|
|
|
142
224
|
/** A single final ASR result; partials never submit a task or replace the draft. */
|
|
@@ -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;
|