kugelaudio 0.2.3 → 0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kugelaudio",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "Official JavaScript/TypeScript SDK for KugelAudio TTS API",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -34,21 +34,22 @@
34
34
  "websocket",
35
35
  "kugelaudio"
36
36
  ],
37
- "author": "KugelAudio <support@kugelaudio.com>",
37
+ "author": "KugelAudio <hello@kugelaudio.com>",
38
38
  "license": "MIT",
39
39
  "repository": {
40
40
  "type": "git",
41
- "url": "https://github.com/kugelaudio/kugelaudio-js"
41
+ "url": "https://github.com/Kugelaudio/KugelAudio",
42
+ "directory": "sdks/js"
42
43
  },
43
44
  "homepage": "https://kugelaudio.com",
44
45
  "bugs": {
45
- "url": "https://github.com/kugelaudio/kugelaudio-js/issues"
46
+ "url": "https://github.com/Kugelaudio/KugelAudio/issues"
46
47
  },
47
48
  "devDependencies": {
48
- "@types/node": "^20.0.0",
49
+ "@types/node": "^25.3.2",
49
50
  "tsup": "^8.0.0",
50
- "typescript": "^5.0.0",
51
- "vitest": "^1.0.0"
51
+ "typescript": "^6.0.2",
52
+ "vitest": "^4.0.18"
52
53
  },
53
54
  "engines": {
54
55
  "node": ">=18.0.0"
@@ -57,4 +58,4 @@
57
58
  "tsx": "^4.21.0",
58
59
  "ws": "^8.18.0"
59
60
  }
60
- }
61
+ }
@@ -0,0 +1,548 @@
1
+ /**
2
+ * Unit tests for TTSResource.toReadable() and keepalive ping mechanism.
3
+ *
4
+ * These tests mock the WebSocket and verify that toReadable() correctly
5
+ * pipes audio chunks into a Node.js Readable stream without gaps or
6
+ * ordering issues — the root cause of KUG-157.
7
+ */
8
+
9
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
10
+ import { KugelAudio } from './client';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Minimal WebSocket mock
14
+ // ---------------------------------------------------------------------------
15
+
16
+ type WsListener = (event: { data: string }) => void;
17
+ type WsCloseListener = (event: { code: number }) => void;
18
+
19
+ interface MockWs {
20
+ readyState: number;
21
+ onopen: (() => void) | null;
22
+ onmessage: WsListener | null;
23
+ onerror: (() => void) | null;
24
+ onclose: WsCloseListener | null;
25
+ send: ReturnType<typeof vi.fn>;
26
+ close: ReturnType<typeof vi.fn>;
27
+ ping?: ReturnType<typeof vi.fn>;
28
+ }
29
+
30
+ let mockWs: MockWs;
31
+
32
+ vi.mock('./websocket', () => ({
33
+ getWebSocket: () => {
34
+ return class MockWebSocket {
35
+ readyState = 0; // CONNECTING
36
+ onopen: (() => void) | null = null;
37
+ onmessage: WsListener | null = null;
38
+ onerror: (() => void) | null = null;
39
+ onclose: WsCloseListener | null = null;
40
+ send = vi.fn();
41
+ close = vi.fn();
42
+ ping = vi.fn();
43
+
44
+ constructor() {
45
+ mockWs = this as unknown as MockWs;
46
+ // Simulate async open
47
+ setTimeout(() => {
48
+ this.readyState = 1; // OPEN
49
+ this.onopen?.();
50
+ }, 0);
51
+ }
52
+ };
53
+ },
54
+ }));
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Helpers
58
+ // ---------------------------------------------------------------------------
59
+
60
+ function makeAudioMsg(index: number, samples = 100): string {
61
+ // Create a Buffer of `samples * 2` bytes (PCM16 = 2 bytes/sample) and base64-encode it.
62
+ const pcm = Buffer.alloc(samples * 2, index); // fill with byte value = index
63
+ return JSON.stringify({
64
+ audio: pcm.toString('base64'),
65
+ enc: 'pcm_s16le',
66
+ idx: index,
67
+ sr: 24000,
68
+ samples,
69
+ });
70
+ }
71
+
72
+ function makeFinalMsg(chunks: number, totalSamples: number): string {
73
+ return JSON.stringify({
74
+ final: true,
75
+ chunks,
76
+ total_samples: totalSamples,
77
+ dur_ms: 100,
78
+ gen_ms: 50,
79
+ ttfa_ms: 30,
80
+ rtf: 0.5,
81
+ });
82
+ }
83
+
84
+ /** Collect all data events from a Readable into a single Buffer. */
85
+ function collectStream(stream: NodeJS.ReadableStream): Promise<Buffer> {
86
+ return new Promise((resolve, reject) => {
87
+ const parts: Buffer[] = [];
88
+ stream.on('data', (chunk: Buffer) => parts.push(chunk));
89
+ stream.on('end', () => resolve(Buffer.concat(parts)));
90
+ stream.on('error', reject);
91
+ });
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Tests
96
+ // ---------------------------------------------------------------------------
97
+
98
+ describe('TTSResource.toReadable()', () => {
99
+ let client: KugelAudio;
100
+
101
+ beforeEach(() => {
102
+ client = new KugelAudio({ apiKey: 'test-key-xxx' });
103
+ });
104
+
105
+ it('returns a Readable that emits raw PCM16 bytes for each audio chunk', async () => {
106
+ const readable = client.tts.toReadable({ text: 'Hello' });
107
+
108
+ // Wait for the mock WebSocket to open and send() to be called
109
+ await new Promise<void>((r) => setTimeout(r, 10));
110
+
111
+ // Simulate three audio chunks followed by final
112
+ mockWs.onmessage?.({ data: makeAudioMsg(0, 100) });
113
+ mockWs.onmessage?.({ data: makeAudioMsg(1, 200) });
114
+ mockWs.onmessage?.({ data: makeAudioMsg(2, 150) });
115
+ mockWs.onmessage?.({ data: makeFinalMsg(3, 450) });
116
+
117
+ const result = await collectStream(readable);
118
+
119
+ // Each PCM16 chunk is samples*2 bytes
120
+ expect(result.byteLength).toBe((100 + 200 + 150) * 2);
121
+ });
122
+
123
+ it('emits chunks in the correct order', async () => {
124
+ const readable = client.tts.toReadable({ text: 'Order test' });
125
+
126
+ await new Promise<void>((r) => setTimeout(r, 10));
127
+
128
+ // Use distinct fill patterns per chunk so ordering is detectable
129
+ const chunk0 = Buffer.alloc(10, 0x01);
130
+ const chunk1 = Buffer.alloc(10, 0x02);
131
+ const chunk2 = Buffer.alloc(10, 0x03);
132
+
133
+ mockWs.onmessage?.({ data: JSON.stringify({ audio: chunk0.toString('base64'), enc: 'pcm_s16le', idx: 0, sr: 24000, samples: 5 }) });
134
+ mockWs.onmessage?.({ data: JSON.stringify({ audio: chunk1.toString('base64'), enc: 'pcm_s16le', idx: 1, sr: 24000, samples: 5 }) });
135
+ mockWs.onmessage?.({ data: JSON.stringify({ audio: chunk2.toString('base64'), enc: 'pcm_s16le', idx: 2, sr: 24000, samples: 5 }) });
136
+ mockWs.onmessage?.({ data: makeFinalMsg(3, 15) });
137
+
138
+ const result = await collectStream(readable);
139
+
140
+ expect(result.byteLength).toBe(30);
141
+ // First 10 bytes should be 0x01, next 10 should be 0x02, last 10 should be 0x03
142
+ expect(result.subarray(0, 10).every((b) => b === 0x01)).toBe(true);
143
+ expect(result.subarray(10, 20).every((b) => b === 0x02)).toBe(true);
144
+ expect(result.subarray(20, 30).every((b) => b === 0x03)).toBe(true);
145
+ });
146
+
147
+ it('destroys the stream with an error on WebSocket error', async () => {
148
+ const readable = client.tts.toReadable({ text: 'Error test' });
149
+
150
+ await new Promise<void>((r) => setTimeout(r, 10));
151
+
152
+ mockWs.onmessage?.({ data: JSON.stringify({ error: 'internal server error' }) });
153
+
154
+ await expect(collectStream(readable)).rejects.toThrow();
155
+ });
156
+
157
+ it('ends the stream cleanly when final message is received', async () => {
158
+ const readable = client.tts.toReadable({ text: 'End test' });
159
+
160
+ await new Promise<void>((r) => setTimeout(r, 10));
161
+
162
+ mockWs.onmessage?.({ data: makeAudioMsg(0, 50) });
163
+ mockWs.onmessage?.({ data: makeFinalMsg(1, 50) });
164
+
165
+ const result = await collectStream(readable);
166
+ expect(result.byteLength).toBe(100); // 50 samples * 2 bytes
167
+ });
168
+
169
+ it('creates the stream synchronously before audio arrives', () => {
170
+ // The Readable must be returned immediately, not after the first chunk.
171
+ const readable = client.tts.toReadable({ text: 'Sync test' });
172
+ expect(readable).toBeDefined();
173
+ expect(typeof readable.pipe).toBe('function');
174
+ expect(typeof readable.on).toBe('function');
175
+ });
176
+ });
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // Multi-region tests
180
+ // ---------------------------------------------------------------------------
181
+
182
+ describe('KugelAudio multi-region', () => {
183
+ it('defaults to EU when no region or prefix is given', () => {
184
+ const client = new KugelAudio({ apiKey: 'ka_test123' });
185
+ expect((client as any)._apiUrl).toBe('https://api.kugelaudio.com');
186
+ });
187
+
188
+ it('selects US endpoint with explicit region', () => {
189
+ const client = new KugelAudio({ apiKey: 'ka_test123', region: 'us' });
190
+ expect((client as any)._apiUrl).toBe('https://us-api.kugelaudio.com');
191
+ });
192
+
193
+ it('selects global endpoint with explicit region', () => {
194
+ const client = new KugelAudio({ apiKey: 'ka_test123', region: 'global' });
195
+ expect((client as any)._apiUrl).toBe('https://global-api.kugelaudio.com');
196
+ });
197
+
198
+ it('detects US region from key prefix and strips it', () => {
199
+ const client = new KugelAudio({ apiKey: 'us-ka_test123' });
200
+ expect((client as any)._apiUrl).toBe('https://us-api.kugelaudio.com');
201
+ expect((client as any)._apiKey).toBe('ka_test123');
202
+ });
203
+
204
+ it('detects global region from key prefix and strips it', () => {
205
+ const client = new KugelAudio({ apiKey: 'global-ka_test123' });
206
+ expect((client as any)._apiUrl).toBe('https://global-api.kugelaudio.com');
207
+ expect((client as any)._apiKey).toBe('ka_test123');
208
+ });
209
+
210
+ it('detects EU region from key prefix and strips it', () => {
211
+ const client = new KugelAudio({ apiKey: 'eu-ka_test123' });
212
+ expect((client as any)._apiUrl).toBe('https://api.kugelaudio.com');
213
+ expect((client as any)._apiKey).toBe('ka_test123');
214
+ });
215
+
216
+ it('explicit region overrides key prefix', () => {
217
+ const client = new KugelAudio({ apiKey: 'us-ka_test123', region: 'global' });
218
+ expect((client as any)._apiUrl).toBe('https://global-api.kugelaudio.com');
219
+ expect((client as any)._apiKey).toBe('ka_test123');
220
+ });
221
+
222
+ it('explicit apiUrl overrides region entirely', () => {
223
+ const client = new KugelAudio({
224
+ apiKey: 'us-ka_test123',
225
+ region: 'global',
226
+ apiUrl: 'https://custom.example.com',
227
+ });
228
+ expect((client as any)._apiUrl).toBe('https://custom.example.com');
229
+ expect((client as any)._apiKey).toBe('ka_test123');
230
+ });
231
+
232
+ it('throws on invalid region', () => {
233
+ expect(() => new KugelAudio({ apiKey: 'ka_test123', region: 'mars' as any })).toThrow(
234
+ /Invalid region/
235
+ );
236
+ });
237
+
238
+ it('ttsUrl defaults to the resolved region URL', () => {
239
+ const client = new KugelAudio({ apiKey: 'us-ka_test123' });
240
+ expect((client as any)._ttsUrl).toBe('https://us-api.kugelaudio.com');
241
+ });
242
+
243
+ it('unprefixed key is not modified', () => {
244
+ const client = new KugelAudio({ apiKey: 'ka_no_prefix' });
245
+ expect((client as any)._apiKey).toBe('ka_no_prefix');
246
+ });
247
+ });
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // Keepalive ping tests
251
+ // ---------------------------------------------------------------------------
252
+
253
+ describe('KugelAudio keepalive ping', () => {
254
+ beforeEach(() => {
255
+ vi.useFakeTimers();
256
+ });
257
+
258
+ afterEach(() => {
259
+ vi.useRealTimers();
260
+ });
261
+
262
+ it('stores default keepalive interval of 20 000 ms', () => {
263
+ const client = new KugelAudio({ apiKey: 'test-key-xxx' });
264
+ expect(client.keepalivePingInterval).toBe(20_000);
265
+ });
266
+
267
+ it('accepts a custom keepalive interval', () => {
268
+ const client = new KugelAudio({ apiKey: 'test-key-xxx', keepalivePingInterval: 5_000 });
269
+ expect(client.keepalivePingInterval).toBe(5_000);
270
+ });
271
+
272
+ it('stores null when keepalive is disabled', () => {
273
+ const client = new KugelAudio({ apiKey: 'test-key-xxx', keepalivePingInterval: null });
274
+ expect(client.keepalivePingInterval).toBeNull();
275
+ });
276
+
277
+ it('calls ws.ping() after each interval fires', async () => {
278
+ const client = new KugelAudio({ apiKey: 'test-key-xxx', keepalivePingInterval: 1_000 });
279
+ // Trigger the connection by opening a stream
280
+ client.tts.toReadable({ text: 'ping test' });
281
+
282
+ // Advance just enough for the mock WebSocket setTimeout(0) to fire (open event)
283
+ await vi.advanceTimersByTimeAsync(10);
284
+
285
+ // Now the WS is open and the keepalive setInterval is registered.
286
+ // Advance 3 more seconds to fire the ping 3 times.
287
+ await vi.advanceTimersByTimeAsync(3_000);
288
+
289
+ expect(mockWs.ping).toBeDefined();
290
+ expect((mockWs.ping as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThanOrEqual(2);
291
+ });
292
+
293
+ it('does not call ws.ping() when keepalive is disabled', async () => {
294
+ const client = new KugelAudio({ apiKey: 'test-key-xxx', keepalivePingInterval: null });
295
+ client.tts.toReadable({ text: 'no ping test' });
296
+
297
+ // Let the WS open
298
+ await vi.advanceTimersByTimeAsync(10);
299
+ // Advance a long time — no pings should fire
300
+ await vi.advanceTimersByTimeAsync(60_000);
301
+
302
+ expect((mockWs.ping as ReturnType<typeof vi.fn>).mock.calls.length).toBe(0);
303
+ });
304
+ });
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // StreamingSession tests (KUG-264)
308
+ // ---------------------------------------------------------------------------
309
+
310
+ function makeChunkCompleteMsg(chunkId: number, audioSeconds: number, genMs: number): string {
311
+ return JSON.stringify({
312
+ chunk_complete: true,
313
+ chunk_id: chunkId,
314
+ audio_seconds: audioSeconds,
315
+ gen_ms: genMs,
316
+ });
317
+ }
318
+
319
+ function makeSessionClosedMsg(totalAudioSeconds: number, totalTextChunks: number, totalAudioChunks: number): string {
320
+ return JSON.stringify({
321
+ session_closed: true,
322
+ total_audio_seconds: totalAudioSeconds,
323
+ total_text_chunks: totalTextChunks,
324
+ total_audio_chunks: totalAudioChunks,
325
+ });
326
+ }
327
+
328
+ function makeGenerationStartedMsg(chunkId: number, text: string): string {
329
+ return JSON.stringify({
330
+ generation_started: true,
331
+ chunk_id: chunkId,
332
+ text,
333
+ });
334
+ }
335
+
336
+ describe('StreamingSession', () => {
337
+ let client: KugelAudio;
338
+
339
+ beforeEach(() => {
340
+ client = new KugelAudio({ apiKey: 'test-key-xxx' });
341
+ });
342
+
343
+ /**
344
+ * Helper: make the mock's close() simulate real WebSocket behaviour —
345
+ * once close() is called, onmessage is nulled (no more message delivery).
346
+ * This reproduces the real-world timing issue where the server's
347
+ * session_closed message arrives after the socket is torn down.
348
+ */
349
+ function makeCloseTearDown(): void {
350
+ mockWs.close = vi.fn(() => {
351
+ mockWs.readyState = 3; // CLOSED
352
+ mockWs.onmessage = null;
353
+ });
354
+ }
355
+
356
+ it('fires onSessionClosed when server sends session_closed after close()', async () => {
357
+ const sessionClosedCalls: Array<{ totalAudioSeconds: number; totalTextChunks: number; totalAudioChunks: number }> = [];
358
+ const chunkCompleteCalls: Array<{ chunkId: number; audioSeconds: number; genMs: number }> = [];
359
+
360
+ const session = client.tts.streamingSession(
361
+ { voiceId: 1 },
362
+ {
363
+ onChunkComplete: (chunkId, audioSeconds, genMs) => {
364
+ chunkCompleteCalls.push({ chunkId, audioSeconds, genMs });
365
+ },
366
+ onSessionClosed: (totalAudioSeconds, totalTextChunks, totalAudioChunks) => {
367
+ sessionClosedCalls.push({ totalAudioSeconds, totalTextChunks, totalAudioChunks });
368
+ },
369
+ },
370
+ );
371
+
372
+ session.connect();
373
+ await new Promise<void>((r) => setTimeout(r, 10));
374
+
375
+ // Multi-send: simulate streaming LLM tokens
376
+ session.send('Hello ');
377
+ session.send('world. ');
378
+ session.send('How are you?');
379
+
380
+ // Server generates first chunk
381
+ mockWs.onmessage?.({ data: makeGenerationStartedMsg(0, 'Hello world.') });
382
+ mockWs.onmessage?.({ data: makeAudioMsg(0, 100) });
383
+ mockWs.onmessage?.({ data: makeChunkCompleteMsg(0, 1.0, 100) });
384
+
385
+ expect(chunkCompleteCalls).toHaveLength(1);
386
+
387
+ // Make close() realistic: socket tears down immediately, killing onmessage
388
+ makeCloseTearDown();
389
+
390
+ // Client calls close() — must wait for session_closed before tearing down
391
+ const closePromise = session.close();
392
+
393
+ // Simulate server processing the final flush and sending responses
394
+ // (this happens asynchronously on the server after receiving {close: true})
395
+ mockWs.onmessage?.({ data: makeGenerationStartedMsg(1, 'How are you?') });
396
+ mockWs.onmessage?.({ data: makeAudioMsg(1, 80) });
397
+ mockWs.onmessage?.({ data: makeChunkCompleteMsg(1, 0.8, 90) });
398
+ mockWs.onmessage?.({ data: makeSessionClosedMsg(1.8, 2, 4) });
399
+
400
+ await closePromise;
401
+
402
+ // onSessionClosed MUST have been called
403
+ expect(sessionClosedCalls).toHaveLength(1);
404
+ expect(sessionClosedCalls[0].totalAudioSeconds).toBe(1.8);
405
+ expect(sessionClosedCalls[0].totalTextChunks).toBe(2);
406
+ expect(sessionClosedCalls[0].totalAudioChunks).toBe(4);
407
+ });
408
+
409
+ it('resolves close() even if server never sends session_closed (quiet timeout)', async () => {
410
+ const session = client.tts.streamingSession(
411
+ { voiceId: 1 },
412
+ {},
413
+ );
414
+
415
+ session.connect();
416
+ await new Promise<void>((r) => setTimeout(r, 10));
417
+ session.send('Hello.');
418
+
419
+ // Simulate some audio
420
+ mockWs.onmessage?.({ data: makeAudioMsg(0, 50) });
421
+ mockWs.onmessage?.({ data: makeChunkCompleteMsg(0, 0.5, 50) });
422
+
423
+ makeCloseTearDown();
424
+ vi.useFakeTimers();
425
+
426
+ const closePromise = session.close();
427
+
428
+ // No further server messages — quiet timeout (15 s) should resolve.
429
+ await vi.advanceTimersByTimeAsync(16_000);
430
+
431
+ await closePromise;
432
+
433
+ vi.useRealTimers();
434
+
435
+ // Should have cleaned up
436
+ expect(session.isConnected).toBe(false);
437
+ });
438
+
439
+ /**
440
+ * Regression: a slow final-flush that streams audio for longer than the
441
+ * old 10 s wall-clock fuse must NOT be truncated. The quiet-timeout fix
442
+ * resets on every incoming frame, so as long as the server keeps sending
443
+ * audio, close() keeps waiting.
444
+ */
445
+ it('does not truncate a slow final flush that streams past the old 10 s fuse', async () => {
446
+ const audioChunks: unknown[] = [];
447
+ const sessionClosedCalls: unknown[] = [];
448
+
449
+ const session = client.tts.streamingSession(
450
+ { voiceId: 1 },
451
+ {
452
+ onChunk: (chunk) => audioChunks.push(chunk),
453
+ onSessionClosed: (totalSecs, chunks, audioCnt) => {
454
+ sessionClosedCalls.push({ totalSecs, chunks, audioCnt });
455
+ },
456
+ },
457
+ );
458
+
459
+ session.connect();
460
+ await new Promise<void>((r) => setTimeout(r, 10));
461
+
462
+ // Buffer text without an explicit flush — server will flush on close.
463
+ session.send('A long final paragraph that takes many seconds to render.');
464
+
465
+ makeCloseTearDown();
466
+ vi.useFakeTimers();
467
+
468
+ const closePromise = session.close();
469
+
470
+ // Server streams audio every 2 s for 18 s — well past the old 10 s fuse.
471
+ // Each frame must reset the quiet timer so close() keeps waiting.
472
+ for (let i = 0; i < 9; i++) {
473
+ await vi.advanceTimersByTimeAsync(2_000);
474
+ mockWs.onmessage?.({ data: makeAudioMsg(i, 200) });
475
+ }
476
+
477
+ // Server finally finishes generation and confirms close.
478
+ mockWs.onmessage?.({ data: makeChunkCompleteMsg(0, 18.0, 17_500) });
479
+ mockWs.onmessage?.({ data: makeSessionClosedMsg(18.0, 1, 9) });
480
+
481
+ await closePromise;
482
+
483
+ vi.useRealTimers();
484
+
485
+ // All 9 audio frames from the slow final flush were delivered.
486
+ expect(audioChunks).toHaveLength(9);
487
+ // session_closed was observed (not truncated by a wall-clock fuse).
488
+ expect(sessionClosedCalls).toHaveLength(1);
489
+ });
490
+
491
+ it('receives audio chunks from final flush before session_closed', async () => {
492
+ const audioChunks: unknown[] = [];
493
+ const sessionClosedCalls: unknown[] = [];
494
+
495
+ const session = client.tts.streamingSession(
496
+ { voiceId: 1 },
497
+ {
498
+ onChunk: (chunk) => audioChunks.push(chunk),
499
+ onSessionClosed: (totalSecs, chunks, audioCnt) => {
500
+ sessionClosedCalls.push({ totalSecs, chunks, audioCnt });
501
+ },
502
+ },
503
+ );
504
+
505
+ session.connect();
506
+ await new Promise<void>((r) => setTimeout(r, 10));
507
+
508
+ // Send partial text that hasn't been flushed
509
+ session.send('Partial text that');
510
+
511
+ makeCloseTearDown();
512
+
513
+ // Client closes — server flushes remaining text and sends audio
514
+ const closePromise = session.close();
515
+
516
+ // Server flushes and generates
517
+ mockWs.onmessage?.({ data: makeGenerationStartedMsg(0, 'Partial text that') });
518
+ mockWs.onmessage?.({ data: makeAudioMsg(0, 200) });
519
+ mockWs.onmessage?.({ data: makeAudioMsg(1, 150) });
520
+ mockWs.onmessage?.({ data: makeChunkCompleteMsg(0, 1.5, 120) });
521
+ mockWs.onmessage?.({ data: makeSessionClosedMsg(1.5, 1, 2) });
522
+
523
+ await closePromise;
524
+
525
+ // Audio from final flush must have been received
526
+ expect(audioChunks).toHaveLength(2);
527
+ expect(sessionClosedCalls).toHaveLength(1);
528
+ });
529
+
530
+ // -------------------------------------------------------------------------
531
+ // connect() awaitability — regression for the "StreamingSession not
532
+ // connected" race fixed alongside KUG-421.
533
+ // -------------------------------------------------------------------------
534
+
535
+ it('connect() returns a promise that resolves when the WS is OPEN', async () => {
536
+ const session = client.tts.streamingSession({ voiceId: 1 }, {});
537
+
538
+ // Before await, the mock is still CONNECTING (readyState 0).
539
+ const ready = session.connect();
540
+ expect(session.isConnected).toBe(false);
541
+
542
+ await ready;
543
+
544
+ // After await, the WS is OPEN and send() works without throwing.
545
+ expect(session.isConnected).toBe(true);
546
+ expect(() => session.send('Hello.', true)).not.toThrow();
547
+ });
548
+ });