@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 +1 -1
- package/router/tts.js +7 -4
- package/voice/browser-capture.js +22 -2
- package/voice/lares-realtime-gateway.js +12 -1
- package/voice/router-audio.js +15 -1
- package/voice/speech-segmenter.js +33 -14
- package/voice/stream-client.js +16 -5
- package/voice/stream-gateway.js +3 -1
- package/voice/tts-input.js +10 -0
- package/voice/tts-preview.js +3 -1
- package/voice/unified-conversation.js +40 -13
package/package.json
CHANGED
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
|
|
58
|
-
if (!
|
|
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
|
|
96
|
-
if (!
|
|
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);
|
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
|
},
|
|
@@ -20,30 +20,49 @@ function boundaries(text) {
|
|
|
20
20
|
return { positions, balanced: !stack.length };
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
function
|
|
24
|
-
const
|
|
25
|
-
|
|
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 =
|
|
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
|
|
44
|
+
if (i < parts.length - 1) ends.push(from);
|
|
45
|
+
else if (sliceClosed) ends.push(slice.length);
|
|
32
46
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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 };
|
package/voice/stream-client.js
CHANGED
|
@@ -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 {
|
|
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(); }
|
package/voice/stream-gateway.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/voice/tts-preview.js
CHANGED
|
@@ -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 (
|
|
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
|
},
|