@olares/lares-core 0.15.28 → 0.15.30
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 +14 -0
- package/package.json +4 -2
- package/voice/browser-capture.js +8 -1
- package/voice/controller.js +35 -4
- package/voice/device-input.js +17 -43
- package/voice/endpoint.js +58 -2
- package/voice/errors.js +40 -0
- package/voice/host-service.js +27 -11
- package/voice/lares-realtime-gateway.js +90 -18
- package/voice/panel.js +13 -1
- package/voice/pcm-player.js +55 -8
- package/voice/presentation.js +11 -0
- package/voice/protocol-client.js +12 -4
- package/voice/service.js +19 -3
- package/voice/speech.js +7 -1
- package/voice/stream-client.js +16 -1
- package/voice/stream-gateway.js +19 -4
- package/voice/synthesis-run.js +11 -1
- package/voice/unified-conversation.js +84 -18
- package/voice/vad.js +169 -0
|
@@ -3,10 +3,14 @@ import { createDeviceInput } from "./device-input.js";
|
|
|
3
3
|
import { createPcmStreamPlayer } from "./pcm-player.js";
|
|
4
4
|
import { createVoiceProtocolClient } from "./protocol-client.js";
|
|
5
5
|
import { createStreamingCapture } from "./stream-client.js";
|
|
6
|
+
import { isTransientVoiceError, transientVoiceError } from "./errors.js";
|
|
6
7
|
import { mintRpcId } from "../larepass/rpc.js";
|
|
7
8
|
|
|
8
9
|
const RATE = 24000;
|
|
9
10
|
const PROGRESS_MS = 250;
|
|
11
|
+
// A commit is final on the Host. If its transcript never comes back the
|
|
12
|
+
// composer must stop waiting instead of sitting on 转写中 for minutes.
|
|
13
|
+
const COMMIT_TIMEOUT_MS = 60_000;
|
|
10
14
|
|
|
11
15
|
export function encodeBase64(bytes) {
|
|
12
16
|
let text = "";
|
|
@@ -26,7 +30,7 @@ export function browserRealtimeSocket(ticket) {
|
|
|
26
30
|
* microphone streams PCM into the socket, the Host transcribes, submits and speaks, and the reply
|
|
27
31
|
* audio plays from the socket instead of a separate synthesis stream.
|
|
28
32
|
*/
|
|
29
|
-
export function createUnifiedConversation({ request, socket: openSocket = browserRealtimeSocket, onPhase, mode = () => "queue", device = "device", metric = () => {}, readAloud = true, preset, presentationSession = false, endpointSilenceMs, idleTimeoutMs, recoveryTimeoutMs, onText = () => {}, onTranscript = null, preparation, media, player: createPlayer = createPcmStreamPlayer, input: inputPort, transcribe = async () => { throw new Error("voice_unsupported"); } }) {
|
|
33
|
+
export function createUnifiedConversation({ request, socket: openSocket = browserRealtimeSocket, onPhase, mode = () => "queue", device = "device", metric = () => {}, readAloud = true, preset, presentationSession = false, endpointSilenceMs, idleTimeoutMs, recoveryTimeoutMs, commitTimeoutMs = COMMIT_TIMEOUT_MS, onText = () => {}, onTranscript = null, preparation, media, player: createPlayer = createPcmStreamPlayer, input: inputPort, transcribe = async () => { throw new Error("voice_unsupported"); } }) {
|
|
30
34
|
const voiceId = mintRpcId();
|
|
31
35
|
const headset = device === "headset";
|
|
32
36
|
const headers = { "x-lares-voice-id": voiceId, "x-lares-voice-device": headset ? "headset" : "device" };
|
|
@@ -47,11 +51,18 @@ export function createUnifiedConversation({ request, socket: openSocket = browse
|
|
|
47
51
|
} });
|
|
48
52
|
const playback = {
|
|
49
53
|
begin(event) {
|
|
50
|
-
reply = { id: event.response?.id, itemId: null, text: "", audio: false, playedMs: 0, chain: Promise.resolve() };
|
|
54
|
+
reply = { id: event.response?.id, itemId: null, text: "", audio: false, playedMs: 0, chain: Promise.resolve(), muted: false };
|
|
51
55
|
},
|
|
52
56
|
write(delta) {
|
|
53
57
|
const current = reply;
|
|
54
58
|
if (!current) return;
|
|
59
|
+
// Read-aloud was turned off locally but the Host still has audio on
|
|
60
|
+
// (a lost output update). Converge by cancelling that reply instead of
|
|
61
|
+
// playing something the user asked not to hear.
|
|
62
|
+
if (readAloud !== true) {
|
|
63
|
+
if (!current.muted) { current.muted = true; protocol?.stopOutput(); controller.observePlayback(false); }
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
55
66
|
if (!current.audio) { current.audio = true; current.chain = current.chain.then(() => player.start({ sampleRate: RATE, channels: 1, encoding: "pcm_s16le" })); controller.observePlayback(true); }
|
|
56
67
|
current.chain = current.chain.then(() => { if (reply === current) player.write(decodeBase64(delta)); });
|
|
57
68
|
current.chain.catch(() => {});
|
|
@@ -59,13 +70,27 @@ export function createUnifiedConversation({ request, socket: openSocket = browse
|
|
|
59
70
|
async done() {
|
|
60
71
|
const current = reply;
|
|
61
72
|
if (!current) return;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (
|
|
65
|
-
|
|
73
|
+
let failure = null;
|
|
74
|
+
try {
|
|
75
|
+
if (current.audio) {
|
|
76
|
+
try { await current.chain; await player.finish(); } catch (cause) { failure = cause; }
|
|
77
|
+
if (reply !== current) return;
|
|
78
|
+
if (failure) {
|
|
79
|
+
// Audio that never reached the speaker is a real, visible failure;
|
|
80
|
+
// release the Host gate too instead of leaving the call muted.
|
|
81
|
+
protocol?.stopOutput();
|
|
82
|
+
controller.notePlaybackError(failure.code || failure.message || "voice_playback_failed");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
protocol?.playbackCompleted(current.itemId);
|
|
86
|
+
}
|
|
87
|
+
} finally {
|
|
88
|
+
// Only this reply may clear playback: a newer reply already owns it.
|
|
89
|
+
if (reply === current) {
|
|
90
|
+
reply = null;
|
|
91
|
+
controller.observePlayback(false);
|
|
92
|
+
}
|
|
66
93
|
}
|
|
67
|
-
if (reply === current) reply = null;
|
|
68
|
-
controller.observePlayback(false);
|
|
69
94
|
},
|
|
70
95
|
async stop() {
|
|
71
96
|
const current = reply;
|
|
@@ -91,11 +116,12 @@ export function createUnifiedConversation({ request, socket: openSocket = browse
|
|
|
91
116
|
// The promise exists for the whole take. An error while audio is still
|
|
92
117
|
// streaming has to reject it; waiting until finish() to attach reject left
|
|
93
118
|
// the composer on 转写中.
|
|
94
|
-
let settle;
|
|
119
|
+
let settle, timer = null, committed = false;
|
|
95
120
|
const result = new Promise((resolve, reject) => {
|
|
96
121
|
settle = (error, text) => {
|
|
97
122
|
if (current.done) return;
|
|
98
123
|
current.done = true;
|
|
124
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
99
125
|
if (turn === current) turn = null;
|
|
100
126
|
if (error) reject(error); else resolve(text ?? "");
|
|
101
127
|
};
|
|
@@ -105,10 +131,25 @@ export function createUnifiedConversation({ request, socket: openSocket = browse
|
|
|
105
131
|
void result.catch(() => {});
|
|
106
132
|
return {
|
|
107
133
|
write(bytes) { if (!current.done && protocol) protocol.append(encodeBase64(bytes)); },
|
|
134
|
+
get committed() { return committed; },
|
|
135
|
+
// The Host submits a conversation turn on commit, so its recording must
|
|
136
|
+
// not be uploaded again. Dictation only returns text and stays replayable.
|
|
137
|
+
get replayable() { return Boolean(onTranscript); },
|
|
108
138
|
finish() {
|
|
109
139
|
if (current.done) return result;
|
|
110
140
|
if (!onTranscript) protocol.setBusyTurn(mode());
|
|
111
141
|
protocol.commit();
|
|
142
|
+
committed = true;
|
|
143
|
+
if (!timer) {
|
|
144
|
+
timer = setTimeout(() => {
|
|
145
|
+
timer = null;
|
|
146
|
+
if (current.done) return;
|
|
147
|
+
// The commit is final on the Host; this client just stops waiting so
|
|
148
|
+
// the composer returns to listening. A late transcript is ignored.
|
|
149
|
+
current.reject?.(transientVoiceError("voice_asr_timeout"));
|
|
150
|
+
}, commitTimeoutMs);
|
|
151
|
+
timer.unref?.();
|
|
152
|
+
}
|
|
112
153
|
return result;
|
|
113
154
|
},
|
|
114
155
|
cancel() { if (!current.done) { protocol?.discard(); settle(null, ""); } },
|
|
@@ -123,16 +164,30 @@ export function createUnifiedConversation({ request, socket: openSocket = browse
|
|
|
123
164
|
capture({ signal, onReady, onSpeech, onTranscribing }) {
|
|
124
165
|
const current = take();
|
|
125
166
|
current.onSpeech = onSpeech; current.onTranscribing = onTranscribing;
|
|
167
|
+
let timer = null;
|
|
168
|
+
const clear = () => { if (timer) { clearTimeout(timer); timer = null; } };
|
|
126
169
|
return new Promise((resolve, reject) => {
|
|
127
|
-
current.
|
|
128
|
-
|
|
129
|
-
|
|
170
|
+
current.armCommit = () => {
|
|
171
|
+
if (timer || turn !== current) return;
|
|
172
|
+
// The Host owns the ASR request. Stop waiting if its transcript never
|
|
173
|
+
// comes back; the take settles without closing the call.
|
|
174
|
+
timer = setTimeout(() => {
|
|
175
|
+
timer = null;
|
|
176
|
+
if (turn !== current) return;
|
|
177
|
+
turn = null;
|
|
178
|
+
current.reject?.(transientVoiceError("voice_asr_timeout"));
|
|
179
|
+
}, commitTimeoutMs);
|
|
180
|
+
timer.unref?.();
|
|
181
|
+
};
|
|
182
|
+
current.resolve = (text) => { clear(); protocol?.deviceInput(false); resolve(text); };
|
|
183
|
+
current.reject = (error) => { clear(); protocol?.deviceInput(false); reject(error); };
|
|
184
|
+
signal.addEventListener("abort", () => { if (turn === current) { turn = null; clear(); protocol?.discard(); protocol?.deviceInput(false); resolve(""); } }, { once: true });
|
|
130
185
|
if (!onTranscript) protocol.setBusyTurn(mode());
|
|
131
186
|
protocol.deviceInput(true);
|
|
132
187
|
onReady?.();
|
|
133
188
|
});
|
|
134
189
|
},
|
|
135
|
-
stop() { protocol?.commit(); },
|
|
190
|
+
stop() { const current = turn; current?.armCommit?.(); protocol?.commit(); },
|
|
136
191
|
cancel() { const current = turn; turn = null; protocol?.discard(); protocol?.deviceInput(false); current?.resolve?.(""); },
|
|
137
192
|
});
|
|
138
193
|
const input = inputPort ?? (headset ? hostInput() : deviceInput());
|
|
@@ -158,7 +213,14 @@ export function createUnifiedConversation({ request, socket: openSocket = browse
|
|
|
158
213
|
case "lares.device.playback": controller.observePlayback(event.playing === true); break;
|
|
159
214
|
case "error": {
|
|
160
215
|
const code = event.error?.code || "voice_failed";
|
|
161
|
-
|
|
216
|
+
// Any error with a take open belongs to that take. Settling it here is
|
|
217
|
+
// what keeps the composer off 转写中; transient contention drops only
|
|
218
|
+
// the take, not the call.
|
|
219
|
+
if (turn) {
|
|
220
|
+
const current = turn;
|
|
221
|
+
turn = null;
|
|
222
|
+
current.reject?.(isTransientVoiceError(code) ? transientVoiceError(code) : Object.assign(new Error(code), { code }));
|
|
223
|
+
}
|
|
162
224
|
if (code === "session_expired") void controller.disconnect("voice_idle_timeout");
|
|
163
225
|
break;
|
|
164
226
|
}
|
|
@@ -206,17 +268,21 @@ export function createUnifiedConversation({ request, socket: openSocket = browse
|
|
|
206
268
|
const poll = async () => {
|
|
207
269
|
if (disposed || onTranscript) return;
|
|
208
270
|
const state = await call("/session");
|
|
209
|
-
|
|
271
|
+
// A paused call still has a chat session. Stop watching only after it is closed,
|
|
272
|
+
// otherwise a dropped socket leaves the composer on "working" after the task ends.
|
|
273
|
+
if (disposed || controller.getSnapshot().connection === "closed") return;
|
|
210
274
|
lastSessionId = state.sessionId;
|
|
211
275
|
if (lastSessionId) controller.bindSession(lastSessionId);
|
|
212
276
|
if (state.error && state.error !== "voice_cancelled") throw new Error(state.error);
|
|
213
277
|
controller.observeAgent(Boolean(state.running) || Boolean(reply), Boolean(reply?.audio));
|
|
214
278
|
};
|
|
215
279
|
const watch = async () => {
|
|
216
|
-
if (disposed || controller.getSnapshot().connection
|
|
280
|
+
if (disposed || controller.getSnapshot().connection === "closed") return;
|
|
217
281
|
try { await poll(); }
|
|
218
|
-
catch (error) {
|
|
219
|
-
|
|
282
|
+
catch (error) {
|
|
283
|
+
if (controller.getSnapshot().connection === "open") await controller.disconnect(error.code || error.message || "voice_disconnected");
|
|
284
|
+
}
|
|
285
|
+
if (!disposed && controller.getSnapshot().connection !== "closed") timer = setTimeout(watch, 500);
|
|
220
286
|
};
|
|
221
287
|
const stop = async () => {
|
|
222
288
|
disposed = true; clearTimeout(timer);
|
package/voice/vad.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { ENDPOINT, createEnergyEndpoint, createSpeechEndpoint, frameRms } from "./endpoint.js";
|
|
2
|
+
|
|
3
|
+
/** Silero v5 frames are 512 samples at 16 kHz. */
|
|
4
|
+
const FRAME_MS = 32;
|
|
5
|
+
const ASSET_ROOT = "/api/lares/voice/vad/";
|
|
6
|
+
|
|
7
|
+
let runtime = null;
|
|
8
|
+
|
|
9
|
+
function loadScript(src) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const existing = document.querySelector(`script[data-lares-vad="${src}"]`);
|
|
12
|
+
if (existing?.dataset.loaded === "true") { resolve(); return; }
|
|
13
|
+
const script = existing ?? document.createElement("script");
|
|
14
|
+
script.dataset.laresVad = src;
|
|
15
|
+
script.addEventListener("load", () => { script.dataset.loaded = "true"; resolve(); }, { once: true });
|
|
16
|
+
// Remove a failed tag so the next attempt creates a fresh one instead of
|
|
17
|
+
// attaching listeners to a script that will never fire again.
|
|
18
|
+
script.addEventListener("error", () => { script.remove(); reject(new Error(`failed to load ${src}`)); }, { once: true });
|
|
19
|
+
if (!existing) { script.src = src; document.head.append(script); }
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function loadRuntime() {
|
|
24
|
+
if (typeof document === "undefined") throw new Error("voice_unsupported");
|
|
25
|
+
await loadScript(`${ASSET_ROOT}ort.js`);
|
|
26
|
+
await loadScript(`${ASSET_ROOT}vad.js`);
|
|
27
|
+
if (!globalThis.vad?.MicVAD) throw new Error("voice_vad_unavailable");
|
|
28
|
+
return globalThis.vad;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One Silero session for the page. `MicVAD.new` builds the ONNX session, and
|
|
33
|
+
* `destroy` releases it, so a recording only pauses that session and the next
|
|
34
|
+
* one starts it again on a new stream.
|
|
35
|
+
*/
|
|
36
|
+
export function createVadSession(factory) {
|
|
37
|
+
let loading;
|
|
38
|
+
let detector;
|
|
39
|
+
let stream = null;
|
|
40
|
+
let ended = () => {};
|
|
41
|
+
let endpoint = createSpeechEndpoint();
|
|
42
|
+
let active = false;
|
|
43
|
+
let spoke = false;
|
|
44
|
+
let options = {};
|
|
45
|
+
let tail = Promise.resolve();
|
|
46
|
+
|
|
47
|
+
function load() {
|
|
48
|
+
loading ??= Promise.resolve()
|
|
49
|
+
.then(() => factory({
|
|
50
|
+
getStream: async () => stream,
|
|
51
|
+
resumeStream: async () => stream,
|
|
52
|
+
onFrame: (prob, frame) => {
|
|
53
|
+
if (!active) return;
|
|
54
|
+
const rms = frameRms(frame);
|
|
55
|
+
options.onLevel?.(rms);
|
|
56
|
+
if (!spoke && prob >= ENDPOINT.speechProb && rms >= ENDPOINT.floor) { spoke = true; options.onSpeech?.(); }
|
|
57
|
+
if (endpoint.push(prob, rms, FRAME_MS)) ended();
|
|
58
|
+
},
|
|
59
|
+
}))
|
|
60
|
+
.then((created) => {
|
|
61
|
+
detector = created;
|
|
62
|
+
return created;
|
|
63
|
+
})
|
|
64
|
+
.catch((err) => {
|
|
65
|
+
loading = undefined;
|
|
66
|
+
throw err;
|
|
67
|
+
});
|
|
68
|
+
return loading;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
preload() {
|
|
73
|
+
return load();
|
|
74
|
+
},
|
|
75
|
+
open(nextStream, onSpeechEnd, nextOptions = {}) {
|
|
76
|
+
const run = tail.then(async () => {
|
|
77
|
+
const created = await load();
|
|
78
|
+
stream = nextStream;
|
|
79
|
+
ended = onSpeechEnd;
|
|
80
|
+
spoke = false;
|
|
81
|
+
// Options belong to this recording, not to whichever open() came last.
|
|
82
|
+
options = nextOptions;
|
|
83
|
+
endpoint = createSpeechEndpoint({ silenceMs: options.silenceMs });
|
|
84
|
+
await created.start();
|
|
85
|
+
active = true;
|
|
86
|
+
});
|
|
87
|
+
tail = run.then(() => {}, () => {});
|
|
88
|
+
return run.then(() => ({
|
|
89
|
+
stop() {
|
|
90
|
+
if (!active) return;
|
|
91
|
+
active = false;
|
|
92
|
+
stream = null;
|
|
93
|
+
tail = tail.then(() => detector.pause()).then(() => {}, () => {});
|
|
94
|
+
},
|
|
95
|
+
}));
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let session = null;
|
|
101
|
+
let ready = false;
|
|
102
|
+
let preloading = null;
|
|
103
|
+
let retryAt = 0;
|
|
104
|
+
|
|
105
|
+
function sileroSession() {
|
|
106
|
+
session ??= createVadSession(async (hooks) => {
|
|
107
|
+
const vad = await loadRuntime();
|
|
108
|
+
return vad.MicVAD.new({
|
|
109
|
+
model: "v5",
|
|
110
|
+
startOnLoad: false,
|
|
111
|
+
baseAssetPath: ASSET_ROOT,
|
|
112
|
+
onnxWASMBasePath: ASSET_ROOT,
|
|
113
|
+
getStream: hooks.getStream,
|
|
114
|
+
pauseStream: async () => {},
|
|
115
|
+
resumeStream: hooks.resumeStream,
|
|
116
|
+
// Olares Desktop's CEF rejects dynamically loaded AudioWorklet modules.
|
|
117
|
+
// ScriptProcessor feeds the same 16 kHz frames into the same Silero model.
|
|
118
|
+
processorType: "ScriptProcessor",
|
|
119
|
+
onFrameProcessed: (probs, frame) => hooks.onFrame(probs.isSpeech, frame),
|
|
120
|
+
onSpeechEnd: () => {},
|
|
121
|
+
onVADMisfire: () => {},
|
|
122
|
+
onSpeechStart: () => {},
|
|
123
|
+
onSpeechRealStart: () => {},
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
return session;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Warm the model once. Returns whether Silero is usable here; a webview without
|
|
131
|
+
* the runtime keeps the energy endpoint instead of failing the take.
|
|
132
|
+
*/
|
|
133
|
+
export function preloadVoiceVad() {
|
|
134
|
+
if (typeof document === "undefined") return Promise.resolve(false);
|
|
135
|
+
if (ready) return Promise.resolve(true);
|
|
136
|
+
// An unavailable runtime is retried eventually, not on every single take.
|
|
137
|
+
if (Date.now() < retryAt) return Promise.resolve(false);
|
|
138
|
+
preloading ??= sileroSession().preload().then(() => { ready = true; return true; }, () => {
|
|
139
|
+
retryAt = Date.now() + 60_000;
|
|
140
|
+
preloading = null;
|
|
141
|
+
return false;
|
|
142
|
+
});
|
|
143
|
+
return preloading;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function voiceVadReady() { return ready; }
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Endpoint backed by Silero when the model is already loaded. `null` tells the
|
|
150
|
+
* caller to use the energy endpoint for this take, so the first recording is
|
|
151
|
+
* never delayed by model download.
|
|
152
|
+
*/
|
|
153
|
+
export function createVoiceVadEndpoint(stream, onSpeechEnd, onSpeech = () => {}, onLevel = () => {}, options = {}) {
|
|
154
|
+
if (!ready) { void preloadVoiceVad(); return null; }
|
|
155
|
+
let handle = null, fallback = null, stopped = false;
|
|
156
|
+
sileroSession().open(stream, onSpeechEnd, { ...options, onSpeech, onLevel }).then((value) => {
|
|
157
|
+
if (stopped) value?.stop(); else handle = value;
|
|
158
|
+
}, () => {
|
|
159
|
+
// The model failed after preload; keep this take usable with energy endpointing.
|
|
160
|
+
if (!stopped) fallback = createEnergyEndpoint(stream, onSpeechEnd, onSpeech, onLevel, options);
|
|
161
|
+
}).catch(() => {});
|
|
162
|
+
return {
|
|
163
|
+
stop() {
|
|
164
|
+
stopped = true;
|
|
165
|
+
handle?.stop();
|
|
166
|
+
fallback?.stop();
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|