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