@olares/lares-core 0.15.10 → 0.15.12
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 +246 -13
- package/larepass/host.js +6 -0
- package/larepass/mux.js +4 -3
- package/larepass/runtime.js +5 -3
- package/larepass/settings.js +3 -0
- package/larepass/transcript.js +4 -6
- package/olares/trusted-host.js +11 -1
- package/package.json +27 -2
- package/router/tts.js +25 -1
- package/tools/http.js +27 -1
- package/voice/agent-session.js +133 -0
- package/voice/agent.js +9 -1
- package/voice/browser-capture.js +274 -0
- package/voice/browser-playback.js +30 -0
- package/voice/capture.js +12 -2
- package/voice/client.js +71 -9
- package/voice/composite-gateway.js +156 -0
- package/voice/config.js +30 -17
- package/voice/controller.js +147 -0
- package/voice/conversation.js +131 -0
- package/voice/device-input.js +79 -0
- package/voice/dialogue.js +71 -0
- package/voice/duplex.js +1 -1
- package/voice/external.js +26 -0
- package/voice/gpu-mode.js +460 -0
- package/voice/gpu-problem.js +47 -0
- package/voice/host/bluetooth-ready.py +2 -0
- package/voice/host-input.js +38 -0
- package/voice/host-service.js +171 -0
- package/voice/input.js +2 -1
- package/voice/install.js +33 -12
- package/voice/languages.js +2 -6
- package/voice/lease.js +16 -0
- package/voice/options.js +23 -0
- package/voice/protocol-client.js +24 -0
- package/voice/realtime-audio.js +107 -0
- package/voice/realtime-conversation.js +122 -0
- package/voice/realtime-gateway.js +162 -0
- package/voice/realtime.js +63 -0
- package/voice/records.js +72 -0
- package/voice/router-audio.js +26 -0
- package/voice/script.js +85 -15
- package/voice/service.js +340 -57
- package/voice/session.js +11 -1
- package/voice/speech.js +228 -44
package/voice/speech.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The utterance a turn may speak: the text of the
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* The utterance a turn may speak: the text of the step being written, read
|
|
3
|
+
* while it streams. Only a step that ends without a tool call answers the
|
|
4
|
+
* turn, and that is not known until the step settles, so a step turns silent
|
|
5
|
+
* the moment a tool call shows up in it, and an interrupted step is silent
|
|
6
|
+
* too. Reasoning is never read.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
function turnOf(event) {
|
|
@@ -13,6 +13,12 @@ function turnOf(event) {
|
|
|
13
13
|
return Number.isFinite(number) ? number : null;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
function stepOf(event) {
|
|
17
|
+
const data = event?.data ?? {};
|
|
18
|
+
const number = Number(data.step ?? data.message?.step);
|
|
19
|
+
return Number.isFinite(number) ? number : 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
16
22
|
function messageContent(event) {
|
|
17
23
|
const data = event?.data ?? {};
|
|
18
24
|
const content = data.message?.content ?? data.content;
|
|
@@ -28,66 +34,244 @@ export function assistantText(event) {
|
|
|
28
34
|
.trim();
|
|
29
35
|
}
|
|
30
36
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
37
|
+
function isChunkEvent(event) {
|
|
38
|
+
return event?.type === "assistant/chunk" || event?.type === "assistant/live-chunk";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function opensToolCall(chunk) {
|
|
42
|
+
return chunk?.type === "tool-call-delta"
|
|
43
|
+
|| (chunk?.type === "block-start" && chunk.blockType === "tool-call")
|
|
44
|
+
|| (chunk?.type === "block-end" && chunk.block?.type === "tool-call");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function stepUtterance(turn, step) {
|
|
48
|
+
return { key: `${turn}:${step}`, turn, step, blocks: {}, text: "", done: false, silent: false };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function withBlock(state, index, text) {
|
|
52
|
+
const blocks = { ...state.blocks, [index]: text };
|
|
53
|
+
const joined = Object.keys(blocks)
|
|
54
|
+
.map(Number)
|
|
55
|
+
.sort((a, b) => a - b)
|
|
56
|
+
.map((at) => blocks[at].trim())
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.join("\n");
|
|
59
|
+
return { ...state, blocks, text: joined };
|
|
36
60
|
}
|
|
37
61
|
|
|
38
62
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
63
|
+
* @typedef {{ key: string, turn: number, step: number, text: string, done: boolean, silent: boolean }} Utterance
|
|
64
|
+
* @param {Utterance | null} state
|
|
65
|
+
* @returns {Utterance | null}
|
|
66
|
+
*/
|
|
67
|
+
export function reduceUtterance(state, event) {
|
|
68
|
+
const type = event?.type;
|
|
69
|
+
if (type === "turn/start") return null;
|
|
70
|
+
if (type === "turn/end") return state && !state.done ? { ...state, done: true } : state;
|
|
71
|
+
const turn = turnOf(event);
|
|
72
|
+
if (turn === null) return state;
|
|
73
|
+
const step = stepOf(event);
|
|
74
|
+
const same = state?.turn === turn && state.step === step;
|
|
75
|
+
if (isChunkEvent(event)) {
|
|
76
|
+
if (same && state.done) return state;
|
|
77
|
+
const chunk = event.data?.chunk;
|
|
78
|
+
const current = same ? state : stepUtterance(turn, step);
|
|
79
|
+
if (opensToolCall(chunk)) return current.silent ? current : { ...current, silent: true };
|
|
80
|
+
const index = Number.isInteger(chunk?.index) ? chunk.index : 0;
|
|
81
|
+
if (chunk?.type === "text-delta" && typeof chunk.text === "string") {
|
|
82
|
+
return withBlock(current, index, `${current.blocks[index] ?? ""}${chunk.text}`);
|
|
83
|
+
}
|
|
84
|
+
if (chunk?.type === "block-end" && chunk.block?.type === "text" && typeof chunk.block.text === "string") {
|
|
85
|
+
return withBlock(current, index, chunk.block.text);
|
|
86
|
+
}
|
|
87
|
+
return state;
|
|
88
|
+
}
|
|
89
|
+
if (type === "assistant/message") {
|
|
90
|
+
const content = messageContent(event);
|
|
91
|
+
return {
|
|
92
|
+
...stepUtterance(turn, step),
|
|
93
|
+
text: assistantText(event),
|
|
94
|
+
done: true,
|
|
95
|
+
silent: Boolean(event.data?.interrupted) || content.some((block) => block?.type === "tool-call"),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
return state;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function matchSpeechEvent(event) {
|
|
102
|
+
const type = event?.type;
|
|
103
|
+
if (type !== "turn/start" && type !== "turn/end" && type !== "assistant/message" && !isChunkEvent(event)) return null;
|
|
104
|
+
const turn = turnOf(event);
|
|
105
|
+
if (turn === null) return null;
|
|
106
|
+
return { id: String(turn), role: type === "turn/start" ? "start" : "update" };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Which utterances a live view reads aloud: only those it watched being
|
|
111
|
+
* written while their turn was running. Opening a session mid-answer, or
|
|
112
|
+
* history that is already finished, stays silent.
|
|
42
113
|
*/
|
|
43
114
|
export function createReplySpeaker() {
|
|
44
115
|
let armed = false;
|
|
45
|
-
let
|
|
116
|
+
let skipKey = null;
|
|
46
117
|
return {
|
|
47
|
-
/** @returns {
|
|
48
|
-
observe({ sessionChanged, wasRunning, running,
|
|
118
|
+
/** @returns {Utterance | null} the utterance to follow now, or null */
|
|
119
|
+
observe({ sessionChanged, wasRunning, running, utterance }) {
|
|
49
120
|
if (sessionChanged) {
|
|
50
121
|
armed = Boolean(running);
|
|
51
|
-
|
|
52
|
-
return
|
|
122
|
+
skipKey = utterance?.key ?? null;
|
|
123
|
+
return null;
|
|
53
124
|
}
|
|
54
125
|
if (!wasRunning && running) {
|
|
55
126
|
armed = true;
|
|
56
|
-
|
|
57
|
-
// history, not watched.
|
|
58
|
-
if (reply) spokenSeq = Math.max(spokenSeq, reply.seq);
|
|
59
|
-
}
|
|
60
|
-
let text = "";
|
|
61
|
-
if (armed && reply && reply.seq > spokenSeq) {
|
|
62
|
-
spokenSeq = reply.seq;
|
|
63
|
-
text = reply.text;
|
|
127
|
+
skipKey = utterance?.key ?? null;
|
|
64
128
|
}
|
|
129
|
+
const follow = armed && utterance && utterance.key !== skipKey ? utterance : null;
|
|
65
130
|
if (!running) armed = false;
|
|
66
|
-
return
|
|
131
|
+
return follow;
|
|
67
132
|
},
|
|
68
133
|
};
|
|
69
134
|
}
|
|
70
135
|
|
|
71
|
-
|
|
72
|
-
|
|
136
|
+
/**
|
|
137
|
+
* Which state of an utterance the reader gets. Streaming reads while the step
|
|
138
|
+
* is written; otherwise the step is read once it has settled.
|
|
139
|
+
* @param {Utterance | null} utterance
|
|
140
|
+
*/
|
|
141
|
+
export function speakable(utterance, streaming) {
|
|
142
|
+
if (!utterance) return null;
|
|
143
|
+
return streaming || utterance.done || utterance.silent ? utterance : null;
|
|
73
144
|
}
|
|
74
145
|
|
|
146
|
+
/** Text past this may have closed a sentence; the host decides whether it did. */
|
|
147
|
+
const BOUNDARY = /[.!?;。!?;…\n]/;
|
|
148
|
+
|
|
75
149
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
150
|
+
* Reads one utterance at a time, sentence by sentence, while it is still being
|
|
151
|
+
* written. The host owns sentence boundaries (sentencex does not run in a
|
|
152
|
+
* page): the text is handed over whenever it may have finished a sentence, and
|
|
153
|
+
* the host answers how many sentences are complete. That keeps a headset
|
|
154
|
+
* reading on without gaps; the page fetches a sentence's audio just before it
|
|
155
|
+
* needs it and plays it through `play`.
|
|
156
|
+
*
|
|
157
|
+
* `halt` stops the page's own audio. `silence` also stops the host, for a step
|
|
158
|
+
* that turned out to call a tool while the headset was reading it.
|
|
159
|
+
*
|
|
160
|
+
* @param {{
|
|
161
|
+
* send: (request: { key: string, text: string, done: boolean, part?: number }) => Promise<any>,
|
|
162
|
+
* play: (sentence: { audio: unknown, bluetooth?: string }) => Promise<unknown>,
|
|
163
|
+
* halt: () => void,
|
|
164
|
+
* silence: () => void,
|
|
165
|
+
* fail?: (err: unknown) => void,
|
|
166
|
+
* }} io
|
|
79
167
|
*/
|
|
80
|
-
export function
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
168
|
+
export function createSentencePlayer({ send, play, halt, silence, fail = () => {}, onState = () => {} }) {
|
|
169
|
+
let gen = 0;
|
|
170
|
+
let current = null;
|
|
171
|
+
// An utterance is read once: finishing it, failing, or being stopped all end it for good.
|
|
172
|
+
let lastKey = null;
|
|
173
|
+
let silencedKey = null;
|
|
86
174
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
175
|
+
const failed = (run, err) => {
|
|
176
|
+
if (run.gen !== gen) return;
|
|
177
|
+
gen += 1;
|
|
178
|
+
run.wake?.();
|
|
179
|
+
current = null;
|
|
180
|
+
onState(false);
|
|
181
|
+
if (err?.code !== "voice_cancelled") fail(err);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const handOver = (run) => {
|
|
185
|
+
if (run.sending || run.gen !== gen) return;
|
|
186
|
+
const due = (run.done && !run.sentDone) || BOUNDARY.test(run.text.slice(run.sentLength));
|
|
187
|
+
if (!due) return;
|
|
188
|
+
const { text, done } = run;
|
|
189
|
+
run.sending = send({ key: run.key, text, done })
|
|
190
|
+
.then((result) => {
|
|
191
|
+
run.sending = null;
|
|
192
|
+
run.sent = Math.max(run.sent, Number(result?.sentences) || 0);
|
|
193
|
+
run.sentLength = text.length;
|
|
194
|
+
run.sentDone = done;
|
|
195
|
+
const wake = run.wake;
|
|
196
|
+
run.wake = null;
|
|
197
|
+
wake?.();
|
|
198
|
+
handOver(run);
|
|
199
|
+
})
|
|
200
|
+
.catch((err) => failed(run, err));
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const fetchPart = (run, part) => {
|
|
204
|
+
const pending = send({ key: run.key, text: run.text, done: run.done, part });
|
|
205
|
+
pending.catch(() => {});
|
|
206
|
+
return pending;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const read = async (run) => {
|
|
210
|
+
let next = null;
|
|
211
|
+
for (let part = 0; run.gen === gen;) {
|
|
212
|
+
if (part >= run.sent) {
|
|
213
|
+
if (run.sentDone) break;
|
|
214
|
+
await new Promise((resolve) => { run.wake = resolve; });
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const sentence = await (next ?? fetchPart(run, part));
|
|
218
|
+
next = part + 1 < run.sent ? fetchPart(run, part + 1) : null;
|
|
219
|
+
if (run.gen !== gen) return;
|
|
220
|
+
await play(sentence);
|
|
221
|
+
part += 1;
|
|
222
|
+
}
|
|
223
|
+
if (run.gen === gen) { current = null; onState(false); }
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const stop = () => {
|
|
227
|
+
gen += 1;
|
|
228
|
+
current?.wake?.();
|
|
229
|
+
if (current) halt();
|
|
230
|
+
current = null;
|
|
231
|
+
onState(false);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
/** Feed the latest state of the utterance; null leaves the reading as it is. */
|
|
236
|
+
follow(utterance) {
|
|
237
|
+
if (!utterance) return;
|
|
238
|
+
if (utterance.silent) {
|
|
239
|
+
if (utterance.key !== lastKey || silencedKey === lastKey) return;
|
|
240
|
+
silencedKey = lastKey;
|
|
241
|
+
stop();
|
|
242
|
+
silence();
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (!utterance.text) return;
|
|
246
|
+
if (current?.key !== utterance.key) {
|
|
247
|
+
if (utterance.key === lastKey) return;
|
|
248
|
+
if (current) stop();
|
|
249
|
+
gen += 1;
|
|
250
|
+
lastKey = utterance.key;
|
|
251
|
+
current = {
|
|
252
|
+
gen,
|
|
253
|
+
key: utterance.key,
|
|
254
|
+
text: utterance.text,
|
|
255
|
+
done: utterance.done,
|
|
256
|
+
sent: 0,
|
|
257
|
+
sentLength: 0,
|
|
258
|
+
sentDone: false,
|
|
259
|
+
sending: null,
|
|
260
|
+
wake: null,
|
|
261
|
+
};
|
|
262
|
+
const run = current;
|
|
263
|
+
onState(true);
|
|
264
|
+
handOver(run);
|
|
265
|
+
read(run).catch((err) => failed(run, err));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
current.text = utterance.text;
|
|
269
|
+
current.done = utterance.done;
|
|
270
|
+
handOver(current);
|
|
271
|
+
},
|
|
272
|
+
stop() {
|
|
273
|
+
stop();
|
|
274
|
+
silence();
|
|
275
|
+
},
|
|
276
|
+
};
|
|
93
277
|
}
|