@kuralle-syrinx/realtime 4.4.1 → 4.5.0
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/dist/base64.d.ts +3 -0
- package/dist/base64.d.ts.map +1 -0
- package/dist/base64.js +16 -0
- package/dist/base64.js.map +1 -0
- package/dist/from-gemini-live.d.ts +67 -0
- package/dist/from-gemini-live.d.ts.map +1 -0
- package/dist/from-gemini-live.js +299 -0
- package/dist/from-gemini-live.js.map +1 -0
- package/dist/from-openai-realtime.d.ts +49 -0
- package/dist/from-openai-realtime.d.ts.map +1 -0
- package/dist/from-openai-realtime.js +80 -0
- package/dist/from-openai-realtime.js.map +1 -0
- package/dist/gemini-translate.d.ts +17 -0
- package/dist/gemini-translate.d.ts.map +1 -0
- package/dist/gemini-translate.js +89 -0
- package/dist/gemini-translate.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/openai-compatible-realtime.d.ts +37 -0
- package/dist/openai-compatible-realtime.d.ts.map +1 -0
- package/dist/openai-compatible-realtime.js +416 -0
- package/dist/openai-compatible-realtime.js.map +1 -0
- package/dist/realtime-adapter.d.ts +100 -0
- package/dist/realtime-adapter.d.ts.map +1 -0
- package/dist/realtime-adapter.js +3 -0
- package/dist/realtime-adapter.js.map +1 -0
- package/dist/realtime-bridge.d.ts +130 -0
- package/dist/realtime-bridge.d.ts.map +1 -0
- package/dist/realtime-bridge.js +659 -0
- package/dist/realtime-bridge.js.map +1 -0
- package/dist/realtime-event-stream.d.ts +16 -0
- package/dist/realtime-event-stream.d.ts.map +1 -0
- package/dist/realtime-event-stream.js +47 -0
- package/dist/realtime-event-stream.js.map +1 -0
- package/package.json +13 -6
- package/src/edge-safety.test.ts +173 -0
- package/src/from-gemini-live.test.ts +366 -0
- package/src/from-openai-realtime.test.ts +769 -0
- package/src/gemini-translate.test.ts +82 -0
- package/src/openai-compatible-realtime.test.ts +293 -0
- package/src/realtime-bridge.test.ts +1573 -0
- package/src/workers-seam.test.ts +143 -0
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
import { Route, categorizeLlmError, } from "@kuralle-syrinx/core";
|
|
3
|
+
import { pcm16BytesToSamples } from "@kuralle-syrinx/core/audio";
|
|
4
|
+
const ENGINE_SAMPLE_RATE_HZ = 16_000;
|
|
5
|
+
const FRAME_SAMPLES_20MS = 320;
|
|
6
|
+
const FRAME_BYTES_20MS = FRAME_SAMPLES_20MS * 2;
|
|
7
|
+
export class RealtimeBridge {
|
|
8
|
+
adapter;
|
|
9
|
+
reasoner;
|
|
10
|
+
delegateToolName;
|
|
11
|
+
opts;
|
|
12
|
+
bus = null;
|
|
13
|
+
contextId = "";
|
|
14
|
+
turnUserText = "";
|
|
15
|
+
/** Authoritative full assistant transcript(s) — providers that emit a final transcript (OpenAI). */
|
|
16
|
+
turnAssistantText = "";
|
|
17
|
+
/** Concatenated streamed transcript fragments — providers that emit deltas only, no final (Gemini Live). */
|
|
18
|
+
turnAssistantDeltas = "";
|
|
19
|
+
pendingSpeechEndedAtMs = null;
|
|
20
|
+
sessionAbort = null;
|
|
21
|
+
inflight;
|
|
22
|
+
delegateTask = null;
|
|
23
|
+
playedMs = 0;
|
|
24
|
+
audioRemainder = new Uint8Array(0);
|
|
25
|
+
disposers = [];
|
|
26
|
+
constructor(adapter, reasoner, delegateToolName = "consult_knowledge", opts = {}) {
|
|
27
|
+
this.adapter = adapter;
|
|
28
|
+
this.reasoner = reasoner;
|
|
29
|
+
this.delegateToolName = delegateToolName;
|
|
30
|
+
this.opts = opts;
|
|
31
|
+
}
|
|
32
|
+
injectContext(text) {
|
|
33
|
+
// Provider history differs here: OpenAI retains system items, while Gemini drops
|
|
34
|
+
// system/developer history, so the adapter selects the provider-safe representation.
|
|
35
|
+
if (this.adapter.injectContext) {
|
|
36
|
+
this.adapter.injectContext(text);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
console.warn("RealtimeBridge: context injection requested but the adapter does not support it");
|
|
40
|
+
}
|
|
41
|
+
async initialize(bus, _cfg) {
|
|
42
|
+
this.bus = bus;
|
|
43
|
+
this.sessionAbort = new AbortController();
|
|
44
|
+
this.disposers.push(bus.on("user.audio_received", (pkt) => {
|
|
45
|
+
const resampled = resamplePcm16Bytes(pkt.audio, ENGINE_SAMPLE_RATE_HZ, this.adapter.caps.inputSampleRateHz);
|
|
46
|
+
this.adapter.sendAudio(resampled);
|
|
47
|
+
}), bus.on("user.text_received", (pkt) => {
|
|
48
|
+
if (pkt.text.trim().length > 0)
|
|
49
|
+
this.adapter.sendText?.(pkt.text);
|
|
50
|
+
}), bus.on("tts.playout_progress", (pkt) => {
|
|
51
|
+
if (pkt.contextId === this.contextId)
|
|
52
|
+
this.playedMs = pkt.playedOutMs;
|
|
53
|
+
}), bus.on("interrupt.tts", () => {
|
|
54
|
+
this.adapter.cancelResponse(this.playedMs);
|
|
55
|
+
this.inflight?.abort();
|
|
56
|
+
}));
|
|
57
|
+
if (this.opts.syrinxTurns) {
|
|
58
|
+
this.disposers.push(bus.on("eos.turn_complete", () => {
|
|
59
|
+
this.adapter.requestResponse?.();
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
await this.adapter.open(this.sessionAbort.signal);
|
|
63
|
+
void this.pump();
|
|
64
|
+
}
|
|
65
|
+
async close() {
|
|
66
|
+
this.sessionAbort?.abort();
|
|
67
|
+
this.inflight?.abort();
|
|
68
|
+
for (const dispose of this.disposers.splice(0))
|
|
69
|
+
dispose();
|
|
70
|
+
await this.adapter.close();
|
|
71
|
+
this.bus = null;
|
|
72
|
+
this.sessionAbort = null;
|
|
73
|
+
}
|
|
74
|
+
async pump() {
|
|
75
|
+
const bus = this.bus;
|
|
76
|
+
if (!bus)
|
|
77
|
+
return;
|
|
78
|
+
try {
|
|
79
|
+
for await (const ev of this.adapter.events) {
|
|
80
|
+
if (!this.bus)
|
|
81
|
+
return;
|
|
82
|
+
await this.handleEvent(bus, ev);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
if (!this.bus || isAbortError(err))
|
|
87
|
+
return;
|
|
88
|
+
this.onError(this.bus, err instanceof Error ? err : new Error(String(err)), false);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async handleEvent(bus, ev) {
|
|
92
|
+
switch (ev.type) {
|
|
93
|
+
case "response_started":
|
|
94
|
+
this.onResponseStarted(bus);
|
|
95
|
+
break;
|
|
96
|
+
case "audio":
|
|
97
|
+
// textOnly: provider audio must not be used (REQ-4) — TTS plugin owns playout.
|
|
98
|
+
if (!this.opts.textOnly)
|
|
99
|
+
this.onAudio(bus, ev.pcm16, ev.sampleRateHz);
|
|
100
|
+
break;
|
|
101
|
+
case "transcript":
|
|
102
|
+
if (ev.role === "user") {
|
|
103
|
+
if (ev.final)
|
|
104
|
+
this.onFinalTranscript(bus, ev.text);
|
|
105
|
+
}
|
|
106
|
+
else if (this.opts.textOnly) {
|
|
107
|
+
// Drive cascade TTS path as text arrives; do not buffer for display-only re-emit.
|
|
108
|
+
this.onTextOnlyAssistantTranscript(bus, ev.text, ev.final);
|
|
109
|
+
}
|
|
110
|
+
else if (ev.final && ev.text.trim()) {
|
|
111
|
+
this.turnAssistantText = this.turnAssistantText
|
|
112
|
+
? `${this.turnAssistantText} ${ev.text.trim()}`
|
|
113
|
+
: ev.text.trim();
|
|
114
|
+
}
|
|
115
|
+
else if (!ev.final && ev.text) {
|
|
116
|
+
// Streamed fragments already carry their own leading spaces — concatenate verbatim.
|
|
117
|
+
this.turnAssistantDeltas += ev.text;
|
|
118
|
+
bus.push(Route.Main, {
|
|
119
|
+
kind: "llm.delta",
|
|
120
|
+
contextId: this.contextId,
|
|
121
|
+
timestampMs: Date.now(),
|
|
122
|
+
text: ev.text,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
break;
|
|
126
|
+
case "tool_call":
|
|
127
|
+
await this.handleToolCall(bus, ev);
|
|
128
|
+
break;
|
|
129
|
+
case "response_done":
|
|
130
|
+
this.onResponseDone(bus);
|
|
131
|
+
// Meter the native front — previously native turns produced no usage at all.
|
|
132
|
+
if (ev.usage && this.contextId) {
|
|
133
|
+
bus.push(Route.Background, {
|
|
134
|
+
kind: "usage.recorded",
|
|
135
|
+
contextId: this.contextId,
|
|
136
|
+
timestampMs: Date.now(),
|
|
137
|
+
stage: "llm",
|
|
138
|
+
...ev.usage,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
break;
|
|
142
|
+
case "speech_started":
|
|
143
|
+
this.onSpeechStarted(bus);
|
|
144
|
+
break;
|
|
145
|
+
case "speech_stopped":
|
|
146
|
+
this.onSpeechStopped(bus);
|
|
147
|
+
break;
|
|
148
|
+
case "resumption_handle": {
|
|
149
|
+
// G4: persist-worthy native-resume handle (Gemini). Background route —
|
|
150
|
+
// a durable host stores the latest and passes it back on reconnect.
|
|
151
|
+
const packet = {
|
|
152
|
+
kind: "realtime.resumption_handle",
|
|
153
|
+
contextId: this.contextId,
|
|
154
|
+
timestampMs: Date.now(),
|
|
155
|
+
handle: ev.handle,
|
|
156
|
+
};
|
|
157
|
+
bus.push(Route.Background, packet);
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
case "error":
|
|
161
|
+
this.onError(bus, ev.cause, ev.recoverable);
|
|
162
|
+
break;
|
|
163
|
+
default:
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async handleToolCall(bus, ev) {
|
|
168
|
+
if (ev.toolName === this.delegateToolName) {
|
|
169
|
+
if (this.reasoner)
|
|
170
|
+
this.dispatchDelegate(bus, ev);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (this.opts.onFrontToolCall) {
|
|
174
|
+
await this.handleFrontTool(bus, ev);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
// No front-tool handler: with a reasoner, an unexpected tool is a recoverable error
|
|
178
|
+
// (without one, tool calls are ignored, as before).
|
|
179
|
+
if (this.reasoner) {
|
|
180
|
+
const cause = new Error(`Unexpected tool call "${ev.toolName}" (expected "${this.delegateToolName}")`);
|
|
181
|
+
if (this.opts.debug)
|
|
182
|
+
console.error("[realtime-bridge]", cause.message);
|
|
183
|
+
this.onError(bus, cause, true);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
async handleFrontTool(bus, ev) {
|
|
187
|
+
if (!this.opts.onFrontToolCall)
|
|
188
|
+
return;
|
|
189
|
+
let result;
|
|
190
|
+
try {
|
|
191
|
+
result = (await this.opts.onFrontToolCall({ toolId: ev.toolId, toolName: ev.toolName, args: ev.args })) ?? "";
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
this.onError(bus, err instanceof Error ? err : new Error(String(err)), true);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
this.adapter.injectToolResult(ev.toolId, result);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Start the reasoner delegate OFF the serial event pump. The pump must keep draining adapter
|
|
201
|
+
* events during the multi-second reasoner "thinking gap" — above all `speech_started`, so a
|
|
202
|
+
* barge-in mid-delegate still fires `interrupt.detected` and (via `interrupt.tts`) aborts the
|
|
203
|
+
* now-unwanted delegate before its answer is voiced over the user. Awaiting the delegate inside
|
|
204
|
+
* the pump loop (the old shape) froze barge-in for the entire gap. `runDelegate` sets
|
|
205
|
+
* `this.inflight` synchronously before its first await, so a subsequently-pumped barge-in sees
|
|
206
|
+
* a live controller to abort.
|
|
207
|
+
*/
|
|
208
|
+
dispatchDelegate(bus, ev) {
|
|
209
|
+
this.delegateTask = this.runDelegate(bus, ev).catch((err) => {
|
|
210
|
+
if (!this.bus || isAbortError(err))
|
|
211
|
+
return;
|
|
212
|
+
this.onError(bus, err instanceof Error ? err : new Error(String(err)), false);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
async runDelegate(bus, ev) {
|
|
216
|
+
// Pin the turn's contextId now: the pump keeps advancing while the delegate streams, so
|
|
217
|
+
// this.contextId may move to a later turn before we finish. The tool_call/tool_result pair
|
|
218
|
+
// must stay bound to the turn that issued the call (R1: never merge parts across turns).
|
|
219
|
+
const contextId = this.contextId;
|
|
220
|
+
if (!contextId)
|
|
221
|
+
return;
|
|
222
|
+
const toolCall = {
|
|
223
|
+
kind: "llm.tool_call",
|
|
224
|
+
contextId,
|
|
225
|
+
timestampMs: Date.now(),
|
|
226
|
+
toolId: ev.toolId,
|
|
227
|
+
toolName: ev.toolName,
|
|
228
|
+
toolArgs: ev.args,
|
|
229
|
+
};
|
|
230
|
+
bus.push(Route.Main, toolCall);
|
|
231
|
+
const queryArg = this.opts.delegateQueryArg ?? "query";
|
|
232
|
+
const rawQuery = ev.args[queryArg];
|
|
233
|
+
const userText = typeof rawQuery === "string" ? rawQuery : "";
|
|
234
|
+
if (userText.trim().length === 0) {
|
|
235
|
+
this.onError(bus, new Error(`delegate tool "${ev.toolName}" called without a string "${queryArg}" argument`), true);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const controller = new AbortController();
|
|
239
|
+
this.inflight = controller;
|
|
240
|
+
let answer = "";
|
|
241
|
+
let grounded = false;
|
|
242
|
+
let blockedMessage;
|
|
243
|
+
let blockedPayload;
|
|
244
|
+
let passStartedMs = Date.now();
|
|
245
|
+
let passTtftRecorded = false;
|
|
246
|
+
const pushConversationMetric = (name, value, timestampMs) => {
|
|
247
|
+
bus.push(Route.Main, {
|
|
248
|
+
kind: "metric.conversation",
|
|
249
|
+
contextId,
|
|
250
|
+
timestampMs,
|
|
251
|
+
name,
|
|
252
|
+
value,
|
|
253
|
+
});
|
|
254
|
+
};
|
|
255
|
+
const recordPassTtft = (timestampMs) => {
|
|
256
|
+
if (passTtftRecorded)
|
|
257
|
+
return;
|
|
258
|
+
passTtftRecorded = true;
|
|
259
|
+
pushConversationMetric("llm.pass_ttft_ms", String(timestampMs - passStartedMs), timestampMs);
|
|
260
|
+
};
|
|
261
|
+
pushConversationMetric("llm.call_started", "1", passStartedMs);
|
|
262
|
+
// G2 observability: the query is on its way to the reasoner (Background route, R4).
|
|
263
|
+
const queryStartedMs = Date.now();
|
|
264
|
+
const delegateQuery = {
|
|
265
|
+
kind: "delegate.query",
|
|
266
|
+
contextId,
|
|
267
|
+
timestampMs: queryStartedMs,
|
|
268
|
+
query: userText,
|
|
269
|
+
toolId: ev.toolId,
|
|
270
|
+
toolName: ev.toolName,
|
|
271
|
+
};
|
|
272
|
+
bus.push(Route.Background, delegateQuery);
|
|
273
|
+
try {
|
|
274
|
+
const messages = this.opts.contextProvider?.() ?? [];
|
|
275
|
+
for await (const part of this.reasoner.stream({
|
|
276
|
+
userText,
|
|
277
|
+
toolArgs: ev.args,
|
|
278
|
+
messages,
|
|
279
|
+
signal: controller.signal,
|
|
280
|
+
})) {
|
|
281
|
+
switch (part.type) {
|
|
282
|
+
case "text-delta":
|
|
283
|
+
recordPassTtft(Date.now());
|
|
284
|
+
answer += part.text;
|
|
285
|
+
break;
|
|
286
|
+
case "tool-call":
|
|
287
|
+
recordPassTtft(Date.now());
|
|
288
|
+
break;
|
|
289
|
+
case "tool-result":
|
|
290
|
+
grounded = true;
|
|
291
|
+
passStartedMs = Date.now();
|
|
292
|
+
passTtftRecorded = false;
|
|
293
|
+
pushConversationMetric("llm.call_started", "1", passStartedMs);
|
|
294
|
+
break;
|
|
295
|
+
case "control": {
|
|
296
|
+
const controlMs = Date.now();
|
|
297
|
+
bus.push(Route.Background, {
|
|
298
|
+
kind: "delegate.result",
|
|
299
|
+
contextId,
|
|
300
|
+
timestampMs: controlMs,
|
|
301
|
+
query: userText,
|
|
302
|
+
answer,
|
|
303
|
+
durationMs: controlMs - queryStartedMs,
|
|
304
|
+
grounded,
|
|
305
|
+
toolId: ev.toolId,
|
|
306
|
+
toolName: ev.toolName,
|
|
307
|
+
control: { name: part.name, payload: part.payload },
|
|
308
|
+
});
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
case "blocked":
|
|
312
|
+
blockedMessage = part.userFacingMessage;
|
|
313
|
+
blockedPayload = part.payload;
|
|
314
|
+
break;
|
|
315
|
+
case "finish":
|
|
316
|
+
if (!answer && part.text)
|
|
317
|
+
answer = part.text;
|
|
318
|
+
break;
|
|
319
|
+
case "suspended": {
|
|
320
|
+
const cause = new Error(part.prompt ?? "delegate suspended — cannot voice inline without resume");
|
|
321
|
+
if (this.opts.debug)
|
|
322
|
+
console.error("[realtime-bridge]", cause.message, part.payload);
|
|
323
|
+
this.onError(bus, cause, false);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
case "error":
|
|
327
|
+
if (!part.recoverable)
|
|
328
|
+
throw part.cause;
|
|
329
|
+
this.onError(bus, part.cause, true);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (blockedMessage !== undefined)
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
catch (err) {
|
|
337
|
+
if (isAbortError(err))
|
|
338
|
+
return;
|
|
339
|
+
this.onError(bus, err instanceof Error ? err : new Error(String(err)), false);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
finally {
|
|
343
|
+
// Only clear if still ours — a newer delegate may have replaced this.inflight.
|
|
344
|
+
if (this.inflight === controller)
|
|
345
|
+
this.inflight = undefined;
|
|
346
|
+
}
|
|
347
|
+
if (blockedMessage !== undefined) {
|
|
348
|
+
const blockedMs = Date.now();
|
|
349
|
+
bus.push(Route.Background, {
|
|
350
|
+
kind: "delegate.result",
|
|
351
|
+
contextId,
|
|
352
|
+
timestampMs: blockedMs,
|
|
353
|
+
query: userText,
|
|
354
|
+
answer: blockedMessage,
|
|
355
|
+
durationMs: blockedMs - queryStartedMs,
|
|
356
|
+
grounded,
|
|
357
|
+
toolId: ev.toolId,
|
|
358
|
+
toolName: ev.toolName,
|
|
359
|
+
blocked: { userFacingMessage: blockedMessage, payload: blockedPayload },
|
|
360
|
+
});
|
|
361
|
+
this.adapter.injectToolResult(ev.toolId, this.formatToolResult(blockedMessage));
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (answer.length === 0) {
|
|
365
|
+
this.onError(bus, new Error("delegate produced no output"), false);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const answeredMs = Date.now();
|
|
369
|
+
const toolResult = {
|
|
370
|
+
kind: "llm.tool_result",
|
|
371
|
+
contextId,
|
|
372
|
+
timestampMs: answeredMs,
|
|
373
|
+
toolId: ev.toolId,
|
|
374
|
+
toolName: ev.toolName,
|
|
375
|
+
result: answer,
|
|
376
|
+
};
|
|
377
|
+
bus.push(Route.Main, toolResult);
|
|
378
|
+
// G2 observability: the reasoner produced the final answer (Background route, R4).
|
|
379
|
+
const delegateResult = {
|
|
380
|
+
kind: "delegate.result",
|
|
381
|
+
contextId,
|
|
382
|
+
timestampMs: answeredMs,
|
|
383
|
+
query: userText,
|
|
384
|
+
answer,
|
|
385
|
+
durationMs: answeredMs - queryStartedMs,
|
|
386
|
+
grounded,
|
|
387
|
+
toolId: ev.toolId,
|
|
388
|
+
toolName: ev.toolName,
|
|
389
|
+
};
|
|
390
|
+
bus.push(Route.Background, delegateResult);
|
|
391
|
+
this.adapter.injectToolResult(ev.toolId, this.formatToolResult(answer));
|
|
392
|
+
}
|
|
393
|
+
/** G1: wrap the buffered delegate answer for the front model (synchronous — R2). */
|
|
394
|
+
formatToolResult(answer) {
|
|
395
|
+
if (this.opts.toolResultFormat === "string")
|
|
396
|
+
return answer;
|
|
397
|
+
const envelope = {
|
|
398
|
+
response_text: answer,
|
|
399
|
+
require_repeat_verbatim: true,
|
|
400
|
+
...(this.opts.renderDirective ? { render: this.opts.renderDirective } : {}),
|
|
401
|
+
};
|
|
402
|
+
return JSON.stringify(envelope);
|
|
403
|
+
}
|
|
404
|
+
onSpeechStarted(bus) {
|
|
405
|
+
if (!this.adapter.caps.emitsServerSpeechStarted || !this.contextId)
|
|
406
|
+
return;
|
|
407
|
+
const packet = {
|
|
408
|
+
kind: "interrupt.detected",
|
|
409
|
+
contextId: this.contextId,
|
|
410
|
+
timestampMs: Date.now(),
|
|
411
|
+
source: "vad",
|
|
412
|
+
};
|
|
413
|
+
bus.push(Route.Critical, packet);
|
|
414
|
+
}
|
|
415
|
+
onSpeechStopped(bus) {
|
|
416
|
+
const timestampMs = Date.now();
|
|
417
|
+
if (!this.contextId) {
|
|
418
|
+
// Server VAD reports speech end before response.created mints the response context.
|
|
419
|
+
this.pendingSpeechEndedAtMs = timestampMs;
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
bus.push(Route.Main, {
|
|
423
|
+
kind: "vad.speech_ended",
|
|
424
|
+
contextId: this.contextId,
|
|
425
|
+
timestampMs,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
onResponseStarted(bus) {
|
|
429
|
+
const previousContextId = this.contextId;
|
|
430
|
+
this.contextId = crypto.randomUUID();
|
|
431
|
+
this.turnUserText = "";
|
|
432
|
+
this.turnAssistantText = "";
|
|
433
|
+
this.turnAssistantDeltas = "";
|
|
434
|
+
this.playedMs = 0;
|
|
435
|
+
this.audioRemainder = new Uint8Array(0);
|
|
436
|
+
if (this.pendingSpeechEndedAtMs !== null) {
|
|
437
|
+
bus.push(Route.Main, {
|
|
438
|
+
kind: "vad.speech_ended",
|
|
439
|
+
contextId: this.contextId,
|
|
440
|
+
timestampMs: this.pendingSpeechEndedAtMs,
|
|
441
|
+
});
|
|
442
|
+
this.pendingSpeechEndedAtMs = null;
|
|
443
|
+
}
|
|
444
|
+
const packet = {
|
|
445
|
+
kind: "turn.change",
|
|
446
|
+
contextId: this.contextId,
|
|
447
|
+
previousContextId,
|
|
448
|
+
reason: "realtime_response_started",
|
|
449
|
+
timestampMs: Date.now(),
|
|
450
|
+
};
|
|
451
|
+
bus.push(Route.Main, packet);
|
|
452
|
+
}
|
|
453
|
+
onAudio(bus, pcm16, sampleRateHz) {
|
|
454
|
+
if (!this.contextId)
|
|
455
|
+
return;
|
|
456
|
+
const resampled = resamplePcm16Bytes(pcm16, sampleRateHz, ENGINE_SAMPLE_RATE_HZ);
|
|
457
|
+
this.audioRemainder = concatBytes(this.audioRemainder, resampled);
|
|
458
|
+
this.emitCoalescedAudio(bus, false);
|
|
459
|
+
}
|
|
460
|
+
onFinalTranscript(bus, text) {
|
|
461
|
+
if (!this.contextId || text.trim().length === 0)
|
|
462
|
+
return;
|
|
463
|
+
this.turnUserText = text;
|
|
464
|
+
const packet = {
|
|
465
|
+
kind: "stt.result",
|
|
466
|
+
contextId: this.contextId,
|
|
467
|
+
timestampMs: Date.now(),
|
|
468
|
+
text,
|
|
469
|
+
confidence: 0,
|
|
470
|
+
};
|
|
471
|
+
bus.push(Route.Main, packet);
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* textOnly: stream assistant transcript into the cascade LLM→segmenter→TTS path.
|
|
475
|
+
* Non-final deltas drive `llm.delta` immediately (no buffer). Final emits `llm.done` only —
|
|
476
|
+
* the C0 adapter's final carries the full accumulated text, so re-emitting as delta would
|
|
477
|
+
* duplicate every streamed fragment.
|
|
478
|
+
*/
|
|
479
|
+
onTextOnlyAssistantTranscript(bus, text, final) {
|
|
480
|
+
if (!this.contextId)
|
|
481
|
+
return;
|
|
482
|
+
const timestampMs = Date.now();
|
|
483
|
+
if (!final && text) {
|
|
484
|
+
const delta = {
|
|
485
|
+
kind: "llm.delta",
|
|
486
|
+
contextId: this.contextId,
|
|
487
|
+
timestampMs,
|
|
488
|
+
text,
|
|
489
|
+
};
|
|
490
|
+
bus.push(Route.Main, delta);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (final && text.trim()) {
|
|
494
|
+
const done = {
|
|
495
|
+
kind: "llm.done",
|
|
496
|
+
contextId: this.contextId,
|
|
497
|
+
timestampMs,
|
|
498
|
+
text,
|
|
499
|
+
};
|
|
500
|
+
bus.push(Route.Main, done);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
onResponseDone(bus) {
|
|
504
|
+
if (!this.contextId)
|
|
505
|
+
return;
|
|
506
|
+
if (!this.opts.textOnly) {
|
|
507
|
+
this.emitCoalescedAudio(bus, true);
|
|
508
|
+
this.audioRemainder = new Uint8Array(0);
|
|
509
|
+
}
|
|
510
|
+
const timestampMs = Date.now();
|
|
511
|
+
const transcripts = this.turnUserText
|
|
512
|
+
? [{
|
|
513
|
+
kind: "stt.result",
|
|
514
|
+
contextId: this.contextId,
|
|
515
|
+
timestampMs,
|
|
516
|
+
text: this.turnUserText,
|
|
517
|
+
confidence: 0,
|
|
518
|
+
}]
|
|
519
|
+
: [];
|
|
520
|
+
const turnComplete = {
|
|
521
|
+
kind: "eos.turn_complete",
|
|
522
|
+
contextId: this.contextId,
|
|
523
|
+
timestampMs,
|
|
524
|
+
text: this.turnUserText,
|
|
525
|
+
transcripts,
|
|
526
|
+
endpointingOwner: "timer",
|
|
527
|
+
endpointingReason: "end_of_speech",
|
|
528
|
+
};
|
|
529
|
+
if (this.opts.textOnly) {
|
|
530
|
+
// Already streamed llm.delta/llm.done above. TTS plugin owns tts.end (REQ-5) — forcing
|
|
531
|
+
// it here would cut playout / break barge-in heard-prefix timing.
|
|
532
|
+
// syrinxTurns: endpointing already emitted the user-turn eos.turn_complete that drove
|
|
533
|
+
// requestResponse; re-emitting here would re-subscribe-fire requestResponse → loop.
|
|
534
|
+
if (!this.opts.syrinxTurns)
|
|
535
|
+
bus.push(Route.Main, turnComplete);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
const ttsEnd = {
|
|
539
|
+
kind: "tts.end",
|
|
540
|
+
contextId: this.contextId,
|
|
541
|
+
timestampMs,
|
|
542
|
+
};
|
|
543
|
+
// Surface the assistant's spoken transcript as agent text so UIs (e.g. the studio) render it.
|
|
544
|
+
// The realtime adapter already produced the audio directly; these packets are display-only.
|
|
545
|
+
// Prefer the authoritative final transcript (OpenAI); fall back to the streamed fragments for
|
|
546
|
+
// providers that only emit non-final deltas (Gemini Live).
|
|
547
|
+
const assistantText = this.turnAssistantText.trim() || this.turnAssistantDeltas.trim();
|
|
548
|
+
if (assistantText) {
|
|
549
|
+
const done = {
|
|
550
|
+
kind: "llm.done",
|
|
551
|
+
contextId: this.contextId,
|
|
552
|
+
timestampMs,
|
|
553
|
+
text: assistantText,
|
|
554
|
+
};
|
|
555
|
+
if (this.turnAssistantDeltas.length === 0) {
|
|
556
|
+
bus.push(Route.Main, {
|
|
557
|
+
kind: "llm.delta",
|
|
558
|
+
contextId: this.contextId,
|
|
559
|
+
timestampMs,
|
|
560
|
+
text: assistantText,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
bus.push(Route.Main, done);
|
|
564
|
+
}
|
|
565
|
+
bus.push(Route.Main, turnComplete, ttsEnd);
|
|
566
|
+
}
|
|
567
|
+
emitCoalescedAudio(bus, flush) {
|
|
568
|
+
if (!this.contextId)
|
|
569
|
+
return;
|
|
570
|
+
let buf = this.audioRemainder;
|
|
571
|
+
if (buf.byteLength === 0)
|
|
572
|
+
return;
|
|
573
|
+
let processLen = buf.byteLength;
|
|
574
|
+
if (processLen % 2 !== 0) {
|
|
575
|
+
processLen -= 1;
|
|
576
|
+
}
|
|
577
|
+
if (processLen < 2)
|
|
578
|
+
return;
|
|
579
|
+
let offset = 0;
|
|
580
|
+
while (offset + FRAME_BYTES_20MS <= processLen) {
|
|
581
|
+
const frame = buf.subarray(offset, offset + FRAME_BYTES_20MS);
|
|
582
|
+
this.pushAudioFrame(bus, frame);
|
|
583
|
+
offset += FRAME_BYTES_20MS;
|
|
584
|
+
}
|
|
585
|
+
const trailingEven = processLen - offset;
|
|
586
|
+
if (flush && trailingEven >= 2) {
|
|
587
|
+
this.pushAudioFrame(bus, buf.subarray(offset, offset + trailingEven));
|
|
588
|
+
offset += trailingEven;
|
|
589
|
+
}
|
|
590
|
+
const leftoverStart = offset;
|
|
591
|
+
const leftoverEnd = buf.byteLength;
|
|
592
|
+
this.audioRemainder =
|
|
593
|
+
leftoverStart < leftoverEnd
|
|
594
|
+
? new Uint8Array(buf.subarray(leftoverStart, leftoverEnd))
|
|
595
|
+
: new Uint8Array(0);
|
|
596
|
+
}
|
|
597
|
+
pushAudioFrame(bus, frame) {
|
|
598
|
+
if (frame.byteLength % 2 !== 0 || frame.byteLength === 0)
|
|
599
|
+
return;
|
|
600
|
+
const packet = {
|
|
601
|
+
kind: "tts.audio",
|
|
602
|
+
contextId: this.contextId,
|
|
603
|
+
timestampMs: Date.now(),
|
|
604
|
+
audio: frame,
|
|
605
|
+
sampleRateHz: ENGINE_SAMPLE_RATE_HZ,
|
|
606
|
+
};
|
|
607
|
+
bus.push(Route.Main, packet);
|
|
608
|
+
}
|
|
609
|
+
onError(bus, cause, isRecoverable) {
|
|
610
|
+
const packet = {
|
|
611
|
+
kind: "llm.error",
|
|
612
|
+
contextId: this.contextId,
|
|
613
|
+
timestampMs: Date.now(),
|
|
614
|
+
component: "bridge",
|
|
615
|
+
category: categorizeLlmError(cause),
|
|
616
|
+
cause,
|
|
617
|
+
isRecoverable,
|
|
618
|
+
};
|
|
619
|
+
bus.push(Route.Critical, packet);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
function concatBytes(a, b) {
|
|
623
|
+
const out = new Uint8Array(a.byteLength + b.byteLength);
|
|
624
|
+
out.set(a, 0);
|
|
625
|
+
out.set(b, a.byteLength);
|
|
626
|
+
return out;
|
|
627
|
+
}
|
|
628
|
+
function resamplePcm16Bytes(pcm16, fromHz, toHz) {
|
|
629
|
+
if (fromHz === toHz)
|
|
630
|
+
return new Uint8Array(pcm16);
|
|
631
|
+
// pcm16 may be a view at an ODD byteOffset (decoded envelope payload) — `new Int16Array(buffer, offset)`
|
|
632
|
+
// throws "start offset … multiple of 2". pcm16BytesToSamples copies via DataView, which is offset-safe.
|
|
633
|
+
// It requires an even byteLength, so drop a trailing odd byte first (matches the prior truncating view).
|
|
634
|
+
const even = pcm16.byteLength % 2 === 0 ? pcm16 : pcm16.subarray(0, pcm16.byteLength - 1);
|
|
635
|
+
const samples = pcm16BytesToSamples(even);
|
|
636
|
+
const out = resamplePcm16(samples, fromHz, toHz);
|
|
637
|
+
const bytes = new Uint8Array(out.byteLength);
|
|
638
|
+
bytes.set(new Uint8Array(out.buffer, out.byteOffset, out.byteLength));
|
|
639
|
+
return bytes;
|
|
640
|
+
}
|
|
641
|
+
function resamplePcm16(samples, fromHz, toHz) {
|
|
642
|
+
if (fromHz === toHz)
|
|
643
|
+
return samples;
|
|
644
|
+
const outLength = Math.max(1, Math.round((samples.length * toHz) / fromHz));
|
|
645
|
+
const out = new Int16Array(outLength);
|
|
646
|
+
const ratio = fromHz / toHz;
|
|
647
|
+
for (let i = 0; i < out.length; i += 1) {
|
|
648
|
+
const src = i * ratio;
|
|
649
|
+
const lo = Math.floor(src);
|
|
650
|
+
const hi = Math.min(samples.length - 1, lo + 1);
|
|
651
|
+
const frac = src - lo;
|
|
652
|
+
out[i] = Math.round(samples[lo] * (1 - frac) + samples[hi] * frac);
|
|
653
|
+
}
|
|
654
|
+
return out;
|
|
655
|
+
}
|
|
656
|
+
function isAbortError(err) {
|
|
657
|
+
return err instanceof Error && err.name === "AbortError";
|
|
658
|
+
}
|
|
659
|
+
//# sourceMappingURL=realtime-bridge.js.map
|