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
|
@@ -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
|
-
/**
|
|
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
|
|
296
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
397
|
-
*
|
|
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
|
-
/**
|
|
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
|
*/
|