@olares/lares-core 0.15.27 → 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.27",
3
+ "version": "0.15.28",
4
4
  "description": "Lares 共享业务核心:Router、workspace、文件/媒体、Olares 身份(无 UI、无平台壳)",
5
5
  "type": "module",
6
6
  "repository": {
@@ -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
  },
@@ -144,7 +144,7 @@ export async function startTranscriptionStream(options) {
144
144
  export async function createStreamingCapture(stream, options) {
145
145
  const port = await (options.port ? options.port(options) : startTranscriptionStream(options));
146
146
  if (!port) return null;
147
- let context, source, node, failure, flushed;
147
+ let context, source, node, failure, flushed, captured = 0;
148
148
  const release = () => {
149
149
  source?.disconnect(); source = null;
150
150
  node?.disconnect(); if (node) node.port.onmessage = null; node = null;
@@ -162,7 +162,11 @@ export async function createStreamingCapture(stream, options) {
162
162
  node = new AudioWorkletNode(context, "lares-pcm-capture", { numberOfOutputs: 1, outputChannelCount: [1] });
163
163
  node.port.onmessage = ({ data }) => {
164
164
  if (data.type === "flushed") { flushed?.(); return; }
165
- 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(); }
166
170
  };
167
171
  source.connect(node); node.connect(context.destination);
168
172
  return {
@@ -172,6 +176,9 @@ export async function createStreamingCapture(stream, options) {
172
176
  // No capture messages may follow the ASR commit, even while finalization waits.
173
177
  release();
174
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 ""; }
175
182
  return await port.finish();
176
183
  } catch (cause) { port.cancel(); throw cause; }
177
184
  finally { release(); }
@@ -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
  },