@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.
- package/README.md +28 -0
- package/package.json +25 -5
- package/src/from-gemini-live.ts +147 -9
- package/src/from-openai-realtime.ts +30 -5
- package/src/index.ts +6 -1
- package/src/openai-compatible-realtime.ts +82 -3
- package/src/realtime-adapter.ts +28 -1
- package/src/realtime-bridge.ts +199 -10
- package/src/edge-safety.test.ts +0 -173
- package/src/from-gemini-live.test.ts +0 -241
- package/src/from-openai-realtime.test.ts +0 -675
- package/src/gemini-translate.test.ts +0 -82
- package/src/realtime-bridge.test.ts +0 -1147
- package/src/workers-seam.test.ts +0 -143
- package/tsconfig.json +0 -21
package/src/realtime-bridge.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
461
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/edge-safety.test.ts
DELETED
|
@@ -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
|
-
});
|