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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kugelaudio",
3
- "version": "0.8.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
+ });
@@ -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
+ }