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.
@@ -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),
@@ -258,7 +259,13 @@ export interface GenerateOptions {
258
259
  maxNewTokens?: number;
259
260
  /** Output sample rate (default: 24000) */
260
261
  sampleRate?: number;
261
- /**
262
+ /**
263
+ * Combined codec+rate token, e.g. 'ulaw_8000' / 'alaw_8000' / 'pcm_8000'.
264
+ * Opt-in; when set it is authoritative and must not contradict sampleRate.
265
+ * Absent ⇒ legacy PCM16 at sampleRate.
266
+ */
267
+ outputFormat?: string;
268
+ /**
262
269
  * Enable text normalization (converts numbers, dates, etc. to spoken words).
263
270
  * When true, text will be normalized before TTS generation.
264
271
  * Default: true
@@ -286,8 +293,9 @@ export interface GenerateOptions {
286
293
  /**
287
294
  * Playback speed multiplier (0.8 = slower, 1.0 = normal, 1.2 = faster).
288
295
  *
289
- * Uses pitch-preserving time-stretching (WSOLA). Inline `<prosody rate="...">` tags
290
- * can also be used for per-segment speed 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).
291
299
  * Range: [0.8, 1.2]. Default: 1.0.
292
300
  */
293
301
  speed?: number;
@@ -298,6 +306,14 @@ export interface GenerateOptions {
298
306
  * server treats the value as trusted once received.
299
307
  */
300
308
  projectId?: number;
309
+ /**
310
+ * Per-request dictionary selection. Omit for the default behavior (all
311
+ * active dictionaries of the project apply, filtered by language). An
312
+ * empty array disables dictionaries for this request. A list of
313
+ * dictionary IDs applies exactly those dictionaries — including
314
+ * inactive ones — bypassing the language filter.
315
+ */
316
+ dictionaryIds?: number[];
301
317
  }
302
318
 
303
319
  /**
@@ -322,7 +338,7 @@ export interface StreamConfig {
322
338
  voiceId?: number;
323
339
  /** Model ID. Default: 'kugel-3'. Legacy ids still accepted; they alias to kugel-3 server-side. */
324
340
  modelId?: string;
325
- /** CFG scale for generation */
341
+ /** Classifier-free guidance scale. Clamped to [1.2, 2.5] (default: 2.0). */
326
342
  cfgScale?: number;
327
343
  /**
328
344
  * Sampling variance. Range [0.0, 1.0]. 0 = most stable, 1 = most variance.
@@ -333,6 +349,8 @@ export interface StreamConfig {
333
349
  maxNewTokens?: number;
334
350
  /** Output sample rate */
335
351
  sampleRate?: number;
352
+ /** Combined codec+rate token (e.g. 'ulaw_8000'); opt-in, set-once per session. */
353
+ outputFormat?: string;
336
354
  /** Auto-flush timeout in milliseconds */
337
355
  flushTimeoutMs?: number;
338
356
  /** Maximum buffer length */
@@ -377,11 +395,57 @@ export interface StreamConfig {
377
395
  /**
378
396
  * Playback speed multiplier (0.8 = slower, 1.0 = normal, 1.2 = faster).
379
397
  *
380
- * Uses pitch-preserving time-stretching (WSOLA). Inline `<prosody rate="...">` tags
381
- * can also be used for per-segment speed 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).
382
401
  * Range: [0.8, 1.2]. Default: 1.0.
383
402
  */
384
403
  speed?: number;
404
+ /**
405
+ * Per-request dictionary selection. Omit for the default behavior (all
406
+ * active dictionaries of the project apply, filtered by language). An
407
+ * empty array disables dictionaries for this request. A list of
408
+ * dictionary IDs applies exactly those dictionaries — including
409
+ * inactive ones — bypassing the language filter.
410
+ */
411
+ dictionaryIds?: number[];
412
+ }
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;
385
449
  }
386
450
 
387
451
  /**
@@ -399,9 +463,18 @@ export interface StreamingSessionCallbacks {
399
463
  * Carries the segment index, total audio duration, and generation time.
400
464
  */
401
465
  onChunkComplete?: (chunkId: number, audioSeconds: number, genMs: number) => void;
466
+ /**
467
+ * Called when the server marks the end of a turn's audio
468
+ * (`{"final": true, ...}` — sent after the last audio frame of every
469
+ * gracefully completed turn, right before `session_closed`). The
470
+ * ElevenLabs `isFinal` equivalent: once this fires, no further audio
471
+ * for the turn will arrive. Not fired on a barge-in cancel — that
472
+ * path fires {@link onInterrupted} instead.
473
+ */
474
+ onFinal?: (totalAudioSeconds: number, totalTextChunks: number, totalAudioChunks: number) => void;
402
475
  /**
403
476
  * Called when the session is fully closed (after `session.close()`).
404
- * Equivalent to `onFinal` on the one-shot endpoint.
477
+ * Fires right after {@link onFinal} and additionally carries usage.
405
478
  */
406
479
  onSessionClosed?: (totalAudioSeconds: number, totalTextChunks: number, totalAudioChunks: number) => void;
407
480
  /** Called when the server begins generating audio for a text segment. */
@@ -419,14 +492,71 @@ export interface StreamingSessionCallbacks {
419
492
  onError?: (error: Error) => void;
420
493
  }
421
494
 
495
+ /**
496
+ * Per-session usage reported in the `session_closed` frame (KUG-1192).
497
+ *
498
+ * Lets you bill your own customers per conversation. `costCents` is the
499
+ * actual amount charged in **EUR cents**. When the charge could not be
500
+ * determined at session end (e.g. a transient billing error) `costCents` is
501
+ * `null` and `costAvailable` is `false` — never a misleading `0`.
502
+ * `audioSeconds` is always reported. On `/ws/tts/multi` usage is reported per
503
+ * context (per conversation) on each `context_closed` frame, not aggregated
504
+ * across contexts.
505
+ */
506
+ export interface SessionUsage {
507
+ /** Total audio generated this session, in seconds (the unit we bill on). */
508
+ audioSeconds: number;
509
+ /** Actual amount charged in EUR cents, or `null` if undetermined. */
510
+ costCents: number | null;
511
+ /** Currency of `costCents` (`"eur"`); present only when `costCents` is set. */
512
+ currency?: string;
513
+ /** Total input characters submitted this session, if reported. */
514
+ characters?: number;
515
+ /** Model that produced the audio, if reported. */
516
+ modelId?: string;
517
+ /** `true` when an authoritative charge was returned for this session. */
518
+ costAvailable: boolean;
519
+ }
520
+
521
+ /**
522
+ * Parse the raw `usage` object (or a legacy `session_closed` payload without
523
+ * one) into a typed {@link SessionUsage}. Returns `null` when no usage info
524
+ * is present.
525
+ */
526
+ export function parseSessionUsage(
527
+ data: Record<string, unknown>,
528
+ ): SessionUsage | null {
529
+ const raw = data.usage as Record<string, unknown> | undefined;
530
+ const source = raw && typeof raw === 'object' ? raw : data;
531
+ const audioSeconds =
532
+ typeof source.audio_seconds === 'number'
533
+ ? source.audio_seconds
534
+ : typeof data.total_audio_seconds === 'number'
535
+ ? data.total_audio_seconds
536
+ : undefined;
537
+ if (audioSeconds === undefined) return null;
538
+ const costCents =
539
+ typeof source.cost_cents === 'number' ? source.cost_cents : null;
540
+ return {
541
+ audioSeconds,
542
+ costCents,
543
+ currency:
544
+ typeof source.currency === 'string' ? source.currency : undefined,
545
+ characters:
546
+ typeof source.characters === 'number' ? source.characters : undefined,
547
+ modelId: typeof source.model_id === 'string' ? source.model_id : undefined,
548
+ costAvailable: costCents !== null,
549
+ };
550
+ }
551
+
422
552
  /**
423
553
  * Audio chunk from streaming TTS.
424
554
  */
425
555
  export interface AudioChunk {
426
556
  /** Raw PCM16 audio as base64 */
427
557
  audio: string;
428
- /** Encoding format */
429
- encoding: 'pcm_s16le';
558
+ /** Encoding format. 'mulaw' / 'alaw' only when output_format requested G.711. */
559
+ encoding: 'pcm_s16le' | 'mulaw' | 'alaw';
430
560
  /** Chunk index */
431
561
  index: number;
432
562
  /** Sample rate */
@@ -453,6 +583,12 @@ export interface GenerationStats {
453
583
  rtf: number;
454
584
  /** Error message if any */
455
585
  error?: string;
586
+ /**
587
+ * Per-request usage (audio time + amount charged), for billing your own
588
+ * customers. Undefined when the server reports no usage. See
589
+ * {@link SessionUsage}.
590
+ */
591
+ usage?: SessionUsage;
456
592
  }
457
593
 
458
594
  /**
@@ -546,7 +682,9 @@ export interface MultiContextConfig {
546
682
  defaultVoiceId?: number;
547
683
  /** Output sample rate (default: 24000) */
548
684
  sampleRate?: number;
549
- /** CFG scale for generation (default: 2.0) */
685
+ /** Combined codec+rate token (e.g. 'ulaw_8000'); opt-in, set-once per context. */
686
+ outputFormat?: string;
687
+ /** Classifier-free guidance scale. Clamped to [1.2, 2.5] (default: 2.0). */
550
688
  cfgScale?: number;
551
689
  /**
552
690
  * Sampling variance. Range [0.0, 1.0]. 0 = most stable, 1 = most variance.
@@ -563,6 +701,14 @@ export interface MultiContextConfig {
563
701
  * the language, which adds ~60-150ms to time-to-first-audio.
564
702
  */
565
703
  language?: string;
704
+ /**
705
+ * Per-request dictionary selection. Omit for the default behavior (all
706
+ * active dictionaries of the project apply, filtered by language). An
707
+ * empty array disables dictionaries for this request. A list of
708
+ * dictionary IDs applies exactly those dictionaries — including
709
+ * inactive ones — bypassing the language filter.
710
+ */
711
+ dictionaryIds?: number[];
566
712
  /** Seconds before context auto-closes (default: 20.0) */
567
713
  inactivityTimeout?: number;
568
714
  }
@@ -601,8 +747,20 @@ export interface MultiContextCallbacks {
601
747
  onContextCreated?: (contextId: string) => void;
602
748
  /** Called when an audio chunk is received */
603
749
  onChunk?: (chunk: MultiContextAudioChunk) => void;
604
- /** Called when a context is closed */
605
- onContextClosed?: (contextId: string) => void;
750
+ /**
751
+ * Called when all audio admitted before a `{flush: true}` has been
752
+ * delivered for a context (`{"final": true, "context_id": ...}`), and
753
+ * once more before {@link onContextClosed} on a graceful close. The
754
+ * ElevenLabs multi-context `is_final` equivalent. Not fired on an
755
+ * immediate (barge-in) close.
756
+ */
757
+ onFinal?: (contextId: string) => void;
758
+ /**
759
+ * Called when a context is closed (terminal). `usage` carries this
760
+ * conversation's audio time + amount charged (undefined if not reported).
761
+ * See {@link SessionUsage}.
762
+ */
763
+ onContextClosed?: (contextId: string, usage?: SessionUsage) => void;
606
764
  /** Called when a context times out */
607
765
  onContextTimeout?: (contextId: string) => void;
608
766
  /** Called when session is closed */
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
  */