@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.
@@ -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
- if (current.audio) {
63
- try { await current.chain; await player.finish(); } catch {}
64
- if (reply !== current) return;
65
- protocol?.playbackCompleted(current.itemId);
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.resolve = (text) => { protocol?.deviceInput(false); resolve(text); };
128
- current.reject = (error) => { protocol?.deviceInput(false); reject(error); };
129
- signal.addEventListener("abort", () => { if (turn === current) { turn = null; protocol?.discard(); protocol?.deviceInput(false); resolve(""); } }, { once: true });
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
- if (turn && ["unsupported_audio_format", "audio_limit_exceeded", "turn_busy", "waiting_for_playback", "upstream_failed"].includes(code)) { const current = turn; turn = null; current.reject?.(new Error(code)); }
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
- if (disposed || controller.getSnapshot().connection !== "open") return;
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 !== "open") return;
280
+ if (disposed || controller.getSnapshot().connection === "closed") return;
217
281
  try { await poll(); }
218
- catch (error) { await controller.disconnect(error.code || error.message || "voice_disconnected"); return; }
219
- if (!disposed) timer = setTimeout(watch, 500);
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
+ }