@kuralle-syrinx/realtime 4.1.0 → 4.3.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.
@@ -89,6 +89,22 @@ export interface RealtimeBridgeOptions {
89
89
  readonly toolName: string;
90
90
  readonly args: Record<string, unknown>;
91
91
  }) => string | undefined | Promise<string | undefined>;
92
+ /**
93
+ * Half-cascade text-only mode: stream assistant transcript into the cascade
94
+ * `llm.delta → segmenter → tts.text` path and ignore provider audio.
95
+ * REQUIRES a TTS plugin registered on the session bus (the bridge does not own the registry).
96
+ */
97
+ readonly textOnly?: boolean;
98
+ /**
99
+ * Syrinx-owned turn detection (REQ-6): on `eos.turn_complete`, call
100
+ * `adapter.requestResponse` so Syrinx endpointing/VAD/InteractionPolicy drives
101
+ * the provider response instead of server VAD.
102
+ *
103
+ * MUST be paired with `turnDetection: null` on the adapter — otherwise the
104
+ * provider's server VAD and Syrinx would BOTH trigger responses (double-turn).
105
+ * Native mode (`syrinxTurns` absent/false) is unchanged.
106
+ */
107
+ readonly syrinxTurns?: boolean;
92
108
  }
93
109
 
94
110
  export class RealtimeBridge implements VoicePlugin {
@@ -99,6 +115,7 @@ export class RealtimeBridge implements VoicePlugin {
99
115
  private turnAssistantText = "";
100
116
  /** Concatenated streamed transcript fragments — providers that emit deltas only, no final (Gemini Live). */
101
117
  private turnAssistantDeltas = "";
118
+ private pendingSpeechEndedAtMs: number | null = null;
102
119
  private sessionAbort: AbortController | null = null;
103
120
  private inflight: AbortController | undefined;
104
121
  private delegateTask: Promise<void> | null = null;
@@ -113,6 +130,16 @@ export class RealtimeBridge implements VoicePlugin {
113
130
  private readonly opts: RealtimeBridgeOptions = {},
114
131
  ) {}
115
132
 
133
+ injectContext(text: string): void {
134
+ // Provider history differs here: OpenAI retains system items, while Gemini drops
135
+ // system/developer history, so the adapter selects the provider-safe representation.
136
+ if (this.adapter.injectContext) {
137
+ this.adapter.injectContext(text);
138
+ return;
139
+ }
140
+ console.warn("RealtimeBridge: context injection requested but the adapter does not support it");
141
+ }
142
+
116
143
  async initialize(bus: PipelineBus, _cfg: PluginConfig): Promise<void> {
117
144
  this.bus = bus;
118
145
  this.sessionAbort = new AbortController();
@@ -138,6 +165,14 @@ export class RealtimeBridge implements VoicePlugin {
138
165
  }),
139
166
  );
140
167
 
168
+ if (this.opts.syrinxTurns) {
169
+ this.disposers.push(
170
+ bus.on("eos.turn_complete", () => {
171
+ this.adapter.requestResponse?.();
172
+ }),
173
+ );
174
+ }
175
+
141
176
  await this.adapter.open(this.sessionAbort.signal);
142
177
  void this.pump();
143
178
  }
@@ -176,11 +211,15 @@ export class RealtimeBridge implements VoicePlugin {
176
211
  this.onResponseStarted(bus);
177
212
  break;
178
213
  case "audio":
179
- this.onAudio(bus, ev.pcm16, ev.sampleRateHz);
214
+ // textOnly: provider audio must not be used (REQ-4) TTS plugin owns playout.
215
+ if (!this.opts.textOnly) this.onAudio(bus, ev.pcm16, ev.sampleRateHz);
180
216
  break;
181
217
  case "transcript":
182
218
  if (ev.role === "user") {
183
219
  if (ev.final) this.onFinalTranscript(bus, ev.text);
220
+ } else if (this.opts.textOnly) {
221
+ // Drive cascade TTS path as text arrives; do not buffer for display-only re-emit.
222
+ this.onTextOnlyAssistantTranscript(bus, ev.text, ev.final);
184
223
  } else if (ev.final && ev.text.trim()) {
185
224
  this.turnAssistantText = this.turnAssistantText
186
225
  ? `${this.turnAssistantText} ${ev.text.trim()}`
@@ -188,6 +227,12 @@ export class RealtimeBridge implements VoicePlugin {
188
227
  } else if (!ev.final && ev.text) {
189
228
  // Streamed fragments already carry their own leading spaces — concatenate verbatim.
190
229
  this.turnAssistantDeltas += ev.text;
230
+ bus.push(Route.Main, {
231
+ kind: "llm.delta",
232
+ contextId: this.contextId,
233
+ timestampMs: Date.now(),
234
+ text: ev.text,
235
+ });
191
236
  }
192
237
  break;
193
238
  case "tool_call":
@@ -195,10 +240,23 @@ export class RealtimeBridge implements VoicePlugin {
195
240
  break;
196
241
  case "response_done":
197
242
  this.onResponseDone(bus);
243
+ // Meter the native front — previously native turns produced no usage at all.
244
+ if (ev.usage && this.contextId) {
245
+ bus.push(Route.Background, {
246
+ kind: "usage.recorded",
247
+ contextId: this.contextId,
248
+ timestampMs: Date.now(),
249
+ stage: "llm",
250
+ ...ev.usage,
251
+ });
252
+ }
198
253
  break;
199
254
  case "speech_started":
200
255
  this.onSpeechStarted(bus);
201
256
  break;
257
+ case "speech_stopped":
258
+ this.onSpeechStopped(bus);
259
+ break;
202
260
  case "resumption_handle": {
203
261
  // G4: persist-worthy native-resume handle (Gemini). Background route —
204
262
  // a durable host stores the latest and passes it back on reconnect.
@@ -310,6 +368,25 @@ export class RealtimeBridge implements VoicePlugin {
310
368
  this.inflight = controller;
311
369
  let answer = "";
312
370
  let grounded = false;
371
+ let blockedMessage: string | undefined;
372
+ let blockedPayload: unknown;
373
+ let passStartedMs = Date.now();
374
+ let passTtftRecorded = false;
375
+ const pushConversationMetric = (name: string, value: string, timestampMs: number): void => {
376
+ bus.push(Route.Main, {
377
+ kind: "metric.conversation",
378
+ contextId,
379
+ timestampMs,
380
+ name,
381
+ value,
382
+ });
383
+ };
384
+ const recordPassTtft = (timestampMs: number): void => {
385
+ if (passTtftRecorded) return;
386
+ passTtftRecorded = true;
387
+ pushConversationMetric("llm.pass_ttft_ms", String(timestampMs - passStartedMs), timestampMs);
388
+ };
389
+ pushConversationMetric("llm.call_started", "1", passStartedMs);
313
390
 
314
391
  // G2 observability: the query is on its way to the reasoner (Background route, R4).
315
392
  const queryStartedMs = Date.now();
@@ -333,10 +410,37 @@ export class RealtimeBridge implements VoicePlugin {
333
410
  })) {
334
411
  switch (part.type) {
335
412
  case "text-delta":
413
+ recordPassTtft(Date.now());
336
414
  answer += part.text;
337
415
  break;
416
+ case "tool-call":
417
+ recordPassTtft(Date.now());
418
+ break;
338
419
  case "tool-result":
339
420
  grounded = true;
421
+ passStartedMs = Date.now();
422
+ passTtftRecorded = false;
423
+ pushConversationMetric("llm.call_started", "1", passStartedMs);
424
+ break;
425
+ case "control": {
426
+ const controlMs = Date.now();
427
+ bus.push(Route.Background, {
428
+ kind: "delegate.result",
429
+ contextId,
430
+ timestampMs: controlMs,
431
+ query: userText,
432
+ answer,
433
+ durationMs: controlMs - queryStartedMs,
434
+ grounded,
435
+ toolId: ev.toolId,
436
+ toolName: ev.toolName,
437
+ control: { name: part.name, payload: part.payload },
438
+ });
439
+ break;
440
+ }
441
+ case "blocked":
442
+ blockedMessage = part.userFacingMessage;
443
+ blockedPayload = part.payload;
340
444
  break;
341
445
  case "finish":
342
446
  if (!answer && part.text) answer = part.text;
@@ -354,6 +458,7 @@ export class RealtimeBridge implements VoicePlugin {
354
458
  this.onError(bus, part.cause, true);
355
459
  return;
356
460
  }
461
+ if (blockedMessage !== undefined) break;
357
462
  }
358
463
  } catch (err) {
359
464
  if (isAbortError(err)) return;
@@ -364,6 +469,24 @@ export class RealtimeBridge implements VoicePlugin {
364
469
  if (this.inflight === controller) this.inflight = undefined;
365
470
  }
366
471
 
472
+ if (blockedMessage !== undefined) {
473
+ const blockedMs = Date.now();
474
+ bus.push(Route.Background, {
475
+ kind: "delegate.result",
476
+ contextId,
477
+ timestampMs: blockedMs,
478
+ query: userText,
479
+ answer: blockedMessage,
480
+ durationMs: blockedMs - queryStartedMs,
481
+ grounded,
482
+ toolId: ev.toolId,
483
+ toolName: ev.toolName,
484
+ blocked: { userFacingMessage: blockedMessage, payload: blockedPayload },
485
+ });
486
+ this.adapter.injectToolResult(ev.toolId, this.formatToolResult(blockedMessage));
487
+ return;
488
+ }
489
+
367
490
  if (answer.length === 0) {
368
491
  this.onError(bus, new Error("delegate produced no output"), false);
369
492
  return;
@@ -417,6 +540,20 @@ export class RealtimeBridge implements VoicePlugin {
417
540
  bus.push(Route.Critical, packet);
418
541
  }
419
542
 
543
+ private onSpeechStopped(bus: PipelineBus): void {
544
+ const timestampMs = Date.now();
545
+ if (!this.contextId) {
546
+ // Server VAD reports speech end before response.created mints the response context.
547
+ this.pendingSpeechEndedAtMs = timestampMs;
548
+ return;
549
+ }
550
+ bus.push(Route.Main, {
551
+ kind: "vad.speech_ended",
552
+ contextId: this.contextId,
553
+ timestampMs,
554
+ });
555
+ }
556
+
420
557
  private onResponseStarted(bus: PipelineBus): void {
421
558
  const previousContextId = this.contextId;
422
559
  this.contextId = crypto.randomUUID();
@@ -425,6 +562,14 @@ export class RealtimeBridge implements VoicePlugin {
425
562
  this.turnAssistantDeltas = "";
426
563
  this.playedMs = 0;
427
564
  this.audioRemainder = new Uint8Array(0);
565
+ if (this.pendingSpeechEndedAtMs !== null) {
566
+ bus.push(Route.Main, {
567
+ kind: "vad.speech_ended",
568
+ contextId: this.contextId,
569
+ timestampMs: this.pendingSpeechEndedAtMs,
570
+ });
571
+ this.pendingSpeechEndedAtMs = null;
572
+ }
428
573
  const packet: TurnChangePacket = {
429
574
  kind: "turn.change",
430
575
  contextId: this.contextId,
@@ -455,10 +600,42 @@ export class RealtimeBridge implements VoicePlugin {
455
600
  bus.push(Route.Main, packet);
456
601
  }
457
602
 
603
+ /**
604
+ * textOnly: stream assistant transcript into the cascade LLM→segmenter→TTS path.
605
+ * Non-final deltas drive `llm.delta` immediately (no buffer). Final emits `llm.done` only —
606
+ * the C0 adapter's final carries the full accumulated text, so re-emitting as delta would
607
+ * duplicate every streamed fragment.
608
+ */
609
+ private onTextOnlyAssistantTranscript(bus: PipelineBus, text: string, final: boolean): void {
610
+ if (!this.contextId) return;
611
+ const timestampMs = Date.now();
612
+ if (!final && text) {
613
+ const delta: LlmDeltaPacket = {
614
+ kind: "llm.delta",
615
+ contextId: this.contextId,
616
+ timestampMs,
617
+ text,
618
+ };
619
+ bus.push(Route.Main, delta);
620
+ return;
621
+ }
622
+ if (final && text.trim()) {
623
+ const done: LlmResponseDonePacket = {
624
+ kind: "llm.done",
625
+ contextId: this.contextId,
626
+ timestampMs,
627
+ text,
628
+ };
629
+ bus.push(Route.Main, done);
630
+ }
631
+ }
632
+
458
633
  private onResponseDone(bus: PipelineBus): void {
459
634
  if (!this.contextId) return;
460
- this.emitCoalescedAudio(bus, true);
461
- this.audioRemainder = new Uint8Array(0);
635
+ if (!this.opts.textOnly) {
636
+ this.emitCoalescedAudio(bus, true);
637
+ this.audioRemainder = new Uint8Array(0);
638
+ }
462
639
 
463
640
  const timestampMs = Date.now();
464
641
  const transcripts: SttResultPacket[] = this.turnUserText
@@ -477,6 +654,16 @@ export class RealtimeBridge implements VoicePlugin {
477
654
  text: this.turnUserText,
478
655
  transcripts,
479
656
  };
657
+
658
+ if (this.opts.textOnly) {
659
+ // Already streamed llm.delta/llm.done above. TTS plugin owns tts.end (REQ-5) — forcing
660
+ // it here would cut playout / break barge-in heard-prefix timing.
661
+ // syrinxTurns: endpointing already emitted the user-turn eos.turn_complete that drove
662
+ // requestResponse; re-emitting here would re-subscribe-fire requestResponse → loop.
663
+ if (!this.opts.syrinxTurns) bus.push(Route.Main, turnComplete);
664
+ return;
665
+ }
666
+
480
667
  const ttsEnd: TextToSpeechEndPacket = {
481
668
  kind: "tts.end",
482
669
  contextId: this.contextId,
@@ -488,19 +675,21 @@ export class RealtimeBridge implements VoicePlugin {
488
675
  // providers that only emit non-final deltas (Gemini Live).
489
676
  const assistantText = this.turnAssistantText.trim() || this.turnAssistantDeltas.trim();
490
677
  if (assistantText) {
491
- const delta: LlmDeltaPacket = {
492
- kind: "llm.delta",
493
- contextId: this.contextId,
494
- timestampMs,
495
- text: assistantText,
496
- };
497
678
  const done: LlmResponseDonePacket = {
498
679
  kind: "llm.done",
499
680
  contextId: this.contextId,
500
681
  timestampMs,
501
682
  text: assistantText,
502
683
  };
503
- bus.push(Route.Main, delta, done);
684
+ if (this.turnAssistantDeltas.length === 0) {
685
+ bus.push(Route.Main, {
686
+ kind: "llm.delta",
687
+ contextId: this.contextId,
688
+ timestampMs,
689
+ text: assistantText,
690
+ });
691
+ }
692
+ bus.push(Route.Main, done);
504
693
  }
505
694
  bus.push(Route.Main, turnComplete, ttsEnd);
506
695
  }
@@ -1,173 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
- //
3
- // R-14: regression gate — the realtime package must run on workerd without
4
- // Buffer, process, or node:crypto. Reintroducing any Node-only primitive in
5
- // src/ should fail this test.
6
-
7
- import { readFileSync, readdirSync } from "node:fs";
8
- import path from "node:path";
9
- import { fileURLToPath } from "node:url";
10
-
11
- import { describe, expect, it } from "vitest";
12
-
13
- import {
14
- PipelineBusImpl,
15
- Route,
16
- type TextToSpeechAudioPacket,
17
- type TurnChangePacket,
18
- } from "@kuralle-syrinx/core";
19
- import type { ManagedSocket, SocketData, SocketFactory } from "@kuralle-syrinx/ws";
20
-
21
- import { bytesToBase64, fromOpenAIRealtime } from "./from-openai-realtime.js";
22
- import { RealtimeBridge } from "./realtime-bridge.js";
23
-
24
- const srcDir = path.dirname(fileURLToPath(import.meta.url));
25
-
26
- interface EdgeMockHarness {
27
- readonly factory: SocketFactory;
28
- readonly sent: string[];
29
- inject(msg: Record<string, unknown>): void;
30
- }
31
-
32
- function createEdgeMockHarness(): EdgeMockHarness {
33
- const sent: string[] = [];
34
- let messageHandler: ((data: SocketData, isBinary: boolean) => void) | null = null;
35
-
36
- const socket: ManagedSocket = {
37
- get isOpen() {
38
- return true;
39
- },
40
- send: (data: SocketData) => {
41
- sent.push(typeof data === "string" ? data : "");
42
- },
43
- keepAlivePing: () => {},
44
- verify: async () => true,
45
- dispose: () => {},
46
- onOpen: (handler) => {
47
- queueMicrotask(() => handler());
48
- },
49
- onMessage: (handler) => {
50
- messageHandler = handler;
51
- },
52
- onClose: () => {},
53
- onError: () => {},
54
- };
55
-
56
- return {
57
- factory: () => socket,
58
- sent,
59
- inject: (msg) => messageHandler?.(JSON.stringify(msg), false),
60
- };
61
- }
62
-
63
- function pcmFromSamples(samples: readonly number[]): Uint8Array {
64
- const pcm = Int16Array.from(samples);
65
- return new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength);
66
- }
67
-
68
- async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
69
- const startedAt = Date.now();
70
- while (Date.now() - startedAt < timeoutMs) {
71
- if (predicate()) return;
72
- await new Promise((resolve) => setTimeout(resolve, 5));
73
- }
74
- throw new Error("Timed out waiting for condition");
75
- }
76
-
77
- function collectSourceFiles(dir: string): string[] {
78
- const out: string[] = [];
79
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
80
- const full = path.join(dir, entry.name);
81
- if (entry.isDirectory()) {
82
- out.push(...collectSourceFiles(full));
83
- continue;
84
- }
85
- if (entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts")) {
86
- out.push(full);
87
- }
88
- }
89
- return out;
90
- }
91
-
92
- describe("edge safety (R-14)", () => {
93
- it("src/ contains no Buffer, process, or node:crypto imports", () => {
94
- const forbidden = [
95
- /\bfrom\s+["']node:crypto["']/,
96
- /\brequire\s*\(\s*["']node:crypto["']\s*\)/,
97
- /\bglobalThis\.Buffer\b/,
98
- /\bglobalThis\.process\b/,
99
- /\bprocess\.env\b/,
100
- /\bBuffer\.from\b/,
101
- /\bBuffer\.alloc\b/,
102
- ];
103
- const hits: string[] = [];
104
- for (const file of collectSourceFiles(srcDir)) {
105
- const text = readFileSync(file, "utf8");
106
- for (const pattern of forbidden) {
107
- if (pattern.test(text)) {
108
- hits.push(`${path.relative(srcDir, file)}: ${pattern.source}`);
109
- }
110
- }
111
- }
112
- expect(hits).toEqual([]);
113
- });
114
-
115
- // NOTE: we do NOT delete globalThis.Buffer/process here — vitest's own worker needs `process`
116
- // (its uncaughtException handler calls process.listeners), so deleting it crashes the runner.
117
- // Edge-safety is enforced statically by the source-scan above; this test proves the audio path
118
- // actually runs end-to-end using only the runtime-agnostic helpers.
119
- it("fromOpenAIRealtime + RealtimeBridge round-trip audio via runtime-agnostic helpers", async () => {
120
- const mock = createEdgeMockHarness();
121
- const adapter = fromOpenAIRealtime({
122
- apiKey: "edge-test-key",
123
- socketFactory: mock.factory,
124
- url: () => "wss://example.test/realtime?model=gpt-realtime-2",
125
- });
126
- const bridge = new RealtimeBridge(adapter);
127
- const bus = new PipelineBusImpl();
128
- const turnChanges: TurnChangePacket[] = [];
129
- const ttsAudio: TextToSpeechAudioPacket[] = [];
130
- bus.on("turn.change", (pkt) => { turnChanges.push(pkt as TurnChangePacket); });
131
- bus.on("tts.audio", (pkt) => { ttsAudio.push(pkt as TextToSpeechAudioPacket); });
132
-
133
- const started = bus.start();
134
-
135
- const initTask = bridge.initialize(bus, {});
136
- await waitFor(() => mock.sent.length > 0);
137
- mock.inject({ type: "session.updated" });
138
- await initTask;
139
-
140
- bus.push(Route.Main, {
141
- kind: "user.audio_received",
142
- contextId: "transport-turn",
143
- timestampMs: Date.now(),
144
- audio: pcmFromSamples([100, 200, 300, 400]),
145
- });
146
-
147
- await waitFor(() =>
148
- mock.sent.some((raw) => (JSON.parse(raw) as { type: string }).type === "input_audio_buffer.append"),
149
- );
150
-
151
- const providerPcm = pcmFromSamples(Array.from({ length: 960 }, (_, i) => i));
152
- mock.inject({ type: "response.created" });
153
- mock.inject({
154
- type: "response.output_audio.delta",
155
- delta: bytesToBase64(providerPcm),
156
- });
157
- mock.inject({ type: "response.done" });
158
-
159
- await waitFor(() => ttsAudio.length > 0 && turnChanges.length > 0);
160
-
161
- const contextId = turnChanges[0]!.contextId;
162
- expect(contextId).toMatch(
163
- /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
164
- );
165
- expect(ttsAudio[0]!.contextId).toBe(contextId);
166
- expect(ttsAudio[0]!.sampleRateHz).toBe(16_000);
167
- expect(ttsAudio[0]!.audio.byteLength).toBeGreaterThan(0);
168
-
169
- await bridge.close();
170
- bus.stop();
171
- await started;
172
- });
173
- });