@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.
@@ -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
- 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;
@@ -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 (turn === current && protocol) protocol.append(encodeBase64(bytes)); },
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
- return new Promise((resolve, reject) => {
94
- current.resolve = resolve; current.reject = reject;
95
- if (!onTranscript) protocol.setBusyTurn(mode());
96
- protocol.commit();
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 (turn === current) { turn = null; protocol?.discard(); } },
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.resolve = (text) => { protocol?.deviceInput(false); resolve(text); };
114
- current.reject = (error) => { protocol?.deviceInput(false); reject(error); };
115
- 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 });
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": { const current = turn; turn = null; current?.resolve?.(event.transcript || ""); break; }
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
- 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
+ }
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() { await poll(); },
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
- 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;
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 !== "open") return;
280
+ if (disposed || controller.getSnapshot().connection === "closed") return;
190
281
  try { await poll(); }
191
- catch (error) { await controller.disconnect(error.code || error.message || "voice_disconnected"); return; }
192
- 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);
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
+ }