@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
package/voice/browser-capture.js
CHANGED
|
@@ -161,8 +161,19 @@ export function createVoiceCapture({
|
|
|
161
161
|
emit("transcribing", { elapsed: took, error: "" });
|
|
162
162
|
abort = new AbortController();
|
|
163
163
|
try {
|
|
164
|
-
|
|
165
|
-
streaming
|
|
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
|
-
|
|
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;
|
package/voice/router-audio.js
CHANGED
|
@@ -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() {
|
|
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
|
},
|
package/voice/stream-client.js
CHANGED
|
@@ -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 {
|
|
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 (
|
|
107
|
+
write(bytes) { if (!current.done && protocol) protocol.append(encodeBase64(bytes)); },
|
|
92
108
|
finish() {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
});
|
|
109
|
+
if (current.done) return result;
|
|
110
|
+
if (!onTranscript) protocol.setBusyTurn(mode());
|
|
111
|
+
protocol.commit();
|
|
112
|
+
return result;
|
|
98
113
|
},
|
|
99
|
-
cancel() { if (
|
|
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": {
|
|
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(
|
|
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
|
},
|