kugelaudio 0.7.0 → 0.9.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/CHANGELOG.md +21 -0
- package/README.md +41 -3
- package/dist/chunk-CB3B6T7F.mjs +391 -0
- package/dist/chunk-D57AKD2S.mjs +391 -0
- package/dist/index.d.mts +200 -16
- package/dist/index.d.ts +200 -16
- package/dist/index.js +287 -23
- package/dist/index.mjs +276 -347
- package/dist/livekit/index.d.mts +222 -0
- package/dist/livekit/index.d.ts +222 -0
- package/dist/livekit/index.js +994 -0
- package/dist/livekit/index.mjs +746 -0
- package/package.json +23 -3
- package/src/cfg-scale.test.ts +33 -0
- package/src/client.test.ts +466 -2
- package/src/client.ts +308 -17
- package/src/index.ts +4 -0
- package/src/livekit/index.ts +32 -0
- package/src/livekit/models.test.ts +30 -0
- package/src/livekit/models.ts +84 -0
- package/src/livekit/tts.functional.test.ts +348 -0
- package/src/livekit/tts.ts +833 -0
- package/src/livekit/wire.test.ts +112 -0
- package/src/livekit/wire.ts +126 -0
- package/src/types.ts +171 -13
- package/src/utils.ts +16 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kugelaudio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Official JavaScript/TypeScript SDK for KugelAudio TTS API",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
"types": "./dist/index.d.ts",
|
|
11
11
|
"import": "./dist/index.mjs",
|
|
12
12
|
"require": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./livekit": {
|
|
15
|
+
"types": "./dist/livekit/index.d.ts",
|
|
16
|
+
"import": "./dist/livekit/index.mjs",
|
|
17
|
+
"require": "./dist/livekit/index.js"
|
|
13
18
|
}
|
|
14
19
|
},
|
|
15
20
|
"files": [
|
|
@@ -19,8 +24,8 @@
|
|
|
19
24
|
"CHANGELOG.md"
|
|
20
25
|
],
|
|
21
26
|
"scripts": {
|
|
22
|
-
"build": "tsup src/index.ts --format cjs,esm --dts",
|
|
23
|
-
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
|
|
27
|
+
"build": "tsup src/index.ts src/livekit/index.ts --format cjs,esm --dts",
|
|
28
|
+
"dev": "tsup src/index.ts src/livekit/index.ts --format cjs,esm --dts --watch",
|
|
24
29
|
"lint": "eslint src/",
|
|
25
30
|
"test": "vitest run",
|
|
26
31
|
"test:watch": "vitest",
|
|
@@ -46,11 +51,26 @@
|
|
|
46
51
|
"url": "https://github.com/Kugelaudio/KugelAudio/issues"
|
|
47
52
|
},
|
|
48
53
|
"devDependencies": {
|
|
54
|
+
"@livekit/agents": "^1.4.11",
|
|
55
|
+
"@livekit/rtc-node": "^0.13.30",
|
|
49
56
|
"@types/node": "^25.3.2",
|
|
57
|
+
"@types/ws": "^8.18.1",
|
|
50
58
|
"tsup": "^8.0.0",
|
|
51
59
|
"typescript": "^6.0.2",
|
|
52
60
|
"vitest": "^4.0.18"
|
|
53
61
|
},
|
|
62
|
+
"peerDependencies": {
|
|
63
|
+
"@livekit/agents": ">=1.0.0",
|
|
64
|
+
"@livekit/rtc-node": ">=0.13.0"
|
|
65
|
+
},
|
|
66
|
+
"peerDependenciesMeta": {
|
|
67
|
+
"@livekit/agents": {
|
|
68
|
+
"optional": true
|
|
69
|
+
},
|
|
70
|
+
"@livekit/rtc-node": {
|
|
71
|
+
"optional": true
|
|
72
|
+
}
|
|
73
|
+
},
|
|
54
74
|
"engines": {
|
|
55
75
|
"node": ">=18.0.0"
|
|
56
76
|
},
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for cfg_scale range clamping (KUG-1342).
|
|
3
|
+
*
|
|
4
|
+
* The accepted classifier-free guidance band is [1.2, 2.5]; values outside
|
|
5
|
+
* it are clamped into the band client-side (matching the server).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect } from 'vitest';
|
|
9
|
+
import { clampCfgScale, MIN_CFG_SCALE, MAX_CFG_SCALE } from './utils';
|
|
10
|
+
|
|
11
|
+
describe('clampCfgScale', () => {
|
|
12
|
+
it('preserves values inside [1.2, 2.5]', () => {
|
|
13
|
+
for (const cfg of [MIN_CFG_SCALE, 2.0, MAX_CFG_SCALE]) {
|
|
14
|
+
expect(clampCfgScale(cfg)).toBe(cfg);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('passes undefined through (server default applies)', () => {
|
|
19
|
+
expect(clampCfgScale(undefined)).toBeUndefined();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('clamps values below the floor up to 1.2', () => {
|
|
23
|
+
for (const cfg of [0, 1.0, 1.19]) {
|
|
24
|
+
expect(clampCfgScale(cfg)).toBe(MIN_CFG_SCALE);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('clamps values above the ceiling down to 2.5', () => {
|
|
29
|
+
for (const cfg of [2.51, 3.0, 5, 10]) {
|
|
30
|
+
expect(clampCfgScale(cfg)).toBe(MAX_CFG_SCALE);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
});
|
package/src/client.test.ts
CHANGED
|
@@ -7,8 +7,12 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
10
|
+
import packageJson from '../package.json';
|
|
10
11
|
import { KugelAudio } from './client';
|
|
11
12
|
import { RateLimitError } from './errors';
|
|
13
|
+
import { parseSessionUsage } from './types';
|
|
14
|
+
|
|
15
|
+
const SDK_VERSION = packageJson.version;
|
|
12
16
|
|
|
13
17
|
// ---------------------------------------------------------------------------
|
|
14
18
|
// Minimal WebSocket mock
|
|
@@ -99,6 +103,43 @@ function collectStream(stream: NodeJS.ReadableStream): Promise<Buffer> {
|
|
|
99
103
|
// Tests
|
|
100
104
|
// ---------------------------------------------------------------------------
|
|
101
105
|
|
|
106
|
+
describe('parseSessionUsage (/ws/tts final + session_closed)', () => {
|
|
107
|
+
it('parses the usage block from a /ws/tts final frame', () => {
|
|
108
|
+
const usage = parseSessionUsage({
|
|
109
|
+
final: true,
|
|
110
|
+
chunks: 3,
|
|
111
|
+
total_samples: 1000,
|
|
112
|
+
dur_ms: 5400,
|
|
113
|
+
gen_ms: 900,
|
|
114
|
+
rtf: 0.17,
|
|
115
|
+
usage: {
|
|
116
|
+
audio_seconds: 5.4,
|
|
117
|
+
characters: 142,
|
|
118
|
+
cost_cents: 0.49,
|
|
119
|
+
currency: 'eur',
|
|
120
|
+
model_id: 'kugel-3',
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
expect(usage).not.toBeNull();
|
|
124
|
+
expect(usage?.audioSeconds).toBe(5.4);
|
|
125
|
+
expect(usage?.costCents).toBe(0.49);
|
|
126
|
+
expect(usage?.costAvailable).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('reports cost null (not zero) when unavailable', () => {
|
|
130
|
+
const usage = parseSessionUsage({
|
|
131
|
+
final: true,
|
|
132
|
+
usage: { audio_seconds: 2.0, cost_cents: null, cost_unavailable: true },
|
|
133
|
+
});
|
|
134
|
+
expect(usage?.costCents).toBeNull();
|
|
135
|
+
expect(usage?.costAvailable).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('returns null when there is no usage info', () => {
|
|
139
|
+
expect(parseSessionUsage({ final: true, chunks: 1 })).toBeNull();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
102
143
|
describe('TTSResource.toReadable()', () => {
|
|
103
144
|
let client: KugelAudio;
|
|
104
145
|
|
|
@@ -271,7 +312,7 @@ describe('KugelAudio SDK metadata', () => {
|
|
|
271
312
|
expect(init).toMatchObject({
|
|
272
313
|
headers: {
|
|
273
314
|
'X-KugelAudio-SDK': 'js',
|
|
274
|
-
'X-KugelAudio-SDK-Version':
|
|
315
|
+
'X-KugelAudio-SDK-Version': SDK_VERSION,
|
|
275
316
|
},
|
|
276
317
|
});
|
|
277
318
|
});
|
|
@@ -283,7 +324,7 @@ describe('KugelAudio SDK metadata', () => {
|
|
|
283
324
|
await new Promise<void>((r) => setTimeout(r, 10));
|
|
284
325
|
|
|
285
326
|
expect(mockWs.url).toContain('sdk=js');
|
|
286
|
-
expect(mockWs.url).toContain(
|
|
327
|
+
expect(mockWs.url).toContain(`sdk_version=${SDK_VERSION}`);
|
|
287
328
|
});
|
|
288
329
|
});
|
|
289
330
|
|
|
@@ -451,6 +492,111 @@ describe('StreamingSession', () => {
|
|
|
451
492
|
expect(sessionClosedCalls[0].totalAudioChunks).toBe(4);
|
|
452
493
|
});
|
|
453
494
|
|
|
495
|
+
it('fires onFinal (end-of-audio) before onSessionClosed on turn end (KUG-1238)', async () => {
|
|
496
|
+
const order: string[] = [];
|
|
497
|
+
let finalStats: { totalAudioSeconds: number; totalTextChunks: number; totalAudioChunks: number } | null = null;
|
|
498
|
+
|
|
499
|
+
const session = client.tts.streamingSession(
|
|
500
|
+
{ voiceId: 1 },
|
|
501
|
+
{
|
|
502
|
+
onFinal: (totalAudioSeconds, totalTextChunks, totalAudioChunks) => {
|
|
503
|
+
order.push('final');
|
|
504
|
+
finalStats = { totalAudioSeconds, totalTextChunks, totalAudioChunks };
|
|
505
|
+
},
|
|
506
|
+
onSessionClosed: () => order.push('session_closed'),
|
|
507
|
+
},
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
session.connect();
|
|
511
|
+
await new Promise<void>((r) => setTimeout(r, 10));
|
|
512
|
+
session.send('Hello.', true);
|
|
513
|
+
|
|
514
|
+
mockWs.onmessage?.({ data: makeAudioMsg(0, 100) });
|
|
515
|
+
mockWs.onmessage?.({ data: makeChunkCompleteMsg(0, 1.0, 100) });
|
|
516
|
+
mockWs.onmessage?.({
|
|
517
|
+
data: JSON.stringify({
|
|
518
|
+
final: true,
|
|
519
|
+
total_audio_seconds: 1.0,
|
|
520
|
+
total_text_chunks: 1,
|
|
521
|
+
total_audio_chunks: 1,
|
|
522
|
+
}),
|
|
523
|
+
});
|
|
524
|
+
mockWs.onmessage?.({ data: makeSessionClosedMsg(1.0, 1, 1) });
|
|
525
|
+
|
|
526
|
+
expect(order).toEqual(['final', 'session_closed']);
|
|
527
|
+
expect(finalStats!.totalAudioSeconds).toBe(1.0);
|
|
528
|
+
expect(finalStats!.totalTextChunks).toBe(1);
|
|
529
|
+
expect(finalStats!.totalAudioChunks).toBe(1);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it('exposes typed per-session usage (cost charged) on lastUsage', async () => {
|
|
533
|
+
const session = client.tts.streamingSession({ voiceId: 1 }, {});
|
|
534
|
+
session.connect();
|
|
535
|
+
await new Promise<void>((r) => setTimeout(r, 10));
|
|
536
|
+
session.send('Hello.');
|
|
537
|
+
|
|
538
|
+
expect(session.lastUsage).toBeNull();
|
|
539
|
+
|
|
540
|
+
mockWs.onmessage?.({
|
|
541
|
+
data: JSON.stringify({
|
|
542
|
+
session_closed: true,
|
|
543
|
+
total_audio_seconds: 5.4,
|
|
544
|
+
usage: {
|
|
545
|
+
audio_seconds: 5.4,
|
|
546
|
+
characters: 142,
|
|
547
|
+
cost_cents: 0.49,
|
|
548
|
+
currency: 'eur',
|
|
549
|
+
model_id: 'kugel-3',
|
|
550
|
+
},
|
|
551
|
+
}),
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
expect(session.lastUsage).not.toBeNull();
|
|
555
|
+
expect(session.lastUsage?.audioSeconds).toBe(5.4);
|
|
556
|
+
expect(session.lastUsage?.characters).toBe(142);
|
|
557
|
+
expect(session.lastUsage?.costCents).toBe(0.49);
|
|
558
|
+
expect(session.lastUsage?.currency).toBe('eur');
|
|
559
|
+
expect(session.lastUsage?.modelId).toBe('kugel-3');
|
|
560
|
+
expect(session.lastUsage?.costAvailable).toBe(true);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it('reports cost as null (not zero) when the charge is unavailable', async () => {
|
|
564
|
+
const session = client.tts.streamingSession({ voiceId: 1 }, {});
|
|
565
|
+
session.connect();
|
|
566
|
+
await new Promise<void>((r) => setTimeout(r, 10));
|
|
567
|
+
session.send('Hi.');
|
|
568
|
+
|
|
569
|
+
mockWs.onmessage?.({
|
|
570
|
+
data: JSON.stringify({
|
|
571
|
+
session_closed: true,
|
|
572
|
+
total_audio_seconds: 2.0,
|
|
573
|
+
usage: {
|
|
574
|
+
audio_seconds: 2.0,
|
|
575
|
+
cost_cents: null,
|
|
576
|
+
cost_unavailable: true,
|
|
577
|
+
model_id: 'kugel-3',
|
|
578
|
+
},
|
|
579
|
+
}),
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
expect(session.lastUsage?.costCents).toBeNull();
|
|
583
|
+
expect(session.lastUsage?.costAvailable).toBe(false);
|
|
584
|
+
expect(session.lastUsage?.audioSeconds).toBe(2.0);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it('falls back to total_audio_seconds for a legacy server with no usage block', async () => {
|
|
588
|
+
const session = client.tts.streamingSession({ voiceId: 1 }, {});
|
|
589
|
+
session.connect();
|
|
590
|
+
await new Promise<void>((r) => setTimeout(r, 10));
|
|
591
|
+
session.send('Hi.');
|
|
592
|
+
|
|
593
|
+
mockWs.onmessage?.({ data: makeSessionClosedMsg(3.0, 1, 2) });
|
|
594
|
+
|
|
595
|
+
expect(session.lastUsage?.audioSeconds).toBe(3.0);
|
|
596
|
+
expect(session.lastUsage?.costCents).toBeNull();
|
|
597
|
+
expect(session.lastUsage?.costAvailable).toBe(false);
|
|
598
|
+
});
|
|
599
|
+
|
|
454
600
|
it('resolves close() even if server never sends session_closed (quiet timeout)', async () => {
|
|
455
601
|
const session = client.tts.streamingSession(
|
|
456
602
|
{ voiceId: 1 },
|
|
@@ -644,6 +790,55 @@ describe('StreamingSession', () => {
|
|
|
644
790
|
expect(JSON.parse(mockWs.send.mock.calls[mockWs.send.mock.calls.length - 1][0] as string).voice_id).toBe(42);
|
|
645
791
|
});
|
|
646
792
|
|
|
793
|
+
// -------------------------------------------------------------------------
|
|
794
|
+
// dictionaryIds — per-request dictionary selection (KUG-1094)
|
|
795
|
+
// -------------------------------------------------------------------------
|
|
796
|
+
|
|
797
|
+
it('first send carries dictionary_ids when configured', async () => {
|
|
798
|
+
const session = client.tts.streamingSession(
|
|
799
|
+
{ voiceId: 1, dictionaryIds: [7, 9] },
|
|
800
|
+
{},
|
|
801
|
+
);
|
|
802
|
+
|
|
803
|
+
session.connect();
|
|
804
|
+
await new Promise<void>((r) => setTimeout(r, 10));
|
|
805
|
+
|
|
806
|
+
session.send('Hello.');
|
|
807
|
+
const sent = JSON.parse(
|
|
808
|
+
mockWs.send.mock.calls[mockWs.send.mock.calls.length - 1][0] as string
|
|
809
|
+
);
|
|
810
|
+
expect(sent.dictionary_ids).toEqual([7, 9]);
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
it('first send carries dictionary_ids: [] (explicit opt-out)', async () => {
|
|
814
|
+
const session = client.tts.streamingSession(
|
|
815
|
+
{ voiceId: 1, dictionaryIds: [] },
|
|
816
|
+
{},
|
|
817
|
+
);
|
|
818
|
+
|
|
819
|
+
session.connect();
|
|
820
|
+
await new Promise<void>((r) => setTimeout(r, 10));
|
|
821
|
+
|
|
822
|
+
session.send('Hello.');
|
|
823
|
+
const sent = JSON.parse(
|
|
824
|
+
mockWs.send.mock.calls[mockWs.send.mock.calls.length - 1][0] as string
|
|
825
|
+
);
|
|
826
|
+
expect(sent.dictionary_ids).toEqual([]);
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
it('omits dictionary_ids when not configured', async () => {
|
|
830
|
+
const session = client.tts.streamingSession({ voiceId: 1 }, {});
|
|
831
|
+
|
|
832
|
+
session.connect();
|
|
833
|
+
await new Promise<void>((r) => setTimeout(r, 10));
|
|
834
|
+
|
|
835
|
+
session.send('Hello.');
|
|
836
|
+
const sent = JSON.parse(
|
|
837
|
+
mockWs.send.mock.calls[mockWs.send.mock.calls.length - 1][0] as string
|
|
838
|
+
);
|
|
839
|
+
expect(sent.dictionary_ids).toBeUndefined();
|
|
840
|
+
});
|
|
841
|
+
|
|
647
842
|
it('cancelCurrent() resolves on quiet timeout if server never acks', async () => {
|
|
648
843
|
const session = client.tts.streamingSession({ voiceId: 1 }, {});
|
|
649
844
|
|
|
@@ -724,4 +919,273 @@ describe('MultiContextSession closeContext', () => {
|
|
|
724
919
|
expect((errors[0].error as RateLimitError).statusCode).toBe(429);
|
|
725
920
|
expect((errors[0].error as RateLimitError).errorCode).toBe('TOO_MANY_CONTEXTS');
|
|
726
921
|
});
|
|
922
|
+
|
|
923
|
+
it('fires onFinal per context on flush completion and graceful close (KUG-1238)', async () => {
|
|
924
|
+
const finals: string[] = [];
|
|
925
|
+
const closed: string[] = [];
|
|
926
|
+
const session = client.tts.createMultiContextSession({ defaultVoiceId: 1 });
|
|
927
|
+
await session.connect({
|
|
928
|
+
onFinal: (contextId) => finals.push(contextId),
|
|
929
|
+
onContextClosed: (contextId) => closed.push(contextId),
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
// Flush boundary: all audio admitted before the flush has been sent.
|
|
933
|
+
mockWs.onmessage?.({
|
|
934
|
+
data: JSON.stringify({ final: true, context_id: 'a' }),
|
|
935
|
+
});
|
|
936
|
+
expect(finals).toEqual(['a']);
|
|
937
|
+
expect(closed).toEqual([]);
|
|
938
|
+
|
|
939
|
+
// Graceful close: final precedes context_closed.
|
|
940
|
+
mockWs.onmessage?.({
|
|
941
|
+
data: JSON.stringify({ final: true, context_id: 'a' }),
|
|
942
|
+
});
|
|
943
|
+
mockWs.onmessage?.({
|
|
944
|
+
data: JSON.stringify({ context_closed: true, context_id: 'a' }),
|
|
945
|
+
});
|
|
946
|
+
expect(finals).toEqual(['a', 'a']);
|
|
947
|
+
expect(closed).toEqual(['a']);
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
it('exposes per-context usage on context_closed (per conversation)', async () => {
|
|
951
|
+
const closed: Array<{ id: string; usage: unknown }> = [];
|
|
952
|
+
const session = client.tts.createMultiContextSession({ defaultVoiceId: 1 });
|
|
953
|
+
await session.connect({
|
|
954
|
+
onContextClosed: (contextId, usage) => closed.push({ id: contextId, usage }),
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
mockWs.onmessage?.({
|
|
958
|
+
data: JSON.stringify({
|
|
959
|
+
context_closed: true,
|
|
960
|
+
context_id: 'narrator',
|
|
961
|
+
usage: { audio_seconds: 4.1, cost_cents: 0.37, currency: 'eur', model_id: 'kugel-3' },
|
|
962
|
+
}),
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
// Available both via the callback arg and the per-context accessor
|
|
966
|
+
expect(closed).toHaveLength(1);
|
|
967
|
+
expect(closed[0].id).toBe('narrator');
|
|
968
|
+
expect((closed[0].usage as { costCents: number }).costCents).toBe(0.37);
|
|
969
|
+
|
|
970
|
+
const u = session.usageFor('narrator');
|
|
971
|
+
expect(u?.audioSeconds).toBe(4.1);
|
|
972
|
+
expect(u?.costCents).toBe(0.37);
|
|
973
|
+
expect(u?.costAvailable).toBe(true);
|
|
974
|
+
expect(session.usageFor('missing')).toBeNull();
|
|
975
|
+
});
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
// ---------------------------------------------------------------------------
|
|
979
|
+
// MultiContextSession createContext wire format (KUG-1233)
|
|
980
|
+
//
|
|
981
|
+
// The server binds a context's voice ONLY from voice_settings.voice_id at
|
|
982
|
+
// context creation. A top-level voice_id updates session config and leaves
|
|
983
|
+
// the context voiceless → MISSING_VOICE_ID on the first text. These tests
|
|
984
|
+
// pin the wire format so it cannot silently regress.
|
|
985
|
+
// ---------------------------------------------------------------------------
|
|
986
|
+
|
|
987
|
+
describe('MultiContextSession createContext wire format (KUG-1233)', () => {
|
|
988
|
+
let client: KugelAudio;
|
|
989
|
+
|
|
990
|
+
beforeEach(() => {
|
|
991
|
+
client = new KugelAudio({ apiKey: 'test-key-xxx' });
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
it('puts voice_id inside voice_settings, never top-level', async () => {
|
|
995
|
+
const session = client.tts.createMultiContextSession({ defaultVoiceId: 42 });
|
|
996
|
+
await session.connect({});
|
|
997
|
+
|
|
998
|
+
session.createContext('narrator', { voiceId: 123 });
|
|
999
|
+
|
|
1000
|
+
const sent = JSON.parse(
|
|
1001
|
+
mockWs.send.mock.calls[mockWs.send.mock.calls.length - 1][0] as string
|
|
1002
|
+
);
|
|
1003
|
+
expect(sent.context_id).toBe('narrator');
|
|
1004
|
+
expect(sent.voice_id).toBeUndefined();
|
|
1005
|
+
expect(sent.voice_settings).toBeDefined();
|
|
1006
|
+
expect(sent.voice_settings.voice_id).toBe(123);
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
it('falls back to defaultVoiceId inside voice_settings', async () => {
|
|
1010
|
+
const session = client.tts.createMultiContextSession({ defaultVoiceId: 42 });
|
|
1011
|
+
await session.connect({});
|
|
1012
|
+
|
|
1013
|
+
session.createContext('narrator');
|
|
1014
|
+
|
|
1015
|
+
const sent = JSON.parse(
|
|
1016
|
+
mockWs.send.mock.calls[mockWs.send.mock.calls.length - 1][0] as string
|
|
1017
|
+
);
|
|
1018
|
+
expect(sent.voice_id).toBeUndefined();
|
|
1019
|
+
expect(sent.voice_settings.voice_id).toBe(42);
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
it('send() to an unknown context auto-creates it with the default voice, even after session start', async () => {
|
|
1023
|
+
const session = client.tts.createMultiContextSession({ defaultVoiceId: 42 });
|
|
1024
|
+
await session.connect({});
|
|
1025
|
+
|
|
1026
|
+
// Simulate a started session (first context confirmed by the server).
|
|
1027
|
+
session.createContext('first');
|
|
1028
|
+
mockWs.onmessage?.({
|
|
1029
|
+
data: JSON.stringify({ session_started: true, session_id: 's1' }),
|
|
1030
|
+
});
|
|
1031
|
+
mockWs.onmessage?.({
|
|
1032
|
+
data: JSON.stringify({ context_created: true, context_id: 'first' }),
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
const callsBefore = mockWs.send.mock.calls.length;
|
|
1036
|
+
session.send('second', 'hello there', true);
|
|
1037
|
+
const frames = mockWs.send.mock.calls
|
|
1038
|
+
.slice(callsBefore)
|
|
1039
|
+
.map((c) => JSON.parse(c[0] as string));
|
|
1040
|
+
|
|
1041
|
+
// First frame: the auto-create with voice_settings.voice_id; then the text.
|
|
1042
|
+
expect(frames).toHaveLength(2);
|
|
1043
|
+
expect(frames[0].context_id).toBe('second');
|
|
1044
|
+
expect(frames[0].voice_settings.voice_id).toBe(42);
|
|
1045
|
+
expect(frames[1].text).toBe('hello there');
|
|
1046
|
+
expect(frames[1].flush).toBe(true);
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
it('does not duplicate the create frame across repeated sends', async () => {
|
|
1050
|
+
const session = client.tts.createMultiContextSession({ defaultVoiceId: 42 });
|
|
1051
|
+
await session.connect({});
|
|
1052
|
+
|
|
1053
|
+
session.send('ctx', 'one');
|
|
1054
|
+
session.send('ctx', 'two');
|
|
1055
|
+
|
|
1056
|
+
const frames = mockWs.send.mock.calls.map((c) => JSON.parse(c[0] as string));
|
|
1057
|
+
const creates = frames.filter((f) => f.voice_settings?.voice_id === 42);
|
|
1058
|
+
expect(creates).toHaveLength(1);
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
it('allows re-creating a context after the server closed it', async () => {
|
|
1062
|
+
const session = client.tts.createMultiContextSession({ defaultVoiceId: 42 });
|
|
1063
|
+
await session.connect({});
|
|
1064
|
+
|
|
1065
|
+
session.send('ctx', 'one');
|
|
1066
|
+
mockWs.onmessage?.({
|
|
1067
|
+
data: JSON.stringify({ context_created: true, context_id: 'ctx' }),
|
|
1068
|
+
});
|
|
1069
|
+
mockWs.onmessage?.({
|
|
1070
|
+
data: JSON.stringify({ context_closed: true, context_id: 'ctx' }),
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
const callsBefore = mockWs.send.mock.calls.length;
|
|
1074
|
+
session.send('ctx', 'again');
|
|
1075
|
+
const frames = mockWs.send.mock.calls
|
|
1076
|
+
.slice(callsBefore)
|
|
1077
|
+
.map((c) => JSON.parse(c[0] as string));
|
|
1078
|
+
expect(frames[0].voice_settings.voice_id).toBe(42);
|
|
1079
|
+
expect(frames[1].text).toBe('again');
|
|
1080
|
+
});
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
// ---------------------------------------------------------------------------
|
|
1084
|
+
// updateSettings() — mid-connection generation-param changes (KUG-1166)
|
|
1085
|
+
// ---------------------------------------------------------------------------
|
|
1086
|
+
|
|
1087
|
+
describe('updateSettings (KUG-1166)', () => {
|
|
1088
|
+
let client: KugelAudio;
|
|
1089
|
+
|
|
1090
|
+
beforeEach(() => {
|
|
1091
|
+
client = new KugelAudio({ apiKey: 'test-key-xxx' });
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
const lastSent = () =>
|
|
1095
|
+
JSON.parse(mockWs.send.mock.calls[mockWs.send.mock.calls.length - 1][0] as string);
|
|
1096
|
+
|
|
1097
|
+
it('stream: sends update_settings (snake_case) and resolves with effective settings', async () => {
|
|
1098
|
+
const session = client.tts.streamingSession({ voiceId: 1, cfgScale: 2.0 }, {});
|
|
1099
|
+
session.connect();
|
|
1100
|
+
await new Promise<void>((r) => setTimeout(r, 10));
|
|
1101
|
+
|
|
1102
|
+
const p = session.updateSettings({ cfgScale: 1.5, speed: 1.1 });
|
|
1103
|
+
expect(lastSent().update_settings).toEqual({ cfg_scale: 1.5, speed: 1.1 });
|
|
1104
|
+
|
|
1105
|
+
mockWs.onmessage?.({
|
|
1106
|
+
data: JSON.stringify({ settings_updated: true, settings: { cfg_scale: 1.5, speed: 1.1 } }),
|
|
1107
|
+
});
|
|
1108
|
+
const eff = await p;
|
|
1109
|
+
expect(eff.cfgScale).toBe(1.5);
|
|
1110
|
+
expect(eff.speed).toBe(1.1);
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
it('stream: syncs local config so the next first send carries the new value', async () => {
|
|
1114
|
+
const session = client.tts.streamingSession({ voiceId: 1, cfgScale: 2.0 }, {});
|
|
1115
|
+
session.connect();
|
|
1116
|
+
await new Promise<void>((r) => setTimeout(r, 10));
|
|
1117
|
+
|
|
1118
|
+
const p = session.updateSettings({ cfgScale: 1.5 });
|
|
1119
|
+
mockWs.onmessage?.({
|
|
1120
|
+
data: JSON.stringify({ settings_updated: true, settings: { cfg_scale: 1.5 } }),
|
|
1121
|
+
});
|
|
1122
|
+
await p;
|
|
1123
|
+
|
|
1124
|
+
session.send('Hello.');
|
|
1125
|
+
// First send serializes config — it must carry the updated cfg_scale, not 2.0.
|
|
1126
|
+
expect(lastSent().cfg_scale).toBe(1.5);
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
it('stream: server rejection rejects the promise without changing local defaults', async () => {
|
|
1130
|
+
const session = client.tts.streamingSession({ voiceId: 1, cfgScale: 2.0 }, {});
|
|
1131
|
+
session.connect();
|
|
1132
|
+
await new Promise<void>((r) => setTimeout(r, 10));
|
|
1133
|
+
|
|
1134
|
+
const p = session.updateSettings({ cfgScale: 99 });
|
|
1135
|
+
mockWs.onmessage?.({
|
|
1136
|
+
data: JSON.stringify({
|
|
1137
|
+
error: 'Invalid settings update: cfg_scale: out of range',
|
|
1138
|
+
error_code: 'VALIDATION_ERROR',
|
|
1139
|
+
code: 400,
|
|
1140
|
+
}),
|
|
1141
|
+
});
|
|
1142
|
+
await expect(p).rejects.toThrow(/Invalid settings update/);
|
|
1143
|
+
|
|
1144
|
+
session.send('Hello.');
|
|
1145
|
+
expect(lastSent().cfg_scale).toBe(2.0);
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
it('stream: empty update throws synchronously', async () => {
|
|
1149
|
+
const session = client.tts.streamingSession({ voiceId: 1 }, {});
|
|
1150
|
+
session.connect();
|
|
1151
|
+
await new Promise<void>((r) => setTimeout(r, 10));
|
|
1152
|
+
expect(() => session.updateSettings({})).toThrow(/at least one parameter/);
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
it('multi: session-scoped update acked, no context_id', async () => {
|
|
1156
|
+
const session = client.tts.createMultiContextSession({ defaultVoiceId: 1, cfgScale: 1.0 });
|
|
1157
|
+
await session.connect({});
|
|
1158
|
+
|
|
1159
|
+
const p = session.updateSettings({ cfgScale: 2.5, normalize: false });
|
|
1160
|
+
expect(lastSent().update_settings).toEqual({ cfg_scale: 2.5, normalize: false });
|
|
1161
|
+
expect(lastSent().context_id).toBeUndefined();
|
|
1162
|
+
|
|
1163
|
+
mockWs.onmessage?.({
|
|
1164
|
+
data: JSON.stringify({ settings_updated: true, settings: { cfg_scale: 2.5, normalize: false } }),
|
|
1165
|
+
});
|
|
1166
|
+
const eff = await p;
|
|
1167
|
+
expect(eff.cfgScale).toBe(2.5);
|
|
1168
|
+
expect(eff.normalize).toBe(false);
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
it('multi: server rejection does not change local defaults', async () => {
|
|
1172
|
+
// cfgScale 2.0 is inside the client-side clamp band [1.2, 2.5] so the
|
|
1173
|
+
// re-send below reflects the local default verbatim — a value below 1.2
|
|
1174
|
+
// would be clamped up and mask whether the rejected update poisoned it.
|
|
1175
|
+
const session = client.tts.createMultiContextSession({ defaultVoiceId: 1, cfgScale: 2.0 });
|
|
1176
|
+
await session.connect({});
|
|
1177
|
+
|
|
1178
|
+
const p = session.updateSettings({ cfgScale: 99 });
|
|
1179
|
+
mockWs.onmessage?.({
|
|
1180
|
+
data: JSON.stringify({
|
|
1181
|
+
error: 'Invalid settings update: cfg_scale: out of range',
|
|
1182
|
+
error_code: 'VALIDATION_ERROR',
|
|
1183
|
+
code: 400,
|
|
1184
|
+
}),
|
|
1185
|
+
});
|
|
1186
|
+
await expect(p).rejects.toThrow(/Invalid settings update/);
|
|
1187
|
+
|
|
1188
|
+
session.createContext('narrator');
|
|
1189
|
+
expect(lastSent().cfg_scale).toBe(2.0);
|
|
1190
|
+
});
|
|
727
1191
|
});
|