@olares/lares-core 0.15.17 → 0.15.18
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/brand/identity.js +25 -2
- package/i18n/chat-model-switch.js +8 -2
- package/i18n/voice.js +78 -2
- package/i18n/webhook.js +39 -0
- package/larepass/mux.js +1 -0
- package/larepass/rpc.js +4 -3
- package/larepass/runtime.js +4 -1
- package/larepass/settings.js +11 -8
- package/package.json +29 -2
- package/router/catalog-cache.js +1 -1
- package/router/catalog-coordinator.js +60 -0
- package/router/catalog-sync.js +17 -62
- package/router/catalog.d.ts +3 -1
- package/router/catalog.js +6 -13
- package/router/default-selection.d.ts +3 -0
- package/router/default-selection.js +22 -0
- package/router/model-authorization.js +53 -0
- package/router/model-state.js +35 -0
- package/router/models.js +2 -6
- package/router/session-model.js +5 -18
- package/router/stt.js +2 -1
- package/router/tts.js +13 -20
- package/voice/activity.js +10 -0
- package/voice/agent-session.js +4 -0
- package/voice/browser-capture.js +72 -6
- package/voice/browser-playback.js +16 -20
- package/voice/client.js +28 -14
- package/voice/composite-gateway.js +7 -3
- package/voice/config.js +6 -0
- package/voice/controller.js +19 -8
- package/voice/conversation.js +48 -15
- package/voice/device-input.js +14 -9
- package/voice/dialogue.js +4 -3
- package/voice/host-service.js +7 -2
- package/voice/interruption.js +44 -0
- package/voice/lares-policy.js +12 -0
- package/voice/options.js +2 -1
- package/voice/panel.js +61 -20
- package/voice/pcm-player.js +51 -0
- package/voice/pcm-worklet.js +71 -0
- package/voice/preferences.js +89 -0
- package/voice/presentation.js +13 -1
- package/voice/reply-stream.js +97 -0
- package/voice/router-audio.js +23 -6
- package/voice/script.js +27 -36
- package/voice/service.js +46 -33
- package/voice/shortcuts.js +54 -5
- package/voice/speech-segmenter.js +119 -0
- package/voice/speech.js +60 -13
- package/voice/spoken-text.js +196 -0
- package/voice/startup.js +9 -0
- package/voice/stream-client.js +165 -0
- package/voice/stream-gateway.js +146 -0
- package/voice/tts-preview.js +27 -0
- package/voice/tts-settings.js +79 -0
- package/voice/tts-voice.js +20 -0
- package/webhook/client.js +34 -0
- package/webhook/config.js +55 -0
- package/webhook/public.js +11 -0
- package/webhook/reply.js +12 -0
package/router/catalog.js
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
const SAFE_MODEL_ID = /^[^\u0000-\u001f\u007f]{1,512}$/;
|
|
2
|
-
/** pi-ai's thinking vocabulary; a Router level outside it has no wire spelling to declare. */
|
|
3
|
-
const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
4
2
|
|
|
5
3
|
/**
|
|
6
4
|
* The capability flags one catalog row declares, named without Router's
|
|
@@ -23,20 +21,12 @@ function capabilities(item) {
|
|
|
23
21
|
return flags;
|
|
24
22
|
}
|
|
25
23
|
|
|
26
|
-
/**
|
|
27
|
-
* The effort levels Router advertises for one model, keyed by pi-ai level with
|
|
28
|
-
* Router's own spelling as the wire value. A dict offering nothing beyond `off`
|
|
29
|
-
* is no capability at all, and pi-ai rejects it.
|
|
30
|
-
*/
|
|
24
|
+
/** Keep Router identifiers and their declared order independent of adapter vocabulary. */
|
|
31
25
|
function reasoningEfforts(item, flags) {
|
|
32
26
|
if (!flags.has("reasoning_effort")) return null;
|
|
33
27
|
const options = Array.isArray(item.reasoning_effort?.options) ? item.reasoning_effort.options : [];
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
const level = String(option ?? "").trim().toLowerCase();
|
|
37
|
-
if (THINKING_LEVELS.has(level)) efforts[level] = level;
|
|
38
|
-
}
|
|
39
|
-
return Object.keys(efforts).some((level) => level !== "off") ? efforts : null;
|
|
28
|
+
const entries = options.filter((value) => typeof value === "string" && value.length > 0);
|
|
29
|
+
return entries.length ? Object.fromEntries(entries.map((value) => [value, value])) : null;
|
|
40
30
|
}
|
|
41
31
|
|
|
42
32
|
/**
|
|
@@ -80,7 +70,10 @@ export function routerCatalogRows(payload) {
|
|
|
80
70
|
supportsVision: flags.has("vision"),
|
|
81
71
|
supportsStt: flags.has("stt") || flags.has("stt_stream"),
|
|
82
72
|
supportsTts: flags.has("tts"),
|
|
73
|
+
supportsSttStream: flags.has("stt_stream"),
|
|
83
74
|
reasoningEfforts: reasoningEfforts(item, flags),
|
|
75
|
+
...(item.reasoning_effort?.default && (item.reasoning_effort.options ?? []).includes(item.reasoning_effort.default)
|
|
76
|
+
? { reasoningDefault: item.reasoning_effort.default } : {}),
|
|
84
77
|
contextWindow,
|
|
85
78
|
maxTokens: outputTokenBudget(tokenCount(item.max_output_tokens), contextWindow),
|
|
86
79
|
});
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export interface ModelSelection { provider: string; model: string; reasoningEffort?: string }
|
|
2
|
+
export function resolveDefaultSelection<T extends ModelSelection>(current: T, models: { id: string }[]): T | ModelSelection;
|
|
3
|
+
export function ensureDefaultSelection(service: { currentSelection(): ModelSelection; saveSelection(next: ModelSelection, options: { expected: ModelSelection }): Promise<void> } | undefined, models: { id: string }[]): Promise<void>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { ROUTER_PROVIDER_ID, isPlaceholderModelId } from "./models.js";
|
|
2
|
+
|
|
3
|
+
export function resolveDefaultSelection(current, models) {
|
|
4
|
+
if (!models.length || current?.provider === ROUTER_PROVIDER_ID && models.some(model => model.id === current.model && !isPlaceholderModelId(model.id))) return current;
|
|
5
|
+
const first = models.find(model => !isPlaceholderModelId(model.id));
|
|
6
|
+
return first ? { provider: ROUTER_PROVIDER_ID, model: first.id } : current;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const pending = new WeakMap();
|
|
10
|
+
export async function ensureDefaultSelection(service, models) {
|
|
11
|
+
if (!service) return;
|
|
12
|
+
if (pending.has(service)) {
|
|
13
|
+
await pending.get(service);
|
|
14
|
+
return ensureDefaultSelection(service, models);
|
|
15
|
+
}
|
|
16
|
+
const current = service.currentSelection();
|
|
17
|
+
const next = resolveDefaultSelection(current, models);
|
|
18
|
+
if (next === current) return;
|
|
19
|
+
const task = service.saveSelection(next, { expected: current });
|
|
20
|
+
pending.set(service, task);
|
|
21
|
+
try { await task; } finally { if (pending.get(service) === task) pending.delete(service); }
|
|
22
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { catalogCache } from "./catalog-cache.js";
|
|
2
|
+
import { catalogCoordinator } from "./catalog-coordinator.js";
|
|
3
|
+
import { isChatModel, isPlaceholderModelId, ROUTER_PROVIDER_ID } from "./models.js";
|
|
4
|
+
|
|
5
|
+
export async function authorizedCatalog(cache = catalogCache) {
|
|
6
|
+
try {
|
|
7
|
+
return { rows: (await cache.get()).rows.filter(isChatModel), failures: [] };
|
|
8
|
+
} catch (error) {
|
|
9
|
+
return { rows: cache.snapshot().rows.filter(isChatModel), failures: [{ id: ROUTER_PROVIDER_ID, name: "Olares Router", message: error instanceof Error ? error.message : String(error) }] };
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function authorizedChatModel(selection, cache) {
|
|
14
|
+
if (isPlaceholderModelId(selection.model)) return false;
|
|
15
|
+
if (selection.provider !== ROUTER_PROVIDER_ID) return false;
|
|
16
|
+
const { rows } = cache ? await cache.get() : catalogCoordinator.committed ?? { rows: [] };
|
|
17
|
+
return rows.some((row) => row.id === selection.model && isChatModel(row));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function routerReasoningSnapshot() { return catalogCoordinator.committed?.rows ?? []; }
|
|
21
|
+
|
|
22
|
+
export function routerReasoningInfo(provider, id, rows = routerReasoningSnapshot()) {
|
|
23
|
+
if (provider !== ROUTER_PROVIDER_ID) return {};
|
|
24
|
+
const row = rows.find((row) => row.id === id);
|
|
25
|
+
if (!row?.reasoningEfforts) return { reasoning: undefined };
|
|
26
|
+
const efforts = Object.keys(row.reasoningEfforts).map((id) => ({ id, name: id }));
|
|
27
|
+
return { reasoning: { efforts, ...(row.reasoningDefault ? { defaultEffort: row.reasoningDefault } : {}) } };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function routerReasoningPayload(payload, id, effort, rows = routerReasoningSnapshot()) {
|
|
31
|
+
const next = { ...payload };
|
|
32
|
+
if (effort === undefined) {
|
|
33
|
+
delete next.reasoning_effort;
|
|
34
|
+
return next;
|
|
35
|
+
}
|
|
36
|
+
const wire = rows.find((row) => row.id === id)?.reasoningEfforts?.[effort];
|
|
37
|
+
if (wire !== effort) throw new Error(`Unsupported reasoning effort: ${effort}`);
|
|
38
|
+
next.reasoning_effort = wire;
|
|
39
|
+
return next;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function normalizeRouterSelection(selection, rows = routerReasoningSnapshot()) {
|
|
43
|
+
if (selection?.provider !== ROUTER_PROVIDER_ID || selection.reasoningEffort !== "off") return selection;
|
|
44
|
+
const efforts = rows.find((row) => row.id === selection.model)?.reasoningEfforts;
|
|
45
|
+
if (!efforts || Object.hasOwn(efforts, "off") || !Object.hasOwn(efforts, "none")) return selection;
|
|
46
|
+
return { ...selection, reasoningEffort: "none" };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function restoreRouterSelection(selection, save, rows = routerReasoningSnapshot()) {
|
|
50
|
+
const next = normalizeRouterSelection(selection, rows);
|
|
51
|
+
if (next !== selection) await save(next);
|
|
52
|
+
return next;
|
|
53
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export function modelState({ status, groups = [], models, current, failures = [], error } = {}) {
|
|
2
|
+
const entries = models ?? groups.flatMap((group) => group.models.map((model) => ({ ...model, provider: group.id })));
|
|
3
|
+
const selected = current?.model?.trim();
|
|
4
|
+
const placeholder = !selected || selected === "default";
|
|
5
|
+
const found = !placeholder && entries.find((row) => row.provider === current.provider && row.id === selected);
|
|
6
|
+
const effort = current?.reasoningEffort;
|
|
7
|
+
const effortUnavailable = found && effort !== undefined
|
|
8
|
+
&& !(found.reasoning?.efforts?.some((row) => row.id === effort)
|
|
9
|
+
|| Object.hasOwn(found.reasoningEfforts ?? {}, effort));
|
|
10
|
+
const state = error || failures.length ? "error"
|
|
11
|
+
: status === "loading" || status === "idle" ? "loading"
|
|
12
|
+
: selected && !found ? "unavailable"
|
|
13
|
+
: !entries.length ? "empty"
|
|
14
|
+
: placeholder ? "unselected" : effortUnavailable ? "effort-unavailable" : "ready";
|
|
15
|
+
return { state, valid: Boolean(found) && state !== "loading" && state !== "error" && !effortUnavailable };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function modelStateLabel(snapshot, locale = "en") {
|
|
19
|
+
const { state } = modelState(snapshot);
|
|
20
|
+
const zh = locale.startsWith("zh");
|
|
21
|
+
const id = snapshot.current?.model;
|
|
22
|
+
const name = id?.split("/").at(-1);
|
|
23
|
+
if (state === "loading") return zh ? "正在加载模型" : "Loading models";
|
|
24
|
+
if (state === "error") return zh ? `${name || "模型目录"} · 加载失败,请刷新` : `${name || "Models"} · refresh failed`;
|
|
25
|
+
if (state === "unavailable") return zh ? `${name} · 不可用` : `${name} · unavailable`;
|
|
26
|
+
if (state === "effort-unavailable") return zh ? `${name} · 思考档位 ${snapshot.current.reasoningEffort} 不可用` : `${name} · effort ${snapshot.current.reasoningEffort} unavailable`;
|
|
27
|
+
if (state === "empty") return zh ? "暂无可用聊天模型" : "No chat models available";
|
|
28
|
+
if (state === "unselected") return zh ? "请选择模型" : "Select a model";
|
|
29
|
+
return name;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function requireComposerModel(snapshot, locale) {
|
|
33
|
+
if (modelState(snapshot).valid) return;
|
|
34
|
+
throw new Error(modelStateLabel(snapshot, locale));
|
|
35
|
+
}
|
package/router/models.js
CHANGED
|
@@ -35,13 +35,9 @@ export function modelsFromRouterCatalog(payload) {
|
|
|
35
35
|
return routerCatalogRows(payload);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
function isMtpModelId(id) {
|
|
39
|
-
return /\bmtp\b/i.test(id);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
38
|
export function pickChatModelId(catalog) {
|
|
43
39
|
const chat = catalog.filter(isChatModel);
|
|
44
|
-
return chat
|
|
40
|
+
return chat[0]?.id ?? null;
|
|
45
41
|
}
|
|
46
42
|
|
|
47
43
|
export function isPlaceholderModelId(id) {
|
|
@@ -64,7 +60,7 @@ export function chatModelsFromRouterCatalog(payload) {
|
|
|
64
60
|
}
|
|
65
61
|
|
|
66
62
|
export function pickDefaultModel(models) {
|
|
67
|
-
return models
|
|
63
|
+
return models[0] ?? null;
|
|
68
64
|
}
|
|
69
65
|
|
|
70
66
|
export function catalogFailure(code, status, message) {
|
package/router/session-model.js
CHANGED
|
@@ -35,30 +35,20 @@ export function isSameSessionModel(current, group, model) {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
export function currentEffortId(current, reasoning) {
|
|
38
|
-
return current?.reasoningEffort
|
|
38
|
+
return current?.reasoningEffort;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
const EFFORT_ORDER = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
42
|
-
|
|
43
41
|
/** Router catalog dict `{ low: "low" }` → the effort menu shape the switcher already uses. */
|
|
44
42
|
export function reasoningFromEfforts(raw) {
|
|
45
43
|
if (!raw || typeof raw !== "object") return undefined;
|
|
46
44
|
if (Array.isArray(raw.efforts)) {
|
|
47
45
|
return raw.efforts.length ? raw : undefined;
|
|
48
46
|
}
|
|
49
|
-
const efforts = []
|
|
50
|
-
|
|
51
|
-
if (raw[id] != null) efforts.push({ id, name: id });
|
|
52
|
-
}
|
|
53
|
-
if (!efforts.length) {
|
|
54
|
-
for (const id of Object.keys(raw)) {
|
|
55
|
-
if (typeof id === "string" && id) efforts.push({ id, name: id });
|
|
56
|
-
}
|
|
57
|
-
}
|
|
47
|
+
const efforts = Object.keys(raw).filter((id) => id && typeof raw[id] === "string")
|
|
48
|
+
.map((id) => ({ id, name: id }));
|
|
58
49
|
if (!efforts.length) return undefined;
|
|
59
50
|
return {
|
|
60
51
|
efforts,
|
|
61
|
-
defaultEffort: efforts.find((row) => row.id !== "off")?.id ?? efforts[0].id,
|
|
62
52
|
};
|
|
63
53
|
}
|
|
64
54
|
|
|
@@ -70,7 +60,6 @@ export function modelSwitchSelection(group, model) {
|
|
|
70
60
|
return {
|
|
71
61
|
provider: group.id,
|
|
72
62
|
model: model.id,
|
|
73
|
-
...(model.reasoning?.defaultEffort === undefined ? {} : { reasoningEffort: model.reasoning.defaultEffort }),
|
|
74
63
|
};
|
|
75
64
|
}
|
|
76
65
|
|
|
@@ -103,9 +92,7 @@ export function effortMenuRows(reasoning, defaultLabel) {
|
|
|
103
92
|
export function effortMenuItems(reasoning) {
|
|
104
93
|
if (reasoning === undefined) return [];
|
|
105
94
|
const items = [];
|
|
106
|
-
|
|
107
|
-
items.push({ id: undefined, providerDefault: true });
|
|
108
|
-
}
|
|
95
|
+
items.push({ id: undefined, providerDefault: true });
|
|
109
96
|
for (const effort of reasoning.efforts) {
|
|
110
97
|
items.push({
|
|
111
98
|
id: effort.id,
|
|
@@ -142,7 +129,7 @@ export function composerModelsFromCatalog(catalog, selection) {
|
|
|
142
129
|
const provider = typeof group?.id === "string" ? group.id : "";
|
|
143
130
|
if (!provider) continue;
|
|
144
131
|
for (const model of group.models ?? []) {
|
|
145
|
-
if (!model?.id) continue;
|
|
132
|
+
if (!model?.id || model.id === "default") continue;
|
|
146
133
|
models.push({
|
|
147
134
|
provider,
|
|
148
135
|
providerName: group.name || provider,
|
package/router/stt.js
CHANGED
|
@@ -92,11 +92,12 @@ const RESOLVED_TTL_MS = 120_000;
|
|
|
92
92
|
export async function listModels(options) {
|
|
93
93
|
try {
|
|
94
94
|
const { rows } = await catalogCache.get({ refresh: options?.refresh });
|
|
95
|
-
return rows.map(({ id, mode, supportsStt, supportsTts }) => ({
|
|
95
|
+
return rows.map(({ id, mode, supportsStt, supportsTts, supportsSttStream }) => ({
|
|
96
96
|
id,
|
|
97
97
|
mode,
|
|
98
98
|
supportsStt: Boolean(supportsStt),
|
|
99
99
|
supportsTts: Boolean(supportsTts),
|
|
100
|
+
supportsSttStream: Boolean(supportsSttStream),
|
|
100
101
|
}));
|
|
101
102
|
} catch (err) {
|
|
102
103
|
const status = err && typeof err === "object" && "status" in err ? Number(err.status) : 503;
|
package/router/tts.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fetchTtsVoices } from "../voice/tts-voice.js";
|
|
1
2
|
import { VoiceError } from "./stt.js";
|
|
2
3
|
import { routerShimBaseUrl } from "./gateway.js";
|
|
3
4
|
|
|
@@ -5,26 +6,10 @@ const REQUEST_TIMEOUT_MS = 180_000;
|
|
|
5
6
|
|
|
6
7
|
const PCM_RATE = 24000;
|
|
7
8
|
|
|
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. */
|
|
9
|
+
/** @returns {Promise<string[]>} Exact preset IDs; failures remain distinguishable from no presets. */
|
|
20
10
|
export async function listTtsVoices(model) {
|
|
21
|
-
const
|
|
22
|
-
|
|
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));
|
|
11
|
+
const voices = await fetchTtsVoices(model, { fetcher: (path, options) => fetch(`${routerShimBaseUrl()}${path.replace("/llm/v1", "")}`, options) });
|
|
12
|
+
return voices.map(row => row.id);
|
|
28
13
|
}
|
|
29
14
|
|
|
30
15
|
/** Kokoro concatenates chunks with no gap. This is the silence we insert between sentences. */
|
|
@@ -132,11 +117,19 @@ export async function synthesizeStream(request, onChunk) {
|
|
|
132
117
|
if (!type.includes("audio/l16")) {
|
|
133
118
|
return { streamed: false, audio: Buffer.from(await res.arrayBuffer()) };
|
|
134
119
|
}
|
|
120
|
+
const sampleRate = Number(res.headers.get("x-audio-sample-rate"));
|
|
121
|
+
if (!Number.isInteger(sampleRate) || sampleRate < 8000 || sampleRate > 192000) throw new VoiceError("voice_audio_format", 502, "missing or invalid PCM sample rate");
|
|
122
|
+
await request.onFormat?.({ sampleRate, channels: 1, encoding: "pcm_s16le" });
|
|
135
123
|
const reader = res.body.getReader();
|
|
124
|
+
try {
|
|
136
125
|
while (true) {
|
|
137
126
|
const { done, value } = await reader.read();
|
|
138
127
|
if (done) break;
|
|
139
128
|
if (value?.byteLength) await onChunk(Buffer.from(value));
|
|
140
129
|
}
|
|
141
|
-
|
|
130
|
+
} finally {
|
|
131
|
+
await reader.cancel().catch(() => {});
|
|
132
|
+
reader.releaseLock();
|
|
133
|
+
}
|
|
134
|
+
return { streamed: true, sampleRate };
|
|
142
135
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const active = new Set(), listeners = new Set();
|
|
2
|
+
export const voiceActivity = {
|
|
3
|
+
getSnapshot: () => active.size > 0,
|
|
4
|
+
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
|
|
5
|
+
};
|
|
6
|
+
export function setVoiceActivity(source, busy) {
|
|
7
|
+
const previous = active.size > 0;
|
|
8
|
+
busy ? active.add(source) : active.delete(source);
|
|
9
|
+
if (previous !== (active.size > 0)) for (const listener of listeners) listener();
|
|
10
|
+
}
|
package/voice/agent-session.js
CHANGED
|
@@ -65,6 +65,10 @@ export function createVoiceAgentSession(api, { sessionId = null, preset, workspa
|
|
|
65
65
|
task.firstTextAt = Date.now();
|
|
66
66
|
metric({ stage: "agent_first_text", requestId: task.requestId, durationMs: task.firstTextAt - task.at });
|
|
67
67
|
}
|
|
68
|
+
if (!replay && task.utterance?.done && !task.utterance.silent && !task.speechReadyAt) {
|
|
69
|
+
task.speechReadyAt = Date.now();
|
|
70
|
+
metric({ stage: "agent_speech_ready", requestId: task.requestId, durationMs: task.speechReadyAt - task.at });
|
|
71
|
+
}
|
|
68
72
|
if (!replay && task.requestId === latestRequest) { utterance = task.utterance; error = task.error; }
|
|
69
73
|
}
|
|
70
74
|
if (event.type === "turn/end" && openTurn === event.data?.turn) { openTurn = null; observedRunning = false; }
|
package/voice/browser-capture.js
CHANGED
|
@@ -32,17 +32,58 @@ async function openDeviceMic(mediaDevices) {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/** Request permission in the user gesture; transfer ownership to exactly one recorder. */
|
|
36
|
+
export function prepareBrowserCapture({ media, onTrace = () => {}, now = Date.now } = {}) {
|
|
37
|
+
let stream = null, cancelled = false, consumed = false;
|
|
38
|
+
const started = now();
|
|
39
|
+
const devices = media?.mediaDevices ?? globalThis.navigator?.mediaDevices;
|
|
40
|
+
const ready = (async () => {
|
|
41
|
+
if (!devices?.getUserMedia) throw new Error("voice_unsupported");
|
|
42
|
+
const pending = openDeviceMic(devices);
|
|
43
|
+
try { onTrace("permission-request", { durationMs: now() - started }); } catch { /* Telemetry is optional. */ }
|
|
44
|
+
const next = await pending;
|
|
45
|
+
try { onTrace("permission", { durationMs: now() - started }); } catch { /* Telemetry is optional. */ }
|
|
46
|
+
if (cancelled) { next.getTracks().forEach(track => track.stop()); return null; }
|
|
47
|
+
stream = next;
|
|
48
|
+
return next;
|
|
49
|
+
})();
|
|
50
|
+
// Permission may fail while other initialization is still pending.
|
|
51
|
+
void ready.catch(() => {});
|
|
52
|
+
return {
|
|
53
|
+
recording() {
|
|
54
|
+
try { onTrace("ready", { durationMs: now() - started }); } catch { /* Telemetry is optional. */ }
|
|
55
|
+
},
|
|
56
|
+
async consume() {
|
|
57
|
+
if (consumed || cancelled) throw new Error("voice_cancelled");
|
|
58
|
+
consumed = true;
|
|
59
|
+
const next = await ready;
|
|
60
|
+
if (cancelled || !next) throw new Error("voice_cancelled");
|
|
61
|
+
stream = null;
|
|
62
|
+
return next;
|
|
63
|
+
},
|
|
64
|
+
cancel() {
|
|
65
|
+
cancelled = true;
|
|
66
|
+
stream?.getTracks().forEach(track => track.stop());
|
|
67
|
+
stream = null;
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
35
72
|
export function createVoiceCapture({
|
|
36
73
|
transcribe,
|
|
74
|
+
streamInput,
|
|
37
75
|
submit,
|
|
38
76
|
onStream,
|
|
39
77
|
onPhase,
|
|
40
78
|
onTrace,
|
|
41
79
|
now = Date.now,
|
|
42
80
|
media,
|
|
81
|
+
preparation,
|
|
43
82
|
}) {
|
|
44
83
|
const mediaDevices = media?.mediaDevices ?? globalThis.navigator?.mediaDevices;
|
|
45
84
|
const Recorder = media?.MediaRecorder ?? globalThis.MediaRecorder;
|
|
85
|
+
let inputAbort = null;
|
|
86
|
+
let streaming = null;
|
|
46
87
|
let recorder = null;
|
|
47
88
|
let detector = null;
|
|
48
89
|
let stream = null;
|
|
@@ -86,10 +127,10 @@ export function createVoiceCapture({
|
|
|
86
127
|
recorder = null;
|
|
87
128
|
};
|
|
88
129
|
|
|
89
|
-
const fail = (code) => {
|
|
130
|
+
const fail = (code, extra = {}) => {
|
|
90
131
|
if (dead) return;
|
|
91
132
|
if (errorTimer) clearTimeout(errorTimer);
|
|
92
|
-
emit("error", { error: code, elapsed: 0 });
|
|
133
|
+
emit("error", { error: code, elapsed: 0, ...extra });
|
|
93
134
|
errorTimer = setTimeout(() => {
|
|
94
135
|
errorTimer = 0;
|
|
95
136
|
if (!dead && phase === "error") emit("idle", { error: "", elapsed: 0 });
|
|
@@ -106,6 +147,7 @@ export function createVoiceCapture({
|
|
|
106
147
|
chunks = [];
|
|
107
148
|
release();
|
|
108
149
|
if (cancel) {
|
|
150
|
+
streaming?.cancel(); streaming = null;
|
|
109
151
|
emit("idle", { error: "", elapsed: 0 });
|
|
110
152
|
return;
|
|
111
153
|
}
|
|
@@ -119,11 +161,12 @@ export function createVoiceCapture({
|
|
|
119
161
|
emit("transcribing", { elapsed: took, error: "" });
|
|
120
162
|
abort = new AbortController();
|
|
121
163
|
try {
|
|
122
|
-
const text = String(await transcribe(blob, abort.signal) ?? "").trim();
|
|
164
|
+
const text = String(await (streaming ? streaming.finish() : transcribe(blob, abort.signal)) ?? "").trim();
|
|
165
|
+
streaming = null;
|
|
123
166
|
if (dead || cancel) return;
|
|
124
167
|
if (!text) {
|
|
125
168
|
trace("no-input", {});
|
|
126
|
-
fail("voice_no_input");
|
|
169
|
+
fail("voice_no_input", { recognizedEmpty: true });
|
|
127
170
|
return;
|
|
128
171
|
}
|
|
129
172
|
trace("transcript", { chars: text.length });
|
|
@@ -171,9 +214,11 @@ export function createVoiceCapture({
|
|
|
171
214
|
async start() {
|
|
172
215
|
if (dead || phase === "preparing" || phase === "recording" || phase === "transcribing") return;
|
|
173
216
|
if (typeof Recorder === "undefined" || !mediaDevices?.getUserMedia) {
|
|
217
|
+
preparation?.cancel();
|
|
174
218
|
fail("voice_unsupported");
|
|
175
219
|
return;
|
|
176
220
|
}
|
|
221
|
+
inputAbort = new AbortController();
|
|
177
222
|
cancel = false;
|
|
178
223
|
released = false;
|
|
179
224
|
finished = false;
|
|
@@ -181,8 +226,9 @@ export function createVoiceCapture({
|
|
|
181
226
|
emit("preparing", { elapsed: 0, error: "" });
|
|
182
227
|
let next;
|
|
183
228
|
try {
|
|
184
|
-
next = await openDeviceMic(mediaDevices);
|
|
229
|
+
next = await (preparation ? preparation.consume() : openDeviceMic(mediaDevices));
|
|
185
230
|
} catch (err) {
|
|
231
|
+
preparation?.cancel();
|
|
186
232
|
const code = micFailure(err);
|
|
187
233
|
trace("mic-fail", { code, name: err?.name || "" });
|
|
188
234
|
if (!dead) fail(code);
|
|
@@ -208,7 +254,11 @@ export function createVoiceCapture({
|
|
|
208
254
|
if (recorder?.state === "inactive") scheduleFinish();
|
|
209
255
|
};
|
|
210
256
|
recorder.onstop = () => scheduleFinish();
|
|
211
|
-
try {
|
|
257
|
+
try {
|
|
258
|
+
const nextDetector = await onStream?.(stream);
|
|
259
|
+
if (dead || cancel) { nextDetector?.stop?.(); release(); return; }
|
|
260
|
+
detector = nextDetector;
|
|
261
|
+
} catch (error) { release(); if (!dead && !cancel) fail(error.message || "voice_unsupported"); return; }
|
|
212
262
|
started = now();
|
|
213
263
|
try {
|
|
214
264
|
recorder.start(CHUNK_MS);
|
|
@@ -224,7 +274,18 @@ export function createVoiceCapture({
|
|
|
224
274
|
return;
|
|
225
275
|
}
|
|
226
276
|
emit("recording", { elapsed: 0, error: "" });
|
|
277
|
+
preparation?.recording();
|
|
227
278
|
timer = setInterval(() => emit("recording", { elapsed: now() - started, error: "" }), 200);
|
|
279
|
+
// The recognizer socket is not the microphone. A take that is already
|
|
280
|
+
// recording keeps its blob when the socket is still closed.
|
|
281
|
+
const pending = streamInput?.(stream, { signal: inputAbort.signal, onPartial: (text) => emit(phase, { partial: text }) });
|
|
282
|
+
if (pending && typeof pending.then === "function") {
|
|
283
|
+
pending.then((port) => {
|
|
284
|
+
if (!port) return;
|
|
285
|
+
if (dead || cancel || finished || released || chunks.length) { port.cancel?.(); return; }
|
|
286
|
+
streaming = port;
|
|
287
|
+
}, () => {});
|
|
288
|
+
} else if (pending) streaming = pending;
|
|
228
289
|
},
|
|
229
290
|
/**
|
|
230
291
|
* Finger-up finishes the take. The mic opens asynchronously, so the finger
|
|
@@ -235,6 +296,7 @@ export function createVoiceCapture({
|
|
|
235
296
|
if (aborting) {
|
|
236
297
|
cancel = true;
|
|
237
298
|
abort?.abort();
|
|
299
|
+
streaming?.cancel(); streaming = null;
|
|
238
300
|
}
|
|
239
301
|
return;
|
|
240
302
|
}
|
|
@@ -248,6 +310,7 @@ export function createVoiceCapture({
|
|
|
248
310
|
recorder: recorder?.state || "none",
|
|
249
311
|
});
|
|
250
312
|
if (aborting) {
|
|
313
|
+
inputAbort?.abort();
|
|
251
314
|
cancel = true;
|
|
252
315
|
released = false;
|
|
253
316
|
if (recorder && recorder.state !== "inactive") recorder.stop();
|
|
@@ -261,12 +324,15 @@ export function createVoiceCapture({
|
|
|
261
324
|
}
|
|
262
325
|
},
|
|
263
326
|
dispose() {
|
|
327
|
+
preparation?.cancel();
|
|
328
|
+
inputAbort?.abort();
|
|
264
329
|
dead = true;
|
|
265
330
|
cancel = true;
|
|
266
331
|
if (errorTimer) clearTimeout(errorTimer);
|
|
267
332
|
if (finishTimer) clearTimeout(finishTimer);
|
|
268
333
|
stopTimer();
|
|
269
334
|
abort?.abort();
|
|
335
|
+
streaming?.cancel(); streaming = null;
|
|
270
336
|
if (recorder && recorder.state !== "inactive") recorder.stop();
|
|
271
337
|
release();
|
|
272
338
|
},
|
|
@@ -1,28 +1,24 @@
|
|
|
1
1
|
export function createBrowserPlayer() {
|
|
2
|
-
let element = null;
|
|
3
|
-
|
|
4
|
-
const stop = () => {
|
|
5
|
-
element?.pause();
|
|
6
|
-
finish?.();
|
|
7
|
-
};
|
|
2
|
+
let element = null, finish = null, paused = false;
|
|
3
|
+
const stop = () => { paused = false; element?.pause(); finish?.(); };
|
|
8
4
|
return {
|
|
5
|
+
canPause: true,
|
|
6
|
+
pause() { paused = true; element?.pause(); },
|
|
7
|
+
resume() { paused = false; return element?.play(); },
|
|
9
8
|
play(blob) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const audio = new Audio(url);
|
|
14
|
-
const done = () => {
|
|
9
|
+
const preservePause = paused;
|
|
10
|
+
stop(); paused = preservePause;
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const url = URL.createObjectURL(blob), audio = new Audio(url);
|
|
13
|
+
const done = (error) => {
|
|
15
14
|
if (finish !== done) return;
|
|
16
|
-
finish = null;
|
|
17
|
-
|
|
18
|
-
URL.revokeObjectURL(url);
|
|
19
|
-
resolve();
|
|
15
|
+
finish = null; element = null; URL.revokeObjectURL(url);
|
|
16
|
+
error ? reject(error) : resolve();
|
|
20
17
|
};
|
|
21
|
-
element = audio;
|
|
22
|
-
|
|
23
|
-
audio.addEventListener("
|
|
24
|
-
audio.
|
|
25
|
-
audio.play().catch(done);
|
|
18
|
+
element = audio; finish = done;
|
|
19
|
+
audio.addEventListener("ended", () => done(), { once: true });
|
|
20
|
+
audio.addEventListener("error", () => done(new Error("voice_playback_failed")), { once: true });
|
|
21
|
+
if (!paused) audio.play().catch(done);
|
|
26
22
|
});
|
|
27
23
|
},
|
|
28
24
|
stop,
|
package/voice/client.js
CHANGED
|
@@ -4,6 +4,9 @@ import { createSnapshotStore } from "../tools/async.js";
|
|
|
4
4
|
export const API = "/api/lares/voice";
|
|
5
5
|
|
|
6
6
|
const settings = createSnapshotStore();
|
|
7
|
+
const diagnostics = createSnapshotStore();
|
|
8
|
+
let configRevision = 0;
|
|
9
|
+
let confirmedConfig = null;
|
|
7
10
|
export const voiceClientId = mintRpcId();
|
|
8
11
|
export function voiceHeaders(device) {
|
|
9
12
|
const selected = device ?? settings.peek()?.config?.device;
|
|
@@ -16,26 +19,33 @@ export function rememberedVoiceSettings() {
|
|
|
16
19
|
|
|
17
20
|
export async function loadVoiceSettings(options = {}) {
|
|
18
21
|
const query = options.force ? "?refresh=1" : "";
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
]);
|
|
22
|
+
const value = await settings.load(async () => {
|
|
23
|
+
const revision = configRevision;
|
|
24
|
+
const [config, models] = await Promise.all([getJson("/config"), getJson(`/capabilities${query}`)]);
|
|
25
|
+
if (revision === configRevision) confirmedConfig = config;
|
|
26
|
+
const selected = models.selected || "";
|
|
25
27
|
return {
|
|
26
|
-
config,
|
|
27
|
-
status,
|
|
28
|
+
config: confirmedConfig || config,
|
|
29
|
+
status: { ok: Boolean(selected), modelAvailable: Boolean(selected), model: selected, language: config.language },
|
|
28
30
|
sttModels: Array.isArray(models.stt) ? models.stt : [],
|
|
29
31
|
ttsModels: Array.isArray(models.tts) ? models.tts : [],
|
|
32
|
+
ttsSelected: models.ttsSelected || "",
|
|
33
|
+
ttsDefault: models.ttsDefault || "",
|
|
30
34
|
chatModels: Array.isArray(models.chat) ? models.chat : [],
|
|
31
35
|
realtimeModels: Array.isArray(models.realtime) ? models.realtime : [],
|
|
32
36
|
realtimeReady: Array.isArray(models.realtimeReady) ? models.realtimeReady : [],
|
|
33
|
-
charts: {
|
|
34
|
-
stt: chartView(models.charts?.stt),
|
|
35
|
-
tts: chartView(models.charts?.tts),
|
|
36
|
-
},
|
|
37
37
|
};
|
|
38
38
|
}, options);
|
|
39
|
+
if (options.diagnostics === false) return value;
|
|
40
|
+
try {
|
|
41
|
+
const detail = await diagnostics.load(async () => {
|
|
42
|
+
const [status, models] = await Promise.all([getJson(`/status${query}`), getJson(`/models${query}`)]);
|
|
43
|
+
return { status, charts: models.charts ? { stt: chartView(models.charts.stt), tts: chartView(models.charts.tts) } : null, diagnosticsError: models.charts ? null : "voice_failed" };
|
|
44
|
+
}, options);
|
|
45
|
+
return { ...settings.peek(), ...detail };
|
|
46
|
+
} catch (error) {
|
|
47
|
+
return { ...settings.peek(), ...diagnostics.peek(), diagnosticsError: error.message };
|
|
48
|
+
}
|
|
39
49
|
}
|
|
40
50
|
|
|
41
51
|
function chartView(row) {
|
|
@@ -109,9 +119,13 @@ export async function saveVoiceSettings(patch) {
|
|
|
109
119
|
body: JSON.stringify(patch),
|
|
110
120
|
});
|
|
111
121
|
if (!res.ok) throw new Error(String(res.status));
|
|
112
|
-
const
|
|
122
|
+
const config = await res.json();
|
|
123
|
+
configRevision++;
|
|
124
|
+
confirmedConfig = config;
|
|
125
|
+
const previous = settings.peek();
|
|
126
|
+
const next = previous ? settings.remember({ ...previous, config }) : await loadVoiceSettings({ diagnostics: false });
|
|
113
127
|
globalThis.window?.dispatchEvent?.(new Event("lares-voice-settings"));
|
|
114
|
-
return next;
|
|
128
|
+
return { ...next, ...diagnostics.peek() };
|
|
115
129
|
}
|
|
116
130
|
|
|
117
131
|
/** Reading mode and the GPU slicing it needs; see core/voice/gpu-mode.js. */
|
|
@@ -56,10 +56,14 @@ export function createCompositeGateway({ service, config, transcribe, synthesize
|
|
|
56
56
|
send({ type: "response.output_audio_transcript.delta", response_id: id, delta: text });
|
|
57
57
|
if (!audioOutput) { done(id); return; }
|
|
58
58
|
scope.outputAbort = new AbortController();
|
|
59
|
-
const
|
|
59
|
+
const output = async (audio) => {
|
|
60
|
+
if (closed || epoch !== generation) return;
|
|
61
|
+
awaitingPlayback = true;
|
|
62
|
+
for (let offset = 0; offset < audio.length; offset += 24000) send({ type: "response.output_audio.delta", response_id: id, item_id: id, delta: audio.subarray(offset, offset + 24000).toString("base64") });
|
|
63
|
+
};
|
|
64
|
+
const audio = await synthesize(text, scope, output);
|
|
60
65
|
if (closed || epoch !== generation) return;
|
|
61
|
-
|
|
62
|
-
for (let offset = 0; offset < audio.length; offset += 24000) send({ type: "response.output_audio.delta", response_id: id, item_id: id, delta: audio.subarray(offset, offset + 24000).toString("base64") });
|
|
66
|
+
if (audio) await output(audio);
|
|
63
67
|
send({ type: "response.output_audio.done", response_id: id });
|
|
64
68
|
done(id);
|
|
65
69
|
};
|