kugelaudio 0.8.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 +8 -0
- package/README.md +30 -1
- package/dist/chunk-CB3B6T7F.mjs +391 -0
- package/dist/chunk-D57AKD2S.mjs +391 -0
- package/dist/index.d.mts +84 -11
- package/dist/index.d.ts +84 -11
- package/dist/index.js +193 -7
- package/dist/index.mjs +186 -334
- 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 +110 -0
- package/src/client.ts +213 -5
- package/src/index.ts +2 -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 +47 -7
- 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
|
@@ -1079,3 +1079,113 @@ describe('MultiContextSession createContext wire format (KUG-1233)', () => {
|
|
|
1079
1079
|
expect(frames[1].text).toBe('again');
|
|
1080
1080
|
});
|
|
1081
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
|
+
});
|
|
1191
|
+
});
|
package/src/client.ts
CHANGED
|
@@ -16,10 +16,12 @@ import type {
|
|
|
16
16
|
AudioChunk,
|
|
17
17
|
AudioResponse,
|
|
18
18
|
CreateVoiceOptions,
|
|
19
|
+
EffectiveSettings,
|
|
19
20
|
GenerateOptions,
|
|
20
21
|
GenerationStats,
|
|
21
22
|
KugelAudioOptions,
|
|
22
23
|
Model,
|
|
24
|
+
SettingsUpdate,
|
|
23
25
|
StreamCallbacks,
|
|
24
26
|
StreamConfig,
|
|
25
27
|
StreamingSessionCallbacks,
|
|
@@ -30,7 +32,7 @@ import type {
|
|
|
30
32
|
WordTimestamp
|
|
31
33
|
} from './types';
|
|
32
34
|
import { parseSessionUsage } from './types';
|
|
33
|
-
import { base64ToArrayBuffer } from './utils';
|
|
35
|
+
import { base64ToArrayBuffer, clampCfgScale } from './utils';
|
|
34
36
|
import { getWebSocket } from './websocket';
|
|
35
37
|
|
|
36
38
|
import type { Region } from './types';
|
|
@@ -78,6 +80,145 @@ function createWs(url: string): WebSocket {
|
|
|
78
80
|
/** WebSocket OPEN readyState constant. */
|
|
79
81
|
const WS_OPEN = 1;
|
|
80
82
|
|
|
83
|
+
/**
|
|
84
|
+
* camelCase SDK field → snake_case wire field for the generation parameters
|
|
85
|
+
* changeable mid-connection via `update_settings` (KUG-1166). The key set is
|
|
86
|
+
* authoritative: anything not here is fixed for the connection's lifetime.
|
|
87
|
+
*/
|
|
88
|
+
const UPDATABLE_SETTINGS: Record<keyof SettingsUpdate, string> = {
|
|
89
|
+
cfgScale: 'cfg_scale',
|
|
90
|
+
temperature: 'temperature',
|
|
91
|
+
speed: 'speed',
|
|
92
|
+
maxNewTokens: 'max_new_tokens',
|
|
93
|
+
language: 'language',
|
|
94
|
+
normalize: 'normalize',
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** Build the snake_case `update_settings` body; throws when empty (KUG-1166). */
|
|
98
|
+
function buildSettingsUpdateBody(settings: SettingsUpdate): Record<string, unknown> {
|
|
99
|
+
const body: Record<string, unknown> = {};
|
|
100
|
+
for (const camel of Object.keys(UPDATABLE_SETTINGS) as (keyof SettingsUpdate)[]) {
|
|
101
|
+
const value = settings[camel];
|
|
102
|
+
if (value !== undefined) body[UPDATABLE_SETTINGS[camel]] = value;
|
|
103
|
+
}
|
|
104
|
+
if (Object.keys(body).length === 0) {
|
|
105
|
+
throw new KugelAudioError(
|
|
106
|
+
'updateSettings requires at least one parameter to change ' +
|
|
107
|
+
`(one of ${Object.keys(UPDATABLE_SETTINGS).join(', ')})`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return body;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Map the server's snake_case `settings` echo back to camelCase. */
|
|
114
|
+
function parseEffectiveSettings(raw: unknown): EffectiveSettings {
|
|
115
|
+
const s = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>;
|
|
116
|
+
return {
|
|
117
|
+
cfgScale: s.cfg_scale as number | undefined,
|
|
118
|
+
temperature: s.temperature as number | null | undefined,
|
|
119
|
+
speed: s.speed as number | undefined,
|
|
120
|
+
maxNewTokens: s.max_new_tokens as number | undefined,
|
|
121
|
+
language: s.language as string | null | undefined,
|
|
122
|
+
normalize: s.normalize as boolean | undefined,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Mirror an accepted settings update onto an SDK config object. */
|
|
127
|
+
function applyAcceptedSettingsUpdate(
|
|
128
|
+
config: Record<string, unknown>,
|
|
129
|
+
settings: SettingsUpdate,
|
|
130
|
+
): void {
|
|
131
|
+
for (const camel of Object.keys(UPDATABLE_SETTINGS) as (keyof SettingsUpdate)[]) {
|
|
132
|
+
if (settings[camel] !== undefined) {
|
|
133
|
+
config[camel] = settings[camel];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Send an `update_settings` message on an open socket and resolve with the
|
|
140
|
+
* server's `settings_updated` echo (KUG-1166). Audio frames that arrive while
|
|
141
|
+
* waiting are passed through to the session's normal handler (not dropped); an
|
|
142
|
+
* `error` frame rejects, as does a quiet timeout or a socket close.
|
|
143
|
+
*/
|
|
144
|
+
function sendUpdateSettings(
|
|
145
|
+
ws: WebSocket,
|
|
146
|
+
body: Record<string, unknown>,
|
|
147
|
+
): Promise<EffectiveSettings> {
|
|
148
|
+
const QUIET_TIMEOUT_MS = 15_000;
|
|
149
|
+
return new Promise<EffectiveSettings>((resolve, reject) => {
|
|
150
|
+
let settled = false;
|
|
151
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
152
|
+
const prevMessage = ws.onmessage;
|
|
153
|
+
const prevClose = ws.onclose;
|
|
154
|
+
|
|
155
|
+
const finish = (action: () => void): void => {
|
|
156
|
+
if (settled) return;
|
|
157
|
+
settled = true;
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
ws.onmessage = prevMessage;
|
|
160
|
+
ws.onclose = prevClose;
|
|
161
|
+
action();
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const armQuietTimer = (): void => {
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
timer = setTimeout(
|
|
167
|
+
() =>
|
|
168
|
+
finish(() =>
|
|
169
|
+
reject(
|
|
170
|
+
new KugelAudioError(
|
|
171
|
+
'Timed out waiting for settings_updated acknowledgement',
|
|
172
|
+
),
|
|
173
|
+
),
|
|
174
|
+
),
|
|
175
|
+
QUIET_TIMEOUT_MS,
|
|
176
|
+
);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
armQuietTimer();
|
|
180
|
+
|
|
181
|
+
ws.onmessage = (event: MessageEvent) => {
|
|
182
|
+
armQuietTimer();
|
|
183
|
+
let data: Record<string, unknown> | null = null;
|
|
184
|
+
try {
|
|
185
|
+
const raw = typeof event.data === 'string'
|
|
186
|
+
? event.data
|
|
187
|
+
: event.data instanceof Buffer
|
|
188
|
+
? event.data.toString()
|
|
189
|
+
: String(event.data);
|
|
190
|
+
data = JSON.parse(raw) as Record<string, unknown>;
|
|
191
|
+
} catch {
|
|
192
|
+
/* ignore parse errors */
|
|
193
|
+
}
|
|
194
|
+
if (data?.settings_updated) {
|
|
195
|
+
finish(() => resolve(parseEffectiveSettings(data!.settings)));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (data?.error) {
|
|
199
|
+
finish(() => reject(new KugelAudioError(String(data!.error))));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
// Unrelated frame (e.g. stray audio from a draining turn): deliver it
|
|
203
|
+
// through the session's normal handler rather than dropping it.
|
|
204
|
+
if (prevMessage) prevMessage.call(ws, event);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
ws.onclose = (event: CloseEvent) => {
|
|
208
|
+
if (prevClose) prevClose.call(ws, event);
|
|
209
|
+
finish(() =>
|
|
210
|
+
reject(
|
|
211
|
+
new ConnectionError(
|
|
212
|
+
'WebSocket closed before settings_updated acknowledgement',
|
|
213
|
+
),
|
|
214
|
+
),
|
|
215
|
+
);
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
ws.send(JSON.stringify({ update_settings: body }));
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
81
222
|
let _languageWarningLogged = false;
|
|
82
223
|
|
|
83
224
|
function warnIfNoLanguage(
|
|
@@ -152,6 +293,7 @@ class VoicesResource {
|
|
|
152
293
|
category: v.category,
|
|
153
294
|
sex: v.sex,
|
|
154
295
|
age: v.age,
|
|
296
|
+
quality: v.quality,
|
|
155
297
|
supportedLanguages: v.supported_languages || [],
|
|
156
298
|
sampleText: v.sample_text,
|
|
157
299
|
avatarUrl: v.avatar_url,
|
|
@@ -711,7 +853,7 @@ class TTSResource {
|
|
|
711
853
|
text: options.text,
|
|
712
854
|
model_id: options.modelId || 'kugel-3',
|
|
713
855
|
voice_id: options.voiceId,
|
|
714
|
-
cfg_scale: options.cfgScale ?? 2.0,
|
|
856
|
+
cfg_scale: clampCfgScale(options.cfgScale) ?? 2.0,
|
|
715
857
|
...(options.temperature !== undefined && { temperature: options.temperature }),
|
|
716
858
|
max_new_tokens: options.maxNewTokens ?? 2048,
|
|
717
859
|
sample_rate: options.sampleRate ?? 24000,
|
|
@@ -747,7 +889,7 @@ class TTSResource {
|
|
|
747
889
|
text: options.text,
|
|
748
890
|
model_id: options.modelId || 'kugel-3',
|
|
749
891
|
voice_id: options.voiceId,
|
|
750
|
-
cfg_scale: options.cfgScale ?? 2.0,
|
|
892
|
+
cfg_scale: clampCfgScale(options.cfgScale) ?? 2.0,
|
|
751
893
|
max_new_tokens: options.maxNewTokens ?? 2048,
|
|
752
894
|
sample_rate: options.sampleRate ?? 24000,
|
|
753
895
|
...(options.outputFormat && { output_format: options.outputFormat }),
|
|
@@ -1195,7 +1337,7 @@ class MultiContextSession {
|
|
|
1195
1337
|
warnIfNoLanguage(this.config.language, this.config.normalize);
|
|
1196
1338
|
if (this.config.sampleRate) msg.sample_rate = this.config.sampleRate;
|
|
1197
1339
|
if (this.config.outputFormat) msg.output_format = this.config.outputFormat;
|
|
1198
|
-
if (this.config.cfgScale) msg.cfg_scale = this.config.cfgScale;
|
|
1340
|
+
if (this.config.cfgScale !== undefined) msg.cfg_scale = clampCfgScale(this.config.cfgScale);
|
|
1199
1341
|
if (this.config.temperature !== undefined) msg.temperature = this.config.temperature;
|
|
1200
1342
|
if (this.config.maxNewTokens) msg.max_new_tokens = this.config.maxNewTokens;
|
|
1201
1343
|
if (this.config.normalize !== undefined) msg.normalize = this.config.normalize;
|
|
@@ -1297,6 +1439,36 @@ class MultiContextSession {
|
|
|
1297
1439
|
}));
|
|
1298
1440
|
}
|
|
1299
1441
|
|
|
1442
|
+
/**
|
|
1443
|
+
* Change the session's generation parameters mid-connection (KUG-1166).
|
|
1444
|
+
*
|
|
1445
|
+
* Session-scoped (there is no `contextId`): the update applies to contexts
|
|
1446
|
+
* started **after** it — a context already streaming keeps the settings it
|
|
1447
|
+
* began with, since generation parameters are bound when a context's engine
|
|
1448
|
+
* session opens. With the common one-context-per-turn pattern that means it
|
|
1449
|
+
* takes effect on the next turn. Per-context `cfgScale` / `maxNewTokens`
|
|
1450
|
+
* passed to {@link createContext} still win for that context.
|
|
1451
|
+
*
|
|
1452
|
+
* Resolves with the generation parameters now in effect (the server's echo).
|
|
1453
|
+
*
|
|
1454
|
+
* @throws {KugelAudioError} if no field is provided, the socket is not open,
|
|
1455
|
+
* or the server rejects the update.
|
|
1456
|
+
*/
|
|
1457
|
+
updateSettings(settings: SettingsUpdate): Promise<EffectiveSettings> {
|
|
1458
|
+
if (!this.ws || this.ws.readyState !== WS_OPEN) {
|
|
1459
|
+
throw new KugelAudioError(
|
|
1460
|
+
'MultiContextSession not connected. Call connect() first.',
|
|
1461
|
+
);
|
|
1462
|
+
}
|
|
1463
|
+
const body = buildSettingsUpdateBody(settings);
|
|
1464
|
+
return sendUpdateSettings(this.ws, body).then((effective) => {
|
|
1465
|
+
// Mirror onto local config only after the server accepts the update, so
|
|
1466
|
+
// rejected values do not leak into future context creation.
|
|
1467
|
+
applyAcceptedSettingsUpdate(this.config as Record<string, unknown>, settings);
|
|
1468
|
+
return effective;
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1300
1472
|
/**
|
|
1301
1473
|
* Close the session and all contexts.
|
|
1302
1474
|
*/
|
|
@@ -1549,7 +1721,7 @@ class StreamingSession {
|
|
|
1549
1721
|
if (!this.configSent) {
|
|
1550
1722
|
if (this.config.voiceId !== undefined) msg.voice_id = this.config.voiceId;
|
|
1551
1723
|
if (this.config.modelId !== undefined) msg.model_id = this.config.modelId;
|
|
1552
|
-
if (this.config.cfgScale !== undefined) msg.cfg_scale = this.config.cfgScale;
|
|
1724
|
+
if (this.config.cfgScale !== undefined) msg.cfg_scale = clampCfgScale(this.config.cfgScale);
|
|
1553
1725
|
if (this.config.temperature !== undefined) msg.temperature = this.config.temperature;
|
|
1554
1726
|
if (this.config.maxNewTokens !== undefined) msg.max_new_tokens = this.config.maxNewTokens;
|
|
1555
1727
|
if (this.config.sampleRate !== undefined) msg.sample_rate = this.config.sampleRate;
|
|
@@ -1749,6 +1921,42 @@ class StreamingSession {
|
|
|
1749
1921
|
this.configSent = false;
|
|
1750
1922
|
}
|
|
1751
1923
|
|
|
1924
|
+
/**
|
|
1925
|
+
* Change generation parameters mid-connection without reconnecting (KUG-1166).
|
|
1926
|
+
*
|
|
1927
|
+
* Sends an `update_settings` message and resolves with the generation
|
|
1928
|
+
* parameters now in effect (the server's echo). Only the six fields of
|
|
1929
|
+
* {@link SettingsUpdate} are updatable; identity / audio-format fields
|
|
1930
|
+
* (`voiceId`, `modelId`, `sampleRate`, `outputFormat`, `dictionaryIds`) are
|
|
1931
|
+
* fixed for the connection — change those with {@link updateConfig} after
|
|
1932
|
+
* {@link endSession} instead.
|
|
1933
|
+
*
|
|
1934
|
+
* The change applies to the **next turn**: a turn already streaming keeps the
|
|
1935
|
+
* settings it started with, so call this between turns.
|
|
1936
|
+
*
|
|
1937
|
+
* @example
|
|
1938
|
+
* ```typescript
|
|
1939
|
+
* const effective = await session.updateSettings({ cfgScale: 1.5, speed: 1.1 });
|
|
1940
|
+
* ```
|
|
1941
|
+
*
|
|
1942
|
+
* @throws {KugelAudioError} if no field is provided, the socket is not open,
|
|
1943
|
+
* or the server rejects the update (e.g. a value out of range).
|
|
1944
|
+
*/
|
|
1945
|
+
updateSettings(settings: SettingsUpdate): Promise<EffectiveSettings> {
|
|
1946
|
+
if (!this.ws || this.ws.readyState !== WS_OPEN) {
|
|
1947
|
+
throw new KugelAudioError(
|
|
1948
|
+
'StreamingSession not connected. Call connect() first.',
|
|
1949
|
+
);
|
|
1950
|
+
}
|
|
1951
|
+
const body = buildSettingsUpdateBody(settings);
|
|
1952
|
+
return sendUpdateSettings(this.ws, body).then((effective) => {
|
|
1953
|
+
// Keep the local config in sync only after the server accepts the
|
|
1954
|
+
// change; rejected updates must not poison later config re-sends.
|
|
1955
|
+
applyAcceptedSettingsUpdate(this.config as Record<string, unknown>, settings);
|
|
1956
|
+
return effective;
|
|
1957
|
+
});
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1752
1960
|
/**
|
|
1753
1961
|
* Close the session and the WebSocket connection.
|
|
1754
1962
|
*
|
package/src/index.ts
CHANGED
|
@@ -54,6 +54,7 @@ export type {
|
|
|
54
54
|
DictionaryEntry,
|
|
55
55
|
DictionaryEntryInput,
|
|
56
56
|
DictionaryEntryListResponse,
|
|
57
|
+
EffectiveSettings,
|
|
57
58
|
GenerateOptions,
|
|
58
59
|
GenerationStats,
|
|
59
60
|
KugelAudioOptions,
|
|
@@ -63,6 +64,7 @@ export type {
|
|
|
63
64
|
MultiContextCallbacks,
|
|
64
65
|
MultiContextConfig,
|
|
65
66
|
SessionUsage,
|
|
67
|
+
SettingsUpdate,
|
|
66
68
|
StreamCallbacks,
|
|
67
69
|
StreamConfig,
|
|
68
70
|
StreamingSessionCallbacks,
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KugelAudio TTS plugin for the LiveKit Agents (Node.js) framework.
|
|
3
|
+
*
|
|
4
|
+
* Import from `kugelaudio/livekit`. Requires `@livekit/agents` and its peer
|
|
5
|
+
* `@livekit/rtc-node` to be installed (they are optional peer dependencies of
|
|
6
|
+
* this SDK). This is the TypeScript counterpart to `kugelaudio.livekit` in the
|
|
7
|
+
* Python SDK.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { TTS } from 'kugelaudio/livekit';
|
|
12
|
+
*
|
|
13
|
+
* const tts = new TTS({ voiceId: 1071, model: 'kugel-3', language: 'en' });
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* @packageDocumentation
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export { TTS, ChunkedStream, SynthesizeStream } from './tts';
|
|
20
|
+
export type { TTSOptions } from './tts';
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
DEFAULT_CFG_SCALE,
|
|
24
|
+
DEFAULT_MAX_NEW_TOKENS,
|
|
25
|
+
DEFAULT_MODEL,
|
|
26
|
+
DEFAULT_SAMPLE_RATE,
|
|
27
|
+
DEFAULT_VOICE_ID,
|
|
28
|
+
SUPPORTED_LANGUAGES,
|
|
29
|
+
SUPPORTED_SAMPLE_RATES,
|
|
30
|
+
validateLanguage,
|
|
31
|
+
} from './models';
|
|
32
|
+
export type { TTSModels } from './models';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { SUPPORTED_LANGUAGES, validateLanguage } from './models';
|
|
3
|
+
|
|
4
|
+
describe('validateLanguage', () => {
|
|
5
|
+
it('returns undefined for null/undefined', () => {
|
|
6
|
+
expect(validateLanguage(undefined)).toBeUndefined();
|
|
7
|
+
expect(validateLanguage(null)).toBeUndefined();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('accepts supported ISO 639-1 codes', () => {
|
|
11
|
+
expect(validateLanguage('de')).toBe('de');
|
|
12
|
+
expect(validateLanguage('en')).toBe('en');
|
|
13
|
+
expect(validateLanguage('yue')).toBe('yue');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('rejects BCP 47 locale tags with a helpful message', () => {
|
|
17
|
+
expect(() => validateLanguage('de-DE')).toThrow(/de-DE/);
|
|
18
|
+
expect(() => validateLanguage('de-DE')).toThrow(/use 'de' instead/);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('rejects unknown codes', () => {
|
|
22
|
+
expect(() => validateLanguage('xx')).toThrow();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('has a non-empty language set covering common languages', () => {
|
|
26
|
+
for (const code of ['de', 'en', 'fr', 'es', 'it', 'zh', 'ja', 'ko', 'ar', 'hi']) {
|
|
27
|
+
expect(SUPPORTED_LANGUAGES.has(code)).toBe(true);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Models, constants, and language validation for the KugelAudio LiveKit
|
|
3
|
+
* Agents TTS plugin.
|
|
4
|
+
*
|
|
5
|
+
* This module has NO dependency on `@livekit/agents`, so it can be imported
|
|
6
|
+
* and unit-tested without the (heavy, native) LiveKit runtime.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Available TTS models. Legacy ids (`kugel-2.5`, `kugel-2-turbo`,
|
|
11
|
+
* `kugel-1-turbo`, `kugel-1`) remain accepted for back-compat — server-side
|
|
12
|
+
* they all alias to `kugel-3` — but new code should use `'kugel-3'`.
|
|
13
|
+
*/
|
|
14
|
+
export type TTSModels =
|
|
15
|
+
| 'kugel-3'
|
|
16
|
+
| 'kugel-2.5'
|
|
17
|
+
| 'kugel-2-turbo'
|
|
18
|
+
| 'kugel-1-turbo'
|
|
19
|
+
| 'kugel-1';
|
|
20
|
+
|
|
21
|
+
/** Default model (matches the public API default; server-side `is_default=kugel-3`). */
|
|
22
|
+
export const DEFAULT_MODEL: TTSModels = 'kugel-3';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Supported output sample rates. The model generates at 24 kHz natively;
|
|
26
|
+
* other rates use server-side resampling.
|
|
27
|
+
*/
|
|
28
|
+
export const SUPPORTED_SAMPLE_RATES = [24000, 22050, 16000, 8000] as const;
|
|
29
|
+
|
|
30
|
+
/** Default output sample rate. */
|
|
31
|
+
export const DEFAULT_SAMPLE_RATE = 24000;
|
|
32
|
+
|
|
33
|
+
/** Default voice id (`null` means use the server default voice). */
|
|
34
|
+
export const DEFAULT_VOICE_ID: number | null = null;
|
|
35
|
+
|
|
36
|
+
/** Default classifier-free guidance scale. */
|
|
37
|
+
export const DEFAULT_CFG_SCALE = 2.0;
|
|
38
|
+
|
|
39
|
+
/** Default maximum number of tokens to generate. */
|
|
40
|
+
export const DEFAULT_MAX_NEW_TOKENS = 2048;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* ISO 639-1 language codes accepted by the API for text normalization.
|
|
44
|
+
* Mirrors `kugelaudio.livekit.tts.SUPPORTED_LANGUAGES` in the Python SDK.
|
|
45
|
+
*/
|
|
46
|
+
export const SUPPORTED_LANGUAGES: ReadonlySet<string> = new Set([
|
|
47
|
+
// Germanic
|
|
48
|
+
'de', 'en', 'nl', 'sv', 'da', 'no',
|
|
49
|
+
// Romance
|
|
50
|
+
'fr', 'es', 'it', 'pt', 'ro',
|
|
51
|
+
// Slavic
|
|
52
|
+
'pl', 'cs', 'uk', 'bg', 'sk', 'sl', 'hr', 'sr',
|
|
53
|
+
// Uralic
|
|
54
|
+
'fi', 'hu',
|
|
55
|
+
// Other European
|
|
56
|
+
'el', 'tr', 'ru',
|
|
57
|
+
// CJK + SEA
|
|
58
|
+
'zh', 'ja', 'ko', 'vi', 'yue', 'th', 'id', 'ms',
|
|
59
|
+
// Semitic + Indic + Other
|
|
60
|
+
'ar', 'hi', 'he', 'fa', 'ur', 'bn', 'ta',
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Validate that `language` is an ISO 639-1 code supported by the API.
|
|
65
|
+
*
|
|
66
|
+
* @param language - The language code, or `undefined`/`null` to leave unset.
|
|
67
|
+
* @returns The validated code, or `undefined` when none was supplied.
|
|
68
|
+
* @throws {Error} If `language` is not a supported ISO 639-1 code. A common
|
|
69
|
+
* mistake is passing a BCP 47 locale tag such as `'de-DE'` instead of `'de'`.
|
|
70
|
+
*/
|
|
71
|
+
export function validateLanguage(
|
|
72
|
+
language: string | null | undefined,
|
|
73
|
+
): string | undefined {
|
|
74
|
+
if (language === null || language === undefined) return undefined;
|
|
75
|
+
if (!SUPPORTED_LANGUAGES.has(language)) {
|
|
76
|
+
const supported = [...SUPPORTED_LANGUAGES].sort().join(', ');
|
|
77
|
+
throw new Error(
|
|
78
|
+
`language must be a supported ISO 639-1 code (${supported}), got ` +
|
|
79
|
+
`'${language}'. Note: BCP 47 tags like 'de-DE' are not accepted — ` +
|
|
80
|
+
`use 'de' instead.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return language;
|
|
84
|
+
}
|