@olares/lares-core 0.15.7 → 0.15.10
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/README.md +2 -1
- package/brand/identity.js +33 -3
- package/drive/cat.js +62 -0
- package/drive/download.js +3 -3
- package/drive/execute.js +8 -2
- package/drive/files-path.js +33 -0
- package/drive/flowstudio-files.js +87 -0
- package/drive/ls.js +3 -3
- package/drive/paths.js +23 -13
- package/drive/tools.js +4 -2
- package/entrance/surface.js +96 -0
- package/files/app-link.js +64 -0
- package/files/backend-client.js +266 -0
- package/files/deliverables.js +55 -1
- package/files/intake.js +0 -63
- package/files/mention.js +1 -51
- package/files/preview-groups.js +71 -3
- package/files/preview-local.js +69 -0
- package/files/preview-workspace.js +114 -13
- package/files/preview.js +171 -16
- package/files/produced-gate.js +197 -0
- package/files/produced-mentions.js +19 -0
- package/files/published-tools.js +69 -0
- package/files/published-turn-data.js +84 -0
- package/i18n/chat-model.js +5 -5
- package/i18n/mcp.js +83 -0
- package/i18n/mobile.js +96 -2
- package/i18n/preview.js +10 -0
- package/i18n/search.js +13 -7
- package/i18n/skills.js +31 -0
- package/i18n/voice.js +88 -19
- package/larepass/chat.js +9 -0
- package/larepass/host.js +7 -2
- package/larepass/mux.js +210 -2
- package/larepass/process-fold.js +239 -0
- package/larepass/queue.js +44 -0
- package/larepass/rpc.js +50 -3
- package/larepass/runtime.js +215 -83
- package/larepass/session-cache.js +6 -0
- package/larepass/settings.js +15 -7
- package/larepass/transcript.js +49 -11
- package/log/report.js +46 -0
- package/mcp/approval.js +152 -0
- package/mcp/client.js +38 -0
- package/mcp/config.js +139 -0
- package/media/catalog-row.js +244 -0
- package/media/generation.js +707 -0
- package/media/tool.js +92 -0
- package/olares/entrance.js +67 -15
- package/olares/identity.js +13 -136
- package/olares/session-identity.js +12 -12
- package/olares/trusted-host.js +0 -29
- package/package.json +49 -4
- package/router/catalog-cache.d.ts +1 -1
- package/router/catalog-cache.js +25 -14
- package/router/catalog-sync.js +8 -3
- package/router/catalog.d.ts +6 -1
- package/router/catalog.js +18 -2
- package/router/gateway.d.ts +9 -1
- package/router/gateway.js +40 -4
- package/router/gemini-schema.js +125 -0
- package/router/generation-guard.js +82 -0
- package/router/models.d.ts +6 -0
- package/router/models.js +22 -3
- package/router/search.js +10 -2
- package/router/session-model.js +53 -0
- package/router/shim.js +104 -3
- package/router/sref.js +126 -0
- package/router/stt.js +43 -12
- package/router/tts.js +118 -0
- package/search/client.js +5 -3
- package/search/config.js +53 -39
- package/search/menu.js +45 -10
- package/skills/client.js +42 -0
- package/skills/packs.js +40 -0
- package/skills/state.d.ts +22 -0
- package/skills/state.js +142 -0
- package/tools/crypto-uuid.js +3 -6
- package/tools/head-script.js +15 -0
- package/voice/agent.js +50 -0
- package/voice/capture.js +250 -0
- package/voice/client.js +126 -24
- package/voice/config.js +11 -1
- package/voice/duplex.js +44 -0
- package/voice/endpoint.js +57 -0
- package/voice/host/bluetooth-ready.py +1196 -0
- package/voice/host/install-host.sh +102 -0
- package/voice/input.js +4 -0
- package/voice/install.js +146 -0
- package/voice/languages.js +11 -1
- package/voice/link.js +86 -0
- package/voice/pcm.js +100 -0
- package/voice/playback.js +7 -0
- package/voice/problem.js +46 -0
- package/voice/recorder.js +79 -4
- package/voice/script.js +35 -0
- package/voice/service.js +253 -10
- package/voice/session.js +128 -0
- package/voice/speech.js +93 -0
- package/files/preview-cache.js +0 -100
package/voice/service.js
CHANGED
|
@@ -1,17 +1,42 @@
|
|
|
1
1
|
import { readConfig, validateConfigPatch, writeConfig } from "./config.js";
|
|
2
|
+
import {
|
|
3
|
+
chartModelId,
|
|
4
|
+
installVoiceChart,
|
|
5
|
+
resumeVoiceChart,
|
|
6
|
+
stopVoiceChart,
|
|
7
|
+
voiceChartsStatus,
|
|
8
|
+
voiceChartStatus,
|
|
9
|
+
} from "./install.js";
|
|
10
|
+
import {
|
|
11
|
+
beginHostCapture,
|
|
12
|
+
endHostCapture,
|
|
13
|
+
openBluetoothPlayback,
|
|
14
|
+
playBluetoothAudio,
|
|
15
|
+
readHostCapture,
|
|
16
|
+
resetHostCapture,
|
|
17
|
+
stopBluetoothPlayback,
|
|
18
|
+
} from "./session.js";
|
|
19
|
+
import { createCaptureSession } from "./capture.js";
|
|
20
|
+
import { createDuplexCoordinator } from "./duplex.js";
|
|
21
|
+
import { normalizeSttPcm } from "./pcm.js";
|
|
22
|
+
import { speechSegments } from "./script.js";
|
|
23
|
+
import { pcmToWav, silencePcm, synthesize, synthesizeStream } from "../router/tts.js";
|
|
2
24
|
import {
|
|
3
25
|
STT_MAX_AUDIO_BYTES,
|
|
4
26
|
VoiceError,
|
|
5
|
-
audioFilenameForContentType,
|
|
6
27
|
listModels,
|
|
7
28
|
pickSttModelId,
|
|
29
|
+
pickTtsModelId,
|
|
8
30
|
resolveSttModel,
|
|
9
31
|
sttModelIds,
|
|
10
32
|
transcribe,
|
|
33
|
+
ttsModelIds,
|
|
11
34
|
} from "../router/stt.js";
|
|
12
35
|
|
|
13
36
|
export { STT_MAX_AUDIO_BYTES };
|
|
14
37
|
|
|
38
|
+
const duplex = createDuplexCoordinator(stopBluetoothPlayback);
|
|
39
|
+
|
|
15
40
|
export async function voiceStatusPayload(options) {
|
|
16
41
|
const config = readConfig();
|
|
17
42
|
let modelAvailable = false;
|
|
@@ -33,23 +58,208 @@ export async function voiceStatusPayload(options) {
|
|
|
33
58
|
|
|
34
59
|
export async function voiceModelsPayload(options) {
|
|
35
60
|
const models = await listModels(options);
|
|
61
|
+
const config = readConfig();
|
|
36
62
|
const ids = models.map((model) => model.id);
|
|
37
63
|
const stt = sttModelIds(models);
|
|
38
|
-
const
|
|
39
|
-
|
|
64
|
+
const tts = ttsModelIds(models);
|
|
65
|
+
let charts = null;
|
|
66
|
+
try {
|
|
67
|
+
charts = await voiceChartsStatus();
|
|
68
|
+
} catch {
|
|
69
|
+
charts = null;
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
models: ids,
|
|
73
|
+
stt,
|
|
74
|
+
tts,
|
|
75
|
+
selected: pickSttModelId(models, config.model),
|
|
76
|
+
ttsSelected: pickTtsModelId(models, config.ttsModel),
|
|
77
|
+
charts,
|
|
78
|
+
};
|
|
40
79
|
}
|
|
41
80
|
|
|
42
81
|
export async function saveVoiceConfig(body) {
|
|
43
82
|
const patch = validateConfigPatch(body);
|
|
44
|
-
if (patch.model) {
|
|
45
|
-
const
|
|
46
|
-
if (!
|
|
83
|
+
if (patch.model || patch.ttsModel) {
|
|
84
|
+
const models = await listModels();
|
|
85
|
+
if (patch.model && !sttModelIds(models).includes(patch.model)) {
|
|
47
86
|
throw new VoiceError("voice_model_unavailable", 400, "selected voice model is not available");
|
|
48
87
|
}
|
|
88
|
+
if (patch.ttsModel && !ttsModelIds(models).includes(patch.ttsModel)) {
|
|
89
|
+
throw new VoiceError("voice_model_unavailable", 400, "selected speech model is not available");
|
|
90
|
+
}
|
|
49
91
|
}
|
|
50
92
|
return writeConfig(patch);
|
|
51
93
|
}
|
|
52
94
|
|
|
95
|
+
export function installVoice(kind) {
|
|
96
|
+
return installVoiceChart(kind);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function stopVoice(kind) {
|
|
100
|
+
return stopVoiceChart(kind);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function resumeVoice(kind) {
|
|
104
|
+
return resumeVoiceChart(kind);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const PROBE_TIMEOUT_MS = 45_000;
|
|
108
|
+
|
|
109
|
+
function probeWav() {
|
|
110
|
+
return pcmToWav(Buffer.alloc(6400), 16000);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Ask the chart's model through Router. An engine that answers — even to
|
|
115
|
+
* reject silence — is connected. A stopped chart is not.
|
|
116
|
+
* @param {"stt" | "tts"} kind
|
|
117
|
+
*/
|
|
118
|
+
export async function probeVoice(kind, options = {}) {
|
|
119
|
+
const status = await (options.status ?? voiceChartStatus)(kind);
|
|
120
|
+
if (status.phase !== "running") {
|
|
121
|
+
throw new VoiceError("voice_probe_failed", 409, "voice app is not running");
|
|
122
|
+
}
|
|
123
|
+
const models = await (options.listModels ?? listModels)({ refresh: true });
|
|
124
|
+
const ids = kind === "tts" ? ttsModelIds(models) : sttModelIds(models);
|
|
125
|
+
const model = chartModelId(kind, ids);
|
|
126
|
+
if (!model) throw new VoiceError("voice_probe_failed", 503, "voice model is not registered");
|
|
127
|
+
if (kind === "tts") {
|
|
128
|
+
const speak = options.synthesize ?? synthesize;
|
|
129
|
+
const audio = await speak({ text: "你好", model, timeoutMs: PROBE_TIMEOUT_MS });
|
|
130
|
+
if (!audio?.length) throw new VoiceError("voice_probe_failed", 502, "speech returned no audio");
|
|
131
|
+
return { ok: true, app: status.app, model };
|
|
132
|
+
}
|
|
133
|
+
const hear = options.transcribe ?? transcribe;
|
|
134
|
+
try {
|
|
135
|
+
await hear({
|
|
136
|
+
bytes: probeWav(),
|
|
137
|
+
filename: "probe.wav",
|
|
138
|
+
contentType: "audio/wav",
|
|
139
|
+
model,
|
|
140
|
+
once: true,
|
|
141
|
+
timeoutMs: PROBE_TIMEOUT_MS,
|
|
142
|
+
});
|
|
143
|
+
} catch (err) {
|
|
144
|
+
if (!(err instanceof VoiceError) || err.code !== "voice_audio_unreadable") {
|
|
145
|
+
const statusCode = err && typeof err === "object" && "status" in err ? Number(err.status) : 502;
|
|
146
|
+
throw new VoiceError("voice_probe_failed", statusCode || 502, err instanceof Error ? err.message : "probe failed");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { ok: true, app: status.app, model };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function cancelledSpeech() {
|
|
153
|
+
return new VoiceError("voice_cancelled", 499, "speech was superseded");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const readouts = new WeakMap();
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Every open page asks to read the same answer when it concludes. A request
|
|
160
|
+
* for the readout already under way joins it instead of restarting the
|
|
161
|
+
* headset and cancelling the page that asked first.
|
|
162
|
+
*/
|
|
163
|
+
export async function speakFromConfig(text, options = {}) {
|
|
164
|
+
const config = options.config ?? readConfig();
|
|
165
|
+
if (!config.speak) throw new VoiceError("voice_speak_disabled", 409, "speech playback is off");
|
|
166
|
+
const segments = speechSegments(text);
|
|
167
|
+
if (!segments.length) throw new VoiceError("voice_config_invalid", 400, "nothing to speak");
|
|
168
|
+
const model = options.model ?? await pickTtsModelId(await listModels(), config.ttsModel);
|
|
169
|
+
if (!model) throw new VoiceError("voice_tts_unavailable", 503, "no speech model available");
|
|
170
|
+
const coordinator = options.coordinator ?? duplex;
|
|
171
|
+
const current = readouts.get(coordinator);
|
|
172
|
+
if (current?.text === text && current.active()) return current.result;
|
|
173
|
+
const lease = coordinator.beginSpeech(options.signal);
|
|
174
|
+
const result = readAloud(segments, model, lease, options);
|
|
175
|
+
readouts.set(coordinator, { text, active: lease.active, result });
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function readAloud(segments, model, lease, options) {
|
|
180
|
+
const openPlayback = options.openPlayback ?? openBluetoothPlayback;
|
|
181
|
+
const synthesize = options.synthesize ?? synthesizeStream;
|
|
182
|
+
const playFile = options.playFile ?? playBluetoothAudio;
|
|
183
|
+
const toWav = options.pcmToWav ?? pcmToWav;
|
|
184
|
+
const gap = options.silencePcm ?? silencePcm;
|
|
185
|
+
let opening;
|
|
186
|
+
let player;
|
|
187
|
+
const abortPlayer = () => player?.abort?.();
|
|
188
|
+
lease.signal.addEventListener("abort", abortPlayer);
|
|
189
|
+
const pcm = [];
|
|
190
|
+
let streamed = false;
|
|
191
|
+
let fileAudio;
|
|
192
|
+
const release = () => {
|
|
193
|
+
lease.signal.removeEventListener("abort", abortPlayer);
|
|
194
|
+
lease.finish();
|
|
195
|
+
};
|
|
196
|
+
// The speaker drains in real time; synthesis must not wait on it, or the
|
|
197
|
+
// browser copy arrives only after the headset has finished.
|
|
198
|
+
let feeding = Promise.resolve();
|
|
199
|
+
const writeChunk = (chunk) => {
|
|
200
|
+
if (!chunk?.length) return;
|
|
201
|
+
pcm.push(chunk);
|
|
202
|
+
if (!lease.active()) return;
|
|
203
|
+
opening ??= openPlayback().catch(() => ({ skipped: true }));
|
|
204
|
+
feeding = feeding.then(async () => {
|
|
205
|
+
player ??= await opening;
|
|
206
|
+
if (player.skipped || !lease.active()) return;
|
|
207
|
+
try {
|
|
208
|
+
await player.write(chunk);
|
|
209
|
+
} catch {
|
|
210
|
+
player.skipped = true;
|
|
211
|
+
player.abort?.();
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
};
|
|
215
|
+
try {
|
|
216
|
+
for (const segment of segments) {
|
|
217
|
+
if (!lease.active()) break;
|
|
218
|
+
const outcome = await synthesize({ text: segment.text, model, signal: lease.signal }, writeChunk);
|
|
219
|
+
if (!outcome.streamed) {
|
|
220
|
+
fileAudio = outcome.audio;
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
streamed = true;
|
|
224
|
+
if (segment.gapMs > 0 && lease.active()) writeChunk(gap(segment.gapMs));
|
|
225
|
+
}
|
|
226
|
+
} catch (err) {
|
|
227
|
+
void opening?.then((opened) => opened.abort?.());
|
|
228
|
+
release();
|
|
229
|
+
if (lease.signal.aborted) throw cancelledSpeech();
|
|
230
|
+
throw err;
|
|
231
|
+
}
|
|
232
|
+
const audio = streamed ? toWav(Buffer.concat(pcm)) : fileAudio;
|
|
233
|
+
const headset = opening ? await opening : null;
|
|
234
|
+
if (!lease.active()) {
|
|
235
|
+
headset?.abort?.();
|
|
236
|
+
release();
|
|
237
|
+
throw cancelledSpeech();
|
|
238
|
+
}
|
|
239
|
+
if (streamed && headset && !headset.skipped) {
|
|
240
|
+
void feeding
|
|
241
|
+
.then(() => (headset.skipped || !lease.active() ? headset.abort?.() : headset.close()))
|
|
242
|
+
.catch(() => headset.abort?.())
|
|
243
|
+
.finally(release);
|
|
244
|
+
return { bluetooth: "played", audio };
|
|
245
|
+
}
|
|
246
|
+
headset?.abort?.();
|
|
247
|
+
let bluetooth = "skipped";
|
|
248
|
+
if (!streamed && fileAudio) {
|
|
249
|
+
try {
|
|
250
|
+
if ((await playFile(fileAudio)).played) bluetooth = "played";
|
|
251
|
+
} catch {
|
|
252
|
+
// Browser playback remains available.
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
release();
|
|
256
|
+
return { bluetooth, audio };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function interruptSpeech() {
|
|
260
|
+
return duplex.interrupt();
|
|
261
|
+
}
|
|
262
|
+
|
|
53
263
|
export function languageForTranscribe(requestedLanguage, configLanguage) {
|
|
54
264
|
if (requestedLanguage === null) return configLanguage || undefined;
|
|
55
265
|
return validateConfigPatch({ language: requestedLanguage }).language || undefined;
|
|
@@ -66,18 +276,51 @@ export async function transcribeFromHttp({ languageParam, contentType, bytes })
|
|
|
66
276
|
return { text };
|
|
67
277
|
}
|
|
68
278
|
|
|
69
|
-
|
|
279
|
+
const hostCapture = createCaptureSession({
|
|
280
|
+
open: beginHostCapture,
|
|
281
|
+
read: (from) => readHostCapture(from),
|
|
282
|
+
close: endHostCapture,
|
|
283
|
+
reset: resetHostCapture,
|
|
284
|
+
transcribe(wav, signal) {
|
|
285
|
+
const config = readConfig();
|
|
286
|
+
return transcribeAudio({
|
|
287
|
+
bytes: wav,
|
|
288
|
+
contentType: "audio/wav",
|
|
289
|
+
language: languageForTranscribe(null, config.language),
|
|
290
|
+
preferred: config.model,
|
|
291
|
+
signal,
|
|
292
|
+
});
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
export async function captureStart() {
|
|
297
|
+
await interruptSpeech();
|
|
298
|
+
return hostCapture.start();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function captureStop(cancel) {
|
|
302
|
+
hostCapture.stop(cancel);
|
|
303
|
+
return hostCapture.status();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function captureStatus() {
|
|
307
|
+
return hostCapture.status();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export async function transcribeAudio({ bytes, contentType, language, preferred, signal }) {
|
|
70
311
|
const model = await resolveSttModel(preferred);
|
|
71
312
|
if (!model) {
|
|
72
313
|
throw new VoiceError("voice_model_unavailable", 503, "no voice model available in the Router catalog");
|
|
73
314
|
}
|
|
74
315
|
if (bytes.length === 0) throw new VoiceError("voice_audio_unreadable", 400, "audio content is empty");
|
|
316
|
+
const pcm = await normalizeSttPcm(bytes, contentType);
|
|
75
317
|
return transcribe({
|
|
76
|
-
bytes,
|
|
77
|
-
filename:
|
|
78
|
-
contentType,
|
|
318
|
+
bytes: pcm,
|
|
319
|
+
filename: "voice.wav",
|
|
320
|
+
contentType: "audio/wav",
|
|
79
321
|
model,
|
|
80
322
|
language,
|
|
81
323
|
preferred,
|
|
324
|
+
signal,
|
|
82
325
|
});
|
|
83
326
|
}
|
package/voice/session.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { once } from "node:events";
|
|
3
|
+
import { createWriteStream } from "node:fs";
|
|
4
|
+
import { mkdir, open, writeFile } from "node:fs/promises";
|
|
5
|
+
import { VoiceError } from "../router/stt.js";
|
|
6
|
+
import { askAgent, liveAgent, voiceDir } from "./agent.js";
|
|
7
|
+
|
|
8
|
+
const CAPTURE_FILE = "capture.s16";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Play speech on the host that owns the headset.
|
|
12
|
+
* No agent means this machine has no speaker to drive.
|
|
13
|
+
*/
|
|
14
|
+
export async function playBluetoothAudio(bytes, options = {}) {
|
|
15
|
+
if (!await liveAgent(options)) return { played: false, skipped: true };
|
|
16
|
+
const dir = voiceDir(options);
|
|
17
|
+
const id = options.id ?? randomUUID();
|
|
18
|
+
const file = `speech-${id}.wav`;
|
|
19
|
+
await mkdir(dir, { recursive: true });
|
|
20
|
+
await writeFile(`${dir}/${file}`, bytes);
|
|
21
|
+
const result = await askAgent("play", { file }, { ...options, id, timeoutMs: options.timeoutMs ?? 20_000 });
|
|
22
|
+
if (result.skipped) return { played: false, skipped: true };
|
|
23
|
+
if (result.playing) return { played: true, sink: result.sink };
|
|
24
|
+
throw new VoiceError("voice_failed", 502, result.error || "speaker did not play");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Open the host speaker so PCM can be written while later sentences are still synthesizing.
|
|
29
|
+
* No agent means there is no headset speaker.
|
|
30
|
+
*/
|
|
31
|
+
export async function openBluetoothPlayback(options = {}) {
|
|
32
|
+
if (!await liveAgent(options)) return { skipped: true };
|
|
33
|
+
const dir = voiceDir(options);
|
|
34
|
+
const id = options.id ?? randomUUID();
|
|
35
|
+
const file = `speech-${id}.pcm`;
|
|
36
|
+
const result = await askAgent("play-stream", { file, rate: 24000, channels: 1 }, {
|
|
37
|
+
...options,
|
|
38
|
+
id,
|
|
39
|
+
timeoutMs: options.timeoutMs ?? 20_000,
|
|
40
|
+
});
|
|
41
|
+
if (result.skipped) return { skipped: true };
|
|
42
|
+
if (!result.playing) {
|
|
43
|
+
throw new VoiceError("voice_failed", 502, result.error || "speaker did not play");
|
|
44
|
+
}
|
|
45
|
+
if (options.writer) return { skipped: false, sink: result.sink, ...options.writer };
|
|
46
|
+
const stream = createWriteStream(`${dir}/${file}`);
|
|
47
|
+
const ready = new Promise((resolve, reject) => {
|
|
48
|
+
stream.once("open", resolve);
|
|
49
|
+
stream.once("error", reject);
|
|
50
|
+
});
|
|
51
|
+
return {
|
|
52
|
+
skipped: false,
|
|
53
|
+
sink: result.sink,
|
|
54
|
+
async write(chunk) {
|
|
55
|
+
await ready;
|
|
56
|
+
if (!stream.write(chunk)) await once(stream, "drain");
|
|
57
|
+
},
|
|
58
|
+
async close() {
|
|
59
|
+
await ready;
|
|
60
|
+
await new Promise((resolve, reject) => {
|
|
61
|
+
stream.once("finish", resolve);
|
|
62
|
+
stream.once("error", reject);
|
|
63
|
+
stream.once("close", () => {
|
|
64
|
+
if (!stream.writableFinished) reject(new Error("speaker stream closed"));
|
|
65
|
+
});
|
|
66
|
+
stream.end();
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
abort() {
|
|
70
|
+
stream.destroy();
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Stop a readout without changing the headset microphone profile. */
|
|
76
|
+
export async function stopBluetoothPlayback(options = {}) {
|
|
77
|
+
if (!await liveAgent(options)) return { stopped: false, skipped: true };
|
|
78
|
+
const result = await askAgent("playback-stop", {}, options);
|
|
79
|
+
return { stopped: result.stopped === true };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Start the headset recording on the machine. No agent means there is no headset mic. */
|
|
83
|
+
export async function beginHostCapture(options = {}) {
|
|
84
|
+
if (!await liveAgent(options)) return { skipped: true };
|
|
85
|
+
const result = await askAgent("capture-start", {}, options);
|
|
86
|
+
if (result.skipped) return { skipped: true };
|
|
87
|
+
if (!result.ready) {
|
|
88
|
+
throw new VoiceError("voice_bluetooth_not_ready", 503, result.error || "bluetooth headset is not capturing");
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
ready: true,
|
|
92
|
+
file: result.file || CAPTURE_FILE,
|
|
93
|
+
offset: Number(result.offset) || 0,
|
|
94
|
+
settle: result.settle === true,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Bytes written after `from`. The call link stays open, so the file keeps growing. */
|
|
99
|
+
export async function readHostCapture(from = 0, options = {}) {
|
|
100
|
+
const start = Number.isFinite(from) ? Math.max(0, Math.trunc(from)) : 0;
|
|
101
|
+
let handle;
|
|
102
|
+
try {
|
|
103
|
+
handle = await open(`${voiceDir(options)}/${CAPTURE_FILE}`, "r");
|
|
104
|
+
const { size } = await handle.stat();
|
|
105
|
+
const length = size - start;
|
|
106
|
+
if (length <= 0) return Buffer.alloc(0);
|
|
107
|
+
const buf = Buffer.alloc(length);
|
|
108
|
+
const { bytesRead } = await handle.read(buf, 0, length, start);
|
|
109
|
+
return bytesRead === buf.length ? buf : buf.subarray(0, bytesRead);
|
|
110
|
+
} catch {
|
|
111
|
+
return Buffer.alloc(0);
|
|
112
|
+
} finally {
|
|
113
|
+
await handle?.close();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function endHostCapture(options = {}) {
|
|
118
|
+
if (!await liveAgent(options)) return { ready: true, skipped: true };
|
|
119
|
+
await askAgent("capture-stop", {}, options);
|
|
120
|
+
return { ready: true };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Recreate a capture transport that produced no microphone samples. */
|
|
124
|
+
export async function resetHostCapture(options = {}) {
|
|
125
|
+
if (!await liveAgent(options)) return { ready: true, skipped: true };
|
|
126
|
+
await askAgent("capture-reset", {}, options);
|
|
127
|
+
return { ready: true };
|
|
128
|
+
}
|
package/voice/speech.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The utterance a turn may speak: the text of the assistant message that
|
|
3
|
+
* concludes it. The agent loop stops stepping once a settled message carries
|
|
4
|
+
* no tool call, so that message is the answer even while turn-stopping work
|
|
5
|
+
* still holds the turn open. Reasoning and text said alongside a tool call
|
|
6
|
+
* are intermediate and stay silent.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
function turnOf(event) {
|
|
10
|
+
const data = event?.data ?? {};
|
|
11
|
+
const turn = data.turn ?? data.message?.turn;
|
|
12
|
+
const number = Number(turn);
|
|
13
|
+
return Number.isFinite(number) ? number : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function messageContent(event) {
|
|
17
|
+
const data = event?.data ?? {};
|
|
18
|
+
const content = data.message?.content ?? data.content;
|
|
19
|
+
return Array.isArray(content) ? content : [];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function assistantText(event) {
|
|
23
|
+
return messageContent(event)
|
|
24
|
+
.filter((block) => block?.type === "text" && typeof block.text === "string")
|
|
25
|
+
.map((block) => block.text.trim())
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
.join("\n")
|
|
28
|
+
.trim();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The answer text when this event concludes its turn, otherwise "". */
|
|
32
|
+
export function concludingReply(event) {
|
|
33
|
+
if (event?.type !== "assistant/message" || event.data?.interrupted) return "";
|
|
34
|
+
if (messageContent(event).some((block) => block?.type === "tool-call")) return "";
|
|
35
|
+
return assistantText(event);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Which concluded answers a live view reads aloud: only those it watched
|
|
40
|
+
* arrive while their turn was running. Opening a session, or history that is
|
|
41
|
+
* already finished, stays silent.
|
|
42
|
+
*/
|
|
43
|
+
export function createReplySpeaker() {
|
|
44
|
+
let armed = false;
|
|
45
|
+
let spokenSeq = -1;
|
|
46
|
+
return {
|
|
47
|
+
/** @returns {string} text to speak now, or "" */
|
|
48
|
+
observe({ sessionChanged, wasRunning, running, reply }) {
|
|
49
|
+
if (sessionChanged) {
|
|
50
|
+
armed = Boolean(running);
|
|
51
|
+
spokenSeq = reply?.seq ?? -1;
|
|
52
|
+
return "";
|
|
53
|
+
}
|
|
54
|
+
if (!wasRunning && running) {
|
|
55
|
+
armed = true;
|
|
56
|
+
// A turn start resets the reply, so one already here was loaded with
|
|
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;
|
|
64
|
+
}
|
|
65
|
+
if (!running) armed = false;
|
|
66
|
+
return text;
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function emptySpeechState() {
|
|
72
|
+
return { text: "", seq: -1 };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A steer that lands after the answer runs another step in the same turn, so
|
|
77
|
+
* a turn may conclude more than once; `seq` tells the answers apart.
|
|
78
|
+
* @param {{ text: string, seq: number }} state
|
|
79
|
+
*/
|
|
80
|
+
export function reduceSpeechEvent(state, event) {
|
|
81
|
+
const current = state ?? emptySpeechState();
|
|
82
|
+
if (event?.type === "turn/start") return emptySpeechState();
|
|
83
|
+
const text = concludingReply(event);
|
|
84
|
+
return text ? { text, seq: Number(event.seq) } : current;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function matchSpeechEvent(event) {
|
|
88
|
+
const type = event?.type;
|
|
89
|
+
if (type !== "turn/start" && type !== "assistant/message") return null;
|
|
90
|
+
const turn = turnOf(event);
|
|
91
|
+
if (turn === null) return null;
|
|
92
|
+
return { id: String(turn), role: type === "turn/start" ? "start" : "update" };
|
|
93
|
+
}
|
package/files/preview-cache.js
DELETED
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { runOlaresDownload } from "../drive/download.js";
|
|
5
|
-
import { sanitizeFilename } from "./filename.js";
|
|
6
|
-
|
|
7
|
-
const inflight = new Map();
|
|
8
|
-
|
|
9
|
-
export function previewCacheRoot(env = process.env) {
|
|
10
|
-
const root = env.LARES_DATA_DIR?.trim();
|
|
11
|
-
return join(root || "/data/lares", "preview-cache");
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function cacheDir(cacheRoot, source) {
|
|
15
|
-
const id = createHash("sha256").update(source).digest("hex").slice(0, 24);
|
|
16
|
-
return join(cacheRoot, id);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
async function readMeta(path) {
|
|
20
|
-
try {
|
|
21
|
-
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
22
|
-
if (parsed && typeof parsed === "object") return parsed;
|
|
23
|
-
} catch {
|
|
24
|
-
// Missing or corrupt meta is a cache miss.
|
|
25
|
-
}
|
|
26
|
-
return null;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
async function localSize(path) {
|
|
30
|
-
try {
|
|
31
|
-
const info = await stat(path);
|
|
32
|
-
return info.isFile() ? info.size : null;
|
|
33
|
-
} catch {
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
async function attachLocal(file, absolutePath) {
|
|
39
|
-
const info = await stat(absolutePath);
|
|
40
|
-
return {
|
|
41
|
-
...file,
|
|
42
|
-
absolutePath,
|
|
43
|
-
size: info.size,
|
|
44
|
-
modifiedAt: info.mtimeMs,
|
|
45
|
-
device: info.dev,
|
|
46
|
-
inode: info.ino,
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
async function materializeOnce(file, options) {
|
|
51
|
-
const download = options.download ?? runOlaresDownload;
|
|
52
|
-
const cacheRoot = options.cacheRoot ?? previewCacheRoot(options.env);
|
|
53
|
-
const dir = cacheDir(cacheRoot, file.path);
|
|
54
|
-
const dest = join(dir, sanitizeFilename(file.name));
|
|
55
|
-
const metaPath = join(dir, "meta.json");
|
|
56
|
-
const expected = { source: file.path, size: file.size, modifiedAt: file.modifiedAt };
|
|
57
|
-
|
|
58
|
-
const meta = await readMeta(metaPath);
|
|
59
|
-
const size = await localSize(dest);
|
|
60
|
-
const metaMatches = meta?.source === expected.source
|
|
61
|
-
&& meta?.size === expected.size
|
|
62
|
-
&& meta?.modifiedAt === expected.modifiedAt;
|
|
63
|
-
|
|
64
|
-
if (metaMatches && size === expected.size) return attachLocal(file, dest);
|
|
65
|
-
|
|
66
|
-
await mkdir(dir, { recursive: true });
|
|
67
|
-
await writeFile(metaPath, JSON.stringify(expected));
|
|
68
|
-
const resume = metaMatches && size !== null && size < expected.size;
|
|
69
|
-
await download(file.path, dest, {
|
|
70
|
-
signal: options.signal,
|
|
71
|
-
overwrite: !resume,
|
|
72
|
-
resume,
|
|
73
|
-
...(options.spawnFn ? { spawnFn: options.spawnFn } : {}),
|
|
74
|
-
});
|
|
75
|
-
const next = await localSize(dest);
|
|
76
|
-
if (next !== expected.size) {
|
|
77
|
-
const error = new Error(
|
|
78
|
-
`downloaded size ${next} does not match files backend size ${expected.size}`,
|
|
79
|
-
);
|
|
80
|
-
error.code = "files_unavailable";
|
|
81
|
-
throw error;
|
|
82
|
-
}
|
|
83
|
-
return attachLocal(file, dest);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Copy one files-backend file into `$LARES_DATA_DIR/preview-cache` so range
|
|
88
|
-
* streaming can use a local handle. Concurrent requests for the same source
|
|
89
|
-
* share one download; a matching size/mtime reuses the cache.
|
|
90
|
-
*/
|
|
91
|
-
export function materializeFilesFile(file, options = {}) {
|
|
92
|
-
const key = file.path;
|
|
93
|
-
const pending = inflight.get(key);
|
|
94
|
-
if (pending) return pending;
|
|
95
|
-
const done = materializeOnce(file, options).finally(() => {
|
|
96
|
-
if (inflight.get(key) === done) inflight.delete(key);
|
|
97
|
-
});
|
|
98
|
-
inflight.set(key, done);
|
|
99
|
-
return done;
|
|
100
|
-
}
|