@olares/lares-core 0.15.9 → 0.15.11
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 +98 -9
- package/larepass/host.js +6 -0
- package/larepass/mux.js +4 -3
- package/larepass/runtime.js +3 -3
- package/larepass/transcript.js +4 -6
- package/log/report.js +46 -0
- package/olares/trusted-host.js +11 -1
- package/package.json +3 -1
- package/router/tts.js +24 -0
- package/tools/http.js +25 -0
- package/voice/client.js +50 -3
- package/voice/config.js +6 -3
- package/voice/gpu-mode.js +372 -0
- package/voice/install.js +24 -7
- package/voice/languages.js +2 -6
- package/voice/script.js +56 -7
- package/voice/service.js +158 -22
- package/voice/speech.js +222 -44
package/i18n/voice.js
CHANGED
|
@@ -24,22 +24,67 @@ export const ZH = {
|
|
|
24
24
|
"mic.idle": "语音输入",
|
|
25
25
|
"mic.blocked": "当前无法输入",
|
|
26
26
|
"lang.auto": "自动检测",
|
|
27
|
-
"status.auto": "自动",
|
|
28
27
|
"settings.title": "语音设置",
|
|
29
|
-
"settings.intro": "点击麦克风开始说话,说完自动转成文字。语音播报开启后,只朗读这一轮的最终回答。",
|
|
30
28
|
"settings.refresh": "刷新",
|
|
31
29
|
"settings.refreshing": "刷新中…",
|
|
32
30
|
"settings.router": "Router 控制台",
|
|
33
31
|
"settings.loading": "正在读取语音设置…",
|
|
34
32
|
"settings.loadFailed": "读取语音设置失败",
|
|
35
33
|
"settings.refreshFailed": "刷新语音模型列表失败",
|
|
36
|
-
"settings.status.ready": "识别就绪 · {model}",
|
|
37
|
-
"settings.status.notReady": "还没有语音识别模型",
|
|
38
34
|
"settings.stt.title": "语音识别",
|
|
39
35
|
"settings.stt.hint": "把麦克风里的话转成文字",
|
|
40
36
|
"settings.tts.title": "语音播报",
|
|
41
37
|
"settings.tts.hint": "只朗读最终回答,思考和工具调用不播报",
|
|
42
38
|
"settings.speak.label": "开启语音播报",
|
|
39
|
+
"settings.group.stt": "语音识别",
|
|
40
|
+
"settings.group.tts": "语音播报",
|
|
41
|
+
"settings.group.bluetooth": "输出设备",
|
|
42
|
+
"settings.stt.model": "识别模型",
|
|
43
|
+
"settings.tts.model": "播报模型",
|
|
44
|
+
"settings.speak.title": "自动朗读回答",
|
|
45
|
+
"settings.phase.running": "运行中",
|
|
46
|
+
"settings.phase.stopped": "已停止",
|
|
47
|
+
"settings.phase.busy": "处理中",
|
|
48
|
+
"settings.phase.failed": "异常",
|
|
49
|
+
"settings.phase.absent": "未安装",
|
|
50
|
+
"settings.mode.title": "播报方式",
|
|
51
|
+
"settings.mode.hint": "边写边读用显存分片,写完再读用时间分片。切换约 2–3 分钟,期间模型不可用",
|
|
52
|
+
"settings.mode.gpu": "显卡:{type}",
|
|
53
|
+
"settings.mode.turns": "写完再读",
|
|
54
|
+
"settings.mode.streaming": "边写边读",
|
|
55
|
+
"settings.mode.switching": "正在切换显卡模式…",
|
|
56
|
+
"settings.mode.resumingApp": "正在恢复 {app}…",
|
|
57
|
+
"settings.mode.partial": "显卡已切换,但这些应用没能恢复:{apps}",
|
|
58
|
+
"settings.mode.failed": "切换显卡模式失败:{error}",
|
|
59
|
+
"settings.mode.mismatch": "显卡当前是{type},和播报方式不一致",
|
|
60
|
+
"settings.mode.noGpu": "没有找到 NVIDIA 显卡",
|
|
61
|
+
"settings.mode.retry": "重试",
|
|
62
|
+
"settings.gpu.MemorySlice": "显存分片",
|
|
63
|
+
"settings.gpu.TimeSlice": "时间分片",
|
|
64
|
+
"settings.gpu.Exclusive": "独占",
|
|
65
|
+
"settings.apps.title": "显卡应用",
|
|
66
|
+
"settings.apps.hint": "时间分片下这些应用轮流使用整张显卡;同时运行太多,显存可能不够",
|
|
67
|
+
"settings.apps.required": "至少 {size} GiB 显存",
|
|
68
|
+
"settings.apps.start": "启动",
|
|
69
|
+
"settings.apps.stop": "停止",
|
|
70
|
+
"settings.apps.starting": "启动中…",
|
|
71
|
+
"settings.apps.stopping": "停止中…",
|
|
72
|
+
"settings.apps.empty": "没有需要显卡的应用",
|
|
73
|
+
"settings.slices.title": "显存分片",
|
|
74
|
+
"settings.slices.gpu": "显卡",
|
|
75
|
+
"settings.slices.used": "已分配 {used} / {total} GiB",
|
|
76
|
+
"settings.slices.hint": "选择哪些应用常驻显卡、各分多少显存。保存后只重启有改动的应用",
|
|
77
|
+
"settings.slices.hintPending": "切到边写边读时,按这里的配置分配显存",
|
|
78
|
+
"settings.slices.size": "{app}的显存",
|
|
79
|
+
"settings.slices.enable": "{app}参与显存分片",
|
|
80
|
+
"settings.slices.empty": "还没有应用绑定到这张显卡",
|
|
81
|
+
"settings.slices.over": "超出显卡显存 {over} GiB",
|
|
82
|
+
"settings.slices.invalid": "开启的应用显存不能低于它的最低要求",
|
|
83
|
+
"settings.slices.restart": "有改动的应用会重启",
|
|
84
|
+
"settings.slices.reset": "还原",
|
|
85
|
+
"settings.slices.save": "保存",
|
|
86
|
+
"settings.slices.apply": "保存并应用",
|
|
87
|
+
"settings.slices.saving": "保存中…",
|
|
43
88
|
"settings.install": "安装",
|
|
44
89
|
"settings.installing": "安装中…",
|
|
45
90
|
"settings.installStarted": "已开始安装,模型下载完成后点刷新",
|
|
@@ -94,23 +139,67 @@ export const EN = {
|
|
|
94
139
|
"mic.idle": "Voice input",
|
|
95
140
|
"mic.blocked": "Input unavailable",
|
|
96
141
|
"lang.auto": "Auto-detect",
|
|
97
|
-
"status.auto": "auto",
|
|
98
142
|
"settings.title": "Voice settings",
|
|
99
|
-
"settings.intro":
|
|
100
|
-
"Click the microphone to start speaking; the take is transcribed into the composer. With playback on, only the turn's final answer is spoken.",
|
|
101
143
|
"settings.refresh": "Refresh",
|
|
102
144
|
"settings.refreshing": "Refreshing…",
|
|
103
145
|
"settings.router": "Router console",
|
|
104
146
|
"settings.loading": "Loading voice settings…",
|
|
105
147
|
"settings.loadFailed": "Failed to load voice settings",
|
|
106
148
|
"settings.refreshFailed": "Failed to refresh voice models",
|
|
107
|
-
"settings.status.ready": "Recognition ready · {model}",
|
|
108
|
-
"settings.status.notReady": "No speech-recognition model yet",
|
|
109
149
|
"settings.stt.title": "Speech recognition",
|
|
110
150
|
"settings.stt.hint": "Turn the microphone into text",
|
|
111
151
|
"settings.tts.title": "Speech playback",
|
|
112
152
|
"settings.tts.hint": "Read only the final answer, not reasoning or tool calls",
|
|
113
153
|
"settings.speak.label": "Speak the final answer",
|
|
154
|
+
"settings.group.stt": "Speech recognition",
|
|
155
|
+
"settings.group.tts": "Speech playback",
|
|
156
|
+
"settings.group.bluetooth": "Output device",
|
|
157
|
+
"settings.stt.model": "Recognition model",
|
|
158
|
+
"settings.tts.model": "Speech model",
|
|
159
|
+
"settings.speak.title": "Read answers aloud",
|
|
160
|
+
"settings.phase.running": "Running",
|
|
161
|
+
"settings.phase.stopped": "Stopped",
|
|
162
|
+
"settings.phase.busy": "Working",
|
|
163
|
+
"settings.phase.failed": "Failed",
|
|
164
|
+
"settings.phase.absent": "Not installed",
|
|
165
|
+
"settings.mode.title": "Reading mode",
|
|
166
|
+
"settings.mode.hint": "While writing uses memory slicing; when done uses time slicing. Switching takes 2–3 minutes; models are unavailable meanwhile",
|
|
167
|
+
"settings.mode.gpu": "GPU: {type}",
|
|
168
|
+
"settings.mode.turns": "When done",
|
|
169
|
+
"settings.mode.streaming": "While writing",
|
|
170
|
+
"settings.mode.switching": "Switching GPU mode…",
|
|
171
|
+
"settings.mode.resumingApp": "Resuming {app}…",
|
|
172
|
+
"settings.mode.partial": "GPU switched, but these apps did not resume: {apps}",
|
|
173
|
+
"settings.mode.failed": "GPU switch failed: {error}",
|
|
174
|
+
"settings.mode.mismatch": "The GPU is in {type}, which does not match the reading mode",
|
|
175
|
+
"settings.mode.noGpu": "No NVIDIA GPU found",
|
|
176
|
+
"settings.mode.retry": "Retry",
|
|
177
|
+
"settings.gpu.MemorySlice": "memory slicing",
|
|
178
|
+
"settings.gpu.TimeSlice": "time slicing",
|
|
179
|
+
"settings.gpu.Exclusive": "exclusive",
|
|
180
|
+
"settings.apps.title": "GPU apps",
|
|
181
|
+
"settings.apps.hint": "With time slicing these apps take turns on the whole GPU; running too many at once may run out of memory",
|
|
182
|
+
"settings.apps.required": "needs {size} GiB",
|
|
183
|
+
"settings.apps.start": "Start",
|
|
184
|
+
"settings.apps.stop": "Stop",
|
|
185
|
+
"settings.apps.starting": "Starting…",
|
|
186
|
+
"settings.apps.stopping": "Stopping…",
|
|
187
|
+
"settings.apps.empty": "No apps need the GPU",
|
|
188
|
+
"settings.slices.title": "Memory slices",
|
|
189
|
+
"settings.slices.gpu": "GPU",
|
|
190
|
+
"settings.slices.used": "{used} / {total} GiB allocated",
|
|
191
|
+
"settings.slices.hint": "Choose which apps stay on the GPU and how much memory each gets. Saving restarts only the apps that changed",
|
|
192
|
+
"settings.slices.hintPending": "Applied when switching to reading while writing",
|
|
193
|
+
"settings.slices.size": "Memory for {app}",
|
|
194
|
+
"settings.slices.enable": "{app} uses a memory slice",
|
|
195
|
+
"settings.slices.empty": "No apps are bound to this GPU yet",
|
|
196
|
+
"settings.slices.over": "{over} GiB over the GPU memory",
|
|
197
|
+
"settings.slices.invalid": "An enabled app cannot go below its minimum memory",
|
|
198
|
+
"settings.slices.restart": "Apps that changed will restart",
|
|
199
|
+
"settings.slices.reset": "Reset",
|
|
200
|
+
"settings.slices.save": "Save",
|
|
201
|
+
"settings.slices.apply": "Save and apply",
|
|
202
|
+
"settings.slices.saving": "Saving…",
|
|
114
203
|
"settings.install": "Install",
|
|
115
204
|
"settings.installing": "Installing…",
|
|
116
205
|
"settings.installStarted": "Install started. Refresh after the model finishes downloading",
|
package/larepass/host.js
CHANGED
|
@@ -3,6 +3,12 @@ export const LARES_API_PREFIX = "/api/lares";
|
|
|
3
3
|
export const MODELS_PATH = `${LARES_API_PREFIX}/models`;
|
|
4
4
|
export const PC_TEST_PROXY = "/laresHost";
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Packaged LarePass page origins (Capacitor Android `androidScheme: http`, iOS).
|
|
8
|
+
* Binary bodies leave those pages as a cross-origin webview `fetch`.
|
|
9
|
+
*/
|
|
10
|
+
export const LAREPASS_PAGE_ORIGINS = ["http://localhost", "https://localhost", "capacitor://localhost"];
|
|
11
|
+
|
|
6
12
|
/** Host plugin routes (`file-preview`, models, voice, …). Same path on PC and LarePass. */
|
|
7
13
|
export function isLaresPluginPath(path) {
|
|
8
14
|
const suffix = String(path ?? "").startsWith("/") ? path : `/${path}`;
|
package/larepass/mux.js
CHANGED
|
@@ -77,11 +77,12 @@ export function classifyRemoteEvent(value) {
|
|
|
77
77
|
return { kind: "pass", eventId: value.eventId };
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// A tool call starting is what turns the step being read aloud silent.
|
|
81
|
+
const LIVE_CHUNKS = new Set(["text-delta", "reasoning-delta", "tool-call-delta", "block-end"]);
|
|
82
|
+
|
|
80
83
|
function liveChunkEvent(seq, turn, step, chunk) {
|
|
81
84
|
if (!chunk || typeof chunk !== "object") return null;
|
|
82
|
-
if (
|
|
83
|
-
return null;
|
|
84
|
-
}
|
|
85
|
+
if (!LIVE_CHUNKS.has(chunk.type)) return null;
|
|
85
86
|
return {
|
|
86
87
|
type: "assistant/chunk",
|
|
87
88
|
seq,
|
package/larepass/runtime.js
CHANGED
|
@@ -47,7 +47,7 @@ function viewOf(state, cache, pendingQuestions, pendingApprovals, inbox) {
|
|
|
47
47
|
messages: snap.messages,
|
|
48
48
|
running: snap.running,
|
|
49
49
|
runningSince: snap.runningSince,
|
|
50
|
-
|
|
50
|
+
utterance: snap.utterance,
|
|
51
51
|
error: state.error || snap.error,
|
|
52
52
|
failed: state.failed,
|
|
53
53
|
phase: state.phase,
|
|
@@ -872,9 +872,9 @@ export function createChatRuntime(client) {
|
|
|
872
872
|
if (typeof client.reportLog !== "function") return Promise.resolve(false);
|
|
873
873
|
return client.reportLog(scope, event, fields);
|
|
874
874
|
},
|
|
875
|
-
speak(
|
|
875
|
+
speak(request) {
|
|
876
876
|
if (typeof client.speak !== "function") return Promise.resolve(null);
|
|
877
|
-
return client.speak(
|
|
877
|
+
return client.speak(request);
|
|
878
878
|
},
|
|
879
879
|
interruptSpeech() {
|
|
880
880
|
if (typeof client.interruptSpeech !== "function") return Promise.resolve(false);
|
package/larepass/transcript.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { producedPathsFromView, turnMediaPathsFromEvent } from "../files/deliverables.js";
|
|
2
2
|
import { durablePathFromToolCall, parseToolArguments } from "../files/published-tools.js";
|
|
3
|
-
import {
|
|
3
|
+
import { reduceUtterance } from "../voice/speech.js";
|
|
4
4
|
import { contextForm, contextProvenance, isHumanUserSource } from "./context-provenance.js";
|
|
5
5
|
import { toolRowModel } from "./tool-row.js";
|
|
6
6
|
import { createTurnMetrics } from "./turn-metrics.js";
|
|
@@ -111,7 +111,7 @@ export function foldTranscript(entries) {
|
|
|
111
111
|
let running = false;
|
|
112
112
|
let runningSince = 0;
|
|
113
113
|
let error = "";
|
|
114
|
-
let
|
|
114
|
+
let utterance = null;
|
|
115
115
|
let streamCursor = { loc: "", kind: "", index: 0 };
|
|
116
116
|
let activeTurn = 0;
|
|
117
117
|
const ended = new Map();
|
|
@@ -238,6 +238,7 @@ export function foldTranscript(entries) {
|
|
|
238
238
|
const data = event.data ?? {};
|
|
239
239
|
const view = entryView(entry, event);
|
|
240
240
|
metrics.observe(event);
|
|
241
|
+
utterance = reduceUtterance(utterance, event);
|
|
241
242
|
|
|
242
243
|
if (event.type === "turn/start") {
|
|
243
244
|
closeTurn();
|
|
@@ -248,7 +249,6 @@ export function foldTranscript(entries) {
|
|
|
248
249
|
running = true;
|
|
249
250
|
runningSince = Number(event.time) || 0;
|
|
250
251
|
error = "";
|
|
251
|
-
reply = null;
|
|
252
252
|
continue;
|
|
253
253
|
}
|
|
254
254
|
|
|
@@ -280,8 +280,6 @@ export function foldTranscript(entries) {
|
|
|
280
280
|
|
|
281
281
|
if (event.type === "assistant/message") {
|
|
282
282
|
applyAssistantMessage(data);
|
|
283
|
-
const text = concludingReply(event);
|
|
284
|
-
if (text) reply = { seq: event.seq, text };
|
|
285
283
|
continue;
|
|
286
284
|
}
|
|
287
285
|
|
|
@@ -376,6 +374,6 @@ export function foldTranscript(entries) {
|
|
|
376
374
|
running,
|
|
377
375
|
runningSince: running ? runningSince : 0,
|
|
378
376
|
error,
|
|
379
|
-
|
|
377
|
+
utterance,
|
|
380
378
|
};
|
|
381
379
|
}
|
package/log/report.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Client diagnostics. One record shape, whether it is written here or posted in. */
|
|
2
|
+
|
|
3
|
+
export const REPORT_PATH = "/api/lares/log";
|
|
4
|
+
|
|
5
|
+
const SCOPE = /^[a-z][a-z0-9-]{0,31}$/;
|
|
6
|
+
const EVENT = /^[a-z][a-z0-9-]{0,39}$/;
|
|
7
|
+
const FIELD = /^[a-z][a-zA-Z0-9]{0,31}$/;
|
|
8
|
+
|
|
9
|
+
export function reportRecord(input = {}) {
|
|
10
|
+
const source = input && typeof input === "object" ? input : {};
|
|
11
|
+
const scope = typeof source.scope === "string" && SCOPE.test(source.scope) ? source.scope : "client";
|
|
12
|
+
const event = typeof source.event === "string" && EVENT.test(source.event) ? source.event : "event";
|
|
13
|
+
const record = { scope, event };
|
|
14
|
+
for (const [key, value] of Object.entries(source)) {
|
|
15
|
+
if (key === "scope" || key === "event" || !FIELD.test(key)) continue;
|
|
16
|
+
if (typeof value === "string") record[key] = value.slice(0, 180);
|
|
17
|
+
else if (typeof value === "number" && Number.isFinite(value)) record[key] = value;
|
|
18
|
+
else if (typeof value === "boolean") record[key] = value;
|
|
19
|
+
}
|
|
20
|
+
return record;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function writeReport(input) {
|
|
24
|
+
const record = reportRecord(input);
|
|
25
|
+
console.log(`[lares:${record.scope}]`, JSON.stringify(record));
|
|
26
|
+
return record;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Fire-and-forget. `request` is the host port when the page has no cookie.
|
|
31
|
+
* A rejected port does not reject the caller.
|
|
32
|
+
*/
|
|
33
|
+
export function postReport(input, options = {}) {
|
|
34
|
+
const body = reportRecord(input);
|
|
35
|
+
const url = options.url ?? REPORT_PATH;
|
|
36
|
+
const send = options.request;
|
|
37
|
+
const pending = typeof send === "function"
|
|
38
|
+
? send(url, { method: "POST", headers: { "content-type": "application/json" }, body })
|
|
39
|
+
: fetch(url, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
credentials: "include",
|
|
42
|
+
headers: { "content-type": "application/json" },
|
|
43
|
+
body: JSON.stringify(body),
|
|
44
|
+
});
|
|
45
|
+
return Promise.resolve(pending).then(() => true, () => false);
|
|
46
|
+
}
|
package/olares/trusted-host.js
CHANGED
|
@@ -2,9 +2,19 @@ export function loopbackAuthority(port) {
|
|
|
2
2
|
return `127.0.0.1:${port}`;
|
|
3
3
|
}
|
|
4
4
|
|
|
5
|
+
/** The browser's Origin, kept for CORS after the loopback rewrite replaces it. */
|
|
6
|
+
const pageOrigins = new WeakMap();
|
|
7
|
+
|
|
8
|
+
export function pageOrigin(headers) {
|
|
9
|
+
return pageOrigins.get(headers) ?? headers?.origin;
|
|
10
|
+
}
|
|
11
|
+
|
|
5
12
|
export function applyLoopbackHeaders(headers, authority) {
|
|
6
13
|
headers.host = authority;
|
|
7
|
-
if (headers.origin !== undefined)
|
|
14
|
+
if (headers.origin !== undefined) {
|
|
15
|
+
if (!pageOrigins.has(headers)) pageOrigins.set(headers, headers.origin);
|
|
16
|
+
headers.origin = `http://${authority}`;
|
|
17
|
+
}
|
|
8
18
|
// Fetch Metadata outlives the origin rewrite. The browser decides this one and
|
|
9
19
|
// we cannot argue with it: a packaged page calling the entrance is `cross-site`
|
|
10
20
|
// however the host and origin now read, and dsh refuses that outright — the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@olares/lares-core",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.11",
|
|
4
4
|
"description": "Lares 共享业务核心:Router、workspace、文件/媒体、Olares 身份(无 UI、无平台壳)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"i18n",
|
|
21
21
|
"icons",
|
|
22
22
|
"larepass",
|
|
23
|
+
"log",
|
|
23
24
|
"media",
|
|
24
25
|
"mcp",
|
|
25
26
|
"olares",
|
|
@@ -122,6 +123,7 @@
|
|
|
122
123
|
"./voice/problem": "./voice/problem.js",
|
|
123
124
|
"./voice/capture": "./voice/capture.js",
|
|
124
125
|
"./voice/install": "./voice/install.js",
|
|
126
|
+
"./voice/gpu-mode": "./voice/gpu-mode.js",
|
|
125
127
|
"./search/config": "./search/config.js",
|
|
126
128
|
"./search/menu": "./search/menu.js",
|
|
127
129
|
"./search/client": "./search/client.js",
|
package/router/tts.js
CHANGED
|
@@ -5,6 +5,28 @@ const REQUEST_TIMEOUT_MS = 180_000;
|
|
|
5
5
|
|
|
6
6
|
const PCM_RATE = 24000;
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Engines pick a speaker per request when none is named, so the readout voice
|
|
10
|
+
* changed between answers. First listed voice the model offers wins.
|
|
11
|
+
*/
|
|
12
|
+
const PREFERRED_VOICES = ["serena", "vivian", "uncle_fu"];
|
|
13
|
+
|
|
14
|
+
export function pickTtsVoice(voices) {
|
|
15
|
+
const ids = voices.filter((id) => typeof id === "string" && id);
|
|
16
|
+
return PREFERRED_VOICES.find((id) => ids.includes(id)) ?? [...ids].sort()[0] ?? null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** @returns {Promise<string[]>} voice ids the model serves; empty when it lists none. */
|
|
20
|
+
export async function listTtsVoices(model) {
|
|
21
|
+
const res = await fetch(`${routerShimBaseUrl()}/audio/voices?model=${encodeURIComponent(model)}`, {
|
|
22
|
+
signal: AbortSignal.timeout(10_000),
|
|
23
|
+
});
|
|
24
|
+
if (!res.ok) return [];
|
|
25
|
+
const body = await res.json().catch(() => null);
|
|
26
|
+
const voices = Array.isArray(body?.voices) ? body.voices : [];
|
|
27
|
+
return voices.map((voice) => (typeof voice === "string" ? voice : voice?.id));
|
|
28
|
+
}
|
|
29
|
+
|
|
8
30
|
/** Kokoro concatenates chunks with no gap. This is the silence we insert between sentences. */
|
|
9
31
|
export function silencePcm(ms, sampleRate = PCM_RATE) {
|
|
10
32
|
const samples = Math.max(0, Math.round((sampleRate * ms) / 1000));
|
|
@@ -47,6 +69,7 @@ export async function synthesize(request) {
|
|
|
47
69
|
model: request.model,
|
|
48
70
|
input: text,
|
|
49
71
|
response_format: "wav",
|
|
72
|
+
...(request.voice ? { voice: request.voice } : {}),
|
|
50
73
|
}),
|
|
51
74
|
signal: AbortSignal.timeout(request.timeoutMs ?? REQUEST_TIMEOUT_MS),
|
|
52
75
|
});
|
|
@@ -89,6 +112,7 @@ export async function synthesizeStream(request, onChunk) {
|
|
|
89
112
|
input: text,
|
|
90
113
|
response_format: "pcm",
|
|
91
114
|
stream: true,
|
|
115
|
+
...(request.voice ? { voice: request.voice } : {}),
|
|
92
116
|
}),
|
|
93
117
|
signal,
|
|
94
118
|
});
|
package/tools/http.js
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
import { LAREPASS_PAGE_ORIGINS } from "../larepass/host.js";
|
|
2
|
+
import { pageOrigin } from "../olares/trusted-host.js";
|
|
3
|
+
|
|
4
|
+
const CORS_HEADERS = "content-type, x-authorization, x-unauth-error, x-lares-session-id, x-lares-file-name, x-lares-upload-request-id";
|
|
5
|
+
|
|
6
|
+
/** @returns {boolean} true when the request came from a LarePass page. */
|
|
7
|
+
function allowLarepassOrigin(req, res) {
|
|
8
|
+
const origin = pageOrigin(req.headers);
|
|
9
|
+
if (!origin || !LAREPASS_PAGE_ORIGINS.includes(origin)) return false;
|
|
10
|
+
res.setHeader("access-control-allow-origin", origin);
|
|
11
|
+
res.setHeader("access-control-allow-credentials", "true");
|
|
12
|
+
res.setHeader("vary", "Origin");
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
|
|
1
16
|
export class HttpError extends Error {
|
|
2
17
|
constructor(code, status, message) {
|
|
3
18
|
super(message);
|
|
@@ -99,12 +114,22 @@ export function createRouteHandler(options) {
|
|
|
99
114
|
const suffix = new URL(req.url ?? "/", "http://x").pathname.slice(options.prefix.length) || "/";
|
|
100
115
|
const path = suffix.replace(/\/+$/, "") || "/";
|
|
101
116
|
const handlers = options.routes[path];
|
|
117
|
+
const cors = allowLarepassOrigin(req, res);
|
|
102
118
|
if (!handlers) {
|
|
103
119
|
sendJson(res, 404, {
|
|
104
120
|
error: { code: "not_found", message: `no route ${path}` },
|
|
105
121
|
});
|
|
106
122
|
return;
|
|
107
123
|
}
|
|
124
|
+
if (cors && method === "OPTIONS") {
|
|
125
|
+
res.writeHead(204, {
|
|
126
|
+
"access-control-allow-methods": Object.keys(handlers).join(", "),
|
|
127
|
+
"access-control-allow-headers": CORS_HEADERS,
|
|
128
|
+
"access-control-max-age": "600",
|
|
129
|
+
});
|
|
130
|
+
res.end();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
108
133
|
const route = handlers[method];
|
|
109
134
|
if (!route) {
|
|
110
135
|
sendJson(res, 405, {
|
package/voice/client.js
CHANGED
|
@@ -32,6 +32,7 @@ export async function loadVoiceSettings(options = {}) {
|
|
|
32
32
|
function chartView(row) {
|
|
33
33
|
const phase = row?.phase;
|
|
34
34
|
return {
|
|
35
|
+
app: typeof row?.app === "string" ? row.app : "",
|
|
35
36
|
phase: phase === "running" || phase === "stopped" || phase === "busy" || phase === "failed" ? phase : "absent",
|
|
36
37
|
state: typeof row?.state === "string" ? row.state : "",
|
|
37
38
|
};
|
|
@@ -62,11 +63,12 @@ export function wavBlob(base64) {
|
|
|
62
63
|
return new Blob([bytes], { type: "audio/wav" });
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
|
|
66
|
+
/** Hands over a streaming answer's text; with `part`, also returns that sentence's audio. */
|
|
67
|
+
export async function postSpeak({ key, text, done, part }) {
|
|
66
68
|
const res = await fetch(`${API}/speak`, {
|
|
67
69
|
method: "POST",
|
|
68
70
|
headers: { "content-type": "application/json" },
|
|
69
|
-
body: JSON.stringify({ text }),
|
|
71
|
+
body: JSON.stringify(Number.isInteger(part) ? { key, text, done, part } : { key, text, done }),
|
|
70
72
|
});
|
|
71
73
|
let body = null;
|
|
72
74
|
try {
|
|
@@ -74,7 +76,11 @@ export async function postSpeak(text) {
|
|
|
74
76
|
} catch {
|
|
75
77
|
body = null;
|
|
76
78
|
}
|
|
77
|
-
if (
|
|
79
|
+
if (res.ok && body && !Number.isInteger(part)) return { sentences: Number(body.sentences) || 0 };
|
|
80
|
+
if (!res.ok || typeof body?.audio !== "string") {
|
|
81
|
+
const code = transcribeErrorCode(body);
|
|
82
|
+
throw Object.assign(new Error(code), { code });
|
|
83
|
+
}
|
|
78
84
|
return {
|
|
79
85
|
bluetooth: body.bluetooth === "played" ? "played" : "skipped",
|
|
80
86
|
blob: wavBlob(body.audio),
|
|
@@ -97,6 +103,47 @@ export async function saveVoiceSettings(patch) {
|
|
|
97
103
|
return loadVoiceSettings({ force: true });
|
|
98
104
|
}
|
|
99
105
|
|
|
106
|
+
/** Reading mode and the GPU slicing it needs; see core/voice/gpu-mode.js. */
|
|
107
|
+
export async function loadGpuMode() {
|
|
108
|
+
const res = await fetch(`${API}/gpu-mode`);
|
|
109
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
110
|
+
return res.json();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function switchGpuMode(streaming) {
|
|
114
|
+
const res = await fetch(`${API}/gpu-mode`, {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers: { "content-type": "application/json" },
|
|
117
|
+
body: JSON.stringify({ streaming }),
|
|
118
|
+
});
|
|
119
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
120
|
+
return res.json();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** @param {{ app: string, enabled: boolean, memory: number }[]} apps memory in bytes */
|
|
124
|
+
export async function saveSlicePlan(apps) {
|
|
125
|
+
const res = await fetch(`${API}/gpu-mode/slices`, {
|
|
126
|
+
method: "POST",
|
|
127
|
+
headers: { "content-type": "application/json" },
|
|
128
|
+
body: JSON.stringify({ apps }),
|
|
129
|
+
});
|
|
130
|
+
const payload = await res.json().catch(() => null);
|
|
131
|
+
if (!res.ok) throw Object.assign(new Error(payload?.error?.message || String(res.status)), { code: payload?.error?.code });
|
|
132
|
+
return payload;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** @param {string} app @param {"resume" | "stop"} op */
|
|
136
|
+
export async function postGpuApp(app, op) {
|
|
137
|
+
const res = await fetch(`${API}/gpu-mode/app`, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: { "content-type": "application/json" },
|
|
140
|
+
body: JSON.stringify({ app, op }),
|
|
141
|
+
});
|
|
142
|
+
const payload = await res.json().catch(() => null);
|
|
143
|
+
if (!res.ok) throw new Error(payload?.error?.message || String(res.status));
|
|
144
|
+
return payload;
|
|
145
|
+
}
|
|
146
|
+
|
|
100
147
|
function transcribeErrorCode(payload) {
|
|
101
148
|
const code = payload && typeof payload === "object" ? payload.error?.code : undefined;
|
|
102
149
|
return typeof code === "string" ? code : "voice_failed";
|
package/voice/config.js
CHANGED
|
@@ -9,6 +9,8 @@ const DEFAULTS = {
|
|
|
9
9
|
ttsModel: "",
|
|
10
10
|
language: "",
|
|
11
11
|
speak: false,
|
|
12
|
+
// Read answers while they are written. Needs the GPU in MemorySlice, see gpu-mode.js.
|
|
13
|
+
streaming: false,
|
|
12
14
|
};
|
|
13
15
|
|
|
14
16
|
const WRITABLE = new Set(Object.keys(DEFAULTS));
|
|
@@ -25,9 +27,9 @@ export function validateConfigPatch(raw) {
|
|
|
25
27
|
const patch = {};
|
|
26
28
|
for (const [key, value] of Object.entries(raw)) {
|
|
27
29
|
if (!WRITABLE.has(key)) invalid(`unknown voice setting: ${key}`);
|
|
28
|
-
if (key === "speak") {
|
|
29
|
-
if (typeof value !== "boolean") invalid(
|
|
30
|
-
patch
|
|
30
|
+
if (key === "speak" || key === "streaming") {
|
|
31
|
+
if (typeof value !== "boolean") invalid(`${key} must be a boolean`);
|
|
32
|
+
patch[key] = value;
|
|
31
33
|
continue;
|
|
32
34
|
}
|
|
33
35
|
if (typeof value !== "string") invalid(`${key} must be a string`);
|
|
@@ -54,6 +56,7 @@ export function readConfig() {
|
|
|
54
56
|
if (SAFE_MODEL.test(ttsModel)) merged.ttsModel = ttsModel;
|
|
55
57
|
if (SUPPORTED_LANGUAGES.has(language)) merged.language = language;
|
|
56
58
|
if (stored?.speak === true) merged.speak = true;
|
|
59
|
+
if (stored?.streaming === true) merged.streaming = true;
|
|
57
60
|
return merged;
|
|
58
61
|
}
|
|
59
62
|
|