@kuralle-syrinx/realtime 2.1.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/LICENSE +22 -0
- package/README.md +152 -0
- package/package.json +22 -0
- package/src/base64.ts +16 -0
- package/src/edge-safety.test.ts +173 -0
- package/src/from-gemini-live.test.ts +197 -0
- package/src/from-gemini-live.ts +274 -0
- package/src/from-openai-realtime.test.ts +447 -0
- package/src/from-openai-realtime.ts +104 -0
- package/src/gemini-translate.test.ts +82 -0
- package/src/gemini-translate.ts +128 -0
- package/src/index.ts +17 -0
- package/src/openai-compatible-realtime.ts +373 -0
- package/src/realtime-adapter.ts +39 -0
- package/src/realtime-bridge.test.ts +653 -0
- package/src/realtime-bridge.ts +422 -0
- package/src/workers-seam.test.ts +143 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Route,
|
|
5
|
+
categorizeLlmError,
|
|
6
|
+
type EndOfSpeechPacket,
|
|
7
|
+
type InterruptTtsPacket,
|
|
8
|
+
type InterruptionDetectedPacket,
|
|
9
|
+
type LlmErrorPacket,
|
|
10
|
+
type LlmDeltaPacket,
|
|
11
|
+
type LlmResponseDonePacket,
|
|
12
|
+
type LlmToolCallPacket,
|
|
13
|
+
type LlmToolResultPacket,
|
|
14
|
+
type PipelineBus,
|
|
15
|
+
type PluginConfig,
|
|
16
|
+
type Reasoner,
|
|
17
|
+
type ReasonerMessage,
|
|
18
|
+
type UserAudioReceivedPacket,
|
|
19
|
+
type SttResultPacket,
|
|
20
|
+
type TextToSpeechAudioPacket,
|
|
21
|
+
type TextToSpeechEndPacket,
|
|
22
|
+
type TextToSpeechPlayoutProgressPacket,
|
|
23
|
+
type TurnChangePacket,
|
|
24
|
+
type VoicePlugin,
|
|
25
|
+
} from "@kuralle-syrinx/core";
|
|
26
|
+
import { pcm16BytesToSamples } from "@kuralle-syrinx/core/audio";
|
|
27
|
+
|
|
28
|
+
import type { RealtimeAdapter, RealtimeEvent } from "./realtime-adapter.js";
|
|
29
|
+
|
|
30
|
+
const ENGINE_SAMPLE_RATE_HZ = 16_000;
|
|
31
|
+
const FRAME_SAMPLES_20MS = 320;
|
|
32
|
+
const FRAME_BYTES_20MS = FRAME_SAMPLES_20MS * 2;
|
|
33
|
+
|
|
34
|
+
export interface RealtimeBridgeOptions {
|
|
35
|
+
readonly debug?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Supplies prior conversation context for delegate Reasoner turns. When omitted the delegate is
|
|
38
|
+
* stateless — each tool call receives only the query extracted from the front-model tool args.
|
|
39
|
+
*/
|
|
40
|
+
readonly contextProvider?: () => readonly ReasonerMessage[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class RealtimeBridge implements VoicePlugin {
|
|
44
|
+
private bus: PipelineBus | null = null;
|
|
45
|
+
private contextId = "";
|
|
46
|
+
private turnUserText = "";
|
|
47
|
+
private turnAssistantText = "";
|
|
48
|
+
private sessionAbort: AbortController | null = null;
|
|
49
|
+
private inflight: AbortController | undefined;
|
|
50
|
+
private playedMs = 0;
|
|
51
|
+
private audioRemainder: Uint8Array<ArrayBufferLike> = new Uint8Array(0);
|
|
52
|
+
private readonly disposers: Array<() => void> = [];
|
|
53
|
+
|
|
54
|
+
constructor(
|
|
55
|
+
private readonly adapter: RealtimeAdapter,
|
|
56
|
+
private readonly reasoner?: Reasoner,
|
|
57
|
+
private readonly delegateToolName = "consult_knowledge",
|
|
58
|
+
private readonly opts: RealtimeBridgeOptions = {},
|
|
59
|
+
) {}
|
|
60
|
+
|
|
61
|
+
async initialize(bus: PipelineBus, _cfg: PluginConfig): Promise<void> {
|
|
62
|
+
this.bus = bus;
|
|
63
|
+
this.sessionAbort = new AbortController();
|
|
64
|
+
|
|
65
|
+
this.disposers.push(
|
|
66
|
+
bus.on<UserAudioReceivedPacket>("user.audio_received", (pkt) => {
|
|
67
|
+
const resampled = resamplePcm16Bytes(
|
|
68
|
+
pkt.audio,
|
|
69
|
+
ENGINE_SAMPLE_RATE_HZ,
|
|
70
|
+
this.adapter.caps.inputSampleRateHz,
|
|
71
|
+
);
|
|
72
|
+
this.adapter.sendAudio(resampled);
|
|
73
|
+
}),
|
|
74
|
+
bus.on<TextToSpeechPlayoutProgressPacket>("tts.playout_progress", (pkt) => {
|
|
75
|
+
if (pkt.contextId === this.contextId) this.playedMs = pkt.playedOutMs;
|
|
76
|
+
}),
|
|
77
|
+
bus.on<InterruptTtsPacket>("interrupt.tts", () => {
|
|
78
|
+
this.adapter.cancelResponse(this.playedMs);
|
|
79
|
+
this.inflight?.abort();
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
await this.adapter.open(this.sessionAbort.signal);
|
|
84
|
+
void this.pump();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async close(): Promise<void> {
|
|
88
|
+
this.sessionAbort?.abort();
|
|
89
|
+
for (const dispose of this.disposers.splice(0)) dispose();
|
|
90
|
+
await this.adapter.close();
|
|
91
|
+
this.bus = null;
|
|
92
|
+
this.sessionAbort = null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private async pump(): Promise<void> {
|
|
96
|
+
const bus = this.bus;
|
|
97
|
+
if (!bus) return;
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
for await (const ev of this.adapter.events) {
|
|
101
|
+
if (!this.bus) return;
|
|
102
|
+
await this.handleEvent(bus, ev);
|
|
103
|
+
}
|
|
104
|
+
} catch (err) {
|
|
105
|
+
if (!this.bus || isAbortError(err)) return;
|
|
106
|
+
this.onError(
|
|
107
|
+
this.bus,
|
|
108
|
+
err instanceof Error ? err : new Error(String(err)),
|
|
109
|
+
false,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private async handleEvent(bus: PipelineBus, ev: RealtimeEvent): Promise<void> {
|
|
115
|
+
switch (ev.type) {
|
|
116
|
+
case "response_started":
|
|
117
|
+
this.onResponseStarted(bus);
|
|
118
|
+
break;
|
|
119
|
+
case "audio":
|
|
120
|
+
this.onAudio(bus, ev.pcm16, ev.sampleRateHz);
|
|
121
|
+
break;
|
|
122
|
+
case "transcript":
|
|
123
|
+
if (ev.final && ev.role === "user") this.onFinalTranscript(bus, ev.text);
|
|
124
|
+
else if (ev.final && ev.role === "assistant" && ev.text.trim()) {
|
|
125
|
+
this.turnAssistantText = this.turnAssistantText
|
|
126
|
+
? `${this.turnAssistantText} ${ev.text.trim()}`
|
|
127
|
+
: ev.text.trim();
|
|
128
|
+
}
|
|
129
|
+
break;
|
|
130
|
+
case "tool_call":
|
|
131
|
+
if (this.reasoner) {
|
|
132
|
+
if (ev.toolName !== this.delegateToolName) {
|
|
133
|
+
const cause = new Error(
|
|
134
|
+
`Unexpected tool call "${ev.toolName}" (expected "${this.delegateToolName}")`,
|
|
135
|
+
);
|
|
136
|
+
if (this.opts.debug) console.error("[realtime-bridge]", cause.message);
|
|
137
|
+
this.onError(bus, cause, true);
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
await this.runDelegate(bus, ev);
|
|
141
|
+
}
|
|
142
|
+
break;
|
|
143
|
+
case "response_done":
|
|
144
|
+
this.onResponseDone(bus);
|
|
145
|
+
break;
|
|
146
|
+
case "speech_started":
|
|
147
|
+
this.onSpeechStarted(bus);
|
|
148
|
+
break;
|
|
149
|
+
case "error":
|
|
150
|
+
this.onError(bus, ev.cause, ev.recoverable);
|
|
151
|
+
break;
|
|
152
|
+
default:
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private async runDelegate(
|
|
158
|
+
bus: PipelineBus,
|
|
159
|
+
ev: { toolId: string; toolName: string; args: Record<string, unknown> },
|
|
160
|
+
): Promise<void> {
|
|
161
|
+
if (!this.contextId) return;
|
|
162
|
+
|
|
163
|
+
const toolCall: LlmToolCallPacket = {
|
|
164
|
+
kind: "llm.tool_call",
|
|
165
|
+
contextId: this.contextId,
|
|
166
|
+
timestampMs: Date.now(),
|
|
167
|
+
toolId: ev.toolId,
|
|
168
|
+
toolName: ev.toolName,
|
|
169
|
+
toolArgs: ev.args,
|
|
170
|
+
};
|
|
171
|
+
bus.push(Route.Main, toolCall);
|
|
172
|
+
|
|
173
|
+
this.inflight = new AbortController();
|
|
174
|
+
let answer = "";
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const messages = this.opts.contextProvider?.() ?? [];
|
|
178
|
+
for await (const part of this.reasoner!.stream({
|
|
179
|
+
userText: String(ev.args["query"] ?? ""),
|
|
180
|
+
messages,
|
|
181
|
+
signal: this.inflight.signal,
|
|
182
|
+
})) {
|
|
183
|
+
switch (part.type) {
|
|
184
|
+
case "text-delta":
|
|
185
|
+
answer += part.text;
|
|
186
|
+
break;
|
|
187
|
+
case "tool-result":
|
|
188
|
+
break;
|
|
189
|
+
case "finish":
|
|
190
|
+
if (!answer && part.text) answer = part.text;
|
|
191
|
+
break;
|
|
192
|
+
case "suspended": {
|
|
193
|
+
const cause = new Error(
|
|
194
|
+
part.prompt ?? "delegate suspended — cannot voice inline without resume",
|
|
195
|
+
);
|
|
196
|
+
if (this.opts.debug) console.error("[realtime-bridge]", cause.message, part.payload);
|
|
197
|
+
this.onError(bus, cause, false);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
case "error":
|
|
201
|
+
if (!part.recoverable) throw part.cause;
|
|
202
|
+
this.onError(bus, part.cause, true);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
} catch (err) {
|
|
207
|
+
if (isAbortError(err)) return;
|
|
208
|
+
this.onError(bus, err instanceof Error ? err : new Error(String(err)), false);
|
|
209
|
+
return;
|
|
210
|
+
} finally {
|
|
211
|
+
this.inflight = undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (answer.length === 0) {
|
|
215
|
+
this.onError(bus, new Error("delegate produced no output"), false);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const toolResult: LlmToolResultPacket = {
|
|
220
|
+
kind: "llm.tool_result",
|
|
221
|
+
contextId: this.contextId,
|
|
222
|
+
timestampMs: Date.now(),
|
|
223
|
+
toolId: ev.toolId,
|
|
224
|
+
toolName: ev.toolName,
|
|
225
|
+
result: answer,
|
|
226
|
+
};
|
|
227
|
+
bus.push(Route.Main, toolResult);
|
|
228
|
+
this.adapter.injectToolResult(ev.toolId, answer);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private onSpeechStarted(bus: PipelineBus): void {
|
|
232
|
+
if (!this.adapter.caps.emitsServerSpeechStarted || !this.contextId) return;
|
|
233
|
+
const packet: InterruptionDetectedPacket = {
|
|
234
|
+
kind: "interrupt.detected",
|
|
235
|
+
contextId: this.contextId,
|
|
236
|
+
timestampMs: Date.now(),
|
|
237
|
+
source: "vad",
|
|
238
|
+
};
|
|
239
|
+
bus.push(Route.Critical, packet);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private onResponseStarted(bus: PipelineBus): void {
|
|
243
|
+
const previousContextId = this.contextId;
|
|
244
|
+
this.contextId = crypto.randomUUID();
|
|
245
|
+
this.turnUserText = "";
|
|
246
|
+
this.turnAssistantText = "";
|
|
247
|
+
this.playedMs = 0;
|
|
248
|
+
this.audioRemainder = new Uint8Array(0);
|
|
249
|
+
const packet: TurnChangePacket = {
|
|
250
|
+
kind: "turn.change",
|
|
251
|
+
contextId: this.contextId,
|
|
252
|
+
previousContextId,
|
|
253
|
+
reason: "realtime_response_started",
|
|
254
|
+
timestampMs: Date.now(),
|
|
255
|
+
};
|
|
256
|
+
bus.push(Route.Main, packet);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private onAudio(bus: PipelineBus, pcm16: Uint8Array, sampleRateHz: number): void {
|
|
260
|
+
if (!this.contextId) return;
|
|
261
|
+
const resampled = resamplePcm16Bytes(pcm16, sampleRateHz, ENGINE_SAMPLE_RATE_HZ);
|
|
262
|
+
this.audioRemainder = concatBytes(this.audioRemainder, resampled);
|
|
263
|
+
this.emitCoalescedAudio(bus, false);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private onFinalTranscript(bus: PipelineBus, text: string): void {
|
|
267
|
+
if (!this.contextId || text.trim().length === 0) return;
|
|
268
|
+
this.turnUserText = text;
|
|
269
|
+
const packet: SttResultPacket = {
|
|
270
|
+
kind: "stt.result",
|
|
271
|
+
contextId: this.contextId,
|
|
272
|
+
timestampMs: Date.now(),
|
|
273
|
+
text,
|
|
274
|
+
confidence: 0,
|
|
275
|
+
};
|
|
276
|
+
bus.push(Route.Main, packet);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private onResponseDone(bus: PipelineBus): void {
|
|
280
|
+
if (!this.contextId) return;
|
|
281
|
+
this.emitCoalescedAudio(bus, true);
|
|
282
|
+
this.audioRemainder = new Uint8Array(0);
|
|
283
|
+
|
|
284
|
+
const timestampMs = Date.now();
|
|
285
|
+
const transcripts: SttResultPacket[] = this.turnUserText
|
|
286
|
+
? [{
|
|
287
|
+
kind: "stt.result",
|
|
288
|
+
contextId: this.contextId,
|
|
289
|
+
timestampMs,
|
|
290
|
+
text: this.turnUserText,
|
|
291
|
+
confidence: 0,
|
|
292
|
+
}]
|
|
293
|
+
: [];
|
|
294
|
+
const turnComplete: EndOfSpeechPacket = {
|
|
295
|
+
kind: "eos.turn_complete",
|
|
296
|
+
contextId: this.contextId,
|
|
297
|
+
timestampMs,
|
|
298
|
+
text: this.turnUserText,
|
|
299
|
+
transcripts,
|
|
300
|
+
};
|
|
301
|
+
const ttsEnd: TextToSpeechEndPacket = {
|
|
302
|
+
kind: "tts.end",
|
|
303
|
+
contextId: this.contextId,
|
|
304
|
+
timestampMs,
|
|
305
|
+
};
|
|
306
|
+
// Surface the assistant's spoken transcript as agent text so UIs (e.g. the studio) render it.
|
|
307
|
+
// The realtime adapter already produced the audio directly; these packets are display-only.
|
|
308
|
+
if (this.turnAssistantText.trim()) {
|
|
309
|
+
const delta: LlmDeltaPacket = {
|
|
310
|
+
kind: "llm.delta",
|
|
311
|
+
contextId: this.contextId,
|
|
312
|
+
timestampMs,
|
|
313
|
+
text: this.turnAssistantText,
|
|
314
|
+
};
|
|
315
|
+
const done: LlmResponseDonePacket = {
|
|
316
|
+
kind: "llm.done",
|
|
317
|
+
contextId: this.contextId,
|
|
318
|
+
timestampMs,
|
|
319
|
+
text: this.turnAssistantText,
|
|
320
|
+
};
|
|
321
|
+
bus.push(Route.Main, delta, done);
|
|
322
|
+
}
|
|
323
|
+
bus.push(Route.Main, turnComplete, ttsEnd);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private emitCoalescedAudio(bus: PipelineBus, flush: boolean): void {
|
|
327
|
+
if (!this.contextId) return;
|
|
328
|
+
|
|
329
|
+
let buf = this.audioRemainder;
|
|
330
|
+
if (buf.byteLength === 0) return;
|
|
331
|
+
|
|
332
|
+
let processLen = buf.byteLength;
|
|
333
|
+
if (processLen % 2 !== 0) {
|
|
334
|
+
processLen -= 1;
|
|
335
|
+
}
|
|
336
|
+
if (processLen < 2) return;
|
|
337
|
+
|
|
338
|
+
let offset = 0;
|
|
339
|
+
while (offset + FRAME_BYTES_20MS <= processLen) {
|
|
340
|
+
const frame = buf.subarray(offset, offset + FRAME_BYTES_20MS);
|
|
341
|
+
this.pushAudioFrame(bus, frame);
|
|
342
|
+
offset += FRAME_BYTES_20MS;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const trailingEven = processLen - offset;
|
|
346
|
+
if (flush && trailingEven >= 2) {
|
|
347
|
+
this.pushAudioFrame(bus, buf.subarray(offset, offset + trailingEven));
|
|
348
|
+
offset += trailingEven;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const leftoverStart = offset;
|
|
352
|
+
const leftoverEnd = buf.byteLength;
|
|
353
|
+
this.audioRemainder =
|
|
354
|
+
leftoverStart < leftoverEnd
|
|
355
|
+
? new Uint8Array(buf.subarray(leftoverStart, leftoverEnd))
|
|
356
|
+
: new Uint8Array(0);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private pushAudioFrame(bus: PipelineBus, frame: Uint8Array): void {
|
|
360
|
+
if (frame.byteLength % 2 !== 0 || frame.byteLength === 0) return;
|
|
361
|
+
const packet: TextToSpeechAudioPacket = {
|
|
362
|
+
kind: "tts.audio",
|
|
363
|
+
contextId: this.contextId,
|
|
364
|
+
timestampMs: Date.now(),
|
|
365
|
+
audio: frame,
|
|
366
|
+
sampleRateHz: ENGINE_SAMPLE_RATE_HZ,
|
|
367
|
+
};
|
|
368
|
+
bus.push(Route.Main, packet);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private onError(bus: PipelineBus, cause: Error, isRecoverable: boolean): void {
|
|
372
|
+
const packet: LlmErrorPacket = {
|
|
373
|
+
kind: "llm.error",
|
|
374
|
+
contextId: this.contextId,
|
|
375
|
+
timestampMs: Date.now(),
|
|
376
|
+
component: "bridge",
|
|
377
|
+
category: categorizeLlmError(cause),
|
|
378
|
+
cause,
|
|
379
|
+
isRecoverable,
|
|
380
|
+
};
|
|
381
|
+
bus.push(Route.Critical, packet);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|
386
|
+
const out = new Uint8Array(a.byteLength + b.byteLength);
|
|
387
|
+
out.set(a, 0);
|
|
388
|
+
out.set(b, a.byteLength);
|
|
389
|
+
return out;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function resamplePcm16Bytes(pcm16: Uint8Array, fromHz: number, toHz: number): Uint8Array {
|
|
393
|
+
if (fromHz === toHz) return new Uint8Array(pcm16);
|
|
394
|
+
// pcm16 may be a view at an ODD byteOffset (decoded envelope payload) — `new Int16Array(buffer, offset)`
|
|
395
|
+
// throws "start offset … multiple of 2". pcm16BytesToSamples copies via DataView, which is offset-safe.
|
|
396
|
+
// It requires an even byteLength, so drop a trailing odd byte first (matches the prior truncating view).
|
|
397
|
+
const even = pcm16.byteLength % 2 === 0 ? pcm16 : pcm16.subarray(0, pcm16.byteLength - 1);
|
|
398
|
+
const samples = pcm16BytesToSamples(even);
|
|
399
|
+
const out = resamplePcm16(samples, fromHz, toHz);
|
|
400
|
+
const bytes = new Uint8Array(out.byteLength);
|
|
401
|
+
bytes.set(new Uint8Array(out.buffer, out.byteOffset, out.byteLength));
|
|
402
|
+
return bytes;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function resamplePcm16(samples: Int16Array, fromHz: number, toHz: number): Int16Array {
|
|
406
|
+
if (fromHz === toHz) return samples;
|
|
407
|
+
const outLength = Math.max(1, Math.round((samples.length * toHz) / fromHz));
|
|
408
|
+
const out = new Int16Array(outLength);
|
|
409
|
+
const ratio = fromHz / toHz;
|
|
410
|
+
for (let i = 0; i < out.length; i += 1) {
|
|
411
|
+
const src = i * ratio;
|
|
412
|
+
const lo = Math.floor(src);
|
|
413
|
+
const hi = Math.min(samples.length - 1, lo + 1);
|
|
414
|
+
const frac = src - lo;
|
|
415
|
+
out[i] = Math.round(samples[lo]! * (1 - frac) + samples[hi]! * frac);
|
|
416
|
+
}
|
|
417
|
+
return out;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function isAbortError(err: unknown): boolean {
|
|
421
|
+
return err instanceof Error && err.name === "AbortError";
|
|
422
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Proves fromOpenAIRealtime composes with createWorkersSocket: fetch-upgrade uses
|
|
4
|
+
// https:// (not wss://), carries Authorization: Bearer, accept()s the socket,
|
|
5
|
+
// and round-trips provider audio into RealtimeEvent.
|
|
6
|
+
|
|
7
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
8
|
+
|
|
9
|
+
import { createWorkersSocket } from "@kuralle-syrinx/ws/workers";
|
|
10
|
+
import type { WebSocketEventLike, WebSocketLike } from "@kuralle-syrinx/ws/web";
|
|
11
|
+
|
|
12
|
+
import { bytesToBase64, fromOpenAIRealtime } from "./from-openai-realtime.js";
|
|
13
|
+
import type { RealtimeEvent } from "./realtime-adapter.js";
|
|
14
|
+
|
|
15
|
+
interface MockWorkerdHarness {
|
|
16
|
+
readonly fetchCalls: Array<{ url: string; headers: Record<string, string> }>;
|
|
17
|
+
readonly sent: string[];
|
|
18
|
+
readonly acceptCalled: boolean;
|
|
19
|
+
inject(msg: Record<string, unknown>): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function installMockWorkerdFetch(): MockWorkerdHarness {
|
|
23
|
+
const fetchCalls: Array<{ url: string; headers: Record<string, string> }> = [];
|
|
24
|
+
const sent: string[] = [];
|
|
25
|
+
let acceptCalled = false;
|
|
26
|
+
const messageListeners = new Set<(event: WebSocketEventLike) => void>();
|
|
27
|
+
|
|
28
|
+
const mockWs: WebSocketLike & { accept(): void } = {
|
|
29
|
+
readyState: 1,
|
|
30
|
+
binaryType: "arraybuffer",
|
|
31
|
+
accept: () => {
|
|
32
|
+
acceptCalled = true;
|
|
33
|
+
},
|
|
34
|
+
send: (data) => {
|
|
35
|
+
if (typeof data === "string") sent.push(data);
|
|
36
|
+
},
|
|
37
|
+
close: () => {},
|
|
38
|
+
addEventListener: (type, listener) => {
|
|
39
|
+
if (type === "message") messageListeners.add(listener);
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const originalFetch = globalThis.fetch;
|
|
44
|
+
globalThis.fetch = async (input, init) => {
|
|
45
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : String(input);
|
|
46
|
+
const headers = (init?.headers ?? {}) as Record<string, string>;
|
|
47
|
+
fetchCalls.push({ url, headers });
|
|
48
|
+
return { status: 101, webSocket: mockWs } as unknown as Response;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
fetchCalls,
|
|
53
|
+
sent,
|
|
54
|
+
get acceptCalled() {
|
|
55
|
+
return acceptCalled;
|
|
56
|
+
},
|
|
57
|
+
inject: (msg) => {
|
|
58
|
+
const payload = JSON.stringify(msg);
|
|
59
|
+
for (const listener of messageListeners) listener({ data: payload });
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let restoreFetch: (() => void) | null = null;
|
|
65
|
+
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
restoreFetch?.();
|
|
68
|
+
restoreFetch = null;
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
async function collectEvents(
|
|
72
|
+
events: AsyncIterable<RealtimeEvent>,
|
|
73
|
+
max = 4,
|
|
74
|
+
): Promise<RealtimeEvent[]> {
|
|
75
|
+
const out: RealtimeEvent[] = [];
|
|
76
|
+
for await (const event of events) {
|
|
77
|
+
out.push(event);
|
|
78
|
+
if (out.length >= max) break;
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
|
|
84
|
+
const startedAt = Date.now();
|
|
85
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
86
|
+
if (predicate()) return;
|
|
87
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
88
|
+
}
|
|
89
|
+
throw new Error("Timed out waiting for condition");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
describe("Workers seam", () => {
|
|
93
|
+
it("opens via createWorkersSocket fetch-upgrade and round-trips audio events", async () => {
|
|
94
|
+
const harness = installMockWorkerdFetch();
|
|
95
|
+
const originalFetch = globalThis.fetch;
|
|
96
|
+
restoreFetch = () => {
|
|
97
|
+
globalThis.fetch = originalFetch;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const adapter = fromOpenAIRealtime({
|
|
101
|
+
apiKey: "workers-test-key",
|
|
102
|
+
socketFactory: createWorkersSocket,
|
|
103
|
+
url: () => "wss://api.openai.com/v1/realtime?model=gpt-realtime-2",
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const eventsTask = collectEvents(adapter.events, 2);
|
|
107
|
+
const openTask = adapter.open(new AbortController().signal);
|
|
108
|
+
|
|
109
|
+
await waitFor(() => harness.fetchCalls.length > 0);
|
|
110
|
+
await waitFor(() => harness.sent.length > 0);
|
|
111
|
+
|
|
112
|
+
expect(harness.fetchCalls[0]!.url).toBe(
|
|
113
|
+
"https://api.openai.com/v1/realtime?model=gpt-realtime-2",
|
|
114
|
+
);
|
|
115
|
+
expect(harness.fetchCalls[0]!.url).not.toMatch(/^wss:\/\//);
|
|
116
|
+
expect(harness.fetchCalls[0]!.headers).toMatchObject({
|
|
117
|
+
Authorization: "Bearer workers-test-key",
|
|
118
|
+
Upgrade: "websocket",
|
|
119
|
+
});
|
|
120
|
+
expect(harness.acceptCalled).toBe(true);
|
|
121
|
+
expect(JSON.parse(harness.sent[0]!)["type"]).toBe("session.update");
|
|
122
|
+
|
|
123
|
+
harness.inject({ type: "session.updated" });
|
|
124
|
+
await openTask;
|
|
125
|
+
|
|
126
|
+
const audioBytes = new Uint8Array([9, 10, 11, 12]);
|
|
127
|
+
harness.inject({ type: "response.created" });
|
|
128
|
+
harness.inject({
|
|
129
|
+
type: "response.output_audio.delta",
|
|
130
|
+
delta: bytesToBase64(audioBytes),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const events = await eventsTask;
|
|
134
|
+
const audioEvent = events.find((e) => e.type === "audio");
|
|
135
|
+
expect(audioEvent).toEqual({
|
|
136
|
+
type: "audio",
|
|
137
|
+
pcm16: audioBytes,
|
|
138
|
+
sampleRateHz: 24_000,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await adapter.close();
|
|
142
|
+
});
|
|
143
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noUncheckedIndexedAccess": true,
|
|
9
|
+
"noImplicitReturns": true,
|
|
10
|
+
"declaration": true,
|
|
11
|
+
"declarationMap": true,
|
|
12
|
+
"sourceMap": true,
|
|
13
|
+
"outDir": "./dist",
|
|
14
|
+
"rootDir": "./src",
|
|
15
|
+
"esModuleInterop": true,
|
|
16
|
+
"skipLibCheck": true,
|
|
17
|
+
"forceConsistentCasingInFileNames": true
|
|
18
|
+
},
|
|
19
|
+
"include": ["src/**/*.ts"],
|
|
20
|
+
"exclude": ["node_modules", "dist"]
|
|
21
|
+
}
|