@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.
@@ -0,0 +1,447 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import type { ManagedSocket, SocketData, SocketFactory } from "@kuralle-syrinx/ws";
6
+
7
+ import type { RealtimeEvent } from "./realtime-adapter.js";
8
+ import { base64ToBytes, bytesToBase64, fromOpenAIRealtime } from "./from-openai-realtime.js";
9
+
10
+ interface MockSocketHarness {
11
+ readonly factory: SocketFactory;
12
+ readonly sent: string[];
13
+ inject(msg: Record<string, unknown>): void;
14
+ }
15
+
16
+ function createMockSocketHarness(): MockSocketHarness {
17
+ const sent: string[] = [];
18
+ let messageHandler: ((data: SocketData, isBinary: boolean) => void) | null = null;
19
+
20
+ const socket: ManagedSocket = {
21
+ get isOpen() {
22
+ return true;
23
+ },
24
+ send: (data: SocketData) => {
25
+ sent.push(typeof data === "string" ? data : "");
26
+ },
27
+ keepAlivePing: () => {},
28
+ verify: async () => true,
29
+ dispose: () => {},
30
+ onOpen: (handler) => {
31
+ queueMicrotask(() => handler());
32
+ },
33
+ onMessage: (handler) => {
34
+ messageHandler = handler;
35
+ },
36
+ onClose: () => {},
37
+ onError: () => {},
38
+ };
39
+
40
+ return {
41
+ factory: () => socket,
42
+ sent,
43
+ inject: (msg) => messageHandler?.(JSON.stringify(msg), false),
44
+ };
45
+ }
46
+
47
+ async function collectEvents(
48
+ events: AsyncIterable<RealtimeEvent>,
49
+ max = 8,
50
+ ): Promise<RealtimeEvent[]> {
51
+ const out: RealtimeEvent[] = [];
52
+ for await (const event of events) {
53
+ out.push(event);
54
+ if (out.length >= max) break;
55
+ }
56
+ return out;
57
+ }
58
+
59
+ async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
60
+ const startedAt = Date.now();
61
+ while (Date.now() - startedAt < timeoutMs) {
62
+ if (predicate()) return;
63
+ await new Promise((resolve) => setTimeout(resolve, 5));
64
+ }
65
+ throw new Error("Timed out waiting for condition");
66
+ }
67
+
68
+ describe("fromOpenAIRealtime", () => {
69
+ it("emits exact client events for open, audio, cancel, and tool result", async () => {
70
+ const mock = createMockSocketHarness();
71
+ const adapter = fromOpenAIRealtime({
72
+ apiKey: "test-key",
73
+ socketFactory: mock.factory,
74
+ url: () => "wss://example.test/realtime?model=gpt-realtime-2",
75
+ tools: [
76
+ {
77
+ name: "consult_knowledge",
78
+ description: "Answer knowledge questions.",
79
+ parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
80
+ },
81
+ ],
82
+ });
83
+
84
+ const openTask = adapter.open(new AbortController().signal);
85
+ await waitFor(() => mock.sent.length > 0);
86
+ mock.inject({ type: "session.updated" });
87
+ await openTask;
88
+
89
+ const sessionUpdate = JSON.parse(mock.sent[0]!) as Record<string, unknown>;
90
+ expect(sessionUpdate).toEqual({
91
+ type: "session.update",
92
+ session: {
93
+ type: "realtime",
94
+ model: "gpt-realtime-2",
95
+ output_modalities: ["audio"],
96
+ audio: {
97
+ input: {
98
+ format: { type: "audio/pcm", rate: 24000 },
99
+ turn_detection: { type: "semantic_vad" },
100
+ },
101
+ output: {
102
+ format: { type: "audio/pcm", rate: 24000 },
103
+ voice: "marin",
104
+ },
105
+ },
106
+ tools: [
107
+ {
108
+ type: "function",
109
+ name: "consult_knowledge",
110
+ description: "Answer knowledge questions.",
111
+ parameters: {
112
+ type: "object",
113
+ properties: { query: { type: "string" } },
114
+ required: ["query"],
115
+ },
116
+ },
117
+ ],
118
+ tool_choice: "auto",
119
+ },
120
+ });
121
+
122
+ const pcm = new Uint8Array([0, 1, 2, 3]);
123
+ adapter.sendAudio(pcm);
124
+ expect(JSON.parse(mock.sent[1]!)).toEqual({
125
+ type: "input_audio_buffer.append",
126
+ audio: bytesToBase64(pcm),
127
+ });
128
+
129
+ mock.inject({ type: "response.created" });
130
+ mock.inject({
131
+ type: "response.output_item.added",
132
+ item: { type: "message", id: "item_assistant_1" },
133
+ });
134
+ adapter.cancelResponse(420);
135
+ expect(JSON.parse(mock.sent[2]!)).toEqual({ type: "response.cancel" });
136
+ expect(JSON.parse(mock.sent[3]!)).toEqual({
137
+ type: "conversation.item.truncate",
138
+ item_id: "item_assistant_1",
139
+ content_index: 0,
140
+ audio_end_ms: 420,
141
+ });
142
+
143
+ adapter.injectToolResult("call_abc", "Late Add Petition required.");
144
+ expect(JSON.parse(mock.sent[4]!)).toEqual({
145
+ type: "conversation.item.create",
146
+ item: {
147
+ type: "function_call_output",
148
+ call_id: "call_abc",
149
+ output: "Late Add Petition required.",
150
+ },
151
+ });
152
+ expect(mock.sent).toHaveLength(5);
153
+
154
+ mock.inject({ type: "response.done", response: {} });
155
+ expect(JSON.parse(mock.sent[5]!)).toEqual({ type: "response.create" });
156
+ });
157
+
158
+ it("normalizes provider server events into RealtimeEvent", async () => {
159
+ const mock = createMockSocketHarness();
160
+ const adapter = fromOpenAIRealtime({
161
+ apiKey: "test-key",
162
+ socketFactory: mock.factory,
163
+ url: () => "wss://example.test/realtime?model=gpt-realtime-2",
164
+ });
165
+
166
+ const eventsTask = collectEvents(adapter.events, 8);
167
+ const openTask = adapter.open(new AbortController().signal);
168
+ await waitFor(() => mock.sent.length > 0);
169
+ mock.inject({ type: "session.updated" });
170
+ await openTask;
171
+
172
+ const audioBytes = new Uint8Array([9, 10, 11, 12]);
173
+ mock.inject({
174
+ type: "response.created",
175
+ });
176
+ mock.inject({
177
+ type: "response.output_audio.delta",
178
+ delta: bytesToBase64(audioBytes),
179
+ });
180
+ mock.inject({ type: "input_audio_buffer.speech_started" });
181
+ mock.inject({
182
+ type: "response.output_audio_transcript.delta",
183
+ delta: "Let me ",
184
+ });
185
+ mock.inject({
186
+ type: "response.output_audio_transcript.done",
187
+ transcript: "Let me check that.",
188
+ });
189
+ mock.inject({
190
+ type: "response.done",
191
+ response: {
192
+ output: [
193
+ {
194
+ type: "function_call",
195
+ call_id: "call_123",
196
+ name: "consult_knowledge",
197
+ arguments: JSON.stringify({ query: "late add biology" }),
198
+ },
199
+ ],
200
+ },
201
+ });
202
+ mock.inject({
203
+ type: "error",
204
+ error: { message: "rate limited", code: "rate_limit_exceeded" },
205
+ });
206
+
207
+ const events = await eventsTask;
208
+ expect(events.slice(0, 7)).toEqual([
209
+ { type: "response_started" },
210
+ { type: "audio", pcm16: audioBytes, sampleRateHz: 24000 },
211
+ { type: "speech_started" },
212
+ { type: "transcript", role: "assistant", text: "Let me ", final: false },
213
+ { type: "transcript", role: "assistant", text: "Let me check that.", final: true },
214
+ {
215
+ type: "tool_call",
216
+ toolId: "call_123",
217
+ toolName: "consult_knowledge",
218
+ args: { query: "late add biology" },
219
+ },
220
+ { type: "response_done" },
221
+ ]);
222
+ expect(events[7]).toMatchObject({ type: "error", recoverable: true });
223
+ if (events[7]?.type === "error") {
224
+ expect(events[7].cause.message).toBe("rate limited");
225
+ }
226
+ });
227
+
228
+ it("exposes gpt-realtime-2 capability flags", () => {
229
+ const mock = createMockSocketHarness();
230
+ const adapter = fromOpenAIRealtime({
231
+ apiKey: "test-key",
232
+ socketFactory: mock.factory,
233
+ });
234
+ expect(adapter.caps).toEqual({
235
+ inputSampleRateHz: 24000,
236
+ outputSampleRateHz: 24000,
237
+ supportsConcurrentToolAudio: true,
238
+ supportsTruncate: true,
239
+ emitsServerSpeechStarted: true,
240
+ });
241
+ });
242
+
243
+ it("R-04: base64 encode/decode works without Buffer or process", async () => {
244
+ const savedBuffer = (globalThis as { Buffer?: unknown }).Buffer;
245
+ const savedProcess = (globalThis as { process?: unknown }).process;
246
+ delete (globalThis as { Buffer?: unknown }).Buffer;
247
+ delete (globalThis as { process?: unknown }).process;
248
+
249
+ try {
250
+ const pcm = new Uint8Array([0, 1, 2, 3, 255, 128]);
251
+ const encoded = bytesToBase64(pcm);
252
+ expect(base64ToBytes(encoded)).toEqual(pcm);
253
+
254
+ const mock = createMockSocketHarness();
255
+ const adapter = fromOpenAIRealtime({
256
+ apiKey: "test-key",
257
+ socketFactory: mock.factory,
258
+ url: () => "wss://example.test/realtime?model=gpt-realtime-2",
259
+ });
260
+
261
+ const openTask = adapter.open(new AbortController().signal);
262
+ await waitFor(() => mock.sent.length > 0);
263
+ mock.inject({ type: "session.updated" });
264
+ await openTask;
265
+
266
+ adapter.sendAudio(pcm);
267
+ const sent = JSON.parse(mock.sent[1]!) as { audio: string };
268
+ expect(sent.audio).toBe(encoded);
269
+ } finally {
270
+ if (savedBuffer !== undefined) (globalThis as { Buffer?: unknown }).Buffer = savedBuffer;
271
+ if (savedProcess !== undefined) (globalThis as { process?: unknown }).process = savedProcess;
272
+ }
273
+ });
274
+
275
+ it("R-06: forwards optional session config into session.update", async () => {
276
+ const mock = createMockSocketHarness();
277
+ const adapter = fromOpenAIRealtime({
278
+ apiKey: "test-key",
279
+ socketFactory: mock.factory,
280
+ url: () => "wss://example.test/realtime?model=gpt-realtime-2",
281
+ instructions: "You are a helpful assistant.",
282
+ modalities: ["audio", "text"],
283
+ temperature: 0.7,
284
+ inputTranscription: { model: "whisper-1" },
285
+ toolChoice: "required",
286
+ inputRateHz: 16000,
287
+ outputRateHz: 16000,
288
+ });
289
+
290
+ const openTask = adapter.open(new AbortController().signal);
291
+ await waitFor(() => mock.sent.length > 0);
292
+ mock.inject({ type: "session.updated" });
293
+ await openTask;
294
+
295
+ expect(JSON.parse(mock.sent[0]!)).toEqual({
296
+ type: "session.update",
297
+ session: {
298
+ type: "realtime",
299
+ model: "gpt-realtime-2",
300
+ output_modalities: ["audio", "text"],
301
+ instructions: "You are a helpful assistant.",
302
+ temperature: 0.7,
303
+ audio: {
304
+ input: {
305
+ format: { type: "audio/pcm", rate: 16000 },
306
+ turn_detection: { type: "semantic_vad" },
307
+ transcription: { model: "whisper-1" },
308
+ },
309
+ output: {
310
+ format: { type: "audio/pcm", rate: 16000 },
311
+ voice: "marin",
312
+ },
313
+ },
314
+ tools: [],
315
+ tool_choice: "required",
316
+ },
317
+ });
318
+ expect(adapter.caps.inputSampleRateHz).toBe(16000);
319
+ expect(adapter.caps.outputSampleRateHz).toBe(16000);
320
+ });
321
+
322
+ it("R-10: skips response.create when requiresResponseCreateAfterToolOutput is false", async () => {
323
+ const mock = createMockSocketHarness();
324
+ const adapter = fromOpenAIRealtime({
325
+ apiKey: "test-key",
326
+ socketFactory: mock.factory,
327
+ url: () => "wss://example.test/realtime?model=gpt-realtime-2",
328
+ requiresResponseCreateAfterToolOutput: false,
329
+ });
330
+
331
+ const openTask = adapter.open(new AbortController().signal);
332
+ await waitFor(() => mock.sent.length > 0);
333
+ mock.inject({ type: "session.updated" });
334
+ await openTask;
335
+
336
+ adapter.injectToolResult("call_xyz", "done");
337
+ expect(mock.sent).toHaveLength(2);
338
+ expect(JSON.parse(mock.sent[1]!)).toEqual({
339
+ type: "conversation.item.create",
340
+ item: {
341
+ type: "function_call_output",
342
+ call_id: "call_xyz",
343
+ output: "done",
344
+ },
345
+ });
346
+ });
347
+
348
+ it("B1: does not send response.create while response is active (cancel-in-flight interleaving)", async () => {
349
+ const mock = createMockSocketHarness();
350
+ const adapter = fromOpenAIRealtime({
351
+ apiKey: "test-key",
352
+ socketFactory: mock.factory,
353
+ url: () => "wss://example.test/realtime?model=gpt-realtime-2",
354
+ });
355
+
356
+ const openTask = adapter.open(new AbortController().signal);
357
+ await waitFor(() => mock.sent.length > 0);
358
+ mock.inject({ type: "session.updated" });
359
+ await openTask;
360
+
361
+ mock.inject({ type: "response.created" });
362
+ mock.inject({
363
+ type: "response.output_item.added",
364
+ item: { type: "message", id: "item_assistant_1" },
365
+ });
366
+
367
+ adapter.cancelResponse(420);
368
+ expect(JSON.parse(mock.sent[1]!)).toEqual({ type: "response.cancel" });
369
+ expect(JSON.parse(mock.sent[2]!)).toEqual({
370
+ type: "conversation.item.truncate",
371
+ item_id: "item_assistant_1",
372
+ content_index: 0,
373
+ audio_end_ms: 420,
374
+ });
375
+
376
+ adapter.injectToolResult("call_abc", "Late Add Petition required.");
377
+ const typesAfterInject = mock.sent.map(
378
+ (raw) => (JSON.parse(raw) as Record<string, unknown>)["type"],
379
+ );
380
+ expect(typesAfterInject).toContain("conversation.item.create");
381
+ expect(typesAfterInject).not.toContain("response.create");
382
+
383
+ mock.inject({ type: "response.done", response: {} });
384
+ const typesAfterDone = mock.sent.map(
385
+ (raw) => (JSON.parse(raw) as Record<string, unknown>)["type"],
386
+ );
387
+ expect(typesAfterDone.filter((t) => t === "response.create")).toHaveLength(1);
388
+ expect(JSON.parse(mock.sent[mock.sent.length - 1]!)).toEqual({ type: "response.create" });
389
+ });
390
+
391
+ it("B1: does not send response.create while response is active (direct inject without cancel)", async () => {
392
+ const mock = createMockSocketHarness();
393
+ const adapter = fromOpenAIRealtime({
394
+ apiKey: "test-key",
395
+ socketFactory: mock.factory,
396
+ url: () => "wss://example.test/realtime?model=gpt-realtime-2",
397
+ });
398
+
399
+ const openTask = adapter.open(new AbortController().signal);
400
+ await waitFor(() => mock.sent.length > 0);
401
+ mock.inject({ type: "session.updated" });
402
+ await openTask;
403
+
404
+ mock.inject({ type: "response.created" });
405
+
406
+ adapter.injectToolResult("call_xyz", "inline result");
407
+ const typesAfterInject = mock.sent.map(
408
+ (raw) => (JSON.parse(raw) as Record<string, unknown>)["type"],
409
+ );
410
+ expect(typesAfterInject).toContain("conversation.item.create");
411
+ expect(typesAfterInject).not.toContain("response.create");
412
+
413
+ mock.inject({ type: "response.done", response: {} });
414
+ expect(
415
+ mock.sent.map((raw) => (JSON.parse(raw) as Record<string, unknown>)["type"]),
416
+ ).toContain("response.create");
417
+ });
418
+
419
+ it("R-11: does not truncate stale item when new response is canceled before output_item.added", async () => {
420
+ const mock = createMockSocketHarness();
421
+ const adapter = fromOpenAIRealtime({
422
+ apiKey: "test-key",
423
+ socketFactory: mock.factory,
424
+ url: () => "wss://example.test/realtime?model=gpt-realtime-2",
425
+ });
426
+
427
+ const openTask = adapter.open(new AbortController().signal);
428
+ await waitFor(() => mock.sent.length > 0);
429
+ mock.inject({ type: "session.updated" });
430
+ await openTask;
431
+
432
+ mock.inject({ type: "response.created" });
433
+ mock.inject({
434
+ type: "response.output_item.added",
435
+ item: { type: "message", id: "item_old" },
436
+ });
437
+ mock.inject({ type: "response.done" });
438
+
439
+ mock.inject({ type: "response.created" });
440
+ adapter.cancelResponse(100);
441
+
442
+ const truncateMessages = mock.sent
443
+ .map((raw) => JSON.parse(raw) as Record<string, unknown>)
444
+ .filter((msg) => msg["type"] === "conversation.item.truncate");
445
+ expect(truncateMessages).toHaveLength(0);
446
+ });
447
+ });
@@ -0,0 +1,104 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import type { SocketFactory } from "@kuralle-syrinx/ws";
4
+
5
+ import { base64ToBytes, bytesToBase64 } from "./base64.js";
6
+ import { createOpenAiCompatibleRealtimeAdapter } from "./openai-compatible-realtime.js";
7
+ import type { RealtimeAdapter, RealtimeToolDef } from "./realtime-adapter.js";
8
+
9
+ const DEFAULT_MODEL = "gpt-realtime-2";
10
+ const DEFAULT_VOICE = "marin";
11
+ const DEFAULT_SAMPLE_RATE_HZ = 24_000;
12
+
13
+ export interface OpenAIRealtimeOptions {
14
+ readonly apiKey: string;
15
+ readonly model?: string;
16
+ readonly voice?: string;
17
+ readonly socketFactory: SocketFactory;
18
+ readonly url?: () => string;
19
+ /** Server turn-detection config. Defaults to semantic_vad; pass `server_vad` or `null` to override. */
20
+ readonly turnDetection?: Record<string, unknown> | null;
21
+ /**
22
+ * Function tools the front model may call (e.g. a delegate tool routed to a Reasoner). Domain-neutral
23
+ * — the caller supplies these. Empty by default (standalone s2s, no delegation).
24
+ */
25
+ readonly tools?: readonly RealtimeToolDef[];
26
+ readonly debug?: boolean;
27
+ /** gpt-realtime-2 requires response.create after function_call_output; set false for providers that do not. */
28
+ readonly requiresResponseCreateAfterToolOutput?: boolean;
29
+ readonly instructions?: string;
30
+ readonly modalities?: readonly string[];
31
+ readonly temperature?: number;
32
+ readonly inputTranscription?: Record<string, unknown> | boolean;
33
+ readonly toolChoice?: string | Record<string, unknown>;
34
+ readonly inputRateHz?: number;
35
+ readonly outputRateHz?: number;
36
+ }
37
+
38
+ export function fromOpenAIRealtime(opts: OpenAIRealtimeOptions): RealtimeAdapter {
39
+ const inputRateHz = opts.inputRateHz ?? DEFAULT_SAMPLE_RATE_HZ;
40
+ const outputRateHz = opts.outputRateHz ?? DEFAULT_SAMPLE_RATE_HZ;
41
+ const model = opts.model ?? DEFAULT_MODEL;
42
+ const voice = opts.voice ?? DEFAULT_VOICE;
43
+
44
+ return createOpenAiCompatibleRealtimeAdapter({
45
+ apiKey: opts.apiKey,
46
+ socketFactory: opts.socketFactory,
47
+ debug: opts.debug,
48
+ defaultModel: DEFAULT_MODEL,
49
+ model: opts.model,
50
+ url: opts.url,
51
+ caps: {
52
+ inputSampleRateHz: inputRateHz,
53
+ outputSampleRateHz: outputRateHz,
54
+ supportsConcurrentToolAudio: true,
55
+ supportsTruncate: true,
56
+ emitsServerSpeechStarted: true,
57
+ },
58
+ buildSessionUpdate: () => {
59
+ const inputAudio: Record<string, unknown> = {
60
+ format: { type: "audio/pcm", rate: inputRateHz },
61
+ turn_detection:
62
+ "turnDetection" in opts ? opts.turnDetection : { type: "semantic_vad" },
63
+ };
64
+ if (opts.inputTranscription !== undefined) {
65
+ inputAudio["transcription"] =
66
+ opts.inputTranscription === true ? { model: "whisper-1" } : opts.inputTranscription;
67
+ }
68
+
69
+ const session: Record<string, unknown> = {
70
+ type: "realtime",
71
+ model,
72
+ output_modalities: opts.modalities ?? ["audio"],
73
+ audio: {
74
+ input: inputAudio,
75
+ output: {
76
+ format: { type: "audio/pcm", rate: outputRateHz },
77
+ voice,
78
+ },
79
+ },
80
+ tools: (opts.tools ?? []).map((t) => ({
81
+ type: "function" as const,
82
+ name: t.name,
83
+ description: t.description,
84
+ parameters: t.parameters,
85
+ })),
86
+ tool_choice: opts.toolChoice ?? "auto",
87
+ };
88
+
89
+ if (opts.instructions !== undefined) {
90
+ session["instructions"] = opts.instructions;
91
+ }
92
+ if (opts.temperature !== undefined) {
93
+ session["temperature"] = opts.temperature;
94
+ }
95
+
96
+ return session;
97
+ },
98
+ supportsTruncate: true,
99
+ requiresResponseCreateAfterToolOutput: opts.requiresResponseCreateAfterToolOutput,
100
+ defaultErrorMessage: "OpenAI Realtime error",
101
+ });
102
+ }
103
+
104
+ export { bytesToBase64, base64ToBytes };
@@ -0,0 +1,82 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { createGeminiTranslateSession } from "./gemini-translate.js";
6
+
7
+ const sendRealtimeInput = vi.fn();
8
+ const closeSession = vi.fn();
9
+
10
+ const liveConnect = vi.fn().mockImplementation(async () => ({
11
+ sendRealtimeInput,
12
+ close: closeSession,
13
+ }));
14
+
15
+ vi.mock("@google/genai", () => ({
16
+ GoogleGenAI: vi.fn().mockImplementation(() => ({
17
+ live: { connect: liveConnect },
18
+ })),
19
+ Modality: { AUDIO: "AUDIO" },
20
+ }));
21
+
22
+ const FRAME_BYTES = 640;
23
+
24
+ function frame(byte: number): Uint8Array {
25
+ return new Uint8Array(FRAME_BYTES).fill(byte);
26
+ }
27
+
28
+ afterEach(() => {
29
+ sendRealtimeInput.mockClear();
30
+ closeSession.mockClear();
31
+ liveConnect.mockClear();
32
+ });
33
+
34
+ describe("createGeminiTranslateSession", () => {
35
+ it("coalesces 20ms frames into 100ms chunks before sendRealtimeInput", async () => {
36
+ const session = await createGeminiTranslateSession({
37
+ apiKey: "test-key",
38
+ targetLanguageCode: "es",
39
+ onAudio: () => {},
40
+ });
41
+
42
+ for (let i = 0; i < 5; i += 1) {
43
+ session.sendAudio(frame(i));
44
+ }
45
+
46
+ expect(sendRealtimeInput).toHaveBeenCalledTimes(1);
47
+ const arg = sendRealtimeInput.mock.calls[0]![0] as { audio: { data: string } };
48
+ const chunk = Buffer.from(arg.audio.data, "base64");
49
+ expect(chunk.byteLength).toBe(3200);
50
+ expect(chunk[0]).toBe(0);
51
+ expect(chunk[639]).toBe(0);
52
+ expect(chunk[640]).toBe(1);
53
+ expect(chunk[3199]).toBe(4);
54
+ });
55
+
56
+ it("flushes remainder on signalAudioStreamEnd and close", async () => {
57
+ const session = await createGeminiTranslateSession({
58
+ apiKey: "test-key",
59
+ targetLanguageCode: "es",
60
+ onAudio: () => {},
61
+ });
62
+
63
+ session.sendAudio(frame(9));
64
+ session.sendAudio(frame(9));
65
+ session.sendAudio(frame(9));
66
+ expect(sendRealtimeInput).toHaveBeenCalledTimes(0);
67
+
68
+ session.signalAudioStreamEnd();
69
+ expect(sendRealtimeInput).toHaveBeenCalledTimes(2);
70
+ const streamEndArg = sendRealtimeInput.mock.calls[0]![0] as { audio: { data: string } };
71
+ expect(Buffer.from(streamEndArg.audio.data, "base64").byteLength).toBe(1920);
72
+ expect(sendRealtimeInput.mock.calls[1]![0]).toEqual({ audioStreamEnd: true });
73
+
74
+ sendRealtimeInput.mockClear();
75
+ session.sendAudio(frame(7));
76
+ await session.close();
77
+ expect(sendRealtimeInput).toHaveBeenCalledTimes(1);
78
+ const closeArg = sendRealtimeInput.mock.calls[0]![0] as { audio: { data: string } };
79
+ expect(Buffer.from(closeArg.audio.data, "base64").byteLength).toBe(640);
80
+ expect(closeSession).toHaveBeenCalledTimes(1);
81
+ });
82
+ });