@olares/lares-core 0.15.27 → 0.15.29
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 +29 -2
- 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 +99 -19
- package/voice/panel.js +13 -1
- package/voice/pcm-player.js +37 -8
- package/voice/presentation.js +14 -0
- package/voice/protocol-client.js +12 -4
- package/voice/router-audio.js +15 -1
- package/voice/service.js +19 -3
- package/voice/speech.js +7 -1
- package/voice/stream-client.js +25 -3
- package/voice/stream-gateway.js +10 -2
- package/voice/synthesis-run.js +11 -1
- package/voice/unified-conversation.js +122 -29
- 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,12 +30,12 @@ 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 }) {
|
|
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" };
|
|
33
37
|
let protocol = null, socketOpen = null, disposed = false, timer = null, lastSessionId = null, continuous = false;
|
|
34
|
-
let turn = null, reply = null, outputSerial = Promise.resolve(), confirmedReadAloud = readAloud;
|
|
38
|
+
let turn = null, reply = null, outputSerial = Promise.resolve(), confirmedReadAloud = readAloud, hostHeard = false;
|
|
35
39
|
const call = async (path, body, signal) => {
|
|
36
40
|
const res = await request(`/api/lares/voice${path}`, { method: body === undefined ? "GET" : "POST", headers, ...(body === undefined ? {} : { body }), signal });
|
|
37
41
|
if (!res?.ok) throw new Error(res?.body?.error?.code || "voice_failed");
|
|
@@ -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;
|
|
@@ -86,21 +111,51 @@ export function createUnifiedConversation({ request, socket: openSocket = browse
|
|
|
86
111
|
const transcription = ({ onPartial }) => {
|
|
87
112
|
if (!protocol) throw new Error("voice_disconnected");
|
|
88
113
|
const current = take();
|
|
114
|
+
hostHeard = false;
|
|
89
115
|
current.onPartial = onPartial;
|
|
116
|
+
// The promise exists for the whole take. An error while audio is still
|
|
117
|
+
// streaming has to reject it; waiting until finish() to attach reject left
|
|
118
|
+
// the composer on 转写中.
|
|
119
|
+
let settle, timer = null, committed = false;
|
|
120
|
+
const result = new Promise((resolve, reject) => {
|
|
121
|
+
settle = (error, text) => {
|
|
122
|
+
if (current.done) return;
|
|
123
|
+
current.done = true;
|
|
124
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
125
|
+
if (turn === current) turn = null;
|
|
126
|
+
if (error) reject(error); else resolve(text ?? "");
|
|
127
|
+
};
|
|
128
|
+
current.resolve = (text) => settle(null, text);
|
|
129
|
+
current.reject = (error) => settle(error);
|
|
130
|
+
});
|
|
131
|
+
void result.catch(() => {});
|
|
90
132
|
return {
|
|
91
|
-
write(bytes) { if (
|
|
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); },
|
|
92
138
|
finish() {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
139
|
+
if (current.done) return result;
|
|
140
|
+
if (!onTranscript) protocol.setBusyTurn(mode());
|
|
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
|
+
}
|
|
153
|
+
return result;
|
|
98
154
|
},
|
|
99
|
-
cancel() { if (
|
|
155
|
+
cancel() { if (!current.done) { protocol?.discard(); settle(null, ""); } },
|
|
100
156
|
};
|
|
101
157
|
};
|
|
102
|
-
const deviceInput = () => createDeviceInput({ media, preparation, onPhase, endpointSilenceMs,
|
|
103
|
-
transcribe: async () => { throw new Error("voice_unsupported"); },
|
|
158
|
+
const deviceInput = () => createDeviceInput({ media, preparation, onPhase, endpointSilenceMs, transcribe,
|
|
104
159
|
streamInput: (stream, options) => createStreamingCapture(stream, { ...options, sampleRate: RATE, port: async () => transcription(options) }),
|
|
105
160
|
});
|
|
106
161
|
// Headset: the Host owns the microphone; the socket only opens and closes it.
|
|
@@ -109,16 +164,30 @@ export function createUnifiedConversation({ request, socket: openSocket = browse
|
|
|
109
164
|
capture({ signal, onReady, onSpeech, onTranscribing }) {
|
|
110
165
|
const current = take();
|
|
111
166
|
current.onSpeech = onSpeech; current.onTranscribing = onTranscribing;
|
|
167
|
+
let timer = null;
|
|
168
|
+
const clear = () => { if (timer) { clearTimeout(timer); timer = null; } };
|
|
112
169
|
return new Promise((resolve, reject) => {
|
|
113
|
-
current.
|
|
114
|
-
|
|
115
|
-
|
|
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 });
|
|
116
185
|
if (!onTranscript) protocol.setBusyTurn(mode());
|
|
117
186
|
protocol.deviceInput(true);
|
|
118
187
|
onReady?.();
|
|
119
188
|
});
|
|
120
189
|
},
|
|
121
|
-
stop() { protocol?.commit(); },
|
|
190
|
+
stop() { const current = turn; current?.armCommit?.(); protocol?.commit(); },
|
|
122
191
|
cancel() { const current = turn; turn = null; protocol?.discard(); protocol?.deviceInput(false); current?.resolve?.(""); },
|
|
123
192
|
});
|
|
124
193
|
const input = inputPort ?? (headset ? hostInput() : deviceInput());
|
|
@@ -128,7 +197,13 @@ export function createUnifiedConversation({ request, socket: openSocket = browse
|
|
|
128
197
|
case "input_audio_buffer.speech_started": turn?.onSpeech?.(); break;
|
|
129
198
|
case "input_audio_buffer.committed": turn?.onTranscribing?.(); break;
|
|
130
199
|
case "conversation.item.input_audio_transcription.delta": if (turn?.onPartial) { turn.partial = `${turn.partial || ""}${event.delta}`; turn.onPartial(turn.partial); } break;
|
|
131
|
-
case "conversation.item.input_audio_transcription.completed": {
|
|
200
|
+
case "conversation.item.input_audio_transcription.completed": {
|
|
201
|
+
// The Host already submitted a non-empty transcript. An empty one did not,
|
|
202
|
+
// so a later blob fallback has to send the words itself.
|
|
203
|
+
hostHeard = Boolean(String(event.transcript || "").trim());
|
|
204
|
+
const current = turn; turn = null; current?.resolve?.(event.transcript || "");
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
132
207
|
case "response.created": playback.begin(event); break;
|
|
133
208
|
case "response.output_item.added": if (reply) reply.itemId = event.item?.id || null; break;
|
|
134
209
|
case "response.output_audio_transcript.delta":
|
|
@@ -138,7 +213,14 @@ export function createUnifiedConversation({ request, socket: openSocket = browse
|
|
|
138
213
|
case "lares.device.playback": controller.observePlayback(event.playing === true); break;
|
|
139
214
|
case "error": {
|
|
140
215
|
const code = event.error?.code || "voice_failed";
|
|
141
|
-
|
|
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
|
+
}
|
|
142
224
|
if (code === "session_expired") void controller.disconnect("voice_idle_timeout");
|
|
143
225
|
break;
|
|
144
226
|
}
|
|
@@ -171,7 +253,14 @@ export function createUnifiedConversation({ request, socket: openSocket = browse
|
|
|
171
253
|
lazy: true,
|
|
172
254
|
create: async () => null,
|
|
173
255
|
// The Host submitted this turn when its audio was committed; only the lifecycle remains to observe.
|
|
174
|
-
async submit(
|
|
256
|
+
async submit({ text } = {}) {
|
|
257
|
+
// Audio that never reached the socket still has a recording. The Host did not
|
|
258
|
+
// see that take, so hand the transcript over as a typed turn instead of dropping it.
|
|
259
|
+
const typed = Boolean(text) && !hostHeard;
|
|
260
|
+
hostHeard = false;
|
|
261
|
+
if (typed) protocol?.say(text);
|
|
262
|
+
await poll();
|
|
263
|
+
},
|
|
175
264
|
cancel: () => call("/session/cancel", {}),
|
|
176
265
|
running: async () => (await call("/session")).running === true,
|
|
177
266
|
},
|
|
@@ -179,17 +268,21 @@ export function createUnifiedConversation({ request, socket: openSocket = browse
|
|
|
179
268
|
const poll = async () => {
|
|
180
269
|
if (disposed || onTranscript) return;
|
|
181
270
|
const state = await call("/session");
|
|
182
|
-
|
|
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;
|
|
183
274
|
lastSessionId = state.sessionId;
|
|
184
275
|
if (lastSessionId) controller.bindSession(lastSessionId);
|
|
185
276
|
if (state.error && state.error !== "voice_cancelled") throw new Error(state.error);
|
|
186
277
|
controller.observeAgent(Boolean(state.running) || Boolean(reply), Boolean(reply?.audio));
|
|
187
278
|
};
|
|
188
279
|
const watch = async () => {
|
|
189
|
-
if (disposed || controller.getSnapshot().connection
|
|
280
|
+
if (disposed || controller.getSnapshot().connection === "closed") return;
|
|
190
281
|
try { await poll(); }
|
|
191
|
-
catch (error) {
|
|
192
|
-
|
|
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);
|
|
193
286
|
};
|
|
194
287
|
const stop = async () => {
|
|
195
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
|
+
}
|