kugelaudio 0.2.2 → 0.3.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/dist/index.js CHANGED
@@ -22,12 +22,18 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  AuthenticationError: () => AuthenticationError,
24
24
  ConnectionError: () => ConnectionError,
25
+ ErrorCodes: () => ErrorCodes,
25
26
  InsufficientCreditsError: () => InsufficientCreditsError,
26
27
  KugelAudio: () => KugelAudio,
27
28
  KugelAudioError: () => KugelAudioError,
28
29
  RateLimitError: () => RateLimitError,
29
30
  ValidationError: () => ValidationError,
31
+ WsCloseCodes: () => WsCloseCodes,
30
32
  base64ToArrayBuffer: () => base64ToArrayBuffer,
33
+ classifyHttpError: () => classifyHttpError,
34
+ classifyWsClose: () => classifyWsClose,
35
+ classifyWsFrame: () => classifyWsFrame,
36
+ classifyWsHandshakeError: () => classifyWsHandshakeError,
31
37
  createWavBlob: () => createWavBlob,
32
38
  createWavFile: () => createWavFile,
33
39
  decodePCM16: () => decodePCM16
@@ -35,49 +41,192 @@ __export(index_exports, {
35
41
  module.exports = __toCommonJS(index_exports);
36
42
 
37
43
  // src/errors.ts
44
+ var ErrorCodes = {
45
+ UNAUTHORIZED: "UNAUTHORIZED",
46
+ RATE_LIMITED: "RATE_LIMITED",
47
+ INSUFFICIENT_CREDITS: "INSUFFICIENT_CREDITS",
48
+ MODEL_UNAVAILABLE: "MODEL_UNAVAILABLE",
49
+ EMPTY_AUDIO: "EMPTY_AUDIO",
50
+ VALIDATION: "VALIDATION_ERROR",
51
+ INTERNAL: "INTERNAL_ERROR",
52
+ NOT_FOUND: "NOT_FOUND"
53
+ };
54
+ var WsCloseCodes = {
55
+ UNAUTHORIZED: 4001,
56
+ INSUFFICIENT_CREDITS: 4003,
57
+ RATE_LIMITED: 4029,
58
+ MODEL_UNAVAILABLE: 4500
59
+ };
60
+ var API_KEYS_URL = "https://app.kugelaudio.com/settings/api-keys";
61
+ var BILLING_URL = "https://app.kugelaudio.com/billing";
38
62
  var KugelAudioError = class _KugelAudioError extends Error {
39
- constructor(message, statusCode) {
40
- super(message);
63
+ constructor(message, options = {}) {
64
+ super(options.requestId ? `${message} (request_id: ${options.requestId})` : message);
41
65
  this.name = "KugelAudioError";
42
- this.statusCode = statusCode;
66
+ this.statusCode = options.statusCode;
67
+ this.errorCode = options.errorCode;
68
+ this.requestId = options.requestId;
69
+ this.retryAfter = options.retryAfter;
43
70
  Object.setPrototypeOf(this, _KugelAudioError.prototype);
44
71
  }
45
72
  };
46
73
  var AuthenticationError = class _AuthenticationError extends KugelAudioError {
47
- constructor(message = "Authentication failed") {
48
- super(message, 401);
74
+ constructor(message, options = {}) {
75
+ super(
76
+ message ?? `KugelAudio rejected the API key. Check it is current at ${API_KEYS_URL}.`,
77
+ { statusCode: 401, errorCode: ErrorCodes.UNAUTHORIZED, ...options }
78
+ );
49
79
  this.name = "AuthenticationError";
50
80
  Object.setPrototypeOf(this, _AuthenticationError.prototype);
51
81
  }
52
82
  };
53
83
  var RateLimitError = class _RateLimitError extends KugelAudioError {
54
- constructor(message = "Rate limit exceeded") {
55
- super(message, 429);
84
+ constructor(message, options = {}) {
85
+ const msg = message ?? (options.retryAfter ? `KugelAudio rate limit hit; retry after ${options.retryAfter}s.` : "KugelAudio rate limit hit; retry shortly.");
86
+ super(msg, { statusCode: 429, errorCode: ErrorCodes.RATE_LIMITED, ...options });
56
87
  this.name = "RateLimitError";
57
88
  Object.setPrototypeOf(this, _RateLimitError.prototype);
58
89
  }
59
90
  };
60
91
  var InsufficientCreditsError = class _InsufficientCreditsError extends KugelAudioError {
61
- constructor(message = "Insufficient credits") {
62
- super(message, 403);
92
+ constructor(message, options = {}) {
93
+ super(
94
+ message ?? `Your KugelAudio account is out of credits. Top up at ${BILLING_URL}.`,
95
+ { statusCode: 402, errorCode: ErrorCodes.INSUFFICIENT_CREDITS, ...options }
96
+ );
63
97
  this.name = "InsufficientCreditsError";
64
98
  Object.setPrototypeOf(this, _InsufficientCreditsError.prototype);
65
99
  }
66
100
  };
67
101
  var ValidationError = class _ValidationError extends KugelAudioError {
68
- constructor(message) {
69
- super(message, 400);
102
+ constructor(message, options = {}) {
103
+ super(message, { statusCode: 400, errorCode: ErrorCodes.VALIDATION, ...options });
70
104
  this.name = "ValidationError";
71
105
  Object.setPrototypeOf(this, _ValidationError.prototype);
72
106
  }
73
107
  };
74
108
  var ConnectionError = class _ConnectionError extends KugelAudioError {
75
- constructor(message = "Failed to connect to server") {
76
- super(message, 503);
109
+ constructor(message, options = {}) {
110
+ super(message, { statusCode: 503, ...options });
77
111
  this.name = "ConnectionError";
78
112
  Object.setPrototypeOf(this, _ConnectionError.prototype);
79
113
  }
80
114
  };
115
+ function build(status, errorCode, message, opts = {}) {
116
+ const common = { ...opts };
117
+ if (status !== void 0) common.statusCode = status;
118
+ if (errorCode !== void 0) common.errorCode = errorCode;
119
+ if (errorCode === ErrorCodes.UNAUTHORIZED || status === 401) {
120
+ return new AuthenticationError(message || void 0, common);
121
+ }
122
+ if (errorCode === ErrorCodes.INSUFFICIENT_CREDITS || status === 402) {
123
+ return new InsufficientCreditsError(message || void 0, common);
124
+ }
125
+ if (errorCode === ErrorCodes.RATE_LIMITED || status === 429) {
126
+ return new RateLimitError(message || void 0, common);
127
+ }
128
+ if (errorCode === ErrorCodes.VALIDATION || status === 400) {
129
+ return new ValidationError(message || "Request validation failed.", common);
130
+ }
131
+ if (errorCode === ErrorCodes.MODEL_UNAVAILABLE || status === 503) {
132
+ const detail = message || "service temporarily unavailable";
133
+ return new ConnectionError(
134
+ `KugelAudio is temporarily unavailable: ${detail}. Retry shortly.`,
135
+ common
136
+ );
137
+ }
138
+ return new KugelAudioError(message || `HTTP ${status}`, common);
139
+ }
140
+ function readHeader(headers, name) {
141
+ if (headers && typeof headers.get === "function") {
142
+ return headers.get(name) ?? void 0;
143
+ }
144
+ const rec = headers;
145
+ return rec[name] ?? rec[name.toLowerCase()] ?? void 0;
146
+ }
147
+ function classifyHttpError(status, bodyText, headers) {
148
+ let errorCode;
149
+ let message = "";
150
+ let retryAfter;
151
+ if (bodyText) {
152
+ try {
153
+ const body = JSON.parse(bodyText);
154
+ if (body && typeof body === "object") {
155
+ errorCode = typeof body.error_code === "string" ? body.error_code : void 0;
156
+ const msg = body.error ?? body.detail;
157
+ if (Array.isArray(msg)) {
158
+ message = msg.map((m) => String(m)).join("; ");
159
+ } else if (typeof msg === "string") {
160
+ message = msg;
161
+ }
162
+ if (typeof body.retry_after === "number") {
163
+ retryAfter = body.retry_after;
164
+ }
165
+ }
166
+ } catch {
167
+ }
168
+ }
169
+ if (retryAfter === void 0) {
170
+ const header = readHeader(headers, "Retry-After") ?? readHeader(headers, "retry-after");
171
+ if (header) {
172
+ const n = Number(header);
173
+ if (Number.isFinite(n)) retryAfter = n;
174
+ }
175
+ }
176
+ const requestId = readHeader(headers, "x-request-id") ?? readHeader(headers, "X-Request-Id");
177
+ if (!message) {
178
+ message = (bodyText || "").trim();
179
+ }
180
+ return build(status, errorCode, message, { requestId, retryAfter });
181
+ }
182
+ function classifyWsFrame(data) {
183
+ const errorCode = data.error_code;
184
+ const message = data.error ?? "Server reported an error.";
185
+ const retryAfter = typeof data.retry_after === "number" ? data.retry_after : void 0;
186
+ return build(void 0, errorCode, message, { retryAfter });
187
+ }
188
+ function classifyWsClose(code, reason) {
189
+ const reasonTxt = (reason ?? "").trim();
190
+ if (code === WsCloseCodes.UNAUTHORIZED) {
191
+ let msg = `KugelAudio rejected the API key. Check it is current at ${API_KEYS_URL}.`;
192
+ if (reasonTxt) msg = `${msg} (${reasonTxt})`;
193
+ return new AuthenticationError(msg);
194
+ }
195
+ if (code === WsCloseCodes.INSUFFICIENT_CREDITS) {
196
+ return new InsufficientCreditsError();
197
+ }
198
+ if (code === WsCloseCodes.RATE_LIMITED) {
199
+ return new RateLimitError();
200
+ }
201
+ if (code === WsCloseCodes.MODEL_UNAVAILABLE) {
202
+ const suffix = reasonTxt ? ` (${reasonTxt})` : "";
203
+ return new ConnectionError(
204
+ `KugelAudio model is temporarily unavailable. Retry shortly.${suffix}`
205
+ );
206
+ }
207
+ const detail = reasonTxt || "no reason given";
208
+ const codeStr = code !== void 0 ? ` (code ${code})` : "";
209
+ return new ConnectionError(
210
+ `KugelAudio WebSocket closed by server: ${detail}${codeStr}.`
211
+ );
212
+ }
213
+ function classifyWsHandshakeError(err) {
214
+ if (!err || typeof err !== "object") return null;
215
+ const e = err;
216
+ let status;
217
+ if (typeof e.statusCode === "number") {
218
+ status = e.statusCode;
219
+ }
220
+ if (status === void 0 && typeof e.message === "string") {
221
+ const m = e.message.match(/Unexpected server response:\s*(\d{3})/i);
222
+ if (m) status = Number(m[1]);
223
+ }
224
+ if (status === void 0) return null;
225
+ if (status === 403) {
226
+ return new AuthenticationError();
227
+ }
228
+ return build(status, void 0, typeof e.message === "string" ? e.message : "");
229
+ }
81
230
 
82
231
  // src/utils.ts
83
232
  function base64ToArrayBuffer(base64) {
@@ -137,33 +286,61 @@ function createWavBlob(audio, sampleRate) {
137
286
 
138
287
  // src/websocket.ts
139
288
  var _cachedWs = null;
289
+ function isNodeJs() {
290
+ return typeof process !== "undefined" && !!process.versions && typeof process.versions.node === "string";
291
+ }
140
292
  function getWebSocket() {
141
293
  if (_cachedWs) return _cachedWs;
294
+ if (isNodeJs()) {
295
+ try {
296
+ const _require = typeof require !== "undefined" ? require : Function('return typeof require !== "undefined" ? require : undefined')();
297
+ if (_require) {
298
+ const ws = _require("ws");
299
+ _cachedWs = ws.default || ws;
300
+ return _cachedWs;
301
+ }
302
+ } catch {
303
+ }
304
+ }
142
305
  if (typeof globalThis !== "undefined" && typeof globalThis.WebSocket !== "undefined") {
143
306
  _cachedWs = globalThis.WebSocket;
144
307
  return _cachedWs;
145
308
  }
146
- try {
147
- const _require = typeof require !== "undefined" ? require : Function('return typeof require !== "undefined" ? require : undefined')();
148
- if (_require) {
149
- const ws = _require("ws");
150
- _cachedWs = ws.default || ws;
151
- return _cachedWs;
152
- }
153
- } catch {
154
- }
155
309
  throw new Error(
156
310
  'WebSocket not available. In Node.js, install the "ws" package: npm install ws'
157
311
  );
158
312
  }
159
313
 
160
314
  // src/client.ts
161
- var DEFAULT_API_URL = "https://api.kugelaudio.com";
315
+ var REGION_URLS = {
316
+ eu: "https://api.kugelaudio.com",
317
+ us: "https://us-api.kugelaudio.com",
318
+ global: "https://global-api.kugelaudio.com"
319
+ };
320
+ var REGION_PREFIXES = ["eu-", "us-", "global-"];
321
+ function parseApiKey(apiKey) {
322
+ for (const prefix of REGION_PREFIXES) {
323
+ if (apiKey.startsWith(prefix)) {
324
+ return { cleanKey: apiKey.slice(prefix.length), detectedRegion: prefix.slice(0, -1) };
325
+ }
326
+ }
327
+ return { cleanKey: apiKey };
328
+ }
162
329
  function createWs(url) {
163
330
  const WS = getWebSocket();
164
331
  return new WS(url);
165
332
  }
166
333
  var WS_OPEN = 1;
334
+ var _languageWarningLogged = false;
335
+ function warnIfNoLanguage(language, normalize) {
336
+ const normEnabled = normalize === void 0 || normalize;
337
+ if (!language && normEnabled && !_languageWarningLogged) {
338
+ _languageWarningLogged = true;
339
+ console.warn(
340
+ "[KugelAudio] No 'language' set with normalization enabled \u2014 the server will auto-detect the language, adding ~60-150ms to TTFA. Set language (e.g., language: 'en') for optimal latency."
341
+ );
342
+ }
343
+ }
167
344
  var ModelsResource = class {
168
345
  constructor(client) {
169
346
  this.client = client;
@@ -197,42 +374,177 @@ var VoicesResource = class {
197
374
  params.set("include_public", String(options.includePublic));
198
375
  }
199
376
  if (options?.limit) params.set("limit", String(options.limit));
377
+ if (options?.offset) params.set("offset", String(options.offset));
200
378
  const query = params.toString();
201
379
  const path = query ? `/v1/voices?${query}` : "/v1/voices";
202
380
  const response = await this.client.request("GET", path);
203
- return response.voices.map((v) => ({
204
- id: v.id,
205
- name: v.name,
206
- description: v.description,
207
- category: v.category,
208
- sex: v.sex,
209
- age: v.age,
210
- supportedLanguages: v.supported_languages || [],
211
- sampleText: v.sample_text,
212
- avatarUrl: v.avatar_url,
213
- sampleUrl: v.sample_url,
214
- isPublic: v.is_public || false,
215
- verified: v.verified || false
216
- }));
381
+ return {
382
+ voices: response.voices.map((v) => ({
383
+ id: v.id,
384
+ name: v.name,
385
+ description: v.description,
386
+ category: v.category,
387
+ sex: v.sex,
388
+ age: v.age,
389
+ supportedLanguages: v.supported_languages || [],
390
+ sampleText: v.sample_text,
391
+ avatarUrl: v.avatar_url,
392
+ sampleUrl: v.sample_url,
393
+ isPublic: v.is_public || false,
394
+ verified: v.verified || false
395
+ })),
396
+ total: response.total,
397
+ limit: response.limit,
398
+ offset: response.offset
399
+ };
217
400
  }
218
401
  /**
219
402
  * Get a specific voice by ID.
220
403
  */
221
404
  async get(voiceId) {
222
405
  const v = await this.client.request("GET", `/v1/voices/${voiceId}`);
406
+ return this.mapVoiceDetail(v);
407
+ }
408
+ /**
409
+ * Create a new voice.
410
+ */
411
+ async create(options) {
412
+ const metadata = {
413
+ name: options.name,
414
+ sex: options.sex,
415
+ description: options.description ?? "",
416
+ category: options.category ?? "conversational",
417
+ age: options.age ?? "middle_age",
418
+ quality: options.quality ?? "mid",
419
+ supported_languages: options.supportedLanguages ?? ["en"],
420
+ is_public: options.isPublic ?? false,
421
+ sample_text: options.sampleText ?? ""
422
+ };
423
+ const formData = new FormData();
424
+ formData.append(
425
+ "metadata",
426
+ new Blob([JSON.stringify(metadata)], { type: "application/json" })
427
+ );
428
+ if (options.referenceFiles) {
429
+ for (const file of options.referenceFiles) {
430
+ formData.append("files", file);
431
+ }
432
+ }
433
+ const v = await this.client.requestMultipart("POST", "/v1/voices", formData);
434
+ return this.mapVoiceDetail(v);
435
+ }
436
+ /**
437
+ * Update an existing voice. Only provided fields are updated.
438
+ */
439
+ async update(voiceId, options) {
440
+ const payload = {};
441
+ if (options.name !== void 0) payload.name = options.name;
442
+ if (options.description !== void 0) payload.description = options.description;
443
+ if (options.category !== void 0) payload.category = options.category;
444
+ if (options.age !== void 0) payload.age = options.age;
445
+ if (options.sex !== void 0) payload.sex = options.sex;
446
+ if (options.quality !== void 0) payload.quality = options.quality;
447
+ if (options.supportedLanguages !== void 0) payload.supported_languages = options.supportedLanguages;
448
+ if (options.isPublic !== void 0) payload.is_public = options.isPublic;
449
+ if (options.sampleText !== void 0) payload.sample_text = options.sampleText;
450
+ const v = await this.client.request("PATCH", `/v1/voices/${voiceId}`, payload);
451
+ return this.mapVoiceDetail(v);
452
+ }
453
+ /**
454
+ * Delete a voice.
455
+ */
456
+ async delete(voiceId) {
457
+ await this.client.request("DELETE", `/v1/voices/${voiceId}`);
458
+ }
459
+ // -- Reference management --
460
+ /**
461
+ * List reference audio files for a voice.
462
+ */
463
+ async listReferences(voiceId) {
464
+ const response = await this.client.request(
465
+ "GET",
466
+ `/v1/voices/${voiceId}/references`
467
+ );
468
+ return response.references.map((r) => this.mapVoiceReference(r));
469
+ }
470
+ /**
471
+ * Upload a reference audio file to a voice.
472
+ *
473
+ * @param voiceId - Voice ID
474
+ * @param file - Audio file (File in browser, Blob in Node.js)
475
+ * @param referenceText - Optional transcript of the reference audio
476
+ */
477
+ async addReference(voiceId, file, referenceText) {
478
+ const formData = new FormData();
479
+ formData.append("file", file);
480
+ if (referenceText) {
481
+ formData.append("reference_text", referenceText);
482
+ }
483
+ const r = await this.client.requestMultipart(
484
+ "POST",
485
+ `/v1/voices/${voiceId}/references`,
486
+ formData
487
+ );
488
+ return this.mapVoiceReference(r);
489
+ }
490
+ /**
491
+ * Delete a reference audio file from a voice.
492
+ */
493
+ async deleteReference(voiceId, referenceId) {
494
+ await this.client.request(
495
+ "DELETE",
496
+ `/v1/voices/${voiceId}/references/${referenceId}`
497
+ );
498
+ }
499
+ // -- Publishing --
500
+ /**
501
+ * Request publication of a voice. Sets it as public and marks it
502
+ * as pending verification by an admin.
503
+ */
504
+ async publish(voiceId) {
505
+ const v = await this.client.request("POST", `/v1/voices/${voiceId}/publish`);
506
+ return this.mapVoiceDetail(v);
507
+ }
508
+ // -- Sample generation --
509
+ /**
510
+ * Trigger sample audio generation for a voice.
511
+ */
512
+ async generateSample(voiceId) {
513
+ const v = await this.client.request(
514
+ "POST",
515
+ `/v1/voices/${voiceId}/generate-sample`
516
+ );
517
+ return this.mapVoiceDetail(v);
518
+ }
519
+ // -- Helpers --
520
+ mapVoiceDetail(v) {
223
521
  return {
224
522
  id: v.id,
225
523
  name: v.name,
226
- description: v.description,
227
- category: v.category,
228
- sex: v.sex,
524
+ description: v.description ?? "",
525
+ generativeVoiceDescription: v.generative_voice_description ?? "",
526
+ supportedLanguages: v.supported_languages ?? [],
527
+ category: v.category ?? "cloned",
229
528
  age: v.age,
230
- supportedLanguages: v.supported_languages || [],
231
- sampleText: v.sample_text,
232
- avatarUrl: v.avatar_url,
529
+ sex: v.sex,
530
+ quality: v.quality ?? "mid",
531
+ isPublic: v.is_public ?? false,
532
+ verified: v.verified ?? false,
533
+ pendingVerification: v.pending_verification ?? false,
233
534
  sampleUrl: v.sample_url,
234
- isPublic: v.is_public || false,
235
- verified: v.verified || false
535
+ avatarUrl: v.avatar_url,
536
+ sampleText: v.sample_text ?? ""
537
+ };
538
+ }
539
+ mapVoiceReference(r) {
540
+ return {
541
+ id: r.id,
542
+ voiceId: r.voice_id,
543
+ name: r.name ?? "",
544
+ referenceText: r.reference_text ?? "",
545
+ s3Path: r.s3_path ?? "",
546
+ audioUrl: r.audio_url,
547
+ isGenerated: r.is_generated ?? false
236
548
  };
237
549
  }
238
550
  };
@@ -244,6 +556,7 @@ var TTSResource = class {
244
556
  this.wsUrl = null;
245
557
  this.pendingRequests = /* @__PURE__ */ new Map();
246
558
  this.requestCounter = 0;
559
+ this.keepaliveTimer = null;
247
560
  }
248
561
  /**
249
562
  * Pre-establish WebSocket connection for faster first request.
@@ -278,10 +591,14 @@ var TTSResource = class {
278
591
  async generate(options) {
279
592
  const chunks = [];
280
593
  let finalStats;
594
+ const allTimestamps = [];
281
595
  await this.stream(options, {
282
596
  onChunk: (chunk) => {
283
597
  chunks.push(base64ToArrayBuffer(chunk.audio));
284
598
  },
599
+ onWordTimestamps: (timestamps) => {
600
+ allTimestamps.push(...timestamps);
601
+ },
285
602
  onFinal: (stats) => {
286
603
  finalStats = stats;
287
604
  }
@@ -299,9 +616,67 @@ var TTSResource = class {
299
616
  samples: finalStats ? finalStats.totalSamples : totalLength / 2,
300
617
  durationMs: finalStats ? finalStats.durationMs : 0,
301
618
  generationMs: finalStats ? finalStats.generationMs : 0,
302
- rtf: finalStats ? finalStats.rtf : 0
619
+ rtf: finalStats ? finalStats.rtf : 0,
620
+ wordTimestamps: allTimestamps
303
621
  };
304
622
  }
623
+ /**
624
+ * Stream audio and return a Node.js Readable stream of raw PCM16 binary data.
625
+ *
626
+ * **Node.js only** — this method requires the `stream` built-in module and is
627
+ * intended for server-side integrations such as Vapi custom TTS endpoints,
628
+ * Express/Fastify handlers, or any pipeline that expects a Node.js `Readable`.
629
+ *
630
+ * Compared to manually wiring `onChunk` to a `Readable`, this method avoids
631
+ * a common race-condition: the stream object is created and returned **before**
632
+ * any chunks arrive, so the caller can safely pipe or attach listeners before
633
+ * the first audio byte is pushed.
634
+ *
635
+ * @example Vapi custom TTS endpoint
636
+ * ```typescript
637
+ * app.post('/synthesize', (req, res) => {
638
+ * res.setHeader('Content-Type', 'audio/pcm');
639
+ * res.setHeader('Transfer-Encoding', 'chunked');
640
+ *
641
+ * const readable = client.tts.toReadable({
642
+ * text: req.body.message.text,
643
+ * modelId: 'kugel-1-turbo',
644
+ * sampleRate: req.body.message.sampleRate,
645
+ * language: 'en',
646
+ * });
647
+ *
648
+ * readable.pipe(res);
649
+ * });
650
+ * ```
651
+ *
652
+ * @param options - TTS generation options (same as `stream()`)
653
+ * @param reuseConnection - Reuse the pooled WebSocket connection (default: true)
654
+ * @returns Node.js Readable stream emitting raw PCM16 binary Buffer chunks
655
+ */
656
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
657
+ toReadable(options, reuseConnection = true) {
658
+ const { Readable } = require("stream");
659
+ const readable = new Readable({ read() {
660
+ } });
661
+ this.stream(
662
+ options,
663
+ {
664
+ onChunk: (chunk) => {
665
+ readable.push(Buffer.from(chunk.audio, "base64"));
666
+ },
667
+ onFinal: () => {
668
+ readable.push(null);
669
+ },
670
+ onError: (error) => {
671
+ readable.destroy(error);
672
+ }
673
+ },
674
+ reuseConnection
675
+ ).catch((error) => {
676
+ readable.destroy(error);
677
+ });
678
+ return readable;
679
+ }
305
680
  /**
306
681
  * Build the WebSocket URL with appropriate auth param.
307
682
  */
@@ -343,10 +718,17 @@ var TTSResource = class {
343
718
  this.wsConnection = ws;
344
719
  this.wsUrl = url;
345
720
  this.setupMessageHandler(ws);
721
+ this.startKeepalive(ws);
346
722
  resolve(ws);
347
723
  };
348
- ws.onerror = () => {
349
- reject(new KugelAudioError("WebSocket connection error"));
724
+ ws.onerror = (event) => {
725
+ const underlying = event?.error ?? event;
726
+ const typed = classifyWsHandshakeError(underlying);
727
+ reject(
728
+ typed ?? new ConnectionError(
729
+ `Could not establish KugelAudio WebSocket connection to ${url}. Check network connectivity.`
730
+ )
731
+ );
350
732
  };
351
733
  });
352
734
  }
@@ -361,7 +743,7 @@ var TTSResource = class {
361
743
  const [requestId, pending] = [...this.pendingRequests.entries()][0] || [];
362
744
  if (!pending) return;
363
745
  if (data.error) {
364
- const error = this.parseError(data.error);
746
+ const error = this.parseError(data);
365
747
  pending.callbacks.onError?.(error);
366
748
  this.pendingRequests.delete(requestId);
367
749
  pending.reject(error);
@@ -374,7 +756,6 @@ var TTSResource = class {
374
756
  totalSamples: data.total_samples,
375
757
  durationMs: data.dur_ms,
376
758
  generationMs: data.gen_ms,
377
- ttfaMs: data.ttfa_ms,
378
759
  rtf: data.rtf,
379
760
  error: data.error
380
761
  };
@@ -393,25 +774,41 @@ var TTSResource = class {
393
774
  };
394
775
  pending.callbacks.onChunk?.(chunk);
395
776
  }
777
+ if (data.word_timestamps) {
778
+ const timestamps = data.word_timestamps.map(
779
+ (w) => ({
780
+ word: w.word,
781
+ startMs: w.start_ms,
782
+ endMs: w.end_ms,
783
+ charStart: w.char_start,
784
+ charEnd: w.char_end,
785
+ score: w.score ?? 1
786
+ })
787
+ );
788
+ pending.callbacks.onWordTimestamps?.(timestamps);
789
+ }
396
790
  } catch (e) {
397
791
  console.error("Failed to parse WebSocket message:", e);
398
792
  }
399
793
  };
400
794
  ws.onclose = (event) => {
795
+ this.stopKeepalive();
401
796
  this.wsConnection = null;
402
797
  this.wsUrl = null;
403
798
  for (const [id, pending] of this.pendingRequests) {
404
799
  pending.callbacks.onClose?.();
405
- if (event.code === 4001) {
406
- pending.reject(new AuthenticationError("Authentication failed"));
407
- } else if (event.code === 4003) {
408
- pending.reject(new InsufficientCreditsError("Insufficient credits"));
800
+ if (event.code === 4001 || event.code === 4003 || event.code === 4029 || event.code === 4500) {
801
+ const error = classifyWsClose(event.code, event.reason);
802
+ pending.callbacks.onError?.(error);
803
+ pending.reject(error);
409
804
  }
410
805
  this.pendingRequests.delete(id);
411
806
  }
412
807
  };
413
808
  ws.onerror = () => {
414
- const error = new KugelAudioError("WebSocket connection error");
809
+ const error = new ConnectionError(
810
+ "KugelAudio WebSocket connection error. Check network connectivity."
811
+ );
415
812
  for (const [id, pending] of this.pendingRequests) {
416
813
  pending.callbacks.onError?.(error);
417
814
  pending.reject(error);
@@ -437,6 +834,7 @@ var TTSResource = class {
437
834
  * Stream with connection pooling (fast path).
438
835
  */
439
836
  async streamWithPooling(options, callbacks) {
837
+ warnIfNoLanguage(options.language, options.normalize);
440
838
  const ws = await this.getConnection();
441
839
  const requestId = ++this.requestCounter;
442
840
  return new Promise((resolve, reject) => {
@@ -447,10 +845,14 @@ var TTSResource = class {
447
845
  model_id: options.modelId || "kugel-1-turbo",
448
846
  voice_id: options.voiceId,
449
847
  cfg_scale: options.cfgScale ?? 2,
848
+ ...options.temperature !== void 0 && { temperature: options.temperature },
450
849
  max_new_tokens: options.maxNewTokens ?? 2048,
451
850
  sample_rate: options.sampleRate ?? 24e3,
452
851
  normalize: options.normalize ?? true,
453
- ...options.language && { language: options.language }
852
+ ...options.language && { language: options.language },
853
+ ...options.wordTimestamps && { word_timestamps: true },
854
+ ...options.speed !== void 0 && { speed: options.speed },
855
+ ...options.projectId !== void 0 && { project_id: options.projectId }
454
856
  }));
455
857
  });
456
858
  }
@@ -458,6 +860,7 @@ var TTSResource = class {
458
860
  * Stream without connection pooling (original behavior).
459
861
  */
460
862
  streamWithoutPooling(options, callbacks) {
863
+ warnIfNoLanguage(options.language, options.normalize);
461
864
  return new Promise((resolve, reject) => {
462
865
  const url = this.buildWsUrl();
463
866
  const ws = createWs(url);
@@ -471,7 +874,10 @@ var TTSResource = class {
471
874
  max_new_tokens: options.maxNewTokens ?? 2048,
472
875
  sample_rate: options.sampleRate ?? 24e3,
473
876
  normalize: options.normalize ?? true,
474
- ...options.language && { language: options.language }
877
+ ...options.language && { language: options.language },
878
+ ...options.wordTimestamps && { word_timestamps: true },
879
+ ...options.speed !== void 0 && { speed: options.speed },
880
+ ...options.projectId !== void 0 && { project_id: options.projectId }
475
881
  }));
476
882
  };
477
883
  ws.onmessage = (event) => {
@@ -479,7 +885,7 @@ var TTSResource = class {
479
885
  const messageData = typeof event.data === "string" ? event.data : event.data instanceof Buffer ? event.data.toString() : String(event.data);
480
886
  const data = JSON.parse(messageData);
481
887
  if (data.error) {
482
- const error = this.parseError(data.error);
888
+ const error = this.parseError(data);
483
889
  callbacks.onError?.(error);
484
890
  ws.close();
485
891
  reject(error);
@@ -492,7 +898,6 @@ var TTSResource = class {
492
898
  totalSamples: data.total_samples,
493
899
  durationMs: data.dur_ms,
494
900
  generationMs: data.gen_ms,
495
- ttfaMs: data.ttfa_ms,
496
901
  rtf: data.rtf,
497
902
  error: data.error
498
903
  };
@@ -511,29 +916,71 @@ var TTSResource = class {
511
916
  };
512
917
  callbacks.onChunk?.(chunk);
513
918
  }
919
+ if (data.word_timestamps) {
920
+ const timestamps = data.word_timestamps.map(
921
+ (w) => ({
922
+ word: w.word,
923
+ startMs: w.start_ms,
924
+ endMs: w.end_ms,
925
+ charStart: w.char_start,
926
+ charEnd: w.char_end,
927
+ score: w.score ?? 1
928
+ })
929
+ );
930
+ callbacks.onWordTimestamps?.(timestamps);
931
+ }
514
932
  } catch (e) {
515
933
  console.error("Failed to parse WebSocket message:", e);
516
934
  }
517
935
  };
518
- ws.onerror = () => {
519
- const error = new KugelAudioError("WebSocket connection error");
936
+ ws.onerror = (event) => {
937
+ const underlying = event?.error ?? event;
938
+ const error = classifyWsHandshakeError(underlying) ?? new ConnectionError(
939
+ "KugelAudio WebSocket connection error. Check network connectivity."
940
+ );
520
941
  callbacks.onError?.(error);
521
942
  reject(error);
522
943
  };
523
944
  ws.onclose = (event) => {
524
945
  callbacks.onClose?.();
525
- if (event.code === 4001) {
526
- reject(new AuthenticationError("Authentication failed"));
527
- } else if (event.code === 4003) {
528
- reject(new InsufficientCreditsError("Insufficient credits"));
946
+ if (event.code === 4001 || event.code === 4003 || event.code === 4029 || event.code === 4500) {
947
+ const error = classifyWsClose(event.code, event.reason);
948
+ callbacks.onError?.(error);
949
+ reject(error);
529
950
  }
530
951
  };
531
952
  });
532
953
  }
954
+ /**
955
+ * Start periodic keepalive pings on the pooled connection.
956
+ * Uses the ws package's ping() in Node.js; silently skips in browsers
957
+ * where WebSocket doesn't expose a ping method.
958
+ */
959
+ startKeepalive(ws) {
960
+ this.stopKeepalive();
961
+ const intervalMs = this.client.keepalivePingInterval;
962
+ if (intervalMs == null || intervalMs <= 0) return;
963
+ this.keepaliveTimer = setInterval(() => {
964
+ if (this.wsConnection !== ws || ws.readyState !== WS_OPEN) {
965
+ this.stopKeepalive();
966
+ return;
967
+ }
968
+ if (typeof ws.ping === "function") {
969
+ ws.ping();
970
+ }
971
+ }, intervalMs);
972
+ }
973
+ stopKeepalive() {
974
+ if (this.keepaliveTimer !== null) {
975
+ clearInterval(this.keepaliveTimer);
976
+ this.keepaliveTimer = null;
977
+ }
978
+ }
533
979
  /**
534
980
  * Close the pooled WebSocket connection.
535
981
  */
536
982
  close() {
983
+ this.stopKeepalive();
537
984
  if (this.wsConnection) {
538
985
  try {
539
986
  this.wsConnection.close();
@@ -543,15 +990,39 @@ var TTSResource = class {
543
990
  this.wsUrl = null;
544
991
  }
545
992
  }
546
- parseError(message) {
547
- const lower = message.toLowerCase();
548
- if (lower.includes("auth") || lower.includes("unauthorized")) {
549
- return new AuthenticationError(message);
550
- }
551
- if (lower.includes("credit")) {
552
- return new InsufficientCreditsError(message);
553
- }
554
- return new KugelAudioError(message);
993
+ parseError(data) {
994
+ return classifyWsFrame(data);
995
+ }
996
+ /**
997
+ * Create a streaming session for LLM integration.
998
+ *
999
+ * The session connects to `/ws/tts/stream` and keeps a persistent
1000
+ * connection across multiple {@link StreamingSession.send} calls.
1001
+ * The server auto-chunks text at sentence boundaries — no client-side
1002
+ * flushing required.
1003
+ *
1004
+ * @param config - Session configuration (voice, model, chunking strategy).
1005
+ * @param callbacks - Callbacks for audio chunks and session lifecycle events.
1006
+ * @returns A {@link StreamingSession} instance. Call `.connect()` before sending.
1007
+ *
1008
+ * @example
1009
+ * ```typescript
1010
+ * const session = client.tts.streamingSession(
1011
+ * { voiceId: 123, autoMode: true, chunkLengthSchedule: [50, 100, 150, 250] },
1012
+ * { onChunk: (chunk) => playAudio(chunk.audio) },
1013
+ * );
1014
+ *
1015
+ * session.connect();
1016
+ *
1017
+ * for await (const token of llmStream) {
1018
+ * session.send(token);
1019
+ * }
1020
+ *
1021
+ * await session.close();
1022
+ * ```
1023
+ */
1024
+ streamingSession(config, callbacks) {
1025
+ return new StreamingSession(this.client, config, callbacks);
555
1026
  }
556
1027
  /**
557
1028
  * Create a multi-context session for concurrent TTS streams.
@@ -571,7 +1042,7 @@ var TTSResource = class {
571
1042
  * console.log(`Audio from ${chunk.contextId}`);
572
1043
  * playAudio(chunk.audio);
573
1044
  * },
574
- * onContextFinal: (contextId) => {
1045
+ * onContextClosed: (contextId) => {
575
1046
  * console.log(`${contextId} finished`);
576
1047
  * },
577
1048
  * });
@@ -610,6 +1081,11 @@ var MultiContextSession = class {
610
1081
  }
611
1082
  /**
612
1083
  * Connect to the multi-context WebSocket endpoint.
1084
+ *
1085
+ * The returned promise resolves once the WebSocket is OPEN so callers can
1086
+ * ``await session.connect(callbacks)`` before invoking
1087
+ * {@link createContext} / {@link send}. Pre-open errors reject with the
1088
+ * typed error.
613
1089
  */
614
1090
  connect(callbacks) {
615
1091
  this.callbacks = callbacks;
@@ -624,9 +1100,8 @@ var MultiContextSession = class {
624
1100
  }
625
1101
  const url = `${wsUrl}/ws/tts/multi?${authParam}=${this.client.apiKey}`;
626
1102
  this.ws = createWs(url);
627
- this.ws.onopen = () => {
628
- };
629
- this.ws.onmessage = (event) => {
1103
+ const ws = this.ws;
1104
+ ws.onmessage = (event) => {
630
1105
  try {
631
1106
  const messageData = typeof event.data === "string" ? event.data : event.data instanceof Buffer ? event.data.toString() : String(event.data);
632
1107
  const data = JSON.parse(messageData);
@@ -657,9 +1132,6 @@ var MultiContextSession = class {
657
1132
  };
658
1133
  this.callbacks.onChunk?.(chunk);
659
1134
  }
660
- if (data.is_final) {
661
- this.callbacks.onContextFinal?.(data.context_id);
662
- }
663
1135
  if (data.context_closed) {
664
1136
  this.contexts.delete(data.context_id);
665
1137
  this.callbacks.onContextClosed?.(data.context_id);
@@ -675,19 +1147,38 @@ var MultiContextSession = class {
675
1147
  console.error("Failed to parse WebSocket message:", e);
676
1148
  }
677
1149
  };
678
- this.ws.onerror = () => {
679
- this.callbacks.onError?.(new KugelAudioError("WebSocket connection error"));
680
- };
681
- this.ws.onclose = (event) => {
682
- if (event.code === 4001) {
683
- this.callbacks.onError?.(new AuthenticationError("Authentication failed"));
684
- } else if (event.code === 4003) {
685
- this.callbacks.onError?.(new InsufficientCreditsError("Insufficient credits"));
686
- }
687
- this.ws = null;
688
- this.isStarted = false;
689
- this.contexts.clear();
690
- };
1150
+ return new Promise((resolve, reject) => {
1151
+ let opened = false;
1152
+ ws.onopen = () => {
1153
+ opened = true;
1154
+ resolve();
1155
+ };
1156
+ ws.onerror = (event) => {
1157
+ const underlying = event?.error ?? event;
1158
+ const err = classifyWsHandshakeError(underlying) ?? new ConnectionError(
1159
+ "KugelAudio multi-context WebSocket connection error. Check network connectivity."
1160
+ );
1161
+ if (!opened) reject(err);
1162
+ this.callbacks.onError?.(err);
1163
+ };
1164
+ ws.onclose = (event) => {
1165
+ let typedErr = null;
1166
+ if (event.code === 4001 || event.code === 4003 || event.code === 4029 || event.code === 4500) {
1167
+ typedErr = classifyWsClose(event.code, event.reason);
1168
+ this.callbacks.onError?.(typedErr);
1169
+ }
1170
+ if (!opened) {
1171
+ reject(
1172
+ typedErr ?? new ConnectionError(
1173
+ `KugelAudio multi-context WebSocket closed before ready (code ${event.code}).`
1174
+ )
1175
+ );
1176
+ }
1177
+ this.ws = null;
1178
+ this.isStarted = false;
1179
+ this.contexts.clear();
1180
+ };
1181
+ });
691
1182
  }
692
1183
  /**
693
1184
  * Create a new context with optional voice settings.
@@ -701,10 +1192,13 @@ var MultiContextSession = class {
701
1192
  context_id: contextId
702
1193
  };
703
1194
  if (!this.isStarted) {
1195
+ warnIfNoLanguage(this.config.language, this.config.normalize);
704
1196
  if (this.config.sampleRate) msg.sample_rate = this.config.sampleRate;
705
1197
  if (this.config.cfgScale) msg.cfg_scale = this.config.cfgScale;
1198
+ if (this.config.temperature !== void 0) msg.temperature = this.config.temperature;
706
1199
  if (this.config.maxNewTokens) msg.max_new_tokens = this.config.maxNewTokens;
707
1200
  if (this.config.normalize !== void 0) msg.normalize = this.config.normalize;
1201
+ if (this.config.language) msg.language = this.config.language;
708
1202
  if (this.config.inactivityTimeout) msg.inactivity_timeout = this.config.inactivityTimeout;
709
1203
  }
710
1204
  const voiceId = options?.voiceId || this.config.defaultVoiceId;
@@ -791,18 +1285,271 @@ var MultiContextSession = class {
791
1285
  return this.ws !== null && this.ws.readyState === WS_OPEN;
792
1286
  }
793
1287
  };
1288
+ var StreamingSession = class {
1289
+ constructor(client, config, callbacks) {
1290
+ this.ws = null;
1291
+ this.configSent = false;
1292
+ this.client = client;
1293
+ this.config = config;
1294
+ this.callbacks = callbacks;
1295
+ }
1296
+ /**
1297
+ * Open the WebSocket connection and authenticate.
1298
+ *
1299
+ * The returned promise resolves once the WebSocket is OPEN, so callers can
1300
+ * ``await session.connect()`` and then ``send()`` without racing the
1301
+ * handshake. Pre-open errors (network failure, 4001 unauthorized, …) reject
1302
+ * the promise with the typed error.
1303
+ */
1304
+ connect() {
1305
+ const wsUrl = this.client.ttsUrl.replace("https://", "wss://").replace("http://", "ws://");
1306
+ let authParam;
1307
+ if (this.client.isToken) {
1308
+ authParam = "token";
1309
+ } else if (this.client.isMasterKey) {
1310
+ authParam = "master_key";
1311
+ } else {
1312
+ authParam = "api_key";
1313
+ }
1314
+ const url = `${wsUrl}/ws/tts/stream?${authParam}=${this.client.apiKey}`;
1315
+ this.ws = createWs(url);
1316
+ const ws = this.ws;
1317
+ ws.onmessage = (event) => {
1318
+ try {
1319
+ const messageData = typeof event.data === "string" ? event.data : event.data instanceof Buffer ? event.data.toString() : String(event.data);
1320
+ const data = JSON.parse(messageData);
1321
+ if (data.error) {
1322
+ this.callbacks.onError?.(new KugelAudioError(data.error));
1323
+ return;
1324
+ }
1325
+ if (data.audio) {
1326
+ const chunk = {
1327
+ audio: data.audio,
1328
+ encoding: data.enc || "pcm_s16le",
1329
+ index: data.idx,
1330
+ sampleRate: data.sr,
1331
+ samples: data.samples
1332
+ };
1333
+ this.callbacks.onChunk?.(chunk);
1334
+ }
1335
+ if (data.word_timestamps) {
1336
+ const timestamps = data.word_timestamps.map((w) => ({
1337
+ word: w.word,
1338
+ startMs: w.start_ms,
1339
+ endMs: w.end_ms,
1340
+ charStart: w.char_start,
1341
+ charEnd: w.char_end,
1342
+ score: w.score ?? 1
1343
+ }));
1344
+ this.callbacks.onWordTimestamps?.(timestamps);
1345
+ }
1346
+ if (data.chunk_complete) {
1347
+ this.callbacks.onChunkComplete?.(
1348
+ data.chunk_id ?? 0,
1349
+ data.audio_seconds ?? 0,
1350
+ data.gen_ms ?? 0
1351
+ );
1352
+ }
1353
+ if (data.generation_started) {
1354
+ this.callbacks.onGenerationStarted?.(data.chunk_id ?? 0, data.text ?? "");
1355
+ }
1356
+ if (data.session_closed) {
1357
+ this.callbacks.onSessionClosed?.(
1358
+ data.total_audio_seconds ?? 0,
1359
+ data.total_text_chunks ?? 0,
1360
+ data.total_audio_chunks ?? 0
1361
+ );
1362
+ }
1363
+ } catch (e) {
1364
+ console.error("[KugelAudio] Failed to parse streaming session message:", e);
1365
+ }
1366
+ };
1367
+ return new Promise((resolve, reject) => {
1368
+ let opened = false;
1369
+ ws.onopen = () => {
1370
+ opened = true;
1371
+ resolve();
1372
+ };
1373
+ ws.onerror = (event) => {
1374
+ const underlying = event?.error ?? event;
1375
+ const err = classifyWsHandshakeError(underlying) ?? new ConnectionError(
1376
+ "KugelAudio streaming WebSocket connection error. Check network connectivity."
1377
+ );
1378
+ if (!opened) reject(err);
1379
+ this.callbacks.onError?.(err);
1380
+ };
1381
+ ws.onclose = (event) => {
1382
+ let typedErr = null;
1383
+ if (event.code === 4001 || event.code === 4003 || event.code === 4029 || event.code === 4500) {
1384
+ typedErr = classifyWsClose(event.code, event.reason);
1385
+ this.callbacks.onError?.(typedErr);
1386
+ }
1387
+ if (!opened) {
1388
+ reject(
1389
+ typedErr ?? new ConnectionError(
1390
+ `KugelAudio streaming WebSocket closed before ready (code ${event.code}).`
1391
+ )
1392
+ );
1393
+ }
1394
+ this.ws = null;
1395
+ this.configSent = false;
1396
+ };
1397
+ });
1398
+ }
1399
+ /**
1400
+ * Send a text chunk to the server (e.g. one LLM output token).
1401
+ *
1402
+ * The server buffers text across multiple calls and starts generating at
1403
+ * natural sentence boundaries automatically — no need to call `flush`.
1404
+ *
1405
+ * @param text - Raw text or LLM token to append to the server buffer.
1406
+ * @param flush - Force immediate generation of whatever is buffered.
1407
+ * **Avoid calling this per-sentence from the client.** Doing so bypasses
1408
+ * the server's semantic chunking, incurs a fresh model prefill cost on
1409
+ * every flush, and makes latency *worse*, not better. Let the server
1410
+ * handle chunking via `chunkLengthSchedule` / `autoMode` instead.
1411
+ */
1412
+ send(text, flush = false) {
1413
+ if (!this.ws || this.ws.readyState !== WS_OPEN) {
1414
+ throw new KugelAudioError("StreamingSession not connected. Call connect() first.");
1415
+ }
1416
+ const msg = { text, flush };
1417
+ if (!this.configSent) {
1418
+ if (this.config.voiceId !== void 0) msg.voice_id = this.config.voiceId;
1419
+ if (this.config.modelId !== void 0) msg.model_id = this.config.modelId;
1420
+ if (this.config.cfgScale !== void 0) msg.cfg_scale = this.config.cfgScale;
1421
+ if (this.config.temperature !== void 0) msg.temperature = this.config.temperature;
1422
+ if (this.config.maxNewTokens !== void 0) msg.max_new_tokens = this.config.maxNewTokens;
1423
+ if (this.config.sampleRate !== void 0) msg.sample_rate = this.config.sampleRate;
1424
+ if (this.config.flushTimeoutMs !== void 0) msg.flush_timeout_ms = this.config.flushTimeoutMs;
1425
+ if (this.config.maxBufferLength !== void 0) msg.max_buffer_length = this.config.maxBufferLength;
1426
+ if (this.config.normalize !== void 0) msg.normalize = this.config.normalize;
1427
+ if (this.config.language !== void 0) msg.language = this.config.language;
1428
+ if (this.config.wordTimestamps) msg.word_timestamps = true;
1429
+ if (this.config.autoMode !== void 0) msg.auto_mode = this.config.autoMode;
1430
+ if (this.config.chunkLengthSchedule?.length) msg.chunk_length_schedule = this.config.chunkLengthSchedule;
1431
+ if (this.config.speed !== void 0) msg.speed = this.config.speed;
1432
+ this.configSent = true;
1433
+ }
1434
+ this.ws.send(JSON.stringify(msg));
1435
+ }
1436
+ /**
1437
+ * End the current session but keep the WebSocket connection open.
1438
+ *
1439
+ * This allows starting a new session on the same connection, avoiding
1440
+ * the overhead of a new WebSocket handshake (~200-300ms). After calling
1441
+ * this, optionally call {@link updateConfig} to change voice/model settings,
1442
+ * then call {@link send} to start the next session.
1443
+ *
1444
+ * The returned promise resolves once the server confirms with a
1445
+ * `session_closed` message, or after a 15 s **quiet** timeout — i.e. 15 s
1446
+ * elapse without *any* server message arriving. The timer resets on every
1447
+ * incoming frame so a long final flush that streams audio for tens of
1448
+ * seconds is not truncated; only a genuinely silent server trips the fuse.
1449
+ */
1450
+ endSession() {
1451
+ if (!this.ws || this.ws.readyState !== WS_OPEN) return Promise.resolve();
1452
+ const ws = this.ws;
1453
+ const QUIET_TIMEOUT_MS = 15e3;
1454
+ return new Promise((resolve) => {
1455
+ let settled = false;
1456
+ let timer;
1457
+ const prevMessage = ws.onmessage;
1458
+ const prevClose = ws.onclose;
1459
+ const done = () => {
1460
+ if (settled) return;
1461
+ settled = true;
1462
+ clearTimeout(timer);
1463
+ ws.onmessage = prevMessage;
1464
+ ws.onclose = prevClose;
1465
+ this.configSent = false;
1466
+ resolve();
1467
+ };
1468
+ const armQuietTimer = () => {
1469
+ clearTimeout(timer);
1470
+ timer = setTimeout(done, QUIET_TIMEOUT_MS);
1471
+ };
1472
+ armQuietTimer();
1473
+ ws.onmessage = (event) => {
1474
+ armQuietTimer();
1475
+ if (prevMessage) prevMessage.call(ws, event);
1476
+ try {
1477
+ const raw = typeof event.data === "string" ? event.data : event.data instanceof Buffer ? event.data.toString() : String(event.data);
1478
+ if (JSON.parse(raw).session_closed) done();
1479
+ } catch {
1480
+ }
1481
+ };
1482
+ ws.onclose = (event) => {
1483
+ this.ws = null;
1484
+ if (prevClose) prevClose.call(ws, event);
1485
+ done();
1486
+ };
1487
+ ws.send(JSON.stringify({ close: true }));
1488
+ });
1489
+ }
1490
+ /**
1491
+ * Update session configuration for the next session.
1492
+ *
1493
+ * Call this after {@link endSession} and before the next {@link send}
1494
+ * to change voice, model, language, or other settings.
1495
+ */
1496
+ updateConfig(config) {
1497
+ Object.assign(this.config, config);
1498
+ this.configSent = false;
1499
+ }
1500
+ /**
1501
+ * Close the session and the WebSocket connection.
1502
+ *
1503
+ * For session reuse without closing the connection, use
1504
+ * {@link endSession} instead.
1505
+ *
1506
+ * The returned promise resolves once the server confirms the close with a
1507
+ * `session_closed` message, or after a 15 s **quiet** timeout (no traffic
1508
+ * from the server in that window). Audio frames from the server-side
1509
+ * final-flush of the still-buffered text are delivered to your callbacks
1510
+ * before this promise resolves, and each frame resets the quiet timer.
1511
+ */
1512
+ async close() {
1513
+ await this.endSession();
1514
+ if (this.ws) {
1515
+ try {
1516
+ this.ws.close();
1517
+ } catch {
1518
+ }
1519
+ this.ws = null;
1520
+ }
1521
+ }
1522
+ /** Whether the underlying WebSocket is open. */
1523
+ get isConnected() {
1524
+ return this.ws !== null && this.ws.readyState === WS_OPEN;
1525
+ }
1526
+ };
794
1527
  var KugelAudio = class _KugelAudio {
795
1528
  constructor(options) {
796
1529
  if (!options.apiKey) {
797
- throw new Error("API key is required");
1530
+ throw new ValidationError(
1531
+ "KugelAudio API key is missing. Set the KUGELAUDIO_API_KEY environment variable or pass { apiKey: ... } to the client. Get a key at https://app.kugelaudio.com/settings/api-keys."
1532
+ );
798
1533
  }
799
- this._apiKey = options.apiKey;
1534
+ const { cleanKey, detectedRegion } = parseApiKey(options.apiKey);
1535
+ this._apiKey = cleanKey;
800
1536
  this._isMasterKey = options.isMasterKey || false;
801
1537
  this._isToken = options.isToken || false;
802
1538
  this._orgId = options.orgId;
803
- this._apiUrl = (options.apiUrl || DEFAULT_API_URL).replace(/\/$/, "");
1539
+ if (options.apiUrl) {
1540
+ this._apiUrl = options.apiUrl.replace(/\/$/, "");
1541
+ } else {
1542
+ const effectiveRegion = options.region || detectedRegion || "eu";
1543
+ if (!(effectiveRegion in REGION_URLS)) {
1544
+ throw new ValidationError(
1545
+ `Invalid region '${effectiveRegion}'. Must be one of: ${Object.keys(REGION_URLS).join(", ")}.`
1546
+ );
1547
+ }
1548
+ this._apiUrl = REGION_URLS[effectiveRegion];
1549
+ }
804
1550
  this._ttsUrl = (options.ttsUrl || this._apiUrl).replace(/\/$/, "");
805
1551
  this._timeout = options.timeout || 6e4;
1552
+ this._keepalivePingInterval = options.keepalivePingInterval !== void 0 ? options.keepalivePingInterval : 2e4;
806
1553
  this.models = new ModelsResource(this);
807
1554
  this.voices = new VoicesResource(this);
808
1555
  this.tts = new TTSResource(this);
@@ -848,6 +1595,10 @@ var KugelAudio = class _KugelAudio {
848
1595
  get ttsUrl() {
849
1596
  return this._ttsUrl;
850
1597
  }
1598
+ /** Get keepalive ping interval in milliseconds, or null if disabled. */
1599
+ get keepalivePingInterval() {
1600
+ return this._keepalivePingInterval;
1601
+ }
851
1602
  /**
852
1603
  * Close the client and release resources.
853
1604
  * This closes any pooled WebSocket connections.
@@ -902,25 +1653,49 @@ var KugelAudio = class _KugelAudio {
902
1653
  signal: controller.signal
903
1654
  });
904
1655
  clearTimeout(timeoutId);
905
- if (response.status === 401) {
906
- throw new AuthenticationError("Invalid API key");
1656
+ if (!response.ok) {
1657
+ const text = await response.text();
1658
+ throw classifyHttpError(response.status, text, response.headers);
907
1659
  }
908
- if (response.status === 403) {
909
- throw new InsufficientCreditsError("Access denied");
1660
+ return await response.json();
1661
+ } catch (error) {
1662
+ clearTimeout(timeoutId);
1663
+ if (error instanceof KugelAudioError) {
1664
+ throw error;
910
1665
  }
911
- if (response.status === 429) {
912
- throw new RateLimitError("Rate limit exceeded");
1666
+ if (error.name === "AbortError") {
1667
+ throw new ConnectionError(
1668
+ `Request to ${method} ${path} timed out after ${this._timeout}ms.`
1669
+ );
913
1670
  }
1671
+ throw new ConnectionError(
1672
+ `Could not reach KugelAudio at ${url}: ${error.message}. Check network connectivity.`
1673
+ );
1674
+ }
1675
+ }
1676
+ /**
1677
+ * Make a multipart/form-data request (for file uploads).
1678
+ * @internal Used by VoicesResource for reference file uploads.
1679
+ */
1680
+ async requestMultipart(method, path, formData) {
1681
+ const url = `${this._apiUrl}${path}`;
1682
+ const headers = {
1683
+ "X-API-Key": this._apiKey,
1684
+ "Authorization": `Bearer ${this._apiKey}`
1685
+ };
1686
+ const controller = new AbortController();
1687
+ const timeoutId = setTimeout(() => controller.abort(), this._timeout);
1688
+ try {
1689
+ const response = await fetch(url, {
1690
+ method,
1691
+ headers,
1692
+ body: formData,
1693
+ signal: controller.signal
1694
+ });
1695
+ clearTimeout(timeoutId);
914
1696
  if (!response.ok) {
915
1697
  const text = await response.text();
916
- let message = `HTTP ${response.status}`;
917
- try {
918
- const json = JSON.parse(text);
919
- message = json.detail || json.error || message;
920
- } catch {
921
- message = text || message;
922
- }
923
- throw new KugelAudioError(message, response.status);
1698
+ throw classifyHttpError(response.status, text, response.headers);
924
1699
  }
925
1700
  return await response.json();
926
1701
  } catch (error) {
@@ -929,9 +1704,13 @@ var KugelAudio = class _KugelAudio {
929
1704
  throw error;
930
1705
  }
931
1706
  if (error.name === "AbortError") {
932
- throw new KugelAudioError("Request timed out");
1707
+ throw new ConnectionError(
1708
+ `Request to ${method} ${path} timed out after ${this._timeout}ms.`
1709
+ );
933
1710
  }
934
- throw new KugelAudioError(`Request failed: ${error.message}`);
1711
+ throw new ConnectionError(
1712
+ `Could not reach KugelAudio at ${url}: ${error.message}. Check network connectivity.`
1713
+ );
935
1714
  }
936
1715
  }
937
1716
  };
@@ -939,12 +1718,18 @@ var KugelAudio = class _KugelAudio {
939
1718
  0 && (module.exports = {
940
1719
  AuthenticationError,
941
1720
  ConnectionError,
1721
+ ErrorCodes,
942
1722
  InsufficientCreditsError,
943
1723
  KugelAudio,
944
1724
  KugelAudioError,
945
1725
  RateLimitError,
946
1726
  ValidationError,
1727
+ WsCloseCodes,
947
1728
  base64ToArrayBuffer,
1729
+ classifyHttpError,
1730
+ classifyWsClose,
1731
+ classifyWsFrame,
1732
+ classifyWsHandshakeError,
948
1733
  createWavBlob,
949
1734
  createWavFile,
950
1735
  decodePCM16