@olares/lares-core 0.15.26 → 0.15.28

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olares/lares-core",
3
- "version": "0.15.26",
3
+ "version": "0.15.28",
4
4
  "description": "Lares 共享业务核心:Router、workspace、文件/媒体、Olares 身份(无 UI、无平台壳)",
5
5
  "type": "module",
6
6
  "repository": {
package/router/tts.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { speechInput } from "../voice/tts-input.js";
1
2
  import { fetchTtsVoices } from "../voice/tts-voice.js";
2
3
  import { VoiceError } from "./stt.js";
3
4
  import { routerShimBaseUrl } from "./gateway.js";
@@ -54,8 +55,9 @@ export function pcmToWav(pcm, sampleRate = PCM_RATE) {
54
55
  * @returns {Promise<Buffer>}
55
56
  */
56
57
  export async function synthesize(request) {
57
- const text = request.text.trim();
58
- if (!text) throw new VoiceError("voice_config_invalid", 400, "nothing to speak");
58
+ const spoken = request.text.trim();
59
+ if (!spoken) throw new VoiceError("voice_config_invalid", 400, "nothing to speak");
60
+ const text = speechInput(request.model, spoken);
59
61
  let res;
60
62
  try {
61
63
  res = await fetch(`${routerShimBaseUrl()}/audio/speech`, {
@@ -92,8 +94,9 @@ export async function synthesize(request) {
92
94
  * @param {(chunk: Buffer) => Promise<void> | void} onChunk
93
95
  */
94
96
  export async function synthesizeStream(request, onChunk) {
95
- const text = request.text.trim();
96
- if (!text) throw new VoiceError("voice_config_invalid", 400, "nothing to speak");
97
+ const spoken = request.text.trim();
98
+ if (!spoken) throw new VoiceError("voice_config_invalid", 400, "nothing to speak");
99
+ const text = speechInput(request.model, spoken);
97
100
  let res;
98
101
  try {
99
102
  const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
@@ -161,8 +161,19 @@ export function createVoiceCapture({
161
161
  emit("transcribing", { elapsed: took, error: "" });
162
162
  abort = new AbortController();
163
163
  try {
164
- const text = String(await (streaming ? streaming.finish() : transcribe(blob, abort.signal)) ?? "").trim();
165
- streaming = null;
164
+ let text = "";
165
+ if (streaming) {
166
+ const port = streaming;
167
+ streaming = null;
168
+ try {
169
+ text = String(await port.finish() ?? "").trim();
170
+ } catch (err) {
171
+ if (dead || abort.signal.aborted || cancel) return;
172
+ text = "";
173
+ }
174
+ }
175
+ // The socket can finish with nothing while the recording still has the words.
176
+ if (!text) text = String(await transcribe(blob, abort.signal) ?? "").trim();
166
177
  if (dead || cancel) return;
167
178
  if (!text) {
168
179
  trace("no-input", {});
@@ -204,6 +215,15 @@ export function createVoiceCapture({
204
215
  recorder.stop();
205
216
  } catch {
206
217
  scheduleFinish();
218
+ return;
219
+ }
220
+ // Some WebViews never fire onstop. The button already switched the UI to
221
+ // transcribing, so finish with the chunks collected so far instead of waiting.
222
+ if (!finishTimer) {
223
+ finishTimer = setTimeout(() => {
224
+ finishTimer = 0;
225
+ void finish();
226
+ }, 1000);
207
227
  }
208
228
  };
209
229
 
@@ -125,6 +125,7 @@ export function createLaresRealtimeGateway({ service, config, transcribe, synthe
125
125
  if (truncate) truncateTo(current, played.get(current.itemId) ?? 0);
126
126
  };
127
127
  const playOut = async (current, audio) => {
128
+ if (current.abort.signal.aborted || current.done || closed) return;
128
129
  if (headset) {
129
130
  if (!current.devicePlaying) { current.devicePlaying = true; send({ type: "lares.device.playback", playing: true }); }
130
131
  hostOutput = hostOutput.then(() => current.abort.signal.aborted ? undefined : port?.write(audio));
@@ -210,7 +211,14 @@ export function createLaresRealtimeGateway({ service, config, transcribe, synthe
210
211
  };
211
212
  const userItem = (id, content) => send({ type: "conversation.item.done", item: { id, object: "realtime.item", type: "message", role: "user", status: "completed", content: [content] } });
212
213
  const commit = async ({ endpointed = false } = {}) => {
213
- if (!bytes) return;
214
+ // A commit with no samples still has a client waiting in transcribing.
215
+ // Returning here never completes that take, so the composer stays on 转写中.
216
+ if (!bytes) {
217
+ const id = itemId || `item_${randomUUID()}`;
218
+ reset();
219
+ send({ type: "conversation.item.input_audio_transcription.completed", item_id: id, content_index: 0, transcript: "" });
220
+ return;
221
+ }
214
222
  // Server VAD keeps listening while a turn is processed; the next endpoint waits for it.
215
223
  if (busy && endpointed) { pendingCommit = true; return; }
216
224
  if (busy) fail("turn_busy");
@@ -382,6 +390,9 @@ export function createLaresRealtimeGateway({ service, config, transcribe, synthe
382
390
  speak(lastSpoken.text, lastSpoken.origin, lastSpoken.key, 0);
383
391
  return;
384
392
  case "response.cancel":
393
+ // The client has stopped listening. A reply that already finished must
394
+ // not keep the next utterance behind waiting_for_playback.
395
+ awaitingPlayback = false;
385
396
  if (busy && turn) generation++;
386
397
  cancel("cancelled");
387
398
  return;
@@ -56,7 +56,21 @@ export function createRouterAudio(config) {
56
56
  let chain = opened;
57
57
  return {
58
58
  write(pcm) { chain = chain.then(() => { if (socket.readyState === 1) socket.send(Buffer.from(convert(pcmSamples(pcm)))); }); chain.catch(() => {}); },
59
- async finish() { await chain; socket.send(JSON.stringify({ type: "finish" })); try { return await final; } finally { socket.close(); } },
59
+ async finish() {
60
+ // Close the socket from a timer so a silent upstream cannot pin `final`:
61
+ // the close handler is what rejects it, and that close used to wait on `final`.
62
+ let timer;
63
+ try {
64
+ await chain;
65
+ if (socket.readyState !== 1) return null;
66
+ socket.send(JSON.stringify({ type: "finish" }));
67
+ timer = setTimeout(() => socket.close(), 12000);
68
+ return await final;
69
+ } finally {
70
+ clearTimeout(timer);
71
+ if (socket.readyState === 1) socket.close();
72
+ }
73
+ },
60
74
  cancel() { socket.terminate(); },
61
75
  };
62
76
  },
@@ -20,30 +20,49 @@ function boundaries(text) {
20
20
  return { positions, balanced: !stack.length };
21
21
  }
22
22
 
23
- function sentences(text, closed) {
24
- const { positions, balanced } = boundaries(text);
25
- const ends = new Set();
23
+ function cjkCuts(text, positions) {
24
+ const cuts = [];
25
+ for (let i = 0; i < text.length; i++) {
26
+ if (!/[。!?]/.test(text[i])) continue;
27
+ let end = i + 1;
28
+ while (/[”’」』))\]]/.test(text[end] || "") && end < text.length) end++;
29
+ if (positions.has(end)) cuts.push(end);
30
+ }
31
+ return [...new Set(cuts)].sort((a, b) => a - b);
32
+ }
33
+
34
+ // sentencex is an English segmenter. A Chinese 。!? already closed the previous
35
+ // sentence; feeding that terminator back in makes it treat "Mr." as another end.
36
+ function sentenceCuts(slice, sliceClosed) {
37
+ const parts = segment("en", slice);
38
+ const ends = [];
26
39
  let from = 0;
27
- const parts = segment("en", text);
28
40
  for (let i = 0; i < parts.length; i++) {
29
- const at = text.indexOf(parts[i], from); if (at < 0) continue;
41
+ const at = slice.indexOf(parts[i], from);
42
+ if (at < 0) continue;
30
43
  from = at + parts[i].length;
31
- if (i < parts.length - 1 || closed) ends.add(from);
44
+ if (i < parts.length - 1) ends.push(from);
45
+ else if (sliceClosed) ends.push(slice.length);
32
46
  }
33
- for (let i = 0; i < text.length; i++) {
34
- if (/[。!?]/.test(text[i])) {
35
- let end = i + 1;
36
- while (/[”’」』))\]]/.test(text[end] || "") && end < text.length) end++;
37
- ends.add(end);
38
- }
47
+ return ends;
48
+ }
49
+
50
+ function sentences(text, closed) {
51
+ const { positions, balanced } = boundaries(text);
52
+ const ends = new Set();
53
+ const cuts = cjkCuts(text, positions);
54
+ let start = 0;
55
+ for (const cut of cuts) {
56
+ for (const end of sentenceCuts(text.slice(start, cut), true)) ends.add(start + end);
57
+ start = cut;
39
58
  }
40
- if (closed) ends.add(text.length);
59
+ for (const end of sentenceCuts(text.slice(start), closed)) ends.add(start + end);
41
60
  const out = []; let consumed = 0;
42
61
  for (const end of [...ends].sort((a, b) => a - b)) {
43
62
  if (!positions.has(end) && !(closed && end === text.length)) continue;
44
63
  const sentence = text.slice(consumed, end).trim();
45
64
  if (sentence.includes("\uFFFC")) break;
46
- if (sentence) out.push(sentence);
65
+ if (/[\p{L}\p{N}]/u.test(sentence)) out.push(sentence);
47
66
  consumed = end;
48
67
  }
49
68
  return { out, consumed, balanced };
@@ -49,7 +49,7 @@ export async function playAudioStream(options) {
49
49
  if (!progressNoted) { progressNoted = true; streamMetric(options, { stage: "playback_progress_sent", frames }); }
50
50
  },
51
51
  onMetric: (event) => { if (event.stage === "playback_started") options.onStatus?.("playing"); streamMetric(options, { ...event, durationMs: Date.now() - started }); } });
52
- let chain = Promise.resolve(), complete = false, firstReceived = false, ready = false, paused = false, segmentIndex = -1;
52
+ let chain = Promise.resolve(), complete = false, ending = false, firstReceived = false, ready = false, paused = false, segmentIndex = -1;
53
53
  options.controls?.({
54
54
  get canPause() { return ready; },
55
55
  pause() { if (!ready || paused) return; paused = true; player.pause(); file.pause(); if (connection.readyState === 1) connection.send(JSON.stringify({ type: "pause" })); },
@@ -69,10 +69,14 @@ export async function playAudioStream(options) {
69
69
  options.signal?.addEventListener("abort", cancelled, { once: true });
70
70
  connection.addEventListener("message", ({ data }) => {
71
71
  if (typeof data !== "string" && !firstReceived) { firstReceived = true; streamMetric(options, { stage: "playback_first_received", durationMs: Date.now() - started }); }
72
+ let event = null;
73
+ if (typeof data === "string") {
74
+ try { event = JSON.parse(data); } catch (error) { chain = chain.then(() => { throw error; }).catch(fail); return; }
75
+ if (event?.type === "done") ending = true;
76
+ }
72
77
  chain = chain.then(async () => {
73
78
  if (options.signal?.aborted) throw new Error("voice_cancelled");
74
79
  if (typeof data !== "string") { player.write(new Uint8Array(data)); return; }
75
- const event = JSON.parse(data);
76
80
  if (event.type === "segment") {
77
81
  if (event.index !== segmentIndex + 1) throw new Error("voice_reply_sequence");
78
82
  segmentIndex = event.index;
@@ -88,7 +92,7 @@ export async function playAudioStream(options) {
88
92
  connection.addEventListener("error", () => fail(dropped()), { once: true });
89
93
  connection.addEventListener("close", () => {
90
94
  options.signal?.removeEventListener("abort", cancelled);
91
- if (!complete) { const error = dropped(); noteFailure(error); reject(error); }
95
+ if (!complete && !ending) { const error = dropped(); noteFailure(error); reject(error); }
92
96
  }, { once: true });
93
97
  if (options.signal?.aborted) cancelled();
94
98
  });
@@ -140,7 +144,7 @@ export async function startTranscriptionStream(options) {
140
144
  export async function createStreamingCapture(stream, options) {
141
145
  const port = await (options.port ? options.port(options) : startTranscriptionStream(options));
142
146
  if (!port) return null;
143
- let context, source, node, failure, flushed;
147
+ let context, source, node, failure, flushed, captured = 0;
144
148
  const release = () => {
145
149
  source?.disconnect(); source = null;
146
150
  node?.disconnect(); if (node) node.port.onmessage = null; node = null;
@@ -158,7 +162,11 @@ export async function createStreamingCapture(stream, options) {
158
162
  node = new AudioWorkletNode(context, "lares-pcm-capture", { numberOfOutputs: 1, outputChannelCount: [1] });
159
163
  node.port.onmessage = ({ data }) => {
160
164
  if (data.type === "flushed") { flushed?.(); return; }
161
- try { port.write(convert(data.samples)); } catch (cause) { failure = cause; port.cancel(); }
165
+ try {
166
+ const pcm = convert(data.samples);
167
+ if (pcm?.length) captured += pcm.length;
168
+ port.write(pcm);
169
+ } catch (cause) { failure = cause; port.cancel(); }
162
170
  };
163
171
  source.connect(node); node.connect(context.destination);
164
172
  return {
@@ -168,6 +176,9 @@ export async function createStreamingCapture(stream, options) {
168
176
  // No capture messages may follow the ASR commit, even while finalization waits.
169
177
  release();
170
178
  if (failure) throw failure;
179
+ // No PCM reached the socket. Committing it makes the Host drop the take
180
+ // without a transcript, and the composer stays on 转写中.
181
+ if (!captured) { port.cancel(); return ""; }
171
182
  return await port.finish();
172
183
  } catch (cause) { port.cancel(); throw cause; }
173
184
  finally { release(); }
@@ -159,7 +159,9 @@ export function createAudioStreamGateway({ config, models, replies, synthesize =
159
159
  else if (event.type === "error") throw fault(event.code || "voice_stream_failed");
160
160
  }
161
161
  };
162
- void pump().catch((cause) => { if (!abort.signal.aborted) error(cause); }).finally(() => { unsub(); close(); });
162
+ // `done` means the bytes have been handed over. The browser still has up to
163
+ // the two-second send-ahead queued. Closing now is a disconnect and stops that tail.
164
+ void pump().catch((cause) => { if (!abort.signal.aborted) error(cause); }).finally(() => unsub());
163
165
  }
164
166
  });
165
167
  },
@@ -0,0 +1,10 @@
1
+ const QWEN_12HZ = /qwen3-tts|audioqwen3tts/i;
2
+
3
+ /** 12Hz Qwen drops its last codec frames. This tail is not spoken, so those frames land on it. */
4
+ export const QWEN_SPEECH_TAIL = " ^.";
5
+
6
+ export function speechInput(model, text) {
7
+ const body = String(text ?? "");
8
+ if (!QWEN_12HZ.test(String(model ?? ""))) return body;
9
+ return body.endsWith(QWEN_SPEECH_TAIL) ? body : `${body}${QWEN_SPEECH_TAIL}`;
10
+ }
@@ -1,8 +1,10 @@
1
+ import { speechInput } from "./tts-input.js";
2
+
1
3
  /** Explicit audition is independent of automatic read-aloud and business sessions. */
2
4
  export async function previewTts({ model, voice, text, signal }, { fetcher = fetch, createAudio = url => new Audio(url) } = {}) {
3
5
  const response = await fetcher("/llm/v1/audio/speech", {
4
6
  method: "POST", headers: { "content-type": "application/json" }, signal,
5
- body: JSON.stringify({ model, input: text, response_format: "wav", ...(voice ? { voice } : {}) }),
7
+ body: JSON.stringify({ model, input: speechInput(model, text), response_format: "wav", ...(voice ? { voice } : {}) }),
6
8
  });
7
9
  if (!response.ok) throw new Error("tts_preview_failed");
8
10
  const blob = await response.blob(); signal.throwIfAborted();
@@ -26,12 +26,12 @@ export function browserRealtimeSocket(ticket) {
26
26
  * microphone streams PCM into the socket, the Host transcribes, submits and speaks, and the reply
27
27
  * audio plays from the socket instead of a separate synthesis stream.
28
28
  */
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 }) {
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"); } }) {
30
30
  const voiceId = mintRpcId();
31
31
  const headset = device === "headset";
32
32
  const headers = { "x-lares-voice-id": voiceId, "x-lares-voice-device": headset ? "headset" : "device" };
33
33
  let protocol = null, socketOpen = null, disposed = false, timer = null, lastSessionId = null, continuous = false;
34
- let turn = null, reply = null, outputSerial = Promise.resolve(), confirmedReadAloud = readAloud;
34
+ let turn = null, reply = null, outputSerial = Promise.resolve(), confirmedReadAloud = readAloud, hostHeard = false;
35
35
  const call = async (path, body, signal) => {
36
36
  const res = await request(`/api/lares/voice${path}`, { method: body === undefined ? "GET" : "POST", headers, ...(body === undefined ? {} : { body }), signal });
37
37
  if (!res?.ok) throw new Error(res?.body?.error?.code || "voice_failed");
@@ -86,21 +86,35 @@ export function createUnifiedConversation({ request, socket: openSocket = browse
86
86
  const transcription = ({ onPartial }) => {
87
87
  if (!protocol) throw new Error("voice_disconnected");
88
88
  const current = take();
89
+ hostHeard = false;
89
90
  current.onPartial = onPartial;
91
+ // The promise exists for the whole take. An error while audio is still
92
+ // streaming has to reject it; waiting until finish() to attach reject left
93
+ // the composer on 转写中.
94
+ let settle;
95
+ const result = new Promise((resolve, reject) => {
96
+ settle = (error, text) => {
97
+ if (current.done) return;
98
+ current.done = true;
99
+ if (turn === current) turn = null;
100
+ if (error) reject(error); else resolve(text ?? "");
101
+ };
102
+ current.resolve = (text) => settle(null, text);
103
+ current.reject = (error) => settle(error);
104
+ });
105
+ void result.catch(() => {});
90
106
  return {
91
- write(bytes) { if (turn === current && protocol) protocol.append(encodeBase64(bytes)); },
107
+ write(bytes) { if (!current.done && protocol) protocol.append(encodeBase64(bytes)); },
92
108
  finish() {
93
- return new Promise((resolve, reject) => {
94
- current.resolve = resolve; current.reject = reject;
95
- if (!onTranscript) protocol.setBusyTurn(mode());
96
- protocol.commit();
97
- });
109
+ if (current.done) return result;
110
+ if (!onTranscript) protocol.setBusyTurn(mode());
111
+ protocol.commit();
112
+ return result;
98
113
  },
99
- cancel() { if (turn === current) { turn = null; protocol?.discard(); } },
114
+ cancel() { if (!current.done) { protocol?.discard(); settle(null, ""); } },
100
115
  };
101
116
  };
102
- const deviceInput = () => createDeviceInput({ media, preparation, onPhase, endpointSilenceMs,
103
- transcribe: async () => { throw new Error("voice_unsupported"); },
117
+ const deviceInput = () => createDeviceInput({ media, preparation, onPhase, endpointSilenceMs, transcribe,
104
118
  streamInput: (stream, options) => createStreamingCapture(stream, { ...options, sampleRate: RATE, port: async () => transcription(options) }),
105
119
  });
106
120
  // Headset: the Host owns the microphone; the socket only opens and closes it.
@@ -128,7 +142,13 @@ export function createUnifiedConversation({ request, socket: openSocket = browse
128
142
  case "input_audio_buffer.speech_started": turn?.onSpeech?.(); break;
129
143
  case "input_audio_buffer.committed": turn?.onTranscribing?.(); break;
130
144
  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; }
145
+ case "conversation.item.input_audio_transcription.completed": {
146
+ // The Host already submitted a non-empty transcript. An empty one did not,
147
+ // so a later blob fallback has to send the words itself.
148
+ hostHeard = Boolean(String(event.transcript || "").trim());
149
+ const current = turn; turn = null; current?.resolve?.(event.transcript || "");
150
+ break;
151
+ }
132
152
  case "response.created": playback.begin(event); break;
133
153
  case "response.output_item.added": if (reply) reply.itemId = event.item?.id || null; break;
134
154
  case "response.output_audio_transcript.delta":
@@ -171,7 +191,14 @@ export function createUnifiedConversation({ request, socket: openSocket = browse
171
191
  lazy: true,
172
192
  create: async () => null,
173
193
  // The Host submitted this turn when its audio was committed; only the lifecycle remains to observe.
174
- async submit() { await poll(); },
194
+ async submit({ text } = {}) {
195
+ // Audio that never reached the socket still has a recording. The Host did not
196
+ // see that take, so hand the transcript over as a typed turn instead of dropping it.
197
+ const typed = Boolean(text) && !hostHeard;
198
+ hostHeard = false;
199
+ if (typed) protocol?.say(text);
200
+ await poll();
201
+ },
175
202
  cancel: () => call("/session/cancel", {}),
176
203
  running: async () => (await call("/session")).running === true,
177
204
  },