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,833 @@
1
+ /**
2
+ * KugelAudio TTS plugin for the LiveKit Agents (Node.js) framework.
3
+ *
4
+ * A TypeScript translation of `kugelaudio.livekit.tts` (Python SDK). Uses a
5
+ * single persistent WebSocket to `/ws/tts/multi` with per-call context IDs; the
6
+ * shared connection multiplexes every context. On barge-in (the framework
7
+ * aborting a stream) the context is closed with `immediate=true` and any late
8
+ * server frames for it are silently discarded. A context's waiter resolves only
9
+ * on the server's `context_closed` frame — sent after every audio frame has
10
+ * been drained — so the audio tail is never clipped.
11
+ */
12
+
13
+ import {
14
+ APIConnectionError,
15
+ APIStatusError,
16
+ APITimeoutError,
17
+ AudioByteStream,
18
+ type APIConnectOptions,
19
+ type TimedString,
20
+ createTimedString,
21
+ log,
22
+ shortuuid,
23
+ tts,
24
+ } from '@livekit/agents';
25
+ import type { AudioFrame } from '@livekit/rtc-node';
26
+ import { type RawData, WebSocket } from 'ws';
27
+ import type { IncomingMessage } from 'node:http';
28
+
29
+ import { classifyWsFrame, classifyWsHandshakeError } from '../errors';
30
+ import { clampCfgScale } from '../utils';
31
+ import {
32
+ DEFAULT_CFG_SCALE,
33
+ DEFAULT_MAX_NEW_TOKENS,
34
+ DEFAULT_MODEL,
35
+ DEFAULT_SAMPLE_RATE,
36
+ DEFAULT_VOICE_ID,
37
+ type TTSModels,
38
+ validateLanguage,
39
+ } from './models';
40
+ import {
41
+ buildClosePayload,
42
+ buildMultiWsUrl,
43
+ buildTextPayload,
44
+ wordTimestampsToTimed,
45
+ type WireOptions,
46
+ } from './wire';
47
+
48
+ const NUM_CHANNELS = 1;
49
+
50
+ /** Options accepted by the {@link TTS} constructor. */
51
+ export interface TTSOptions {
52
+ /**
53
+ * KugelAudio API key. Falls back to the `KUGELAUDIO_API_KEY` environment
54
+ * variable. Prefix with `"eu-"` to select the direct EU endpoint; the prefix
55
+ * is stripped before auth.
56
+ */
57
+ apiKey?: string;
58
+ /** TTS model. Defaults to `'kugel-3'`. */
59
+ model?: TTSModels | string;
60
+ /** Voice id. `null`/omitted uses the server default voice. */
61
+ voiceId?: number | null;
62
+ /** Output sample rate in Hz (24000, 22050, 16000, 8000). Defaults to 24000. */
63
+ sampleRate?: number;
64
+ /** Classifier-free guidance scale. Clamped to [1.2, 2.5]. Defaults to 2.0. */
65
+ cfgScale?: number;
66
+ /** Maximum tokens to generate. Defaults to 2048. */
67
+ maxNewTokens?: number;
68
+ /** Apply loudness normalization to the output audio. Defaults to true. */
69
+ normalize?: boolean;
70
+ /**
71
+ * Request per-chunk word-level timestamps for aligned transcript / barge-in.
72
+ * Defaults to false. Enabling advertises the `alignedTranscript` capability.
73
+ */
74
+ wordTimestamps?: boolean;
75
+ /**
76
+ * ISO 639-1 language code for text normalization (e.g. `'de'`). When set,
77
+ * skips server-side auto-detection, saving ~60-150ms per request.
78
+ */
79
+ language?: string;
80
+ /** API base URL. Overrides the default geo-routed endpoint. */
81
+ baseURL?: string;
82
+ }
83
+
84
+ interface ResolvedTTSOptions extends WireOptions {
85
+ apiKey: string;
86
+ baseURL: string;
87
+ }
88
+
89
+ const DEFAULT_BASE_URL = 'https://api.kugelaudio.com';
90
+ const EU_BASE_URL = 'https://api.eu.kugelaudio.com';
91
+
92
+ /** Strip an `eu-`/`us-`/`global-` region prefix from an API key. */
93
+ function parseApiKey(apiKey: string): { cleanKey: string; region?: string } {
94
+ for (const prefix of ['eu-', 'us-', 'global-']) {
95
+ if (apiKey.startsWith(prefix)) {
96
+ return { cleanKey: apiKey.slice(prefix.length), region: prefix.slice(0, -1) };
97
+ }
98
+ }
99
+ return { cleanKey: apiKey };
100
+ }
101
+
102
+ /** Convert an ingress WS error frame into LiveKit's status exception. */
103
+ function apiStatusErrorFromFrame(
104
+ data: { error?: string; error_code?: string; code?: number },
105
+ requestId: string,
106
+ ): APIStatusError {
107
+ const err = classifyWsFrame(data);
108
+ const statusCode = err.statusCode ?? (typeof data.code === 'number' ? data.code : 500);
109
+ return new APIStatusError({
110
+ message: err.message,
111
+ options: { statusCode, requestId, body: data },
112
+ });
113
+ }
114
+
115
+ /** Convert a KugelAudio connection/handshake error into a LiveKit exception. */
116
+ function toLiveKitConnError(err: unknown): APIConnectionError {
117
+ if (err instanceof Error && /handshake has timed out/i.test(err.message)) {
118
+ return new APITimeoutError({ message: 'Timed out connecting to /ws/tts/multi' });
119
+ }
120
+ const typed = classifyWsHandshakeError(err);
121
+ if (typed && typed.statusCode !== undefined) {
122
+ return new APIStatusError({
123
+ message: typed.message,
124
+ options: { statusCode: typed.statusCode },
125
+ });
126
+ }
127
+ const message = err instanceof Error ? err.message : String(err);
128
+ return new APIConnectionError({ message: `Failed to connect to /ws/tts/multi: ${message}` });
129
+ }
130
+
131
+ function bufferToArrayBuffer(buf: Buffer): ArrayBuffer {
132
+ return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer;
133
+ }
134
+
135
+ /** A resolvable/rejectable promise handle. */
136
+ interface Deferred {
137
+ promise: Promise<void>;
138
+ resolve: () => void;
139
+ reject: (err: Error) => void;
140
+ settled: boolean;
141
+ }
142
+
143
+ function deferred(): Deferred {
144
+ const d = { settled: false } as Deferred;
145
+ d.promise = new Promise<void>((resolve, reject) => {
146
+ d.resolve = () => {
147
+ if (d.settled) return;
148
+ d.settled = true;
149
+ resolve();
150
+ };
151
+ d.reject = (err: Error) => {
152
+ if (d.settled) return;
153
+ d.settled = true;
154
+ reject(err);
155
+ };
156
+ });
157
+ // Swallow unhandled-rejection noise; consumers await via waitForContextIdle.
158
+ d.promise.catch(() => {});
159
+ return d;
160
+ }
161
+
162
+ function delay(ms: number): Promise<void> {
163
+ return new Promise((resolve) => setTimeout(resolve, ms));
164
+ }
165
+
166
+ /**
167
+ * Minimal async mutex: `lock()` resolves once the caller holds the lock and
168
+ * returns a release function. Serialises connection (re)creation so a burst of
169
+ * concurrent synthesis calls opens only one WebSocket.
170
+ */
171
+ class Mutex {
172
+ #tail: Promise<void> = Promise.resolve();
173
+
174
+ async lock(): Promise<() => void> {
175
+ let release!: () => void;
176
+ const next = new Promise<void>((resolve) => {
177
+ release = resolve;
178
+ });
179
+ const prev = this.#tail;
180
+ this.#tail = this.#tail.then(() => next);
181
+ await prev;
182
+ return release;
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Turns raw PCM byte chunks into LiveKit {@link AudioFrame}s, deferring the
188
+ * last produced frame so it can be flagged `final` once the segment ends. Word
189
+ * timings are attached to the next emitted frame. Mirrors the frame-emission
190
+ * behaviour of the Python plugin's `AudioEmitter`.
191
+ */
192
+ class AudioSink {
193
+ #bstream: AudioByteStream;
194
+ #lastFrame: AudioFrame | undefined;
195
+ #pendingTimed: TimedString[] = [];
196
+ #ended = false;
197
+
198
+ constructor(
199
+ sampleRate: number,
200
+ private readonly put: (audio: tts.SynthesizedAudio) => void,
201
+ private readonly requestId: string,
202
+ private readonly segmentId: string,
203
+ ) {
204
+ this.#bstream = new AudioByteStream(sampleRate, NUM_CHANNELS);
205
+ }
206
+
207
+ #emit(final: boolean): void {
208
+ if (!this.#lastFrame) return;
209
+ this.put({
210
+ requestId: this.requestId,
211
+ segmentId: this.segmentId,
212
+ frame: this.#lastFrame,
213
+ final,
214
+ timedTranscripts: this.#pendingTimed.length > 0 ? this.#pendingTimed : undefined,
215
+ });
216
+ this.#lastFrame = undefined;
217
+ this.#pendingTimed = [];
218
+ }
219
+
220
+ pushAudio(bytes: Buffer): void {
221
+ if (this.#ended) return;
222
+ for (const frame of this.#bstream.write(bufferToArrayBuffer(bytes))) {
223
+ this.#emit(false);
224
+ this.#lastFrame = frame;
225
+ }
226
+ }
227
+
228
+ pushTimed(words: TimedString[]): void {
229
+ if (this.#ended) return;
230
+ this.#pendingTimed.push(...words);
231
+ }
232
+
233
+ /** Flush any buffered audio and emit the terminal (`final`) frame. */
234
+ end(): void {
235
+ if (this.#ended) return;
236
+ this.#ended = true;
237
+ for (const frame of this.#bstream.flush()) {
238
+ this.#emit(false);
239
+ this.#lastFrame = frame;
240
+ }
241
+ this.#emit(true);
242
+ }
243
+ }
244
+
245
+ interface ContextData {
246
+ sink: AudioSink;
247
+ waiter: Deferred;
248
+ isStreaming: boolean;
249
+ lastActivityAt: number;
250
+ }
251
+
252
+ /**
253
+ * Single persistent WebSocket to `/ws/tts/multi` with background send/recv
254
+ * loops. Each synthesis registers a unique `context_id`; the recv loop routes
255
+ * server messages by id and silently drops messages for unknown/closed
256
+ * contexts, which is what makes barge-in safe.
257
+ */
258
+ class Connection {
259
+ #opts: ResolvedTTSOptions;
260
+ #ws: WebSocket | null = null;
261
+ #isCurrent = true;
262
+ #closed = false;
263
+ #activeContexts = new Set<string>();
264
+ #contexts = new Map<string, ContextData>();
265
+ #inputQueue: Record<string, unknown>[] = [];
266
+ #inputResolver: (() => void) | null = null;
267
+ #sendTask: Promise<void> | null = null;
268
+ #recvTask: Promise<void> | null = null;
269
+ #logger = log();
270
+
271
+ constructor(opts: ResolvedTTSOptions) {
272
+ this.#opts = opts;
273
+ }
274
+
275
+ get isCurrent(): boolean {
276
+ return this.#isCurrent;
277
+ }
278
+
279
+ get closed(): boolean {
280
+ return this.#closed;
281
+ }
282
+
283
+ markNonCurrent(): void {
284
+ this.#isCurrent = false;
285
+ }
286
+
287
+ async connect(timeoutMs = 10_000): Promise<void> {
288
+ const url = buildMultiWsUrl(this.#opts.baseURL, this.#opts.apiKey);
289
+ await new Promise<void>((resolve, reject) => {
290
+ let opened = false;
291
+ const ws = new WebSocket(url, { handshakeTimeout: timeoutMs });
292
+ this.#ws = ws;
293
+
294
+ ws.on('open', () => {
295
+ opened = true;
296
+ this.#sendTask = this.#sendLoop();
297
+ this.#recvTask = this.#recvLoop();
298
+ resolve();
299
+ });
300
+ ws.on('unexpected-response', (_req: unknown, res: IncomingMessage) => {
301
+ if (!opened) {
302
+ reject(toLiveKitConnError({ statusCode: res.statusCode, message: res.statusMessage }));
303
+ }
304
+ });
305
+ ws.on('error', (err: Error) => {
306
+ if (!opened) reject(toLiveKitConnError(err));
307
+ });
308
+ });
309
+ }
310
+
311
+ registerContext(
312
+ contextId: string,
313
+ sink: AudioSink,
314
+ waiter: Deferred,
315
+ isStreaming: boolean,
316
+ ): ContextData | null {
317
+ if (this.#closed) {
318
+ waiter.reject(
319
+ new APIConnectionError({ message: 'Connection closed before request started' }),
320
+ );
321
+ return null;
322
+ }
323
+ const ctx: ContextData = {
324
+ sink,
325
+ waiter,
326
+ isStreaming,
327
+ lastActivityAt: Date.now(),
328
+ };
329
+ this.#contexts.set(contextId, ctx);
330
+ return ctx;
331
+ }
332
+
333
+ sendText(contextId: string, text: string, flush: boolean): void {
334
+ const includeConfig = !this.#activeContexts.has(contextId);
335
+ if (includeConfig) this.#activeContexts.add(contextId);
336
+ this.#enqueue(buildTextPayload(contextId, text, this.#opts, { flush, includeConfig }));
337
+ }
338
+
339
+ closeContext(contextId: string, immediate: boolean): void {
340
+ this.#enqueue(buildClosePayload(contextId, immediate));
341
+ }
342
+
343
+ cleanupContext(contextId: string): void {
344
+ this.#contexts.delete(contextId);
345
+ this.#activeContexts.delete(contextId);
346
+ }
347
+
348
+ #enqueue(payload: Record<string, unknown>): void {
349
+ if (this.#closed) return;
350
+ this.#inputQueue.push(payload);
351
+ this.#inputResolver?.();
352
+ }
353
+
354
+ async #sendLoop(): Promise<void> {
355
+ try {
356
+ while (!this.#closed) {
357
+ if (this.#inputQueue.length === 0) {
358
+ await new Promise<void>((resolve) => {
359
+ this.#inputResolver = resolve;
360
+ });
361
+ this.#inputResolver = null;
362
+ }
363
+ if (this.#closed) break;
364
+ const payload = this.#inputQueue.shift();
365
+ if (!payload) continue;
366
+ if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) break;
367
+ this.#ws.send(JSON.stringify(payload));
368
+ }
369
+ } catch (err) {
370
+ this.#logger.warn({ error: err }, 'kugelaudio livekit send loop error');
371
+ this.#isCurrent = false;
372
+ if (this.#ws && this.#ws.readyState === WebSocket.OPEN) this.#ws.close();
373
+ }
374
+ }
375
+
376
+ async #recvLoop(): Promise<void> {
377
+ const ws = this.#ws;
378
+ if (!ws) return;
379
+ try {
380
+ await new Promise<void>((resolve) => {
381
+ const onMessage = (raw: RawData) => {
382
+ const text =
383
+ typeof raw === 'string'
384
+ ? raw
385
+ : Array.isArray(raw)
386
+ ? Buffer.concat(raw).toString()
387
+ : raw instanceof ArrayBuffer
388
+ ? Buffer.from(raw).toString()
389
+ : (raw as Buffer).toString();
390
+ try {
391
+ this.#handleMessage(JSON.parse(text));
392
+ } catch (err) {
393
+ this.#logger.warn({ error: err }, 'failed to parse /ws/tts/multi message');
394
+ }
395
+ };
396
+ const finish = () => {
397
+ ws.off('message', onMessage);
398
+ ws.off('close', finish);
399
+ ws.off('error', finish);
400
+ resolve();
401
+ };
402
+ ws.on('message', onMessage);
403
+ ws.on('close', finish);
404
+ ws.on('error', finish);
405
+ });
406
+ } finally {
407
+ // Connection dropped — reject any still-pending waiters so their streams
408
+ // don't hang, and mark dead so the next call reconnects.
409
+ this.#isCurrent = false;
410
+ for (const ctx of this.#contexts.values()) {
411
+ ctx.waiter.reject(
412
+ new APIConnectionError({ message: 'WebSocket connection closed unexpectedly' }),
413
+ );
414
+ }
415
+ }
416
+ }
417
+
418
+ #handleMessage(data: Record<string, unknown>): void {
419
+ if (data.session_closed) {
420
+ // Terminal frame: the server is tearing the session down and will close
421
+ // the socket next. Mark non-current now so no new synthesis grabs this
422
+ // dying connection; the close event rejects any pending waiters.
423
+ this.#isCurrent = false;
424
+ return;
425
+ }
426
+ const contextId = typeof data.context_id === 'string' ? data.context_id : undefined;
427
+
428
+ if (data.error) {
429
+ if (contextId) {
430
+ const ctx = this.#contexts.get(contextId);
431
+ ctx?.waiter.reject(apiStatusErrorFromFrame(data, contextId));
432
+ this.cleanupContext(contextId);
433
+ }
434
+ return;
435
+ }
436
+
437
+ const ctx = contextId ? this.#contexts.get(contextId) : undefined;
438
+ // Messages for unknown/cleaned-up contexts are silently discarded — the
439
+ // core barge-in safety property.
440
+ if (!ctx) return;
441
+
442
+ ctx.lastActivityAt = Date.now();
443
+
444
+ if (data.context_timeout) {
445
+ ctx.waiter.reject(new APITimeoutError({ message: 'context timed out' }));
446
+ this.cleanupContext(contextId!);
447
+ return;
448
+ }
449
+
450
+ if (typeof data.audio === 'string') {
451
+ ctx.sink.pushAudio(Buffer.from(data.audio, 'base64'));
452
+ }
453
+
454
+ if (Array.isArray(data.word_timestamps)) {
455
+ ctx.sink.pushTimed(
456
+ wordTimestampsToTimed(data.word_timestamps as never).map((w) => createTimedString(w)),
457
+ );
458
+ }
459
+
460
+ if (data.chunk_complete && !ctx.isStreaming) {
461
+ // Non-streaming context: close after the server confirms generation is
462
+ // done, avoiding the race where close arrives before audio is generated.
463
+ this.closeContext(contextId!, false);
464
+ }
465
+
466
+ if (data.context_closed) {
467
+ ctx.sink.end();
468
+ ctx.waiter.resolve();
469
+ this.cleanupContext(contextId!);
470
+ }
471
+ }
472
+
473
+ async close(): Promise<void> {
474
+ if (this.#closed) return;
475
+ this.#closed = true;
476
+ this.#inputResolver?.();
477
+ for (const ctx of this.#contexts.values()) {
478
+ ctx.waiter.reject(new APIConnectionError({ message: 'Connection closed' }));
479
+ }
480
+ this.#contexts.clear();
481
+ if (this.#ws) {
482
+ try {
483
+ if (this.#ws.readyState === WebSocket.OPEN) {
484
+ this.#ws.send(JSON.stringify({ close_socket: true }));
485
+ }
486
+ this.#ws.close();
487
+ } catch {
488
+ // KEEP-JUSTIFIED: best-effort socket teardown; the recv loop's finally
489
+ // already rejected pending waiters, and a failed close has no recovery.
490
+ }
491
+ this.#ws = null;
492
+ }
493
+ await this.#sendTask?.catch(() => {});
494
+ await this.#recvTask?.catch(() => {});
495
+ }
496
+ }
497
+
498
+ /**
499
+ * Block until the context's waiter settles, failing only when the server has
500
+ * been silent for this context for longer than `idleThresholdMs`. Long
501
+ * generations do not time out as long as frames keep arriving. Mirrors the
502
+ * Python plugin's `_wait_for_context_idle`.
503
+ */
504
+ async function waitForContextIdle(
505
+ ctx: ContextData,
506
+ idleThresholdMs: number,
507
+ ): Promise<void> {
508
+ const tick = Math.min(1000, Math.max(100, idleThresholdMs / 4));
509
+ for (;;) {
510
+ const result = await Promise.race([
511
+ ctx.waiter.promise.then(() => 'done' as const),
512
+ delay(tick).then(() => 'tick' as const),
513
+ ]);
514
+ if (result === 'done') return;
515
+ if (Date.now() - ctx.lastActivityAt >= idleThresholdMs) {
516
+ throw new APITimeoutError({ message: 'timed out waiting for KugelAudio audio' });
517
+ }
518
+ }
519
+ }
520
+
521
+ /**
522
+ * KugelAudio Text-to-Speech plugin for LiveKit Agents.
523
+ *
524
+ * @example
525
+ * ```ts
526
+ * import { TTS } from 'kugelaudio/livekit';
527
+ * import { AgentSession } from '@livekit/agents';
528
+ *
529
+ * const session = new AgentSession({
530
+ * tts: new TTS({ voiceId: 1071, model: 'kugel-3', language: 'en' }),
531
+ * // ...stt, llm, vad
532
+ * });
533
+ * ```
534
+ */
535
+ export class TTS extends tts.TTS {
536
+ #opts: ResolvedTTSOptions;
537
+ #streams = new Set<SynthesizeStream>();
538
+ #currentConnection: Connection | null = null;
539
+ #connectionLock = new Mutex();
540
+ #logger = log();
541
+
542
+ label = 'kugelaudio.TTS';
543
+
544
+ constructor(opts: TTSOptions = {}) {
545
+ const sampleRate = opts.sampleRate ?? DEFAULT_SAMPLE_RATE;
546
+ const wordTimestamps = opts.wordTimestamps ?? false;
547
+
548
+ super(sampleRate, NUM_CHANNELS, {
549
+ streaming: true,
550
+ alignedTranscript: wordTimestamps,
551
+ });
552
+
553
+ const apiKey = opts.apiKey ?? process.env.KUGELAUDIO_API_KEY;
554
+ if (!apiKey) {
555
+ throw new Error(
556
+ 'KUGELAUDIO_API_KEY must be set or apiKey must be provided to the TTS constructor.',
557
+ );
558
+ }
559
+
560
+ const { cleanKey } = parseApiKey(apiKey);
561
+ const language = validateLanguage(opts.language);
562
+
563
+ let baseURL: string;
564
+ if (opts.baseURL) {
565
+ baseURL = opts.baseURL.replace(/\/$/, '');
566
+ } else {
567
+ const { region } = parseApiKey(apiKey);
568
+ baseURL = region === 'eu' ? EU_BASE_URL : DEFAULT_BASE_URL;
569
+ }
570
+
571
+ this.#opts = {
572
+ apiKey: cleanKey,
573
+ baseURL,
574
+ model: opts.model ?? DEFAULT_MODEL,
575
+ voiceId: opts.voiceId ?? DEFAULT_VOICE_ID,
576
+ sampleRate,
577
+ cfgScale: clampCfgScale(opts.cfgScale ?? DEFAULT_CFG_SCALE) ?? DEFAULT_CFG_SCALE,
578
+ maxNewTokens: opts.maxNewTokens ?? DEFAULT_MAX_NEW_TOKENS,
579
+ wordTimestamps,
580
+ normalize: opts.normalize ?? true,
581
+ language,
582
+ };
583
+ }
584
+
585
+ get model(): string {
586
+ return this.#opts.model;
587
+ }
588
+
589
+ get provider(): string {
590
+ return 'KugelAudio';
591
+ }
592
+
593
+ /** Get or create the persistent `/ws/tts/multi` connection. */
594
+ async currentConnection(timeoutMs = 10_000): Promise<Connection> {
595
+ const unlock = await this.#connectionLock.lock();
596
+ try {
597
+ if (
598
+ this.#currentConnection &&
599
+ this.#currentConnection.isCurrent &&
600
+ !this.#currentConnection.closed
601
+ ) {
602
+ return this.#currentConnection;
603
+ }
604
+ const conn = new Connection({ ...this.#opts });
605
+ await conn.connect(timeoutMs);
606
+ this.#currentConnection = conn;
607
+ return conn;
608
+ } finally {
609
+ unlock();
610
+ }
611
+ }
612
+
613
+ /**
614
+ * Eagerly establish the WebSocket connection to remove handshake latency
615
+ * from the first synthesis. Errors are logged and retried on first use.
616
+ */
617
+ prewarm(): void {
618
+ this.currentConnection()
619
+ .then(() => this.#logger.info('KugelAudio TTS connection pre-warmed'))
620
+ .catch((err) =>
621
+ this.#logger.warn(
622
+ { error: err },
623
+ 'Failed to prewarm KugelAudio connection; will retry on first synthesis',
624
+ ),
625
+ );
626
+ }
627
+
628
+ /**
629
+ * Update TTS options at runtime. Changing any option marks the current
630
+ * connection non-current so the next synthesis opens a fresh one.
631
+ */
632
+ updateOptions(opts: Partial<Omit<TTSOptions, 'apiKey' | 'baseURL'>>): void {
633
+ let changed = false;
634
+ const set = <K extends keyof ResolvedTTSOptions>(key: K, value: ResolvedTTSOptions[K]) => {
635
+ if (this.#opts[key] !== value) {
636
+ this.#opts[key] = value;
637
+ changed = true;
638
+ }
639
+ };
640
+
641
+ if (opts.model !== undefined) set('model', opts.model);
642
+ if (opts.voiceId !== undefined) set('voiceId', opts.voiceId);
643
+ if (opts.cfgScale !== undefined) {
644
+ set('cfgScale', clampCfgScale(opts.cfgScale) ?? DEFAULT_CFG_SCALE);
645
+ }
646
+ if (opts.maxNewTokens !== undefined) set('maxNewTokens', opts.maxNewTokens);
647
+ if (opts.normalize !== undefined) set('normalize', opts.normalize);
648
+ if (opts.wordTimestamps !== undefined) set('wordTimestamps', opts.wordTimestamps);
649
+ if (opts.language !== undefined) set('language', validateLanguage(opts.language));
650
+
651
+ if (changed && this.#currentConnection) {
652
+ const old = this.#currentConnection;
653
+ old.markNonCurrent();
654
+ this.#currentConnection = null;
655
+ old.close().catch(() => {});
656
+ }
657
+ }
658
+
659
+ synthesize(
660
+ text: string,
661
+ connOptions?: APIConnectOptions,
662
+ abortSignal?: AbortSignal,
663
+ ): ChunkedStream {
664
+ return new ChunkedStream(this, text, { ...this.#opts }, connOptions, abortSignal);
665
+ }
666
+
667
+ stream(options?: { connOptions?: APIConnectOptions }): SynthesizeStream {
668
+ const stream = new SynthesizeStream(this, { ...this.#opts }, options?.connOptions);
669
+ this.#streams.add(stream);
670
+ // The framework closes (aborts) every stream when its speech turn ends;
671
+ // drop our reference then so a long-lived session doesn't accumulate one
672
+ // entry per turn.
673
+ stream.abortSignal.addEventListener('abort', () => this.#streams.delete(stream), {
674
+ once: true,
675
+ });
676
+ return stream;
677
+ }
678
+
679
+ async close(): Promise<void> {
680
+ for (const stream of this.#streams) stream.close();
681
+ this.#streams.clear();
682
+ if (this.#currentConnection) {
683
+ await this.#currentConnection.close();
684
+ this.#currentConnection = null;
685
+ }
686
+ }
687
+ }
688
+
689
+ /** Non-streaming (one-shot) synthesis over the shared `/ws/tts/multi` connection. */
690
+ export class ChunkedStream extends tts.ChunkedStream {
691
+ #tts: TTS;
692
+ #opts: ResolvedTTSOptions;
693
+ #connOptions: APIConnectOptions;
694
+
695
+ label = 'kugelaudio.ChunkedStream';
696
+
697
+ constructor(
698
+ ttsInstance: TTS,
699
+ text: string,
700
+ opts: ResolvedTTSOptions,
701
+ connOptions?: APIConnectOptions,
702
+ abortSignal?: AbortSignal,
703
+ ) {
704
+ super(text, ttsInstance, connOptions, abortSignal);
705
+ this.#tts = ttsInstance;
706
+ this.#opts = opts;
707
+ this.#connOptions = connOptions ?? { maxRetry: 3, retryIntervalMs: 2000, timeoutMs: 10000 };
708
+ }
709
+
710
+ protected async run(): Promise<void> {
711
+ const contextId = shortuuid();
712
+ const connection = await this.#tts.currentConnection(this.#connOptions.timeoutMs);
713
+ const sink = new AudioSink(this.#opts.sampleRate, (a) => this.queue.put(a), contextId, contextId);
714
+ const waiter = deferred();
715
+ const ctx = connection.registerContext(contextId, sink, waiter, false);
716
+ if (!ctx) {
717
+ await waiter.promise; // register rejected — surface the error.
718
+ return;
719
+ }
720
+
721
+ const onAbort = () => waiter.reject(new Error('aborted'));
722
+ this.abortController.signal.addEventListener('abort', onAbort, { once: true });
723
+
724
+ try {
725
+ connection.sendText(contextId, this.inputText, true);
726
+ await waitForContextIdle(ctx, this.#connOptions.timeoutMs);
727
+ } catch (err) {
728
+ connection.closeContext(contextId, true);
729
+ connection.cleanupContext(contextId);
730
+ if (this.abortController.signal.aborted) return;
731
+ throw err;
732
+ } finally {
733
+ this.abortController.signal.removeEventListener('abort', onAbort);
734
+ }
735
+ }
736
+ }
737
+
738
+ /**
739
+ * Streaming synthesis: text tokens are pushed via the LiveKit input channel and
740
+ * forwarded to the shared connection; audio is routed back by `context_id`.
741
+ */
742
+ export class SynthesizeStream extends tts.SynthesizeStream {
743
+ #tts: TTS;
744
+ #opts: ResolvedTTSOptions;
745
+ #contextId: string | undefined;
746
+
747
+ label = 'kugelaudio.SynthesizeStream';
748
+
749
+ constructor(ttsInstance: TTS, opts: ResolvedTTSOptions, connOptions?: APIConnectOptions) {
750
+ super(ttsInstance, connOptions);
751
+ this.#tts = ttsInstance;
752
+ this.#opts = opts;
753
+ }
754
+
755
+ /** The server-side context id of the current (latest) run attempt. */
756
+ get contextId(): string | undefined {
757
+ return this.#contextId;
758
+ }
759
+
760
+ protected async run(): Promise<void> {
761
+ // Fresh id per attempt: the base class retries run() on retryable API
762
+ // errors, and a retried attempt must not collide with the failed
763
+ // attempt's server-side context (mirrors the Python plugin's per-_run id).
764
+ const contextId = shortuuid();
765
+ this.#contextId = contextId;
766
+ const timeoutMs = this.connOptions.timeoutMs;
767
+ const connection = await this.#tts.currentConnection(timeoutMs);
768
+ const sink = new AudioSink(this.#opts.sampleRate, (a) => this.queue.put(a), contextId, contextId);
769
+ const waiter = deferred();
770
+ const ctx = connection.registerContext(contextId, sink, waiter, true);
771
+ if (!ctx) {
772
+ await waiter.promise; // register rejected — surface the error.
773
+ return;
774
+ }
775
+
776
+ const onAbort = () => waiter.reject(new Error('aborted'));
777
+ this.abortController.signal.addEventListener('abort', onAbort, { once: true });
778
+
779
+ // Cancellation handle for the input loop — the JS equivalent of the
780
+ // Python plugin's `gracefully_cancel(input_t)`. Without it, an error or
781
+ // timeout would leave run() blocked on the input channel until the LLM
782
+ // finishes its turn, while the loop keeps re-creating the dead context
783
+ // server-side (wasted GPU on audio nobody consumes).
784
+ const STOP = Symbol('stop');
785
+ const stopInput = deferred();
786
+ let failed = false;
787
+
788
+ const inputTask = async () => {
789
+ for (;;) {
790
+ const result = await Promise.race([
791
+ this.input.next(),
792
+ stopInput.promise.then(() => STOP),
793
+ ]);
794
+ if (typeof result === 'symbol') return; // STOP
795
+
796
+ if (result.done) break;
797
+ if (this.abortController.signal.aborted || failed) return;
798
+ const data = result.value;
799
+ if (data === SynthesizeStream.FLUSH_SENTINEL) {
800
+ connection.sendText(contextId, '', true);
801
+ continue;
802
+ }
803
+ if (!data) continue;
804
+ connection.sendText(contextId, data, false);
805
+ }
806
+ // Input channel closed. On a normal end-of-input, flush and close the
807
+ // context gracefully so the tail drains. On barge-in (abort) or failure
808
+ // the catch below issues an immediate close instead.
809
+ if (!this.abortController.signal.aborted && !failed) {
810
+ connection.sendText(contextId, '', true);
811
+ connection.closeContext(contextId, false);
812
+ }
813
+ };
814
+
815
+ const input = inputTask();
816
+ // Surface an input-side failure onto the waiter so run() doesn't hang.
817
+ input.catch((err) => waiter.reject(err instanceof Error ? err : new Error(String(err))));
818
+
819
+ try {
820
+ await waitForContextIdle(ctx, timeoutMs);
821
+ } catch (err) {
822
+ failed = true;
823
+ connection.closeContext(contextId, true);
824
+ connection.cleanupContext(contextId);
825
+ if (this.abortController.signal.aborted) return;
826
+ throw err;
827
+ } finally {
828
+ this.abortController.signal.removeEventListener('abort', onAbort);
829
+ stopInput.resolve();
830
+ await input.catch(() => {});
831
+ }
832
+ }
833
+ }