@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.
Files changed (100) hide show
  1. package/README.md +2 -1
  2. package/brand/identity.js +33 -3
  3. package/drive/cat.js +62 -0
  4. package/drive/download.js +3 -3
  5. package/drive/execute.js +8 -2
  6. package/drive/files-path.js +33 -0
  7. package/drive/flowstudio-files.js +87 -0
  8. package/drive/ls.js +3 -3
  9. package/drive/paths.js +23 -13
  10. package/drive/tools.js +4 -2
  11. package/entrance/surface.js +96 -0
  12. package/files/app-link.js +64 -0
  13. package/files/backend-client.js +266 -0
  14. package/files/deliverables.js +55 -1
  15. package/files/intake.js +0 -63
  16. package/files/mention.js +1 -51
  17. package/files/preview-groups.js +71 -3
  18. package/files/preview-local.js +69 -0
  19. package/files/preview-workspace.js +114 -13
  20. package/files/preview.js +171 -16
  21. package/files/produced-gate.js +197 -0
  22. package/files/produced-mentions.js +19 -0
  23. package/files/published-tools.js +69 -0
  24. package/files/published-turn-data.js +84 -0
  25. package/i18n/chat-model.js +5 -5
  26. package/i18n/mcp.js +83 -0
  27. package/i18n/mobile.js +96 -2
  28. package/i18n/preview.js +10 -0
  29. package/i18n/search.js +13 -7
  30. package/i18n/skills.js +31 -0
  31. package/i18n/voice.js +88 -19
  32. package/larepass/chat.js +9 -0
  33. package/larepass/host.js +7 -2
  34. package/larepass/mux.js +210 -2
  35. package/larepass/process-fold.js +239 -0
  36. package/larepass/queue.js +44 -0
  37. package/larepass/rpc.js +50 -3
  38. package/larepass/runtime.js +215 -83
  39. package/larepass/session-cache.js +6 -0
  40. package/larepass/settings.js +15 -7
  41. package/larepass/transcript.js +49 -11
  42. package/log/report.js +46 -0
  43. package/mcp/approval.js +152 -0
  44. package/mcp/client.js +38 -0
  45. package/mcp/config.js +139 -0
  46. package/media/catalog-row.js +244 -0
  47. package/media/generation.js +707 -0
  48. package/media/tool.js +92 -0
  49. package/olares/entrance.js +67 -15
  50. package/olares/identity.js +13 -136
  51. package/olares/session-identity.js +12 -12
  52. package/olares/trusted-host.js +0 -29
  53. package/package.json +49 -4
  54. package/router/catalog-cache.d.ts +1 -1
  55. package/router/catalog-cache.js +25 -14
  56. package/router/catalog-sync.js +8 -3
  57. package/router/catalog.d.ts +6 -1
  58. package/router/catalog.js +18 -2
  59. package/router/gateway.d.ts +9 -1
  60. package/router/gateway.js +40 -4
  61. package/router/gemini-schema.js +125 -0
  62. package/router/generation-guard.js +82 -0
  63. package/router/models.d.ts +6 -0
  64. package/router/models.js +22 -3
  65. package/router/search.js +10 -2
  66. package/router/session-model.js +53 -0
  67. package/router/shim.js +104 -3
  68. package/router/sref.js +126 -0
  69. package/router/stt.js +43 -12
  70. package/router/tts.js +118 -0
  71. package/search/client.js +5 -3
  72. package/search/config.js +53 -39
  73. package/search/menu.js +45 -10
  74. package/skills/client.js +42 -0
  75. package/skills/packs.js +40 -0
  76. package/skills/state.d.ts +22 -0
  77. package/skills/state.js +142 -0
  78. package/tools/crypto-uuid.js +3 -6
  79. package/tools/head-script.js +15 -0
  80. package/voice/agent.js +50 -0
  81. package/voice/capture.js +250 -0
  82. package/voice/client.js +126 -24
  83. package/voice/config.js +11 -1
  84. package/voice/duplex.js +44 -0
  85. package/voice/endpoint.js +57 -0
  86. package/voice/host/bluetooth-ready.py +1196 -0
  87. package/voice/host/install-host.sh +102 -0
  88. package/voice/input.js +4 -0
  89. package/voice/install.js +146 -0
  90. package/voice/languages.js +11 -1
  91. package/voice/link.js +86 -0
  92. package/voice/pcm.js +100 -0
  93. package/voice/playback.js +7 -0
  94. package/voice/problem.js +46 -0
  95. package/voice/recorder.js +79 -4
  96. package/voice/script.js +35 -0
  97. package/voice/service.js +253 -10
  98. package/voice/session.js +128 -0
  99. package/voice/speech.js +93 -0
  100. package/files/preview-cache.js +0 -100
@@ -0,0 +1,102 @@
1
+ #!/bin/sh
2
+ # Install the host Bluetooth audio pieces Lares talks to. A machine without
3
+ # Bluetooth still has to boot Lares, so every step here is allowed to fail.
4
+ set -u
5
+
6
+ VOICE_DIR=${LARES_HOST_VOICE_DIR:-}
7
+ PROC=/host-proc/1/ns
8
+ # The app runs this overlay when hot reload is on. Install that copy, or a
9
+ # reboot puts the image's agent back beside a newer app.
10
+ IMAGE_SCRIPT=/app/packages/core/voice/host/bluetooth-ready.py
11
+ OVERLAY_SCRIPT=/devsrc/packages/core/voice/host/bluetooth-ready.py
12
+ SCRIPT=$IMAGE_SCRIPT
13
+ if [ -f /devsrc/.hotreload ] && [ -f /app/.lares-image-id ] && [ -f /devsrc/.lares-seeded-image-id ] \
14
+ && [ "$(cat /app/.lares-image-id)" = "$(cat /devsrc/.lares-seeded-image-id)" ] \
15
+ && [ -f "$OVERLAY_SCRIPT" ]; then
16
+ SCRIPT=$OVERLAY_SCRIPT
17
+ fi
18
+
19
+ if [ -z "$VOICE_DIR" ] || ! command -v nsenter >/dev/null 2>&1 || [ ! -e "$PROC/mnt" ] || [ ! -f "$SCRIPT" ]; then
20
+ echo "bluetooth host setup skipped: cannot enter the host" >&2
21
+ exit 0
22
+ fi
23
+
24
+ enter() {
25
+ nsenter --mount="$PROC/mnt" --net="$PROC/net" -- "$@"
26
+ }
27
+
28
+ if ! enter /bin/true; then
29
+ echo "bluetooth host setup skipped: host namespace refused" >&2
30
+ exit 0
31
+ fi
32
+
33
+ enter /bin/sh -c '
34
+ missing=""
35
+ command -v python3 >/dev/null 2>&1 || missing="$missing python3"
36
+ command -v bluetoothctl >/dev/null 2>&1 || missing="$missing bluez"
37
+ dpkg -s libspa-0.2-bluetooth >/dev/null 2>&1 || missing="$missing libspa-0.2-bluetooth"
38
+ if [ -z "$missing" ]; then
39
+ exit 0
40
+ fi
41
+ export DEBIAN_FRONTEND=noninteractive
42
+ if apt-get update -qq && apt-get install -y --no-install-recommends $missing; then
43
+ echo "$missing" | grep -q libspa-0.2-bluetooth && touch /run/lares-bluetooth-plugin-new
44
+ else
45
+ echo "bluetooth host packages failed:$missing" >&2
46
+ fi
47
+ '
48
+
49
+ enter /usr/bin/tee /usr/local/sbin/bluetooth-voice-ready >/dev/null < "$SCRIPT"
50
+ enter /bin/chmod 755 /usr/local/sbin/bluetooth-voice-ready
51
+
52
+ enter /usr/bin/tee /etc/systemd/system/bluetooth-wideband-speech.service >/dev/null <<'EOF'
53
+ [Unit]
54
+ Description=Enable Bluetooth wideband speech for HFP
55
+ After=bluetooth.service
56
+ PartOf=bluetooth.service
57
+
58
+ [Service]
59
+ Type=oneshot
60
+ ExecStart=/usr/local/sbin/bluetooth-voice-ready wideband
61
+ RemainAfterExit=yes
62
+
63
+ [Install]
64
+ WantedBy=bluetooth.service
65
+ EOF
66
+
67
+ enter /usr/bin/tee /etc/systemd/system/bluetooth-voice-ready.service >/dev/null <<EOF
68
+ [Unit]
69
+ Description=Lares Bluetooth voice agent
70
+ After=bluetooth.service bluetooth-wideband-speech.service
71
+
72
+ [Service]
73
+ Type=simple
74
+ Environment=LARES_VOICE_BLUETOOTH_DIR=${VOICE_DIR}
75
+ ExecStart=/usr/local/sbin/bluetooth-voice-ready serve
76
+ Restart=always
77
+ RestartSec=2
78
+
79
+ [Install]
80
+ WantedBy=multi-user.target
81
+ EOF
82
+
83
+ enter /bin/sh -c '
84
+ mkdir -p "$LARES_HOST_VOICE_DIR"
85
+ chmod 777 "$LARES_HOST_VOICE_DIR"
86
+ # A drop-in here keeps running after this unit file is rewritten.
87
+ rm -rf /etc/systemd/system/bluetooth-voice-ready.service.d
88
+ rm -f /usr/local/sbin/bluetooth-voice-ready-keep
89
+ systemctl daemon-reload
90
+ systemctl enable bluetooth-wideband-speech.service bluetooth-voice-ready.service
91
+ systemctl restart bluetooth-wideband-speech.service || true
92
+ systemctl restart bluetooth-voice-ready.service || true
93
+ if [ -f /run/lares-bluetooth-plugin-new ] && id olares >/dev/null 2>&1; then
94
+ rm -f /run/lares-bluetooth-plugin-new
95
+ uid=$(id -u olares)
96
+ if [ -d "/run/user/$uid" ]; then
97
+ runuser -u olares -- env XDG_RUNTIME_DIR="/run/user/$uid" systemctl --user restart wireplumber || true
98
+ fi
99
+ fi
100
+ '
101
+
102
+ exit 0
package/voice/input.js ADDED
@@ -0,0 +1,4 @@
1
+ /** Where a take is recorded. The desktop page uses the headset; the phone page uses its own mic. */
2
+ export function captureSource(surface) {
3
+ return surface === "mobile" ? "device" : "headset";
4
+ }
@@ -0,0 +1,146 @@
1
+ import { spawn } from "node:child_process";
2
+ import { VoiceError } from "../router/stt.js";
3
+
4
+ /** Charts the voice panel installs, stops, and resumes through olares-cli market. */
5
+ export const VOICE_CHARTS = {
6
+ stt: { app: "audioqwen3asr06v3", version: "1.0.0", model: "Qwen3-ASR-0.6B" },
7
+ tts: { app: "audioqwen3tts06v3", version: "1.0.0", model: "Qwen3-TTS-12Hz-0.6B-CustomVoice" },
8
+ };
9
+
10
+ const OUTPUT_LIMIT = 8000;
11
+
12
+ function chartFor(kind) {
13
+ const chart = VOICE_CHARTS[kind];
14
+ if (!chart) throw new VoiceError("voice_config_invalid", 400, "unknown voice chart");
15
+ return chart;
16
+ }
17
+
18
+ /**
19
+ * Market row → what the install button should do next.
20
+ * `running` (and a stop that did not release the app) offers 停用.
21
+ * `stopped` offers 启用. Anything still in flight stays on 安装中.
22
+ * @param {Record<string, unknown> | null | undefined} row
23
+ */
24
+ export function chartPhase(row) {
25
+ if (!row || typeof row !== "object") return "absent";
26
+ const state = typeof row.state === "string" ? row.state : "";
27
+ if (!state || state === "uninstalled") return "absent";
28
+ if (state === "running" || state === "stopFailed") return "running";
29
+ if (state === "stopped") return "stopped";
30
+ if (state.endsWith("Failed") || state.endsWith("Canceled")) return "failed";
31
+ return "busy";
32
+ }
33
+
34
+ /** Model id Router registered for this chart, if the catalog has one. */
35
+ export function chartModelId(kind, ids) {
36
+ const chart = VOICE_CHARTS[kind];
37
+ if (!chart) return null;
38
+ const list = (Array.isArray(ids) ? ids : []).map((id) => String(id ?? ""));
39
+ return list.find((id) => id === chart.app || id.startsWith(`${chart.app}/`) || id.includes(chart.model)) ?? null;
40
+ }
41
+
42
+ function describe(chart, row) {
43
+ return {
44
+ app: chart.app,
45
+ phase: chartPhase(row),
46
+ state: typeof row?.state === "string" ? row.state : "",
47
+ };
48
+ }
49
+
50
+ function runMarket(args, spawnFn) {
51
+ return new Promise((resolve, reject) => {
52
+ const child = spawnFn("olares-cli", args, {
53
+ env: process.env,
54
+ stdio: ["ignore", "pipe", "pipe"],
55
+ });
56
+ let stdout = "";
57
+ let stderr = "";
58
+ const take = (bucket) => (chunk) => {
59
+ if (bucket === "stdout") stdout = `${stdout}${chunk}`.slice(-OUTPUT_LIMIT);
60
+ else stderr = `${stderr}${chunk}`.slice(-OUTPUT_LIMIT);
61
+ };
62
+ child.stdout?.on("data", take("stdout"));
63
+ child.stderr?.on("data", take("stderr"));
64
+ child.on("error", (error) => {
65
+ reject(new VoiceError("voice_install_failed", 502, error.message));
66
+ });
67
+ child.on("close", (code) => {
68
+ let json = null;
69
+ try {
70
+ json = JSON.parse(stdout);
71
+ } catch {
72
+ json = null;
73
+ }
74
+ const text = `${stdout}\n${stderr}`;
75
+ const message = (json && typeof json.message === "string" && json.message.trim())
76
+ || stderr.trim()
77
+ || stdout.trim()
78
+ || `olares-cli exited ${code}`;
79
+ resolve({ code: code ?? 1, json, text, message });
80
+ });
81
+ });
82
+ }
83
+
84
+ function accepted(result) {
85
+ if (/already installed/i.test(result.text)) return true;
86
+ if (result.code !== 0) return false;
87
+ return result.json?.status !== "failed";
88
+ }
89
+
90
+ /**
91
+ * Install from the profile's market catalog. Returns once Market accepts;
92
+ * weight download continues, and status then reports `busy` until `running`.
93
+ * @param {"stt" | "tts"} kind
94
+ * @param {{ spawnFn?: typeof spawn }} [options]
95
+ */
96
+ export async function installVoiceChart(kind, options = {}) {
97
+ const chart = chartFor(kind);
98
+ const spawnFn = options.spawnFn ?? spawn;
99
+ const result = await runMarket(
100
+ ["market", "install", chart.app, "-s", "market.test", "--version", chart.version, "--compute-mode", "nvidia", "-o", "json"],
101
+ spawnFn,
102
+ );
103
+ if (!accepted(result)) {
104
+ throw new VoiceError("voice_install_failed", 502, result.message);
105
+ }
106
+ return { app: chart.app, version: chart.version };
107
+ }
108
+
109
+ /** @param {"stt" | "tts"} kind */
110
+ export async function voiceChartStatus(kind, options = {}) {
111
+ const chart = chartFor(kind);
112
+ const result = await runMarket(
113
+ ["market", "status", chart.app, "-a", "-o", "json"],
114
+ options.spawnFn ?? spawn,
115
+ );
116
+ if (!result.json) throw new VoiceError("voice_install_failed", 502, result.message);
117
+ return describe(chart, result.json);
118
+ }
119
+
120
+ export async function voiceChartsStatus(options = {}) {
121
+ const [stt, tts] = await Promise.all([
122
+ voiceChartStatus("stt", options),
123
+ voiceChartStatus("tts", options),
124
+ ]);
125
+ return { stt, tts };
126
+ }
127
+
128
+ async function settle(kind, verb, code, options) {
129
+ const chart = chartFor(kind);
130
+ const result = await runMarket(
131
+ ["market", verb, chart.app, "--watch", "-o", "json"],
132
+ options.spawnFn ?? spawn,
133
+ );
134
+ if (!accepted(result)) throw new VoiceError(code, 502, result.message);
135
+ return voiceChartStatus(kind, options);
136
+ }
137
+
138
+ /** Stop the chart and let Market release its memory and GPU. */
139
+ export function stopVoiceChart(kind, options = {}) {
140
+ return settle(kind, "stop", "voice_stop_failed", options);
141
+ }
142
+
143
+ /** Start a stopped chart again. */
144
+ export function resumeVoiceChart(kind, options = {}) {
145
+ return settle(kind, "resume", "voice_resume_failed", options);
146
+ }
@@ -14,7 +14,17 @@ export function voiceValueFromMenu(id) {
14
14
  return id === VOICE_AUTO ? "" : id;
15
15
  }
16
16
 
17
- export const EMPTY_VOICE_CONFIG = { model: "", language: "" };
17
+ export const EMPTY_VOICE_CONFIG = { model: "", ttsModel: "", language: "", speak: false };
18
+
19
+ export function voiceChoiceItems(ids) {
20
+ return ids.map((id) => ({ id, label: id }));
21
+ }
22
+
23
+ /** Stored choice when it is still listed, otherwise the first available model. */
24
+ export function selectedVoice(stored, ids) {
25
+ if (stored && ids.includes(stored)) return stored;
26
+ return ids[0] ?? "";
27
+ }
18
28
 
19
29
  export function voiceLanguageItems(autoLabel) {
20
30
  return [{ id: VOICE_AUTO, label: autoLabel }, ...STT_LANGUAGE_CHOICES];
package/voice/link.js ADDED
@@ -0,0 +1,86 @@
1
+ import { VoiceError } from "../router/stt.js";
2
+ import { askAgent, liveAgent } from "./agent.js";
3
+
4
+ const SCAN_WAIT_MS = 45_000;
5
+ const CONNECT_WAIT_MS = 90_000;
6
+ const ADDRESS = /^[0-9A-F]{2}(?::[0-9A-F]{2}){5}$/;
7
+
8
+ export function normalizeBluetoothAddress(value) {
9
+ const address = String(value ?? "").trim().toUpperCase();
10
+ if (!ADDRESS.test(address)) {
11
+ throw new VoiceError("voice_bluetooth_invalid", 400, "invalid bluetooth address");
12
+ }
13
+ return address;
14
+ }
15
+
16
+ export function normalizeBluetoothDevices(raw) {
17
+ if (!Array.isArray(raw)) return [];
18
+ const devices = [];
19
+ for (const item of raw) {
20
+ if (!item || typeof item !== "object") continue;
21
+ const address = String(item.address ?? "").trim().toUpperCase();
22
+ if (!ADDRESS.test(address)) continue;
23
+ const name = String(item.name ?? "").replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, 80);
24
+ devices.push({
25
+ address,
26
+ name: name || address,
27
+ paired: item.paired === true,
28
+ connected: item.connected === true,
29
+ });
30
+ }
31
+ return devices;
32
+ }
33
+
34
+ /** Bring the headset up. No agent means this machine has nothing to prepare. */
35
+ export async function ensureBluetoothReady(options = {}) {
36
+ if (!await liveAgent(options)) return { ready: true, skipped: true };
37
+ const result = await askAgent("ensure", {}, options);
38
+ if (result.ready) return result;
39
+ throw new VoiceError("voice_bluetooth_not_ready", 503, result.error || "bluetooth headset is not capturing");
40
+ }
41
+
42
+ /** Devices advertising during this scan, including ones already connected. */
43
+ export async function scanBluetoothDevices(options = {}) {
44
+ const result = await askAgent("scan", {}, { ...options, timeoutMs: options.timeoutMs ?? SCAN_WAIT_MS });
45
+ if (result.op !== "scan") {
46
+ throw new VoiceError("voice_bluetooth_unavailable", 503, "bluetooth agent does not support scanning");
47
+ }
48
+ if (result.error) {
49
+ const code = result.code === "voice_bluetooth_unavailable"
50
+ ? "voice_bluetooth_unavailable"
51
+ : "voice_bluetooth_scan_failed";
52
+ throw new VoiceError(code, code === "voice_bluetooth_unavailable" ? 503 : 502, String(result.error));
53
+ }
54
+ return { devices: normalizeBluetoothDevices(result.devices) };
55
+ }
56
+
57
+ export async function connectBluetoothDevice(address, options = {}) {
58
+ const mac = normalizeBluetoothAddress(address);
59
+ const result = await askAgent("connect", { address: mac }, {
60
+ ...options,
61
+ timeoutMs: options.timeoutMs ?? CONNECT_WAIT_MS,
62
+ });
63
+ if (result.op !== "connect") {
64
+ throw new VoiceError("voice_bluetooth_unavailable", 503, "bluetooth agent does not support connecting");
65
+ }
66
+ if (!result.connected) {
67
+ const code = result.code === "voice_bluetooth_unavailable"
68
+ ? "voice_bluetooth_unavailable"
69
+ : "voice_bluetooth_connect_failed";
70
+ throw new VoiceError(code, code === "voice_bluetooth_unavailable" ? 503 : 502, result.error || "bluetooth connect failed");
71
+ }
72
+ const [device] = normalizeBluetoothDevices([{
73
+ address: result.address || mac,
74
+ name: result.name,
75
+ paired: true,
76
+ connected: true,
77
+ }]);
78
+ return device ?? { address: mac, name: mac, paired: true, connected: true };
79
+ }
80
+
81
+ export async function bluetoothRequest(body, options) {
82
+ const op = body && typeof body === "object" ? body.op : "";
83
+ if (op === "scan") return scanBluetoothDevices(options);
84
+ if (op === "connect") return connectBluetoothDevice(body.address, options);
85
+ throw new VoiceError("voice_bluetooth_invalid", 400, "unknown bluetooth operation");
86
+ }
package/voice/pcm.js ADDED
@@ -0,0 +1,100 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { VoiceError } from "../router/stt.js";
6
+
7
+ /** Parakeet transcribes 16 kHz mono PCM. 48 kHz browser captures come back as a short wrong word. */
8
+ export const STT_SAMPLE_RATE = 16_000;
9
+ const PREPARE_TIMEOUT_MS = 30_000;
10
+
11
+ const INPUT_EXT = {
12
+ "audio/webm": "webm",
13
+ "audio/ogg": "ogg",
14
+ "audio/mp4": "m4a",
15
+ "audio/mpeg": "mp3",
16
+ "audio/aac": "aac",
17
+ "audio/wav": "wav",
18
+ "audio/wave": "wav",
19
+ "audio/x-wav": "wav",
20
+ };
21
+
22
+ export function sttInputExtension(contentType) {
23
+ const base = String(contentType ?? "").split(";", 1)[0].trim().toLowerCase();
24
+ return INPUT_EXT[base] ?? "bin";
25
+ }
26
+
27
+ export function sttPcmArgs(inputPath, outputPath) {
28
+ return [
29
+ "-hide_banner",
30
+ "-loglevel", "error",
31
+ "-y",
32
+ "-i", inputPath,
33
+ "-ac", "1",
34
+ "-ar", String(STT_SAMPLE_RATE),
35
+ "-c:a", "pcm_s16le",
36
+ "-f", "wav",
37
+ outputPath,
38
+ ];
39
+ }
40
+
41
+ function assertSttWav(wav) {
42
+ if (wav.length < 44 || wav.toString("ascii", 0, 4) !== "RIFF" || wav.toString("ascii", 8, 12) !== "WAVE") {
43
+ throw new VoiceError("voice_audio_unreadable", 400, "audio could not be prepared");
44
+ }
45
+ if (wav.readUInt16LE(20) !== 1 || wav.readUInt16LE(22) !== 1 || wav.readUInt32LE(24) !== STT_SAMPLE_RATE || wav.readUInt16LE(34) !== 16) {
46
+ throw new VoiceError("voice_audio_unreadable", 400, "audio could not be prepared");
47
+ }
48
+ }
49
+
50
+ function runFfmpeg(ffmpegBin, args, spawnFn) {
51
+ return new Promise((resolve, reject) => {
52
+ const child = spawnFn(ffmpegBin, args, { stdio: ["ignore", "ignore", "pipe"] });
53
+ const errors = [];
54
+ const timer = setTimeout(() => {
55
+ child.kill("SIGKILL");
56
+ }, PREPARE_TIMEOUT_MS);
57
+ child.stderr?.on("data", (chunk) => {
58
+ errors.push(chunk);
59
+ });
60
+ child.on("error", (error) => {
61
+ clearTimeout(timer);
62
+ reject(error);
63
+ });
64
+ child.on("close", (code) => {
65
+ clearTimeout(timer);
66
+ if (code === 0) {
67
+ resolve();
68
+ return;
69
+ }
70
+ const detail = Buffer.concat(errors).toString("utf8").trim().slice(-300);
71
+ reject(new Error(detail || `ffmpeg exited ${code}`));
72
+ });
73
+ });
74
+ }
75
+
76
+ /**
77
+ * Decode whatever the browser recorded into the one PCM layout the speech model accepts.
78
+ * @param {Buffer} bytes
79
+ * @param {string} [contentType]
80
+ * @param {{ spawnFn?: typeof spawn, ffmpegBin?: string }} [options]
81
+ * @returns {Promise<Buffer>}
82
+ */
83
+ export async function normalizeSttPcm(bytes, contentType, options = {}) {
84
+ const dir = await mkdtemp(join(tmpdir(), "lares-stt-"));
85
+ const inputPath = join(dir, `in.${sttInputExtension(contentType)}`);
86
+ const outputPath = join(dir, "out.wav");
87
+ try {
88
+ await writeFile(inputPath, bytes);
89
+ await runFfmpeg(options.ffmpegBin ?? "ffmpeg", sttPcmArgs(inputPath, outputPath), options.spawnFn ?? spawn);
90
+ const wav = await readFile(outputPath);
91
+ assertSttWav(wav);
92
+ return wav;
93
+ } catch (err) {
94
+ if (err instanceof VoiceError) throw err;
95
+ const detail = err instanceof Error ? err.message : String(err);
96
+ throw new VoiceError("voice_audio_unreadable", 400, detail || "audio could not be prepared");
97
+ } finally {
98
+ await rm(dir, { recursive: true, force: true });
99
+ }
100
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The phone plays the same answer the headset plays.
3
+ * The desktop page shares the room with that headset, and its browser does not open the call link.
4
+ */
5
+ export function browserPlays(surface, bluetooth) {
6
+ return !(surface === "pc" && bluetooth === "played");
7
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * What to tell the user when answer playback cannot happen.
3
+ * Recognized codes stay in the product language. A generic failure keeps the
4
+ * server's own detail so the notice says what actually broke.
5
+ */
6
+
7
+ const REPLACED = {
8
+ voice_model_unavailable: "voice_tts_unavailable",
9
+ voice_timeout: "voice_speak_timeout",
10
+ voice_failed: "voice_speak_failed",
11
+ };
12
+
13
+ const WITH_DETAIL = new Set(["voice_speak_failed", "voice_speak_timeout"]);
14
+ const STOCK = new Set([
15
+ "speech timed out",
16
+ "no speech model available",
17
+ "speech playback is off",
18
+ "nothing to speak",
19
+ ]);
20
+
21
+ export function speakToggleNotice(ttsModels) {
22
+ return Array.isArray(ttsModels) && ttsModels.length === 0 ? "voice_tts_unavailable" : "";
23
+ }
24
+
25
+ function translated(translate, code) {
26
+ if (!code) return "";
27
+ const key = `error.${code}`;
28
+ const text = translate(key);
29
+ return text === key ? "" : text;
30
+ }
31
+
32
+ export function speakProblemText(translate, err) {
33
+ const code = err && typeof err === "object" && typeof err.code === "string" ? err.code : "";
34
+ const raw = err instanceof Error ? err.message : typeof err === "string" ? err : "";
35
+ const message = raw && raw !== code && !STOCK.has(raw) ? raw : "";
36
+ const mapped = REPLACED[code] || (code.startsWith("voice_") ? code : "");
37
+ const text = translated(translate, mapped);
38
+ if (text && message && WITH_DETAIL.has(mapped)) return `${text}: ${message}`;
39
+ if (text) return text;
40
+ if (!code && message.startsWith("voice_")) {
41
+ const fromMessage = translated(translate, REPLACED[message] || message);
42
+ if (fromMessage) return fromMessage;
43
+ }
44
+ if (message) return message;
45
+ return translated(translate, "voice_speak_failed") || "voice_speak_failed";
46
+ }
package/voice/recorder.js CHANGED
@@ -16,14 +16,89 @@ export const RECORDING_AUDIO = {
16
16
  autoGainControl: true,
17
17
  };
18
18
 
19
- export function isComposerVoiceReady(phase, setDraft) {
20
- return phase === "plain" && typeof setDraft === "function";
21
- }
22
-
23
19
  export function isRecordingTooShort(durationMs, byteSize) {
24
20
  return durationMs < MIN_RECORDING_MS || byteSize < MIN_RECORDING_BYTES;
25
21
  }
26
22
 
23
+ /** Chrome's "Default" input is not the Pulse default. Match the headset the host just armed. */
24
+ export function pickInputDevice(devices, label) {
25
+ const wanted = String(label ?? "").trim();
26
+ if (!wanted) return "";
27
+ const match = (devices ?? []).find((device) => (
28
+ device?.kind === "audioinput"
29
+ && device.deviceId
30
+ && device.deviceId !== "default"
31
+ && device.label === wanted
32
+ ));
33
+ return match?.deviceId ?? "";
34
+ }
35
+
36
+ async function deviceIdForLabel(mediaDevices, label) {
37
+ const listed = async () => pickInputDevice(await mediaDevices.enumerateDevices(), label);
38
+ const found = await listed();
39
+ if (found) return found;
40
+ const devices = await mediaDevices.enumerateDevices();
41
+ if (devices.some((device) => device?.kind === "audioinput" && device.label)) return "";
42
+ const probe = await mediaDevices.getUserMedia({ audio: true });
43
+ probe.getTracks().forEach((track) => track.stop());
44
+ return listed();
45
+ }
46
+
47
+ /**
48
+ * Open the headset Chrome lists under `ready.label`.
49
+ * Without a label there is no headset to aim at, so the browser default is used.
50
+ */
51
+ export async function openRecordingStream(ready, mediaDevices = globalThis.navigator?.mediaDevices) {
52
+ const label = String(ready?.label ?? "").trim();
53
+ const audio = { ...RECORDING_AUDIO };
54
+ if (label) {
55
+ const deviceId = await deviceIdForLabel(mediaDevices, label);
56
+ if (!deviceId) throw new Error("voice_bluetooth_not_ready");
57
+ audio.deviceId = { exact: deviceId };
58
+ }
59
+ return mediaDevices.getUserMedia({ audio });
60
+ }
61
+
62
+ export function frameHasAudio(samples) {
63
+ for (let i = 0; i < samples.length; i += 1) {
64
+ if (samples[i] !== 0) return true;
65
+ }
66
+ return false;
67
+ }
68
+
69
+ /**
70
+ * The headset link stays at digital silence for a couple of seconds after it opens.
71
+ * Speech spoken into that hole never reaches the recognizer.
72
+ */
73
+ export function waitUntilAudible(stream, timeoutMs = 4000) {
74
+ const AudioCtx = globalThis.AudioContext || globalThis.webkitAudioContext;
75
+ if (!AudioCtx) return Promise.resolve(false);
76
+ const ctx = new AudioCtx();
77
+ const source = ctx.createMediaStreamSource(stream);
78
+ const proc = ctx.createScriptProcessor(4096, 1, 1);
79
+ const mute = ctx.createGain();
80
+ mute.gain.value = 0;
81
+ source.connect(proc);
82
+ proc.connect(mute);
83
+ mute.connect(ctx.destination);
84
+ return new Promise((resolve) => {
85
+ const finish = (heard) => {
86
+ proc.onaudioprocess = null;
87
+ proc.disconnect();
88
+ source.disconnect();
89
+ mute.disconnect();
90
+ void ctx.close?.();
91
+ resolve(heard);
92
+ };
93
+ const timer = setTimeout(() => finish(false), timeoutMs);
94
+ proc.onaudioprocess = (event) => {
95
+ if (!frameHasAudio(event.inputBuffer.getChannelData(0))) return;
96
+ clearTimeout(timer);
97
+ finish(true);
98
+ };
99
+ });
100
+ }
101
+
27
102
  export function pickRecordingMime(recorder = globalThis.MediaRecorder) {
28
103
  if (typeof recorder === "undefined" || recorder === undefined) return "";
29
104
  for (const type of MIME_CANDIDATES) {
@@ -0,0 +1,35 @@
1
+ import { segment } from "sentencex";
2
+
3
+ /**
4
+ * Kokoro's pipeline splits on newlines and stitches the audio with no gap, and
5
+ * it does not treat punctuation as a pause. Sentence boundaries come from
6
+ * Wikimedia sentencex (English abbreviations, decimals, ellipsis). The gap
7
+ * matches Kokoro-FastAPI's sentence padding: 150ms, doubled for . ! ?
8
+ */
9
+ export const SENTENCE_GAP_MS = 300;
10
+
11
+ function plain(text) {
12
+ return String(text ?? "")
13
+ .replace(/```[\s\S]*?```/g, " ")
14
+ .replace(/`([^`]+)`/g, "$1")
15
+ .replace(/!\[[^\]]*]\([^)]*\)/g, " ")
16
+ .replace(/\[([^\]]+)]\([^)]*\)/g, "$1")
17
+ .replace(/[#>*_]+/g, " ")
18
+ .replace(/\s+/g, " ")
19
+ .trim()
20
+ .slice(0, 2000);
21
+ }
22
+
23
+ export function speechSegments(text) {
24
+ const source = plain(text);
25
+ if (!source) return [];
26
+ const sentences = segment("en", source).map((part) => part.trim()).filter(Boolean);
27
+ return sentences.map((sentence, index) => ({
28
+ text: sentence,
29
+ gapMs: index === sentences.length - 1 ? 0 : SENTENCE_GAP_MS,
30
+ }));
31
+ }
32
+
33
+ export function speechScript(text) {
34
+ return speechSegments(text).map((part) => part.text).join("\n");
35
+ }