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.
@@ -0,0 +1,112 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ appendSdkQuery,
4
+ buildClosePayload,
5
+ buildMultiWsUrl,
6
+ buildTextPayload,
7
+ wordTimestampsToTimed,
8
+ type WireOptions,
9
+ } from './wire';
10
+
11
+ const opts: WireOptions = {
12
+ model: 'kugel-3',
13
+ voiceId: 1071,
14
+ sampleRate: 24000,
15
+ cfgScale: 2.0,
16
+ maxNewTokens: 2048,
17
+ wordTimestamps: false,
18
+ normalize: true,
19
+ };
20
+
21
+ describe('buildMultiWsUrl', () => {
22
+ it('rewrites https to wss and targets /ws/tts/multi with the api key', () => {
23
+ const url = buildMultiWsUrl('https://api.kugelaudio.com', 'secret');
24
+ expect(url).toContain('wss://api.kugelaudio.com/ws/tts/multi');
25
+ expect(url).toContain('api_key=secret');
26
+ });
27
+
28
+ it('rewrites http to ws for local dev', () => {
29
+ expect(buildMultiWsUrl('http://localhost:8000', 'k')).toContain('ws://localhost:8000/ws/tts/multi');
30
+ });
31
+
32
+ it('appends sdk telemetry params', () => {
33
+ const url = buildMultiWsUrl('https://api.kugelaudio.com', 'k');
34
+ expect(url).toContain('sdk=js');
35
+ expect(url).toContain('sdk_version=');
36
+ });
37
+ });
38
+
39
+ describe('appendSdkQuery', () => {
40
+ it('uses ? for the first param and & thereafter', () => {
41
+ expect(appendSdkQuery('https://x/y')).toContain('?sdk=js');
42
+ expect(appendSdkQuery('https://x/y?a=b')).toContain('&sdk=js');
43
+ });
44
+ });
45
+
46
+ describe('buildTextPayload', () => {
47
+ it('sends a bare text frame when config is not requested', () => {
48
+ const msg = buildTextPayload('ctx1', 'hello', opts);
49
+ expect(msg).toEqual({ text: 'hello', context_id: 'ctx1' });
50
+ });
51
+
52
+ it('marks flush when requested', () => {
53
+ expect(buildTextPayload('ctx1', 'hi', opts, { flush: true })).toMatchObject({ flush: true });
54
+ });
55
+
56
+ it('attaches session + voice config on the first frame', () => {
57
+ const msg = buildTextPayload('ctx1', 'hi', opts, { includeConfig: true });
58
+ expect(msg).toMatchObject({
59
+ model_id: 'kugel-3',
60
+ sample_rate: 24000,
61
+ word_timestamps: false,
62
+ normalize: true,
63
+ voice_settings: { voice_id: 1071 },
64
+ });
65
+ });
66
+
67
+ it('omits default generation params from voice_settings', () => {
68
+ const msg = buildTextPayload('ctx1', 'hi', opts, { includeConfig: true });
69
+ expect(msg.voice_settings).toEqual({ voice_id: 1071 });
70
+ });
71
+
72
+ it('includes non-default cfg_scale and max_new_tokens', () => {
73
+ const msg = buildTextPayload('ctx1', 'hi', { ...opts, cfgScale: 1.5, maxNewTokens: 4096 }, {
74
+ includeConfig: true,
75
+ });
76
+ expect(msg.voice_settings).toMatchObject({ cfg_scale: 1.5, max_new_tokens: 4096 });
77
+ });
78
+
79
+ it('omits voice_settings entirely when the voice is the server default', () => {
80
+ const msg = buildTextPayload('ctx1', 'hi', { ...opts, voiceId: null }, { includeConfig: true });
81
+ expect(msg).not.toHaveProperty('voice_settings');
82
+ });
83
+
84
+ it('includes language only when set', () => {
85
+ expect(buildTextPayload('c', 'x', opts, { includeConfig: true })).not.toHaveProperty('language');
86
+ const withLang = buildTextPayload('c', 'x', { ...opts, language: 'de' }, { includeConfig: true });
87
+ expect(withLang).toMatchObject({ language: 'de' });
88
+ });
89
+ });
90
+
91
+ describe('buildClosePayload', () => {
92
+ it('drains by default', () => {
93
+ expect(buildClosePayload('ctx1')).toEqual({ close_context: true, context_id: 'ctx1' });
94
+ });
95
+
96
+ it('sets immediate for barge-in', () => {
97
+ expect(buildClosePayload('ctx1', true)).toMatchObject({ immediate: true });
98
+ });
99
+ });
100
+
101
+ describe('wordTimestampsToTimed', () => {
102
+ it('converts server milliseconds to seconds', () => {
103
+ const timed = wordTimestampsToTimed([
104
+ { word: 'Hallo', start_ms: 0, end_ms: 500 },
105
+ { word: 'Welt', start_ms: 500, end_ms: 1200 },
106
+ ]);
107
+ expect(timed).toEqual([
108
+ { text: 'Hallo', startTime: 0, endTime: 0.5 },
109
+ { text: 'Welt', startTime: 0.5, endTime: 1.2 },
110
+ ]);
111
+ });
112
+ });
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Pure wire-protocol helpers for the KugelAudio `/ws/tts/multi` endpoint used
3
+ * by the LiveKit plugin.
4
+ *
5
+ * Kept free of any `@livekit/agents` import so the message-shaping logic can be
6
+ * unit-tested without the LiveKit runtime. Mirrors the send loop of
7
+ * `kugelaudio.livekit.tts._Connection` in the Python SDK.
8
+ */
9
+
10
+ import packageJson from '../../package.json';
11
+
12
+ const SDK_NAME = 'js';
13
+ const SDK_VERSION = packageJson.version;
14
+
15
+ /** Generation parameters carried on the first message of each context. */
16
+ export interface WireOptions {
17
+ model: string;
18
+ voiceId: number | null;
19
+ sampleRate: number;
20
+ cfgScale: number;
21
+ maxNewTokens: number;
22
+ wordTimestamps: boolean;
23
+ normalize: boolean;
24
+ language?: string;
25
+ }
26
+
27
+ /** Append the `sdk` / `sdk_version` query params used for server-side telemetry. */
28
+ export function appendSdkQuery(url: string): string {
29
+ const separator = url.includes('?') ? '&' : '?';
30
+ return (
31
+ `${url}${separator}sdk=${encodeURIComponent(SDK_NAME)}` +
32
+ `&sdk_version=${encodeURIComponent(SDK_VERSION)}`
33
+ );
34
+ }
35
+
36
+ /**
37
+ * Build the `wss://.../ws/tts/multi` URL from an `https://`/`http://` base URL.
38
+ * The API key is passed as a query parameter, matching the Python plugin.
39
+ */
40
+ export function buildMultiWsUrl(baseUrl: string, apiKey: string): string {
41
+ const wsBase = baseUrl.replace('https://', 'wss://').replace('http://', 'ws://');
42
+ return appendSdkQuery(`${wsBase}/ws/tts/multi?api_key=${apiKey}`);
43
+ }
44
+
45
+ /**
46
+ * Build the JSON payload for a text message to a context.
47
+ *
48
+ * When `includeConfig` is true (the first message sent for a context), the
49
+ * session + voice configuration is attached, exactly as the Python plugin does
50
+ * on the first frame of a new `context_id`.
51
+ */
52
+ export function buildTextPayload(
53
+ contextId: string,
54
+ text: string,
55
+ opts: WireOptions,
56
+ { flush = false, includeConfig = false }: { flush?: boolean; includeConfig?: boolean } = {},
57
+ ): Record<string, unknown> {
58
+ const msg: Record<string, unknown> = { text, context_id: contextId };
59
+ if (flush) msg.flush = true;
60
+
61
+ if (includeConfig) {
62
+ msg.model_id = opts.model;
63
+ msg.sample_rate = opts.sampleRate;
64
+ msg.word_timestamps = opts.wordTimestamps;
65
+ msg.normalize = opts.normalize;
66
+ if (opts.language !== undefined) msg.language = opts.language;
67
+
68
+ const voiceSettings: Record<string, unknown> = {};
69
+ if (opts.voiceId !== null && opts.voiceId !== undefined) {
70
+ voiceSettings.voice_id = opts.voiceId;
71
+ }
72
+ // Only send non-default generation params, mirroring the Python plugin.
73
+ if (opts.cfgScale !== 2.0) voiceSettings.cfg_scale = opts.cfgScale;
74
+ if (opts.maxNewTokens !== 2048) voiceSettings.max_new_tokens = opts.maxNewTokens;
75
+ if (Object.keys(voiceSettings).length > 0) msg.voice_settings = voiceSettings;
76
+ }
77
+
78
+ return msg;
79
+ }
80
+
81
+ /**
82
+ * Build the JSON payload that closes a context.
83
+ *
84
+ * `immediate` cancels in-flight generation (barge-in); omitting it drains any
85
+ * queued audio first.
86
+ */
87
+ export function buildClosePayload(
88
+ contextId: string,
89
+ immediate = false,
90
+ ): Record<string, unknown> {
91
+ const msg: Record<string, unknown> = {
92
+ close_context: true,
93
+ context_id: contextId,
94
+ };
95
+ if (immediate) msg.immediate = true;
96
+ return msg;
97
+ }
98
+
99
+ /** A single word-timing entry as delivered by the server (`start_ms`/`end_ms`). */
100
+ export interface RawWordTimestamp {
101
+ word: string;
102
+ start_ms: number;
103
+ end_ms: number;
104
+ }
105
+
106
+ /** A framework-agnostic word timing in seconds. */
107
+ export interface TimedWord {
108
+ text: string;
109
+ startTime: number;
110
+ endTime: number;
111
+ }
112
+
113
+ /**
114
+ * Convert the server's `word_timestamps` payload (integer milliseconds) into
115
+ * seconds-based {@link TimedWord}s, matching the Python plugin's
116
+ * `_word_timestamps_to_timed`.
117
+ */
118
+ export function wordTimestampsToTimed(
119
+ timestamps: RawWordTimestamp[],
120
+ ): TimedWord[] {
121
+ return timestamps.map((ts) => ({
122
+ text: ts.word,
123
+ startTime: ts.start_ms / 1000,
124
+ endTime: ts.end_ms / 1000,
125
+ }));
126
+ }
package/src/types.ts CHANGED
@@ -39,6 +39,7 @@ export interface Voice {
39
39
  category?: VoiceCategory;
40
40
  sex?: VoiceSex;
41
41
  age?: VoiceAge;
42
+ quality?: string;
42
43
  supportedLanguages: string[];
43
44
  sampleText?: string;
44
45
  avatarUrl?: string;
@@ -244,7 +245,7 @@ export interface GenerateOptions {
244
245
  modelId?: string;
245
246
  /** Voice ID to use */
246
247
  voiceId?: number;
247
- /** CFG scale for generation (default: 2.0) */
248
+ /** Classifier-free guidance scale. Clamped to [1.2, 2.5] (default: 2.0). */
248
249
  cfgScale?: number;
249
250
  /**
250
251
  * Sampling variance. Range [0.0, 1.0]. 0 = most stable (near-greedy),
@@ -292,8 +293,9 @@ export interface GenerateOptions {
292
293
  /**
293
294
  * Playback speed multiplier (0.8 = slower, 1.0 = normal, 1.2 = faster).
294
295
  *
295
- * Uses pitch-preserving time-stretching (WSOLA); applies uniformly to the
296
- * whole request (no per-span control).
296
+ * Uses pitch-preserving time-stretching (WSOLA); applies to the whole
297
+ * request. Wrap text in `<prosody rate="slow|medium|fast|0.8-1.2">` to
298
+ * override the rate for a span (the span rate wins inside the span).
297
299
  * Range: [0.8, 1.2]. Default: 1.0.
298
300
  */
299
301
  speed?: number;
@@ -336,7 +338,7 @@ export interface StreamConfig {
336
338
  voiceId?: number;
337
339
  /** Model ID. Default: 'kugel-3'. Legacy ids still accepted; they alias to kugel-3 server-side. */
338
340
  modelId?: string;
339
- /** CFG scale for generation */
341
+ /** Classifier-free guidance scale. Clamped to [1.2, 2.5] (default: 2.0). */
340
342
  cfgScale?: number;
341
343
  /**
342
344
  * Sampling variance. Range [0.0, 1.0]. 0 = most stable, 1 = most variance.
@@ -393,8 +395,9 @@ export interface StreamConfig {
393
395
  /**
394
396
  * Playback speed multiplier (0.8 = slower, 1.0 = normal, 1.2 = faster).
395
397
  *
396
- * Uses pitch-preserving time-stretching (WSOLA); applies uniformly to the
397
- * whole request (no per-span control).
398
+ * Uses pitch-preserving time-stretching (WSOLA); applies to the whole
399
+ * request. Wrap text in `<prosody rate="slow|medium|fast|0.8-1.2">` to
400
+ * override the rate for a span (the span rate wins inside the span).
398
401
  * Range: [0.8, 1.2]. Default: 1.0.
399
402
  */
400
403
  speed?: number;
@@ -408,6 +411,43 @@ export interface StreamConfig {
408
411
  dictionaryIds?: number[];
409
412
  }
410
413
 
414
+ /**
415
+ * Generation parameters changeable mid-connection via
416
+ * {@link StreamingSession.updateSettings} / {@link MultiContextSession.updateSettings}
417
+ * (KUG-1166). Every field is optional; an update changes only the fields it
418
+ * carries. Identity / audio-format fields (`voiceId`, `modelId`, `sampleRate`,
419
+ * `outputFormat`, `dictionaryIds`) are NOT here — they are fixed for the
420
+ * connection's lifetime and the server rejects them in an update.
421
+ */
422
+ export interface SettingsUpdate {
423
+ /** Classifier-free guidance scale (0.0–10.0). */
424
+ cfgScale?: number;
425
+ /** Sampling variance (0.0–1.0). */
426
+ temperature?: number;
427
+ /** Playback speed multiplier (0.8–1.2). */
428
+ speed?: number;
429
+ /** Maximum tokens per generation (1–8192). */
430
+ maxNewTokens?: number;
431
+ /** Language code for normalization (e.g. `'de'`). */
432
+ language?: string;
433
+ /** Enable text normalization. */
434
+ normalize?: boolean;
435
+ }
436
+
437
+ /**
438
+ * The generation parameters in effect after an
439
+ * {@link StreamingSession.updateSettings} call — the server's echo. Fields the
440
+ * session never set come back `null` (e.g. `temperature`, `language`).
441
+ */
442
+ export interface EffectiveSettings {
443
+ cfgScale?: number;
444
+ temperature?: number | null;
445
+ speed?: number;
446
+ maxNewTokens?: number;
447
+ language?: string | null;
448
+ normalize?: boolean;
449
+ }
450
+
411
451
  /**
412
452
  * Event callbacks for a streaming session (`/ws/tts/stream`).
413
453
  *
@@ -644,7 +684,7 @@ export interface MultiContextConfig {
644
684
  sampleRate?: number;
645
685
  /** Combined codec+rate token (e.g. 'ulaw_8000'); opt-in, set-once per context. */
646
686
  outputFormat?: string;
647
- /** CFG scale for generation (default: 2.0) */
687
+ /** Classifier-free guidance scale. Clamped to [1.2, 2.5] (default: 2.0). */
648
688
  cfgScale?: number;
649
689
  /**
650
690
  * Sampling variance. Range [0.0, 1.0]. 0 = most stable, 1 = most variance.
package/src/utils.ts CHANGED
@@ -2,6 +2,22 @@
2
2
  * Utility functions for KugelAudio SDK.
3
3
  */
4
4
 
5
+ /**
6
+ * Accepted classifier-free guidance band. Values outside [MIN, MAX] are
7
+ * clamped into the band (both client-side and by the server).
8
+ */
9
+ export const MIN_CFG_SCALE = 1.2;
10
+ export const MAX_CFG_SCALE = 2.5;
11
+
12
+ /**
13
+ * Clamp `cfgScale` into [1.2, 2.5]. `undefined` passes through unchanged
14
+ * (the server default is applied).
15
+ */
16
+ export function clampCfgScale(cfgScale: number | undefined): number | undefined {
17
+ if (cfgScale === undefined) return undefined;
18
+ return Math.min(MAX_CFG_SCALE, Math.max(MIN_CFG_SCALE, cfgScale));
19
+ }
20
+
5
21
  /**
6
22
  * Decode base64 string to ArrayBuffer.
7
23
  */