speechrevolutions 0.2.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,786 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.SpeechRevolutionsClient = exports.SpeechRevolutions = exports.STTClient = void 0;
37
+ const exceptions_js_1 = require("./exceptions.js");
38
+ const progress_js_1 = require("./progress.js");
39
+ const sse_js_1 = require("./sse.js");
40
+ const transcript_js_1 = require("./transcript.js");
41
+ const upload_js_1 = require("./upload.js");
42
+ const types_js_1 = require("./types.js");
43
+ /**
44
+ * Identifies the SDK to the platform, which makes a client-side problem findable
45
+ * in our edge logs without the caller reproducing it. It is also insurance: the
46
+ * edge answers a request with NO User-Agent with a bare 403, which is how the C#
47
+ * client turned out to be unable to reach production at all while passing every
48
+ * test that pointed at a local mock.
49
+ */
50
+ const USER_AGENT = "speechrevolutions-node/0.2.0";
51
+ const DEFAULT_BASE_URL = "https://api.speechrevolutions.com";
52
+ const UPLOAD_PROGRESS_INTERVAL_MS = 10_000;
53
+ const UPLOAD_MAX_ATTEMPTS = 4;
54
+ const UPLOAD_BASE_DELAY_MS = 1_000;
55
+ const SSE_MAX_RECONNECTS = 10;
56
+ const SSE_RECONNECT_DELAY_MS = 3_000;
57
+ const POLL_INTERVAL_MS = 5_000;
58
+ const DEFAULT_MAX_RETRIES = 3;
59
+ const DEFAULT_RETRY_BACKOFF_MS = 500;
60
+ const RETRY_BACKOFF_MAX_MS = 30_000;
61
+ const RETRY_STATUS_CODES = new Set([429, 500, 502, 503, 504]);
62
+ const REQUEST_ID_HEADERS = ["x-request-id", "x-amzn-requestid", "cf-ray"];
63
+ /**
64
+ * Endpoints that CREATE a job, and so are not safe to blindly retry.
65
+ *
66
+ * A job is created the moment the server handles one of these; the response
67
+ * carrying the job_id back is what can be lost. Retrying after the request may
68
+ * have arrived creates a SECOND job for the same audio — two transcripts, two
69
+ * charges — and the caller never learns about the orphan. The API has no
70
+ * idempotency key, so the only safe rule is to retry these solely when the
71
+ * request provably never reached the server.
72
+ *
73
+ * Every other endpoint either reads, or acts on a jobId the caller already
74
+ * holds, and stays fully retryable.
75
+ */
76
+ const JOB_CREATING_PATHS = new Set([
77
+ "/api/v1/upload",
78
+ "/api/v1/upload/multipart/create",
79
+ ]);
80
+ function createsJob(path) {
81
+ const clean = path.split("?")[0].replace(/\/+$/, "");
82
+ return JOB_CREATING_PATHS.has(clean);
83
+ }
84
+ /**
85
+ * Network error codes that mean no connection was ever established, so the
86
+ * request cannot have been processed. Anything else (a reset mid-flight, a
87
+ * headers timeout) is ambiguous and must not be retried for a create.
88
+ */
89
+ const NEVER_SENT_CODES = new Set([
90
+ "ECONNREFUSED",
91
+ "ENOTFOUND",
92
+ "EAI_AGAIN",
93
+ "EHOSTUNREACH",
94
+ "ENETUNREACH",
95
+ "UND_ERR_CONNECT_TIMEOUT",
96
+ ]);
97
+ function neverReachedServer(err) {
98
+ const code = err?.cause?.code
99
+ ?? err?.code;
100
+ return typeof code === "string" && NEVER_SENT_CODES.has(code);
101
+ }
102
+ function extractRequestId(headers) {
103
+ for (const name of REQUEST_ID_HEADERS) {
104
+ const value = headers.get(name);
105
+ if (value)
106
+ return value;
107
+ }
108
+ return undefined;
109
+ }
110
+ /** Parse a Retry-After header (delta-seconds form) into milliseconds. */
111
+ function parseRetryAfterMs(value) {
112
+ if (!value)
113
+ return undefined;
114
+ const secs = Number(value);
115
+ return Number.isFinite(secs) ? Math.max(0, secs) * 1000 : undefined;
116
+ }
117
+ function resolveApiKey(apiKey) {
118
+ if (apiKey)
119
+ return apiKey;
120
+ const env = (typeof process !== "undefined" &&
121
+ (process.env.SPEECHREVOLUTIONS_API_KEY || process.env.STT_API_KEY)) ||
122
+ undefined;
123
+ if (env)
124
+ return env;
125
+ throw new exceptions_js_1.AuthenticationError("apiKey is required (pass apiKey or set SPEECHREVOLUTIONS_API_KEY / STT_API_KEY)");
126
+ }
127
+ /**
128
+ * Resolves the API host: an explicit option, then the environment, then
129
+ * production.
130
+ *
131
+ * Symmetric with the API key — if a caller can supply a key from the
132
+ * environment, they can point it at an environment too. Needed for staging, for
133
+ * an egress proxy or gateway, and for running any published example against
134
+ * something that is not production.
135
+ */
136
+ function resolveBaseUrl(baseUrl) {
137
+ const env = (typeof process !== "undefined" &&
138
+ (process.env.SPEECHREVOLUTIONS_BASE_URL || process.env.STT_BASE_URL)) ||
139
+ undefined;
140
+ return (baseUrl ?? env ?? DEFAULT_BASE_URL).replace(/\/$/, "");
141
+ }
142
+ /** Internal signal that a multipart upload should fall back to single-shot. */
143
+ class MultipartUnavailable extends Error {
144
+ }
145
+ class STTClient {
146
+ apiKey;
147
+ baseUrl;
148
+ timeout;
149
+ fetchFn;
150
+ maxRetries;
151
+ retryBackoffMs;
152
+ requestInit;
153
+ multipart;
154
+ constructor(opts = {}) {
155
+ const options = typeof opts === "string" ? { apiKey: opts } : opts ?? {};
156
+ this.apiKey = resolveApiKey(options.apiKey);
157
+ this.baseUrl = resolveBaseUrl(options.baseUrl);
158
+ this.timeout = options.timeout ?? 600;
159
+ this.fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis);
160
+ this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
161
+ this.retryBackoffMs = options.retryBackoffMs ?? DEFAULT_RETRY_BACKOFF_MS;
162
+ this.requestInit = options.requestInit ?? {};
163
+ // Prefer multipart; falls back to a single presigned PUT if the server has
164
+ // multipart disabled (404) or a multipart upload fails mid-flight.
165
+ this.multipart = options.multipart ?? true;
166
+ }
167
+ retryDelayMs(attempt, retryAfterMs) {
168
+ if (retryAfterMs !== undefined)
169
+ return Math.min(retryAfterMs, RETRY_BACKOFF_MAX_MS);
170
+ return Math.min(this.retryBackoffMs * 2 ** (attempt - 1), RETRY_BACKOFF_MAX_MS);
171
+ }
172
+ // high-level
173
+ /**
174
+ * Transcribe a local file path, remote URL, bytes, or Blob. A URL is handed
175
+ * to the platform to fetch, so nothing is uploaded from here.
176
+ */
177
+ async transcribe(audio, options = {}, onProgress) {
178
+ const opts = (0, types_js_1.resolveOptions)(options);
179
+ const onTranscribeProgress = onProgress ?? options.onProgress;
180
+ const show = options.progress ?? false;
181
+ if (typeof audio === "string" && isUrl(audio)) {
182
+ const { jobId, downloadUrl } = await this.submitUrl(audio, opts);
183
+ return this.awaitTranscript(jobId, downloadUrl, opts, onTranscribeProgress, show);
184
+ }
185
+ const { data, fileSize } = await readAudio(audio);
186
+ // Upload phase: byte-level "Uploading" bar, then a "Transcribing" bar.
187
+ const upload = (0, progress_js_1.resolveProgress)(options.onUploadProgress, show, {
188
+ label: "Uploading",
189
+ bytesMode: true,
190
+ });
191
+ let jobId;
192
+ let jobDownloadUrl;
193
+ try {
194
+ ({ jobId, downloadUrl: jobDownloadUrl } = await this.ingestUpload(data, fileSize, opts, upload.callback));
195
+ }
196
+ finally {
197
+ upload.printer?.close();
198
+ }
199
+ return this.awaitTranscript(jobId, jobDownloadUrl, opts, onTranscribeProgress, show);
200
+ }
201
+ /** Waits out the transcription phase and parses the result. */
202
+ async awaitTranscript(jobId, jobDownloadUrl, opts, onProgress, show) {
203
+ const transcribeProgress = (0, progress_js_1.resolveProgress)(onProgress, show, { label: "Transcribing" });
204
+ let content;
205
+ let downloadUrl;
206
+ try {
207
+ ({ content, downloadUrl } = await this.waitForResult(jobId, jobDownloadUrl, transcribeProgress.callback));
208
+ }
209
+ finally {
210
+ transcribeProgress.printer?.close();
211
+ }
212
+ return (0, transcript_js_1.parseTranscript)({
213
+ jobId,
214
+ content,
215
+ outputType: opts.outputType,
216
+ downloadUrl,
217
+ });
218
+ }
219
+ /** Registers a job the platform fetches itself. No bytes leave this process. */
220
+ async submitUrl(audioUrl, opts) {
221
+ const data = await this.apiRequest("POST", "/api/v1/upload", { ...this.uploadBody(undefined, opts), audio_url: audioUrl });
222
+ return { jobId: String(data.job_id), downloadUrl: String(data.download_url) };
223
+ }
224
+ async transcribeUrl(url, options = {}, onProgress) {
225
+ return this.transcribe(url, options, onProgress);
226
+ }
227
+ async transcribeFile(path, options = {}, onProgress) {
228
+ return this.transcribe(path, options, onProgress);
229
+ }
230
+ /**
231
+ * Upload and enqueue a job, returning its `jobId` WITHOUT waiting for the
232
+ * result. Collect it later via a webhook (`callbackUrl`) or by polling
233
+ * `getJobStatus` / `getTranscript`. Ideal for batch workloads — submit many,
234
+ * then gather — since it holds no long-lived connection per job.
235
+ */
236
+ async submit(audio, options = {}) {
237
+ const opts = (0, types_js_1.resolveOptions)(options);
238
+ if (typeof audio === "string" && isUrl(audio)) {
239
+ const { jobId } = await this.submitUrl(audio, opts);
240
+ return jobId;
241
+ }
242
+ const { data, fileSize } = await readAudio(audio);
243
+ const upload = (0, progress_js_1.resolveProgress)(options.onUploadProgress, options.progress ?? false, {
244
+ label: "Uploading",
245
+ bytesMode: true,
246
+ });
247
+ let jobId;
248
+ try {
249
+ ({ jobId } = await this.ingestUpload(data, fileSize, opts, upload.callback));
250
+ }
251
+ finally {
252
+ upload.printer?.close();
253
+ }
254
+ return jobId;
255
+ }
256
+ // upload flow
257
+ /** JSON body shared by the single-shot and multipart create endpoints. */
258
+ uploadBody(fileSize, opts) {
259
+ return {
260
+ ...(fileSize === undefined ? {} : { file_size: fileSize }),
261
+ output_type: opts.outputType,
262
+ word_timestamps: opts.wordTimestamps,
263
+ speaker_labels: opts.speakerLabels,
264
+ nltk: opts.nltk,
265
+ tier: opts.tier,
266
+ ...(opts.customVocabulary ? { custom_vocabulary: opts.customVocabulary } : {}),
267
+ ...(opts.callbackUrl ? { callback_url: opts.callbackUrl } : {}),
268
+ };
269
+ }
270
+ /**
271
+ * Get audio into the platform and return `{ jobId, downloadUrl }`. Prefers a
272
+ * multipart upload (when enabled) and falls back to a single presigned PUT if
273
+ * the server has multipart disabled or a multipart upload fails mid-flight.
274
+ */
275
+ async ingestUpload(data, fileSize, opts, onProgress) {
276
+ if (this.multipart) {
277
+ try {
278
+ return await this.uploadMultipart(data, fileSize, opts, onProgress);
279
+ }
280
+ catch (err) {
281
+ if (!(err instanceof MultipartUnavailable))
282
+ throw err;
283
+ // multipart unavailable — fall through to the single-shot path
284
+ }
285
+ }
286
+ const job = await this.createUploadJob(fileSize, opts);
287
+ await this.uploadAudio(job.uploadUrl, data, { jobId: job.jobId, onProgress });
288
+ await this.completeUpload(job.jobId);
289
+ return { jobId: job.jobId, downloadUrl: job.downloadUrl };
290
+ }
291
+ /**
292
+ * S3 multipart flow: create -> PUT each part -> complete. Throws
293
+ * {@link MultipartUnavailable} if the server has multipart disabled (404) or a
294
+ * mid-flight failure means we should retry via the single-shot path.
295
+ */
296
+ async uploadMultipart(data, fileSize, opts, onProgress) {
297
+ let created;
298
+ try {
299
+ created = await this.apiRequest("POST", "/api/v1/upload/multipart/create", this.uploadBody(fileSize, opts));
300
+ }
301
+ catch (err) {
302
+ // The route returns 404 when multipart is disabled.
303
+ if (err instanceof exceptions_js_1.JobNotFoundError)
304
+ throw new MultipartUnavailable();
305
+ throw err;
306
+ }
307
+ const jobId = String(created.job_id);
308
+ const partSize = Number(created.part_size);
309
+ const byteCb = (0, upload_js_1.byteProgressAdapter)(onProgress);
310
+ const completedParts = [];
311
+ let uploaded = 0;
312
+ try {
313
+ for (const part of created.parts) {
314
+ const number = Number(part.part_number);
315
+ const start = (number - 1) * partSize;
316
+ const chunk = data.subarray(start, start + partSize);
317
+ const etag = await this.putPart(part.url, chunk);
318
+ completedParts.push({ part_number: number, etag });
319
+ uploaded += chunk.length;
320
+ byteCb?.(uploaded, fileSize);
321
+ }
322
+ await this.apiRequest("POST", "/api/v1/upload/multipart/complete", {
323
+ job_id: jobId,
324
+ parts: completedParts,
325
+ });
326
+ }
327
+ catch (err) {
328
+ // Roll back the partial upload, then fall back to a single-shot PUT.
329
+ try {
330
+ await this.apiRequest("POST", "/api/v1/upload/multipart/abort", { job_id: jobId });
331
+ }
332
+ catch {
333
+ /* best effort */
334
+ }
335
+ throw new MultipartUnavailable(String(err));
336
+ }
337
+ return { jobId, downloadUrl: String(created.download_url) };
338
+ }
339
+ /** PUT one part to its presigned URL and return the S3 ETag. */
340
+ async putPart(url, chunk) {
341
+ const resp = await this.fetchFn(url, { method: "PUT", body: toArrayBuffer(chunk) });
342
+ if (resp.status !== 200 && resp.status !== 204) {
343
+ throw new exceptions_js_1.UploadError(`part upload failed (HTTP ${resp.status})`);
344
+ }
345
+ const etag = resp.headers.get("ETag") ?? resp.headers.get("etag");
346
+ if (!etag)
347
+ throw new exceptions_js_1.UploadError("part upload response missing ETag header");
348
+ return etag;
349
+ }
350
+ async createUploadJob(fileSize, options = {}) {
351
+ const opts = (0, types_js_1.resolveOptions)(options);
352
+ const body = this.uploadBody(fileSize, opts);
353
+ const data = await this.apiRequest("POST", "/api/v1/upload", body);
354
+ return {
355
+ jobId: String(data.job_id),
356
+ uploadUrl: data.upload_url,
357
+ downloadUrl: data.download_url,
358
+ contentType: data.content_type ?? "application/octet-stream",
359
+ expiresIn: data.expires_in ?? 0,
360
+ };
361
+ }
362
+ async touchUploadProgress(jobId) {
363
+ await this.apiRequest("POST", "/api/v1/upload/progress", { job_id: jobId });
364
+ }
365
+ async uploadAudio(uploadUrl, data, opts = {}) {
366
+ const contentType = opts.contentType ?? "application/octet-stream";
367
+ const byteCb = (0, upload_js_1.byteProgressAdapter)(opts.onProgress);
368
+ const stop = { stopped: false };
369
+ let heartbeat;
370
+ if (opts.jobId) {
371
+ heartbeat = setInterval(() => {
372
+ if (stop.stopped)
373
+ return;
374
+ void this.touchUploadProgress(opts.jobId).catch(() => undefined);
375
+ }, UPLOAD_PROGRESS_INTERVAL_MS);
376
+ }
377
+ let lastError;
378
+ try {
379
+ for (let attempt = 1; attempt <= UPLOAD_MAX_ATTEMPTS; attempt++) {
380
+ try {
381
+ // A fresh streamed body per attempt so retries restart progress from 0.
382
+ await this.putUpload(uploadUrl, data, contentType, byteCb);
383
+ return;
384
+ }
385
+ catch (err) {
386
+ lastError = err;
387
+ if (attempt < UPLOAD_MAX_ATTEMPTS) {
388
+ await sleep(UPLOAD_BASE_DELAY_MS * 2 ** (attempt - 1));
389
+ }
390
+ }
391
+ }
392
+ }
393
+ finally {
394
+ stop.stopped = true;
395
+ if (heartbeat)
396
+ clearInterval(heartbeat);
397
+ }
398
+ throw new exceptions_js_1.UploadError(`Upload failed after ${UPLOAD_MAX_ATTEMPTS} attempts: ${String(lastError)}`);
399
+ }
400
+ async completeUpload(jobId) {
401
+ await this.apiRequest("POST", "/api/v1/upload/complete", { job_id: jobId });
402
+ }
403
+ // progress / result
404
+ async waitForResult(jobId, downloadUrl, onProgress, timeout = this.timeout) {
405
+ const sseUrl = await this.waitSSE(jobId, downloadUrl, onProgress, timeout);
406
+ if (sseUrl === null) {
407
+ const content = await this.waitPoll(jobId, downloadUrl, timeout);
408
+ return { content, downloadUrl };
409
+ }
410
+ const content = await this.downloadResult(sseUrl);
411
+ return { content, downloadUrl: sseUrl };
412
+ }
413
+ async downloadResult(downloadUrl) {
414
+ const resp = await this.fetchFn(downloadUrl);
415
+ if (!resp.ok) {
416
+ throw new exceptions_js_1.APIError(`Download failed (HTTP ${resp.status})`, {
417
+ statusCode: resp.status,
418
+ body: await resp.text().then((t) => t.slice(0, 300)),
419
+ });
420
+ }
421
+ return new Uint8Array(await resp.arrayBuffer());
422
+ }
423
+ // job management
424
+ async cancelJob(jobId) {
425
+ await this.apiRequest("POST", "/api/v1/jobs/cancel", { job_id: jobId });
426
+ }
427
+ async checkFailed(jobIds) {
428
+ const data = await this.apiRequest("POST", "/api/v1/jobs/check-failed", { job_ids: jobIds.map(String) });
429
+ return data.failed_jobs ?? [];
430
+ }
431
+ // retrieval (get by id / list)
432
+ /** Fetch a job's current status (and a fresh download URL once complete). */
433
+ async getJobStatus(jobId) {
434
+ const data = await this.apiRequest("GET", `/api/v1/jobs/${encodeURIComponent(jobId)}`);
435
+ return {
436
+ jobId: String(data.job_id ?? jobId),
437
+ status: String(data.status ?? ""),
438
+ downloadUrl: data.download_url,
439
+ failedStage: data.failed_stage,
440
+ reason: data.reason,
441
+ };
442
+ }
443
+ /**
444
+ * Fetch and parse a completed job's transcript by id. Throws
445
+ * {@link JobFailedError} if it failed, or {@link APIError} if still processing.
446
+ */
447
+ async getTranscript(jobId, opts = {}) {
448
+ const status = await this.getJobStatus(jobId);
449
+ if (status.status === "failed") {
450
+ throw new exceptions_js_1.JobFailedError(`Job ${jobId} failed`, {
451
+ step: status.failedStage,
452
+ reason: status.reason,
453
+ });
454
+ }
455
+ if (status.status !== "completed" || !status.downloadUrl) {
456
+ throw new exceptions_js_1.APIError(`Job ${jobId} is not complete (status=${status.status})`);
457
+ }
458
+ const content = await this.downloadResult(status.downloadUrl);
459
+ return (0, transcript_js_1.parseTranscript)({
460
+ jobId,
461
+ content,
462
+ outputType: opts.outputType ?? "json",
463
+ downloadUrl: status.downloadUrl,
464
+ });
465
+ }
466
+ /** List the caller's most-recent jobs (newest first), cursor-paginated. */
467
+ async listJobs(opts = {}) {
468
+ const params = new URLSearchParams();
469
+ params.set("limit", String(opts.limit ?? 50));
470
+ if (opts.before)
471
+ params.set("before", opts.before);
472
+ const data = await this.apiRequest("GET", `/api/v1/jobs?${params.toString()}`);
473
+ return {
474
+ jobs: (data.jobs ?? []).map((j) => ({ jobId: j.job_id, createdAt: j.created_at })),
475
+ nextBefore: data.next_before ?? null,
476
+ };
477
+ }
478
+ // internals
479
+ headers(extra) {
480
+ return {
481
+ "X-API-Key": this.apiKey,
482
+ "Content-Type": "application/json",
483
+ "User-Agent": USER_AGENT,
484
+ ...extra,
485
+ };
486
+ }
487
+ async apiRequest(method, path, body) {
488
+ const url = `${this.baseUrl}${path}`;
489
+ // Job-creating calls retry only when the request provably never landed;
490
+ // anything else would risk a duplicate job and a duplicate charge.
491
+ const creating = createsJob(path);
492
+ let attempt = 0;
493
+ while (true) {
494
+ attempt += 1;
495
+ let resp;
496
+ try {
497
+ resp = await this.fetchFn(url, {
498
+ ...this.requestInit,
499
+ method,
500
+ headers: this.headers(),
501
+ body: body === undefined ? undefined : JSON.stringify(body),
502
+ });
503
+ }
504
+ catch (err) {
505
+ if (isAbort(err))
506
+ throw err;
507
+ // Retry transient network failures with exponential backoff.
508
+ const safe = !creating || neverReachedServer(err);
509
+ if (safe && attempt <= this.maxRetries) {
510
+ await sleep(this.retryDelayMs(attempt));
511
+ continue;
512
+ }
513
+ throw new exceptions_js_1.APIError(`Cannot connect to ${this.baseUrl}: ${String(err)}`);
514
+ }
515
+ // Retry throttling / transient server errors, honoring Retry-After.
516
+ // For a create, only 429 is safe: the server refused it outright, so no
517
+ // job exists. A 5xx may well have created one before failing.
518
+ if (RETRY_STATUS_CODES.has(resp.status) &&
519
+ attempt <= this.maxRetries &&
520
+ (!creating || resp.status === 429)) {
521
+ const retryAfterMs = parseRetryAfterMs(resp.headers.get("Retry-After"));
522
+ await sleep(this.retryDelayMs(attempt, retryAfterMs));
523
+ continue;
524
+ }
525
+ await this.raiseForStatus(resp);
526
+ if (!resp.body || resp.status === 204)
527
+ return {};
528
+ const text = await resp.text();
529
+ if (!text)
530
+ return {};
531
+ try {
532
+ return JSON.parse(text);
533
+ }
534
+ catch {
535
+ return { raw: text };
536
+ }
537
+ }
538
+ }
539
+ async raiseForStatus(resp) {
540
+ if (resp.status === 200 || resp.status === 204)
541
+ return;
542
+ const body = await resp.text().then((t) => t.slice(0, 300)).catch(() => undefined);
543
+ const requestId = extractRequestId(resp.headers);
544
+ if (resp.status === 401)
545
+ throw new exceptions_js_1.AuthenticationError(undefined, { statusCode: 401, requestId, body });
546
+ if (resp.status === 404)
547
+ throw new exceptions_js_1.JobNotFoundError(undefined, { statusCode: 404, requestId, body });
548
+ if (resp.status === 429) {
549
+ throw new exceptions_js_1.RateLimitError(undefined, {
550
+ statusCode: 429,
551
+ requestId,
552
+ body,
553
+ retryAfter: (parseRetryAfterMs(resp.headers.get("Retry-After")) ?? 0) / 1000 || undefined,
554
+ });
555
+ }
556
+ throw new exceptions_js_1.APIError(`Unexpected response (HTTP ${resp.status})`, {
557
+ statusCode: resp.status,
558
+ requestId,
559
+ body,
560
+ });
561
+ }
562
+ async putUpload(uploadUrl, data, contentType, byteCb) {
563
+ let resp;
564
+ if (byteCb) {
565
+ // Presigned PUT with progress: stream the body in chunks and report after
566
+ // each. An explicit Content-Length keeps undici from switching to
567
+ // Transfer-Encoding: chunked (which S3 rejects); `duplex: "half"` is
568
+ // required by Node when the body is a stream / async iterable.
569
+ const init = {
570
+ method: "PUT",
571
+ headers: {
572
+ "Content-Type": contentType,
573
+ "Content-Length": String(data.byteLength),
574
+ },
575
+ body: (0, upload_js_1.iterWithProgress)(data, byteCb),
576
+ duplex: "half",
577
+ };
578
+ resp = await this.fetchFn(uploadUrl, init);
579
+ }
580
+ else {
581
+ resp = await this.fetchFn(uploadUrl, {
582
+ method: "PUT",
583
+ headers: { "Content-Type": contentType },
584
+ body: toArrayBuffer(data),
585
+ });
586
+ }
587
+ if (resp.status !== 200 && resp.status !== 204) {
588
+ const text = await resp.text().then((t) => t.slice(0, 200));
589
+ throw new exceptions_js_1.UploadError(`HTTP ${resp.status}: ${text}`);
590
+ }
591
+ }
592
+ async waitSSE(jobId, fallbackDownloadUrl, onProgress, timeout) {
593
+ const start = Date.now();
594
+ let lastEventId;
595
+ let reconnects = 0;
596
+ while (true) {
597
+ const elapsed = (Date.now() - start) / 1000;
598
+ if (elapsed >= timeout) {
599
+ throw new exceptions_js_1.TimeoutError(`Timed out after ${timeout}s waiting for job ${jobId}`);
600
+ }
601
+ if (reconnects > SSE_MAX_RECONNECTS)
602
+ return null;
603
+ if (reconnects > 0)
604
+ await sleep(SSE_RECONNECT_DELAY_MS);
605
+ const result = await this.sseAttempt(jobId, start, timeout, lastEventId, fallbackDownloadUrl, onProgress);
606
+ lastEventId = result.lastEventId;
607
+ if (result.outcome === "done")
608
+ return result.downloadUrl ?? fallbackDownloadUrl;
609
+ if (result.outcome === "failed")
610
+ throw new exceptions_js_1.JobFailedError("Job failed");
611
+ if (result.outcome === "timeout") {
612
+ throw new exceptions_js_1.TimeoutError(`Timed out after ${timeout}s waiting for job ${jobId}`);
613
+ }
614
+ reconnects += 1;
615
+ }
616
+ }
617
+ async sseAttempt(jobId, start, timeout, lastEventId, fallbackDownloadUrl, onProgress) {
618
+ const elapsed = (Date.now() - start) / 1000;
619
+ if (elapsed >= timeout)
620
+ return { outcome: "timeout", lastEventId };
621
+ const headers = {
622
+ "X-API-Key": this.apiKey,
623
+ "User-Agent": USER_AGENT,
624
+ Accept: "text/event-stream",
625
+ };
626
+ if (lastEventId !== undefined)
627
+ headers["Last-Event-ID"] = lastEventId;
628
+ let resp;
629
+ try {
630
+ const controller = new AbortController();
631
+ const remainingMs = Math.max(1000, (timeout - elapsed) * 1000);
632
+ const timer = setTimeout(() => controller.abort(), remainingMs);
633
+ try {
634
+ resp = await this.fetchFn(`${this.baseUrl}/api/v1/jobs/${jobId}/stream`, {
635
+ method: "GET",
636
+ headers,
637
+ signal: controller.signal,
638
+ });
639
+ }
640
+ finally {
641
+ clearTimeout(timer);
642
+ }
643
+ }
644
+ catch {
645
+ return { outcome: "reconnect", lastEventId };
646
+ }
647
+ if (resp.status === 401)
648
+ throw new exceptions_js_1.AuthenticationError();
649
+ if (resp.status === 429)
650
+ throw new exceptions_js_1.RateLimitError();
651
+ if (resp.status !== 200)
652
+ return { outcome: "reconnect", lastEventId };
653
+ if (!resp.body)
654
+ return { outcome: "reconnect", lastEventId };
655
+ const reader = resp.body.getReader();
656
+ try {
657
+ for await (const sse of (0, sse_js_1.parseSSEStream)(reader)) {
658
+ if (sse.id)
659
+ lastEventId = sse.id;
660
+ const nowElapsed = (Date.now() - start) / 1000;
661
+ if (nowElapsed >= timeout)
662
+ return { outcome: "timeout", lastEventId };
663
+ const eventType = sse.event ?? "message";
664
+ let data = {};
665
+ try {
666
+ data = JSON.parse(sse.data ?? "{}");
667
+ }
668
+ catch {
669
+ data = { raw: sse.data };
670
+ }
671
+ if (eventType === "progress") {
672
+ // Fire the callback inline so progress is live, not replayed in a
673
+ // burst after the stream closes.
674
+ onProgress?.((0, types_js_1.makeProgressEvent)({
675
+ completed: asInt(data.completed),
676
+ total: asInt(data.total),
677
+ step: typeof data.step === "string" ? data.step : undefined,
678
+ elapsedSeconds: nowElapsed,
679
+ raw: data,
680
+ }));
681
+ }
682
+ else if (eventType === "completed") {
683
+ const dl = typeof data.download_url === "string"
684
+ ? data.download_url
685
+ : fallbackDownloadUrl;
686
+ return { outcome: "done", downloadUrl: dl, lastEventId };
687
+ }
688
+ else if (eventType === "failed") {
689
+ const step = String(data.step ?? "unknown");
690
+ const reason = String(data.reason ?? "unknown");
691
+ throw new exceptions_js_1.JobFailedError(`Job failed at step=${step}: ${reason}`, {
692
+ step,
693
+ reason,
694
+ });
695
+ }
696
+ }
697
+ return { outcome: "reconnect", lastEventId };
698
+ }
699
+ catch (err) {
700
+ if (err instanceof exceptions_js_1.JobFailedError || err instanceof exceptions_js_1.AuthenticationError)
701
+ throw err;
702
+ return { outcome: "reconnect", lastEventId };
703
+ }
704
+ finally {
705
+ reader.releaseLock();
706
+ }
707
+ }
708
+ async waitPoll(jobId, downloadUrl, timeout) {
709
+ const start = Date.now();
710
+ const maxAttempts = Math.max(1, Math.floor(timeout / (POLL_INTERVAL_MS / 1000)));
711
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
712
+ if ((Date.now() - start) / 1000 >= timeout)
713
+ break;
714
+ try {
715
+ const failed = await this.checkFailed([jobId]);
716
+ if (failed[0])
717
+ throw new exceptions_js_1.JobFailedError(`Job ${jobId} has failed`);
718
+ }
719
+ catch (err) {
720
+ if (err instanceof exceptions_js_1.AuthenticationError || err instanceof exceptions_js_1.JobFailedError)
721
+ throw err;
722
+ }
723
+ try {
724
+ const resp = await this.fetchFn(downloadUrl);
725
+ if (resp.ok)
726
+ return new Uint8Array(await resp.arrayBuffer());
727
+ }
728
+ catch {
729
+ // ignore probe errors
730
+ }
731
+ if (attempt < maxAttempts)
732
+ await sleep(POLL_INTERVAL_MS);
733
+ }
734
+ throw new exceptions_js_1.TimeoutError(`Job ${jobId} did not complete within ${timeout}s`);
735
+ }
736
+ }
737
+ exports.STTClient = STTClient;
738
+ // helpers
739
+ function sleep(ms) {
740
+ return new Promise((resolve) => setTimeout(resolve, ms));
741
+ }
742
+ /** True for an aborted request, which must propagate rather than be retried. */
743
+ function isAbort(err) {
744
+ return (err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError"));
745
+ }
746
+ function toArrayBuffer(data) {
747
+ const copy = new Uint8Array(data.byteLength);
748
+ copy.set(data);
749
+ return copy.buffer;
750
+ }
751
+ function asInt(value) {
752
+ if (value === undefined || value === null)
753
+ return undefined;
754
+ const n = Number(value);
755
+ return Number.isFinite(n) ? n : undefined;
756
+ }
757
+ function isUrl(value) {
758
+ return /^https?:\/\//i.test(value);
759
+ }
760
+ async function readAudio(audio) {
761
+ let data;
762
+ if (typeof audio === "string") {
763
+ const { readFile } = await Promise.resolve().then(() => __importStar(require("node:fs/promises")));
764
+ data = new Uint8Array(await readFile(audio));
765
+ }
766
+ else if (audio instanceof ArrayBuffer) {
767
+ data = new Uint8Array(audio);
768
+ }
769
+ else if (audio instanceof Uint8Array) {
770
+ data = audio;
771
+ }
772
+ else if (typeof Blob !== "undefined" && audio instanceof Blob) {
773
+ data = new Uint8Array(await audio.arrayBuffer());
774
+ }
775
+ else {
776
+ throw new TypeError(`Unsupported audio type: ${typeof audio}`);
777
+ }
778
+ if (data.byteLength === 0)
779
+ throw new Error("Audio is empty");
780
+ return { data, fileSize: data.byteLength };
781
+ }
782
+ /** @deprecated Use STTClient — alias for Deepgram / ElevenLabs naming. */
783
+ class SpeechRevolutions extends STTClient {
784
+ }
785
+ exports.SpeechRevolutions = SpeechRevolutions;
786
+ exports.SpeechRevolutionsClient = SpeechRevolutions;