@transcribe-api/sdk 0.1.0 → 0.1.2

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.
Files changed (4) hide show
  1. package/README.md +71 -9
  2. package/index.js +1806 -6
  3. package/package.json +21 -16
  4. package/worker.js +1358 -0
package/index.js CHANGED
@@ -1,16 +1,1816 @@
1
+ const DEFAULT_BASE_URL = "https://api.transcribeapi.com/v1";
2
+ const MAX_BATCH_FILES = 10000;
3
+ const MAX_BATCH_TOTAL_SIZE_BYTES = 10 * 1024 * 1024 * 1024;
4
+ const MAX_SYNC_AUDIO_BYTES = 30 * 1024 * 1024;
5
+ const MAX_SYNC_AUDIO_SECONDS = 10 * 60;
6
+ const MULTIPART_UPLOAD_THRESHOLD_BYTES = 100 * 1024 * 1024;
7
+ const DEFAULT_MULTIPART_CONCURRENCY = 8;
8
+ const MAX_MULTIPART_CONCURRENCY = 32;
9
+ const MAX_MULTIPART_ADAPTIVE_ATTEMPTS = 12;
10
+ const MULTIPART_IDLE_WAIT_MS = 50;
11
+ const MULTIPART_RESUME_STATE_VERSION = 1;
12
+ const MIN_POLLING_INTERVAL_SECONDS = 10;
13
+ const DEFAULT_POLLING_SPINNER_INTERVAL_MS = 150;
14
+ const TERMINAL_JOB_STATUSES = new Set(["completed", "failed", "insufficient_funds"]);
15
+ const BATCH_MP4_UNSUPPORTED_MESSAGE = "Batch uploads do not support .mp4 for MVP. Supported batch audio formats: mp3, mpeg, mpga, m4a, wav, webm.";
16
+ const BATCH_UNSUPPORTED_MESSAGE = "Unsupported batch audio format. Supported batch audio formats: mp3, mpeg, mpga, m4a, wav, webm.";
17
+
18
+ export class TranscribeAPIError extends Error {
19
+ constructor(error, { status = null, code = null, extra = null, response = null } = {}) {
20
+ super(error);
21
+ this.name = "TranscribeAPIError";
22
+ this.status = status;
23
+ this.code = code;
24
+ this.error = error;
25
+ this.response = response;
26
+ if (extra && typeof extra === "object") {
27
+ for (const [key, value] of Object.entries(extra)) {
28
+ if (key === "status" || key === "stack" || key === "name") {
29
+ continue;
30
+ }
31
+ this[key] = value;
32
+ }
33
+ }
34
+ }
35
+ }
36
+
37
+ function sleep(ms) {
38
+ return new Promise((resolve) => setTimeout(resolve, ms));
39
+ }
40
+
41
+ function extractErrorInfo(error) {
42
+ return {
43
+ name: error?.name || null,
44
+ message: error?.message || String(error),
45
+ code: error?.code || error?.cause?.code || null,
46
+ status: error?.status || null,
47
+ cause_message: error?.cause?.message || null,
48
+ };
49
+ }
50
+
51
+ function isRetryableError(error) {
52
+ const code = String(error?.code || error?.cause?.code || "");
53
+ if (
54
+ code === "ECONNRESET"
55
+ || code === "ETIMEDOUT"
56
+ || code === "ECONNREFUSED"
57
+ || code === "EPIPE"
58
+ || code === "UND_ERR_SOCKET"
59
+ || code === "UND_ERR_CONNECT_TIMEOUT"
60
+ || code === "ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC"
61
+ ) {
62
+ return true;
63
+ }
64
+ if (String(error?.message || "").includes("fetch failed")) {
65
+ return true;
66
+ }
67
+ return !error?.status || error.status === 429 || error.status >= 500;
68
+ }
69
+
70
+ async function retry(operation, { attempts = 3, baseDelayMs = 250 } = {}) {
71
+ let lastError;
72
+ for (let index = 0; index < attempts; index += 1) {
73
+ try {
74
+ return await operation(index);
75
+ } catch (error) {
76
+ lastError = error;
77
+ const retryable = isRetryableError(error);
78
+ if (!retryable || index === attempts - 1) {
79
+ throw error;
80
+ }
81
+ await sleep(baseDelayMs * (2 ** index));
82
+ }
83
+ }
84
+ throw lastError;
85
+ }
86
+
87
+ function normalizeMultipartConcurrency(value) {
88
+ if (value === undefined || value === null || value === "") {
89
+ return DEFAULT_MULTIPART_CONCURRENCY;
90
+ }
91
+ const parsed = Number.parseInt(String(value), 10);
92
+ if (!Number.isInteger(parsed) || parsed < 1) {
93
+ throw new TranscribeAPIError("`multipartConcurrency` must be an integer >= 1.", {
94
+ code: "invalid_multipart_concurrency",
95
+ });
96
+ }
97
+ return Math.min(parsed, MAX_MULTIPART_CONCURRENCY);
98
+ }
99
+
100
+ function normalizePollingConfig(polling) {
101
+ if (polling === undefined || polling === null || polling === false) {
102
+ return null;
103
+ }
104
+ if (typeof polling !== "object" || Array.isArray(polling)) {
105
+ throw new TranscribeAPIError("`polling` must be an object with `interval` in seconds.", {
106
+ code: "invalid_polling_config",
107
+ });
108
+ }
109
+
110
+ const interval = Number.parseInt(String(polling.interval ?? ""), 10);
111
+ if (!Number.isInteger(interval) || interval < MIN_POLLING_INTERVAL_SECONDS) {
112
+ throw new TranscribeAPIError(`\`polling.interval\` must be an integer >= ${MIN_POLLING_INTERVAL_SECONDS} seconds.`, {
113
+ code: "invalid_polling_interval",
114
+ });
115
+ }
116
+
117
+ if (polling.timeout === undefined || polling.timeout === null || polling.timeout === "") {
118
+ return {
119
+ interval,
120
+ timeout: null,
121
+ };
122
+ }
123
+
124
+ const timeout = Number.parseInt(String(polling.timeout), 10);
125
+ if (!Number.isInteger(timeout) || timeout < interval) {
126
+ throw new TranscribeAPIError("`polling.timeout` must be an integer >= `polling.interval` in seconds.", {
127
+ code: "invalid_polling_timeout",
128
+ });
129
+ }
130
+
131
+ return {
132
+ interval,
133
+ timeout,
134
+ };
135
+ }
136
+
137
+ function isNodePathFile(file) {
138
+ return Boolean(file?.path && typeof file.path === "string");
139
+ }
140
+
141
+ function multipartResumeStatePath(filePath) {
142
+ return `${filePath}.transcribe-upload.json`;
143
+ }
144
+
145
+ function normalizeCompletedParts(parts) {
146
+ if (!Array.isArray(parts)) {
147
+ return [];
148
+ }
149
+ const deduped = new Map();
150
+ for (const part of parts) {
151
+ const partNumber = Number(part?.part_number ?? part?.partNumber);
152
+ const etag = String(part?.etag || "").trim();
153
+ if (!Number.isInteger(partNumber) || partNumber < 1 || !etag) {
154
+ continue;
155
+ }
156
+ deduped.set(partNumber, {
157
+ part_number: partNumber,
158
+ etag,
159
+ });
160
+ }
161
+ return Array.from(deduped.values()).sort((a, b) => a.part_number - b.part_number);
162
+ }
163
+
164
+ function serializeUploadForResume(upload) {
165
+ if (!upload || typeof upload !== "object") {
166
+ return null;
167
+ }
168
+ if (upload.type === "multipart") {
169
+ return {
170
+ type: "multipart",
171
+ key: upload.key || null,
172
+ upload_id: upload.upload_id || null,
173
+ part_size: Number(upload.part_size || 0),
174
+ };
175
+ }
176
+ if (upload.type === "put") {
177
+ return {
178
+ type: "put",
179
+ key: upload.key || null,
180
+ headers: upload.headers || {},
181
+ };
182
+ }
183
+ return null;
184
+ }
185
+
186
+ async function readMultipartResumeState(file, { baseUrl } = {}) {
187
+ if (!isNodePathFile(file)) {
188
+ return null;
189
+ }
190
+ try {
191
+ const fs = await import("node:fs/promises");
192
+ const raw = await fs.readFile(multipartResumeStatePath(file.path), "utf8");
193
+ const parsed = JSON.parse(raw);
194
+ if (
195
+ Number(parsed?.version) !== MULTIPART_RESUME_STATE_VERSION
196
+ || String(parsed?.file_path || "") !== String(file.path)
197
+ || Number(parsed?.file_size || 0) !== Number(file.size || 0)
198
+ || String(parsed?.file_name || "") !== String(file.name || "")
199
+ ) {
200
+ return null;
201
+ }
202
+ if (baseUrl && String(parsed?.api_base_url || "") !== String(baseUrl)) {
203
+ return null;
204
+ }
205
+ const upload = serializeUploadForResume(parsed.upload);
206
+ if (!upload) {
207
+ return null;
208
+ }
209
+ return {
210
+ version: MULTIPART_RESUME_STATE_VERSION,
211
+ api_base_url: String(parsed.api_base_url || ""),
212
+ job_id: String(parsed.job_id || ""),
213
+ model: parsed.model || null,
214
+ file_path: String(parsed.file_path || ""),
215
+ file_name: String(parsed.file_name || ""),
216
+ file_size: Number(parsed.file_size || 0),
217
+ upload,
218
+ completed_parts: normalizeCompletedParts(parsed.completed_parts),
219
+ };
220
+ } catch {
221
+ return null;
222
+ }
223
+ }
224
+
225
+ async function writeMultipartResumeState(file, state) {
226
+ if (!isNodePathFile(file) || !state) {
227
+ return;
228
+ }
229
+ const fs = await import("node:fs/promises");
230
+ await fs.writeFile(
231
+ multipartResumeStatePath(file.path),
232
+ JSON.stringify(state, null, 2),
233
+ "utf8",
234
+ );
235
+ }
236
+
237
+ async function deleteMultipartResumeState(file) {
238
+ if (!isNodePathFile(file)) {
239
+ return;
240
+ }
241
+ try {
242
+ const fs = await import("node:fs/promises");
243
+ await fs.unlink(multipartResumeStatePath(file.path));
244
+ } catch {
245
+ // Ignore missing or inaccessible state files.
246
+ }
247
+ }
248
+
249
+ function buildMultipartResumeState({ baseUrl, jobId, model, file, upload, completedParts }) {
250
+ return {
251
+ version: MULTIPART_RESUME_STATE_VERSION,
252
+ api_base_url: baseUrl,
253
+ job_id: jobId,
254
+ model: model || null,
255
+ file_path: file.path,
256
+ file_name: file.name,
257
+ file_size: file.size,
258
+ upload: serializeUploadForResume(upload),
259
+ completed_parts: normalizeCompletedParts(completedParts),
260
+ };
261
+ }
262
+
263
+ function contentTypeFromName(name = "") {
264
+ const lower = String(name).toLowerCase();
265
+ if (lower.endsWith(".mp3")) return "audio/mpeg";
266
+ if (lower.endsWith(".mpeg")) return "audio/mpeg";
267
+ if (lower.endsWith(".mpga")) return "audio/mpeg";
268
+ if (lower.endsWith(".wav")) return "audio/wav";
269
+ if (lower.endsWith(".m4a")) return "audio/mp4";
270
+ if (lower.endsWith(".webm")) return "audio/webm";
271
+ return "application/octet-stream";
272
+ }
273
+
274
+ function isRemoteFileInput(input) {
275
+ return Boolean(input && typeof input === "object" && typeof input.url === "string");
276
+ }
277
+
278
+ function isRemoteBatchItem(input) {
279
+ return Boolean(input && typeof input === "object" && typeof input.url === "string");
280
+ }
281
+
282
+ function defaultReferenceId(index) {
283
+ return `file_${String(index + 1).padStart(6, "0")}`;
284
+ }
285
+
286
+ function normalizeBatchInputItem(item, index) {
287
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
288
+ throw new TranscribeAPIError("Each batch item must be an object with `reference_id` and either `file` or `url`.", {
289
+ code: "invalid_batch_item",
290
+ });
291
+ }
292
+
293
+ const referenceId = String(item.reference_id || "").trim();
294
+ if (!referenceId) {
295
+ throw new TranscribeAPIError(`files[${index}].reference_id is required.`, {
296
+ code: "missing_reference_id",
297
+ });
298
+ }
299
+
300
+ const hasFile = Object.prototype.hasOwnProperty.call(item, "file");
301
+ const hasUrl = typeof item.url === "string" && item.url.trim();
302
+ if (hasFile && hasUrl) {
303
+ throw new TranscribeAPIError(`files[${index}] must include either \`file\` or \`url\`, not both.`, {
304
+ code: "invalid_batch_item",
305
+ });
306
+ }
307
+ if (!hasFile && !hasUrl) {
308
+ throw new TranscribeAPIError(`files[${index}] must include either \`file\` or \`url\`.`, {
309
+ code: "invalid_batch_item",
310
+ });
311
+ }
312
+
313
+ return {
314
+ referenceId,
315
+ file: hasFile ? item.file : null,
316
+ url: hasUrl ? item.url.trim() : null,
317
+ durationEstimateSec: item.durationEstimateSec || item.duration_estimate_sec || null,
318
+ };
319
+ }
320
+
321
+ function uploadDescriptorForFile(referenceId, file) {
322
+ const descriptor = { reference_id: referenceId };
323
+ if (Number(file?.size || 0) >= MULTIPART_UPLOAD_THRESHOLD_BYTES) {
324
+ descriptor.size_bytes = Number(file.size || 0);
325
+ }
326
+ return descriptor;
327
+ }
328
+
329
+ function normalizeResponseUploads(response) {
330
+ if (Array.isArray(response?.uploads)) {
331
+ return response.uploads;
332
+ }
333
+ if (response?.upload) {
334
+ return [{
335
+ reference_id: response.reference_id || defaultReferenceId(0),
336
+ upload: response.upload,
337
+ }];
338
+ }
339
+ return [];
340
+ }
341
+
342
+ function uploadFromResponse(response, referenceId) {
343
+ if (response?.upload) {
344
+ return response.upload;
345
+ }
346
+ const entry = normalizeResponseUploads(response)
347
+ .find((candidate) => candidate.reference_id === referenceId);
348
+ return entry?.upload || null;
349
+ }
350
+
351
+ function emitProgress(handler, event) {
352
+ if (typeof handler !== "function") {
353
+ return;
354
+ }
355
+ handler(event);
356
+ }
357
+
358
+ function renderProgressBar(loaded, total, width = 30) {
359
+ if (!total) {
360
+ return `[${".".repeat(width)}]`;
361
+ }
362
+ const ratio = Math.max(0, Math.min(1, Number(loaded || 0) / Number(total || 1)));
363
+ const filled = Math.round(ratio * width);
364
+ return `[${"|".repeat(filled)}${".".repeat(Math.max(0, width - filled))}]`;
365
+ }
366
+
367
+ function formatBytes(bytes) {
368
+ const value = Number(bytes || 0);
369
+ if (value < 1024) return `${value} B`;
370
+ if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KB`;
371
+ if (value < 1024 ** 3) return `${(value / 1024 ** 2).toFixed(1)} MB`;
372
+ return `${(value / 1024 ** 3).toFixed(2)} GB`;
373
+ }
374
+
375
+ function createSdkLoggerProgressHandler(logger = console) {
376
+ let activeProgressLine = false;
377
+
378
+ const writeLine = (line) => {
379
+ if (activeProgressLine && typeof process !== "undefined" && process?.stdout?.write) {
380
+ process.stdout.write("\n");
381
+ activeProgressLine = false;
382
+ }
383
+ if (typeof logger?.log === "function") {
384
+ logger.log(line);
385
+ }
386
+ };
387
+
388
+ const writeProgress = (line) => {
389
+ if (typeof process !== "undefined" && process?.stdout?.write) {
390
+ process.stdout.write(`\r${line}`);
391
+ activeProgressLine = true;
392
+ return;
393
+ }
394
+ if (typeof logger?.log === "function") {
395
+ logger.log(line);
396
+ }
397
+ };
398
+
399
+ return (event) => {
400
+ if (!event || typeof event !== "object") {
401
+ return;
402
+ }
403
+ if (event.event === "upload_started") {
404
+ writeLine(`Uploading ${event.uploadFiles}/${event.totalFiles} local file(s) for ${event.jobId}, ${formatBytes(event.totalBytes)} total`);
405
+ return;
406
+ }
407
+ if (event.event === "upload_progress") {
408
+ const loaded = Number(event.batchLoaded ?? event.loaded ?? 0);
409
+ const total = Number(event.batchTotal ?? event.total ?? 0);
410
+ const percent = total ? ((loaded / total) * 100).toFixed(1) : "0.0";
411
+ writeProgress(`Uploading ${renderProgressBar(loaded, total)} ${percent}% (${formatBytes(loaded)} / ${formatBytes(total)})`);
412
+ if (total && loaded >= total && activeProgressLine && typeof process !== "undefined" && process?.stdout?.write) {
413
+ process.stdout.write("\n");
414
+ activeProgressLine = false;
415
+ }
416
+ return;
417
+ }
418
+ if (event.event === "upload_completed") {
419
+ if (event.suppressLog) {
420
+ return;
421
+ }
422
+ writeLine(`Uploaded completed: ${JSON.stringify(event.response, null, 2)}`);
423
+ }
424
+ };
425
+ }
426
+
427
+ function createSdkPollingLogger(logger = console) {
428
+ let spinnerTimer = null;
429
+ let activeLine = false;
430
+ let currentFrameIndex = 0;
431
+ let currentStatus = "waiting";
432
+ let currentJobId = "unknown_job";
433
+ let lastRenderedLength = 0;
434
+ const frames = ["|", "/", "-", "\\"];
435
+
436
+ const stopSpinnerLine = () => {
437
+ if (spinnerTimer) {
438
+ clearInterval(spinnerTimer);
439
+ spinnerTimer = null;
440
+ }
441
+ if (activeLine && typeof process !== "undefined" && process?.stdout?.write) {
442
+ process.stdout.write("\n");
443
+ activeLine = false;
444
+ }
445
+ };
446
+
447
+ const renderLine = () => {
448
+ const frame = frames[currentFrameIndex % frames.length];
449
+ currentFrameIndex += 1;
450
+ const rawLine = `${frame} Polling ${currentJobId} (${currentStatus})`;
451
+ const paddedLine = rawLine.padEnd(lastRenderedLength, " ");
452
+ lastRenderedLength = paddedLine.length;
453
+ if (typeof process !== "undefined" && process?.stdout?.write) {
454
+ process.stdout.write(`\r${paddedLine}`);
455
+ activeLine = true;
456
+ return;
457
+ }
458
+ if (typeof logger?.log === "function") {
459
+ logger.log(rawLine);
460
+ }
461
+ };
462
+
463
+ const writeLine = (line) => {
464
+ stopSpinnerLine();
465
+ if (typeof logger?.log === "function") {
466
+ logger.log(line);
467
+ }
468
+ };
469
+
470
+ return {
471
+ start({ jobId, jobStatus }) {
472
+ currentJobId = jobId || currentJobId;
473
+ currentStatus = jobStatus || currentStatus;
474
+ currentFrameIndex = 0;
475
+ if (typeof process !== "undefined" && process?.stdout?.write) {
476
+ renderLine();
477
+ spinnerTimer = setInterval(renderLine, DEFAULT_POLLING_SPINNER_INTERVAL_MS);
478
+ return;
479
+ }
480
+ writeLine(`Polling ${currentJobId} (${currentStatus})`);
481
+ },
482
+ update({ jobStatus }) {
483
+ currentStatus = jobStatus || currentStatus;
484
+ if (!spinnerTimer && !(typeof process !== "undefined" && process?.stdout?.write)) {
485
+ writeLine(`Polling ${currentJobId} (${currentStatus})`);
486
+ }
487
+ },
488
+ finish({ jobStatus, resultUrl }) {
489
+ currentStatus = jobStatus || currentStatus;
490
+ writeLine(resultUrl
491
+ ? `Polling complete: ${currentStatus} - ${resultUrl}`
492
+ : `Polling complete: ${currentStatus}`);
493
+ },
494
+ timeout({ timeoutSeconds, jobStatus }) {
495
+ currentStatus = jobStatus || currentStatus;
496
+ writeLine(`Polling stopped: timeout after ${timeoutSeconds}s (${currentStatus})`);
497
+ },
498
+ };
499
+ }
500
+
501
+ function composeProgressHandler({ onProgress, showLogs = false, logger = console } = {}) {
502
+ const logHandler = showLogs ? createSdkLoggerProgressHandler(logger) : null;
503
+ if (!logHandler) {
504
+ return typeof onProgress === "function" ? onProgress : null;
505
+ }
506
+ return (event) => {
507
+ emitProgress(logHandler, event);
508
+ emitProgress(onProgress, event);
509
+ };
510
+ }
511
+
512
+ function assertSupportedBatchFormat(file) {
513
+ const lowerName = String(file?.name || "").toLowerCase();
514
+ const lowerType = String(file?.type || contentTypeFromName(file?.name)).toLowerCase();
515
+
516
+ if (lowerName.endsWith(".mp4") || lowerType === "video/mp4") {
517
+ throw new TranscribeAPIError(BATCH_MP4_UNSUPPORTED_MESSAGE, { code: "unsupported_batch_format" });
518
+ }
519
+
520
+ if (
521
+ lowerName.endsWith(".mp3")
522
+ || lowerName.endsWith(".mpeg")
523
+ || lowerName.endsWith(".mpga")
524
+ || lowerName.endsWith(".m4a")
525
+ || lowerName.endsWith(".wav")
526
+ || lowerName.endsWith(".webm")
527
+ || lowerType.includes("audio/mpeg")
528
+ || lowerType.includes("mpga")
529
+ || lowerType.includes("audio/mp4")
530
+ || lowerType.includes("audio/x-m4a")
531
+ || lowerType.includes("audio/wav")
532
+ || lowerType.includes("audio/wave")
533
+ || lowerType.includes("audio/webm")
534
+ || lowerType.includes("video/webm")
535
+ ) {
536
+ return;
537
+ }
538
+
539
+ throw new TranscribeAPIError(BATCH_UNSUPPORTED_MESSAGE, { code: "unsupported_batch_format" });
540
+ }
541
+
542
+ function estimateDurationFromSize(sizeBytes) {
543
+ return Math.max(1, Math.ceil(Number(sizeBytes || 0) / 16000));
544
+ }
545
+
546
+ function ascii(bytes, offset, length) {
547
+ return Array.from(bytes.slice(offset, offset + length), (byte) => String.fromCharCode(byte)).join("");
548
+ }
549
+
550
+ function readUint32(bytes, offset, littleEndian = false) {
551
+ return new DataView(bytes.buffer, bytes.byteOffset + offset, 4).getUint32(0, littleEndian);
552
+ }
553
+
554
+ function readUint64(bytes, offset) {
555
+ const high = readUint32(bytes, offset);
556
+ const low = readUint32(bytes, offset + 4);
557
+ return high * 2 ** 32 + low;
558
+ }
559
+
560
+ function syncSafeInteger(bytes, offset) {
561
+ return ((bytes[offset] & 0x7f) << 21)
562
+ | ((bytes[offset + 1] & 0x7f) << 14)
563
+ | ((bytes[offset + 2] & 0x7f) << 7)
564
+ | (bytes[offset + 3] & 0x7f);
565
+ }
566
+
567
+ function id3Offset(bytes) {
568
+ if (bytes.length >= 10 && ascii(bytes, 0, 3) === "ID3") {
569
+ return 10 + syncSafeInteger(bytes, 6);
570
+ }
571
+ return 0;
572
+ }
573
+
574
+ const MP3_BITRATES = {
575
+ V1L3: [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320],
576
+ V2L3: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160],
577
+ };
578
+
579
+ const MP3_SAMPLE_RATES = {
580
+ 3: [44100, 48000, 32000],
581
+ 2: [22050, 24000, 16000],
582
+ 0: [11025, 12000, 8000],
583
+ };
584
+
585
+ function mp3FrameInfoAt(bytes, offset) {
586
+ if (offset + 4 > bytes.length || bytes[offset] !== 0xff || (bytes[offset + 1] & 0xe0) !== 0xe0) {
587
+ return null;
588
+ }
589
+
590
+ const versionBits = (bytes[offset + 1] >> 3) & 0x03;
591
+ const layerBits = (bytes[offset + 1] >> 1) & 0x03;
592
+ const bitrateIndex = (bytes[offset + 2] >> 4) & 0x0f;
593
+ const sampleRateIndex = (bytes[offset + 2] >> 2) & 0x03;
594
+ const padding = (bytes[offset + 2] >> 1) & 0x01;
595
+ if (versionBits === 1 || layerBits !== 1 || bitrateIndex === 0 || bitrateIndex === 15 || sampleRateIndex === 3) {
596
+ return null;
597
+ }
598
+
599
+ const sampleRate = MP3_SAMPLE_RATES[versionBits]?.[sampleRateIndex];
600
+ const bitrateKbps = versionBits === 3
601
+ ? MP3_BITRATES.V1L3[bitrateIndex]
602
+ : MP3_BITRATES.V2L3[bitrateIndex];
603
+ if (!sampleRate || !bitrateKbps) {
604
+ return null;
605
+ }
606
+
607
+ const bitrate = bitrateKbps * 1000;
608
+ const frameLength = Math.floor(((versionBits === 3 ? 144 : 72) * bitrate) / sampleRate + padding);
609
+ if (frameLength <= 0) {
610
+ return null;
611
+ }
612
+
613
+ return {
614
+ bitrate,
615
+ sampleRate,
616
+ frameLength,
617
+ };
618
+ }
619
+
620
+ function findMp3Frame(bytes, startOffset = 0) {
621
+ for (let offset = Math.max(0, startOffset); offset < bytes.length - 4; offset += 1) {
622
+ const info = mp3FrameInfoAt(bytes, offset);
623
+ if (info) {
624
+ return { offset, ...info };
625
+ }
626
+ }
627
+ return null;
628
+ }
629
+
630
+ function parseMp3Duration(bytes, objectSize) {
631
+ const offset = id3Offset(bytes);
632
+ const firstFrame = findMp3Frame(bytes, offset);
633
+ if (!firstFrame) {
634
+ return null;
635
+ }
636
+ const audioBytes = Math.max(0, Number(objectSize || bytes.length) - firstFrame.offset);
637
+ const duration = (audioBytes * 8) / firstFrame.bitrate;
638
+ return Number.isFinite(duration) && duration > 0 ? duration : null;
639
+ }
640
+
641
+ function parseWavDuration(bytes) {
642
+ if (bytes.length < 44 || ascii(bytes, 0, 4) !== "RIFF" || ascii(bytes, 8, 4) !== "WAVE") {
643
+ return null;
644
+ }
645
+
646
+ let offset = 12;
647
+ let byteRate = 0;
648
+ let dataSize = 0;
649
+ while (offset + 8 <= bytes.length) {
650
+ const chunkId = ascii(bytes, offset, 4);
651
+ const chunkSize = readUint32(bytes, offset + 4, true);
652
+ if (chunkId === "fmt " && offset + 20 <= bytes.length) {
653
+ byteRate = readUint32(bytes, offset + 16, true);
654
+ } else if (chunkId === "data") {
655
+ dataSize = chunkSize;
656
+ break;
657
+ }
658
+ offset += 8 + chunkSize + (chunkSize % 2);
659
+ }
660
+
661
+ if (!byteRate || !dataSize) {
662
+ return null;
663
+ }
664
+ return dataSize / byteRate;
665
+ }
666
+
667
+ function parseMp4Duration(bytes) {
668
+ for (let offset = 0; offset + 32 < bytes.length; offset += 1) {
669
+ if (ascii(bytes, offset + 4, 4) !== "mvhd") {
670
+ continue;
671
+ }
672
+ const size = readUint32(bytes, offset);
673
+ if (size < 32 || offset + size > bytes.length + 8) {
674
+ continue;
675
+ }
676
+ const version = bytes[offset + 8];
677
+ if (version === 0 && offset + 28 <= bytes.length) {
678
+ const timescale = readUint32(bytes, offset + 20);
679
+ const duration = readUint32(bytes, offset + 24);
680
+ return timescale > 0 ? duration / timescale : null;
681
+ }
682
+ if (version === 1 && offset + 40 <= bytes.length) {
683
+ const timescale = readUint32(bytes, offset + 28);
684
+ const duration = readUint64(bytes, offset + 32);
685
+ return timescale > 0 ? duration / timescale : null;
686
+ }
687
+ }
688
+ return null;
689
+ }
690
+
691
+ function typeFromNameOrContentType(name = "", contentType = "") {
692
+ const lowerName = String(name || "").toLowerCase();
693
+ const lowerType = String(contentType || "").toLowerCase();
694
+ if (lowerType.includes("wav") || lowerName.endsWith(".wav")) {
695
+ return "wav";
696
+ }
697
+ if (
698
+ lowerType.includes("mp4")
699
+ || lowerType.includes("m4a")
700
+ || lowerType.includes("audio/x-m4a")
701
+ || lowerName.endsWith(".m4a")
702
+ ) {
703
+ return "m4a";
704
+ }
705
+ return "mp3";
706
+ }
707
+
708
+ async function estimateDurationFromFile(file) {
709
+ const headerLength = Math.min(Number(file?.size || 0), 2 * 1024 * 1024);
710
+ if (!headerLength) {
711
+ return estimateDurationFromSize(file?.size);
712
+ }
713
+
714
+ try {
715
+ let bytes;
716
+ if (typeof file?.readSlice === "function") {
717
+ bytes = new Uint8Array(await file.readSlice(0, headerLength));
718
+ } else if (typeof file?.slice === "function") {
719
+ bytes = new Uint8Array(await file.slice(0, headerLength).arrayBuffer());
720
+ } else {
721
+ return estimateDurationFromSize(file?.size);
722
+ }
723
+ const type = typeFromNameOrContentType(file?.name, file?.type || contentTypeFromName(file?.name));
724
+ const duration = type === "wav"
725
+ ? parseWavDuration(bytes)
726
+ : type === "m4a"
727
+ ? parseMp4Duration(bytes)
728
+ : parseMp3Duration(bytes, file.size);
729
+ if (Number.isFinite(duration) && duration > 0) {
730
+ return Math.max(1, Math.ceil(duration));
731
+ }
732
+ } catch {
733
+ // Fall back to the coarse size heuristic when metadata parsing fails.
734
+ }
735
+
736
+ return estimateDurationFromSize(file?.size);
737
+ }
738
+
739
+ async function fileFromPath(pathValue) {
740
+ const fs = await import("node:fs/promises");
741
+ const path = await import("node:path");
742
+ const stats = await fs.stat(pathValue);
743
+ const name = path.basename(pathValue);
744
+ return {
745
+ name,
746
+ size: stats.size,
747
+ type: contentTypeFromName(name),
748
+ path: pathValue,
749
+ async readSlice(start, end) {
750
+ const handle = await fs.open(pathValue, "r");
751
+ try {
752
+ const length = Math.max(0, end - start);
753
+ const buffer = Buffer.alloc(length);
754
+ const { bytesRead } = await handle.read(buffer, 0, length, start);
755
+ return buffer.subarray(0, bytesRead);
756
+ } finally {
757
+ await handle.close();
758
+ }
759
+ },
760
+ };
761
+ }
762
+
763
+ function makeFile(parts, name, type) {
764
+ if (typeof File !== "undefined") {
765
+ return new File(parts, name, { type });
766
+ }
767
+ const blob = new Blob(parts, { type });
768
+ Object.defineProperty(blob, "name", { value: name });
769
+ return blob;
770
+ }
771
+
772
+ async function normalizeFile(input, fallbackName = "audio.mp3") {
773
+ if (
774
+ input
775
+ && typeof input === "object"
776
+ && typeof input.name === "string"
777
+ && Number.isFinite(Number(input.size))
778
+ && typeof input.type === "string"
779
+ && (typeof input.readSlice === "function" || typeof input.slice === "function")
780
+ ) {
781
+ return input;
782
+ }
783
+ if (typeof input === "string") {
784
+ return fileFromPath(input);
785
+ }
786
+ if (typeof File !== "undefined" && input instanceof File) {
787
+ return input;
788
+ }
789
+ if (input instanceof Blob) {
790
+ return input.name ? input : makeFile([input], fallbackName, input.type || contentTypeFromName(fallbackName));
791
+ }
792
+ if (input?.data instanceof Uint8Array || input?.data instanceof ArrayBuffer) {
793
+ return makeFile([input.data], input.name || fallbackName, input.type || contentTypeFromName(input.name || fallbackName));
794
+ }
795
+ throw new TranscribeAPIError("Invalid file. Provide a path, File, Blob, or { data, name, type }.");
796
+ }
797
+
798
+ async function parseApiResponse(response) {
799
+ const text = await response.text();
800
+ let data = {};
801
+ try {
802
+ data = text ? JSON.parse(text) : {};
803
+ } catch {
804
+ data = { raw: text };
805
+ }
806
+ if (!response.ok) {
807
+ throw new TranscribeAPIError(data.error || text || `HTTP ${response.status}`, {
808
+ status: response.status,
809
+ code: data.code || null,
810
+ extra: data,
811
+ response: data,
812
+ });
813
+ }
814
+ return data;
815
+ }
816
+
817
+ function normalizeHeaders(headers = {}) {
818
+ return Object.fromEntries(
819
+ Object.entries(headers)
820
+ .filter(([, value]) => value !== undefined && value !== null)
821
+ .map(([key, value]) => [key, String(value)]),
822
+ );
823
+ }
824
+
825
+ function isNodeStreamBody(body) {
826
+ return Boolean(body && typeof body === "object" && typeof body.pipe === "function");
827
+ }
828
+
829
+ async function putNodeStream(upload, streamBody, headers) {
830
+ const http = await import("node:http");
831
+ const https = await import("node:https");
832
+ const { pipeline } = await import("node:stream/promises");
833
+ const target = new URL(upload.url || upload);
834
+ const transport = target.protocol === "https:" ? https : http;
835
+
836
+ return new Promise((resolve, reject) => {
837
+ let settled = false;
838
+ const finishResolve = (value) => {
839
+ if (settled) {
840
+ return;
841
+ }
842
+ settled = true;
843
+ resolve(value);
844
+ };
845
+ const finishReject = (error) => {
846
+ if (settled) {
847
+ return;
848
+ }
849
+ settled = true;
850
+ reject(error);
851
+ };
852
+ const request = transport.request(target, {
853
+ method: "PUT",
854
+ headers,
855
+ agent: false,
856
+ }, (response) => {
857
+ response.resume();
858
+ response.on("error", finishReject);
859
+ response.on("end", () => {
860
+ finishResolve({
861
+ ok: response.statusCode >= 200 && response.statusCode < 300,
862
+ status: response.statusCode || 0,
863
+ headers: {
864
+ get(name) {
865
+ const value = response.headers[String(name || "").toLowerCase()];
866
+ return Array.isArray(value) ? value[0] : value || null;
867
+ },
868
+ },
869
+ });
870
+ });
871
+ });
872
+
873
+ request.on("error", finishReject);
874
+ request.on("socket", (socket) => {
875
+ socket.on("error", finishReject);
876
+ });
877
+ request.setTimeout(120000, () => {
878
+ request.destroy(new Error("stream_upload_timeout"));
879
+ });
880
+ if (typeof streamBody.on === "function") {
881
+ streamBody.on("error", finishReject);
882
+ }
883
+ pipeline(streamBody, request).catch(finishReject);
884
+ });
885
+ }
886
+
887
+ async function putObjectWithRetry(upload, body, {
888
+ onProgress,
889
+ loadedOffset = 0,
890
+ totalBytes = 0,
891
+ contentLength = null,
892
+ progressMeta = null,
893
+ debugContext = null,
894
+ } = {}) {
895
+ const response = await retry(async (attemptIndex) => {
896
+ const resolvedBody = typeof body === "function" ? await body() : body;
897
+ const headers = normalizeHeaders({
898
+ ...(upload.headers || {}),
899
+ ...(contentLength !== null ? { "Content-Length": contentLength } : {}),
900
+ ...(typeof body === "function" ? { Connection: "close" } : {}),
901
+ });
902
+ const putResponse = isNodeStreamBody(resolvedBody)
903
+ ? await putNodeStream(upload, resolvedBody, headers)
904
+ : await fetch(upload.url || upload, {
905
+ method: "PUT",
906
+ headers,
907
+ body: resolvedBody,
908
+ });
909
+ if (!putResponse.ok) {
910
+ throw new TranscribeAPIError(`R2 upload failed with HTTP ${putResponse.status}.`, {
911
+ status: putResponse.status,
912
+ code: "upload_failed",
913
+ extra: {
914
+ ...(progressMeta || {}),
915
+ ...(debugContext || {}),
916
+ content_length: contentLength,
917
+ },
918
+ });
919
+ }
920
+ return putResponse;
921
+ }, {
922
+ attempts: 5,
923
+ baseDelayMs: 1000,
924
+ });
925
+ const transferredBytes = Number(
926
+ contentLength
927
+ ?? body?.size
928
+ ?? body?.byteLength
929
+ ?? 0,
930
+ );
931
+ const loaded = loadedOffset + transferredBytes;
932
+ if (onProgress) {
933
+ onProgress({
934
+ loaded,
935
+ total: totalBytes || loaded,
936
+ ...(progressMeta || {}),
937
+ });
938
+ }
939
+ return response;
940
+ }
941
+
942
+ async function openFileBody(file, start = 0, end = null) {
943
+ if (!file?.path) {
944
+ return file;
945
+ }
946
+ const fs = await import("node:fs");
947
+ return fs.createReadStream(file.path, end === null ? { start } : { start, end: end - 1 });
948
+ }
949
+
950
+ async function runWithConcurrency(items, concurrency, worker) {
951
+ let index = 0;
952
+ const workerCount = Math.max(1, Math.min(items.length || 1, concurrency));
953
+ const workers = Array.from({ length: workerCount }, async () => {
954
+ while (true) {
955
+ const current = index;
956
+ index += 1;
957
+ if (current >= items.length) {
958
+ return;
959
+ }
960
+ await worker(items[current], current);
961
+ }
962
+ });
963
+ await Promise.all(workers);
964
+ }
965
+
966
+ function multipartCompleteXml(parts) {
967
+ const rows = parts
968
+ .sort((a, b) => a.partNumber - b.partNumber)
969
+ .map((part) => `<Part><PartNumber>${part.partNumber}</PartNumber><ETag>${part.etag}</ETag></Part>`)
970
+ .join("");
971
+ return `<CompleteMultipartUpload>${rows}</CompleteMultipartUpload>`;
972
+ }
973
+
974
+ async function uploadMultipart(upload, file, {
975
+ onProgress,
976
+ multipartConcurrency = DEFAULT_MULTIPART_CONCURRENCY,
977
+ resumeState = null,
978
+ onPartComplete = null,
979
+ onConcurrencyChange = null,
980
+ } = {}) {
981
+ const completed = normalizeCompletedParts(resumeState?.completed_parts).map((part) => ({
982
+ partNumber: part.part_number,
983
+ etag: part.etag,
984
+ }));
985
+ const totalParts = upload.parts.length;
986
+ const completedPartNumbers = new Set(completed.map((part) => part.partNumber));
987
+ let completedBytes = completed.reduce((total, part) => {
988
+ const start = (part.partNumber - 1) * upload.part_size;
989
+ const end = Math.min(file.size, start + upload.part_size);
990
+ return total + Math.max(0, end - start);
991
+ }, 0);
992
+ const concurrency = normalizeMultipartConcurrency(multipartConcurrency);
993
+ let targetConcurrency = concurrency;
994
+ let activeWorkers = 0;
995
+ let fatalError = null;
996
+ const pendingParts = upload.parts.filter((part) => !completedPartNumbers.has(part.part_number));
997
+ const partAttempts = new Map();
998
+
999
+ if (onProgress && completedBytes > 0) {
1000
+ onProgress({
1001
+ loaded: completedBytes,
1002
+ total: file.size,
1003
+ uploadType: "multipart",
1004
+ totalParts,
1005
+ multipartConcurrency: targetConcurrency,
1006
+ resumed: true,
1007
+ });
1008
+ }
1009
+
1010
+ const workers = Array.from({ length: concurrency }, async (_, workerIndex) => {
1011
+ while (true) {
1012
+ if (fatalError) {
1013
+ return;
1014
+ }
1015
+ if (completedPartNumbers.size >= totalParts) {
1016
+ return;
1017
+ }
1018
+ if (workerIndex >= targetConcurrency) {
1019
+ await sleep(MULTIPART_IDLE_WAIT_MS);
1020
+ continue;
1021
+ }
1022
+ const part = pendingParts.shift();
1023
+ if (!part) {
1024
+ if (!activeWorkers) {
1025
+ return;
1026
+ }
1027
+ await sleep(MULTIPART_IDLE_WAIT_MS);
1028
+ continue;
1029
+ }
1030
+
1031
+ activeWorkers += 1;
1032
+ const start = (part.part_number - 1) * upload.part_size;
1033
+ const end = Math.min(file.size, start + upload.part_size);
1034
+ const chunkSize = end - start;
1035
+ try {
1036
+ const response = await putObjectWithRetry(
1037
+ { url: part.url },
1038
+ file.path ? (() => openFileBody(file, start, end)) : file.slice(start, end),
1039
+ {
1040
+ onProgress: null,
1041
+ loadedOffset: 0,
1042
+ totalBytes: file.size,
1043
+ contentLength: chunkSize,
1044
+ progressMeta: {
1045
+ uploadType: "multipart",
1046
+ partNumber: part.part_number,
1047
+ totalParts,
1048
+ chunkBytes: chunkSize,
1049
+ },
1050
+ debugContext: {
1051
+ file_name: file?.name || null,
1052
+ file_size: file?.size || null,
1053
+ range_start: start,
1054
+ range_end_exclusive: end,
1055
+ },
1056
+ },
1057
+ );
1058
+ const etag = response.headers.get("ETag") || response.headers.get("etag");
1059
+ completed.push({
1060
+ partNumber: part.part_number,
1061
+ etag,
1062
+ });
1063
+ completedPartNumbers.add(part.part_number);
1064
+ completedBytes += chunkSize;
1065
+ if (onPartComplete) {
1066
+ try {
1067
+ await onPartComplete({
1068
+ partNumber: part.part_number,
1069
+ etag,
1070
+ completedParts: completed,
1071
+ });
1072
+ } catch {
1073
+ // Resume-state persistence is best-effort.
1074
+ }
1075
+ }
1076
+ if (onProgress) {
1077
+ onProgress({
1078
+ loaded: completedBytes,
1079
+ total: file.size,
1080
+ uploadType: "multipart",
1081
+ partNumber: part.part_number,
1082
+ totalParts,
1083
+ chunkBytes: chunkSize,
1084
+ multipartConcurrency: targetConcurrency,
1085
+ });
1086
+ }
1087
+ } catch (error) {
1088
+ const attempts = (partAttempts.get(part.part_number) || 0) + 1;
1089
+ partAttempts.set(part.part_number, attempts);
1090
+ const retryable = isRetryableError(error);
1091
+ if (retryable && attempts < MAX_MULTIPART_ADAPTIVE_ATTEMPTS) {
1092
+ const previousConcurrency = targetConcurrency;
1093
+ targetConcurrency = Math.max(1, Math.floor(targetConcurrency / 2));
1094
+ pendingParts.push(part);
1095
+ if (onConcurrencyChange && targetConcurrency !== previousConcurrency) {
1096
+ try {
1097
+ onConcurrencyChange({
1098
+ previousConcurrency,
1099
+ nextConcurrency: targetConcurrency,
1100
+ partNumber: part.part_number,
1101
+ attempts,
1102
+ error: extractErrorInfo(error),
1103
+ });
1104
+ } catch {
1105
+ // Ignore observer failures.
1106
+ }
1107
+ }
1108
+ activeWorkers -= 1;
1109
+ await sleep(Math.min(10000, 1000 * (2 ** Math.min(attempts - 1, 3))));
1110
+ continue;
1111
+ }
1112
+ fatalError = new TranscribeAPIError(
1113
+ `Multipart upload failed for part ${part.part_number}/${totalParts}.`,
1114
+ {
1115
+ status: error?.status || null,
1116
+ code: error?.code || "multipart_part_upload_failed",
1117
+ extra: {
1118
+ cause_error: error?.error || error?.message || String(error),
1119
+ cause_code: error?.code || error?.cause?.code || null,
1120
+ cause_status: error?.status || null,
1121
+ upload_type: "multipart",
1122
+ part_number: part.part_number,
1123
+ total_parts: totalParts,
1124
+ chunk_bytes: chunkSize,
1125
+ completed_bytes_before_failure: completedBytes,
1126
+ multipart_concurrency: targetConcurrency,
1127
+ file_name: file?.name || null,
1128
+ file_size: file?.size || null,
1129
+ range_start: start,
1130
+ range_end_exclusive: end,
1131
+ attempts,
1132
+ },
1133
+ },
1134
+ );
1135
+ activeWorkers -= 1;
1136
+ return;
1137
+ }
1138
+ activeWorkers -= 1;
1139
+ }
1140
+ });
1141
+
1142
+ await Promise.all(workers);
1143
+ if (fatalError) {
1144
+ throw fatalError;
1145
+ }
1146
+
1147
+ const completeResponse = await retry(async () => {
1148
+ const response = await fetch(upload.complete_url, {
1149
+ method: "POST",
1150
+ headers: { "Content-Type": "application/xml" },
1151
+ body: multipartCompleteXml(completed),
1152
+ });
1153
+ if (!response.ok) {
1154
+ throw new TranscribeAPIError(`R2 multipart complete failed with HTTP ${response.status}.`, {
1155
+ status: response.status,
1156
+ code: "multipart_complete_failed",
1157
+ });
1158
+ }
1159
+ return response;
1160
+ });
1161
+ return completeResponse;
1162
+ }
1163
+
1164
+ async function uploadUsingInstructions(upload, file, options = {}) {
1165
+ if (upload.type === "multipart") {
1166
+ return uploadMultipart(upload, file, options);
1167
+ }
1168
+ const body = file.path ? (() => openFileBody(file)) : file;
1169
+ return putObjectWithRetry(upload, body, {
1170
+ onProgress: options.onProgress,
1171
+ loadedOffset: 0,
1172
+ totalBytes: file.size,
1173
+ contentLength: file.size,
1174
+ progressMeta: {
1175
+ uploadType: "single_put",
1176
+ chunkBytes: file.size,
1177
+ },
1178
+ debugContext: {
1179
+ file_name: file?.name || null,
1180
+ file_size: file?.size || null,
1181
+ },
1182
+ });
1183
+ }
1184
+
1185
+ function assertBatchLimits(totalFiles, totalSizeBytes) {
1186
+ if (totalFiles > MAX_BATCH_FILES) {
1187
+ throw new TranscribeAPIError(`Batch jobs support up to ${MAX_BATCH_FILES} files.`, {
1188
+ code: "too_many_files",
1189
+ });
1190
+ }
1191
+ if (totalSizeBytes > MAX_BATCH_TOTAL_SIZE_BYTES) {
1192
+ throw new TranscribeAPIError("Batch jobs support up to 10GB total.", {
1193
+ code: "batch_too_large",
1194
+ });
1195
+ }
1196
+ }
1197
+
1198
+ class BigFileJob {
1199
+ constructor(client, file, createResponse, options) {
1200
+ this.client = client;
1201
+ this.file = file;
1202
+ this.createResponse = createResponse;
1203
+ this.referenceId = options.referenceId || defaultReferenceId(0);
1204
+ this.jobId = createResponse.job_id;
1205
+ this.jobStatus = createResponse.job_status;
1206
+ this.model = createResponse.model || options.model || null;
1207
+ this.uploadInfo = uploadFromResponse(createResponse, this.referenceId);
1208
+ this.options = options;
1209
+ this.resumeState = options.resumeState || null;
1210
+ this.resumeWriteChain = Promise.resolve();
1211
+ }
1212
+
1213
+ async persistResumeState(completedParts = this.resumeState?.completed_parts || []) {
1214
+ if (!isNodePathFile(this.file) || this.uploadInfo?.type !== "multipart") {
1215
+ return;
1216
+ }
1217
+ const state = buildMultipartResumeState({
1218
+ baseUrl: this.client.baseUrl,
1219
+ jobId: this.jobId,
1220
+ model: this.model,
1221
+ file: this.file,
1222
+ upload: this.uploadInfo,
1223
+ completedParts,
1224
+ });
1225
+ this.resumeState = state;
1226
+ this.resumeWriteChain = this.resumeWriteChain
1227
+ .catch(() => {})
1228
+ .then(() => writeMultipartResumeState(this.file, state))
1229
+ .catch(() => {});
1230
+ await this.resumeWriteChain;
1231
+ }
1232
+
1233
+ async clearResumeState() {
1234
+ await this.resumeWriteChain.catch(() => {});
1235
+ await deleteMultipartResumeState(this.file);
1236
+ this.resumeState = null;
1237
+ }
1238
+
1239
+ async upload({ onProgress } = {}) {
1240
+ const progress = onProgress || this.options.onProgress;
1241
+ if (!this.uploadInfo) {
1242
+ return this.createResponse;
1243
+ }
1244
+ if (this.uploadInfo.type === "multipart" && isNodePathFile(this.file)) {
1245
+ const persisted = this.resumeState || await readMultipartResumeState(this.file, {
1246
+ baseUrl: this.client.baseUrl,
1247
+ });
1248
+ if (persisted?.job_id === this.jobId && persisted.upload?.type === "multipart") {
1249
+ this.resumeState = persisted;
1250
+ try {
1251
+ const refreshed = await this.client.refreshBigFileUpload(this.jobId, {
1252
+ upload: persisted.upload,
1253
+ });
1254
+ this.uploadInfo = refreshed;
1255
+ } catch (error) {
1256
+ if (error?.status === 404 || error?.code === "not_found") {
1257
+ await this.clearResumeState();
1258
+ }
1259
+ if (error?.status === 409) {
1260
+ const currentJob = await this.client.jobs.get(this.jobId);
1261
+ this.jobStatus = currentJob?.job_status || this.jobStatus;
1262
+ this.uploadInfo = null;
1263
+ await this.clearResumeState();
1264
+ return currentJob;
1265
+ }
1266
+ if (!(error?.status === 404 || error?.code === "not_found")) {
1267
+ throw error;
1268
+ }
1269
+ }
1270
+ }
1271
+ await this.persistResumeState(this.resumeState?.completed_parts || []);
1272
+ }
1273
+ await uploadUsingInstructions(this.uploadInfo, this.file, {
1274
+ onProgress: progress,
1275
+ multipartConcurrency: this.options.multipartConcurrency,
1276
+ resumeState: this.resumeState,
1277
+ onPartComplete: async ({ completedParts }) => {
1278
+ if (!isNodePathFile(this.file) || this.uploadInfo?.type !== "multipart") {
1279
+ return;
1280
+ }
1281
+ await this.persistResumeState(completedParts.map((part) => ({
1282
+ part_number: part.partNumber,
1283
+ etag: part.etag,
1284
+ })));
1285
+ },
1286
+ onConcurrencyChange: (event) => {
1287
+ if (typeof onProgress === "function" || typeof this.options.onProgress === "function") {
1288
+ (onProgress || this.options.onProgress)({
1289
+ uploadType: "multipart",
1290
+ event: "adaptive_concurrency",
1291
+ previousConcurrency: event.previousConcurrency,
1292
+ multipartConcurrency: event.nextConcurrency,
1293
+ partNumber: event.partNumber,
1294
+ attempts: event.attempts,
1295
+ error: event.error,
1296
+ });
1297
+ }
1298
+ },
1299
+ });
1300
+ await this.clearResumeState();
1301
+ const completion = await this.client.jobs.complete(this.jobId);
1302
+ emitProgress(progress, {
1303
+ event: "upload_completed",
1304
+ jobId: this.jobId,
1305
+ response: completion,
1306
+ suppressLog: Boolean(this.client.polling),
1307
+ });
1308
+ return completion;
1309
+ }
1310
+ }
1311
+
1312
+ class RemoteBigFileJob {
1313
+ constructor(client, createResponse, options) {
1314
+ this.client = client;
1315
+ this.createResponse = createResponse;
1316
+ this.jobId = createResponse.job_id;
1317
+ this.jobStatus = createResponse.job_status;
1318
+ this.model = createResponse.model || options.model || null;
1319
+ this.options = options;
1320
+ }
1321
+
1322
+ async upload() {
1323
+ return this.createResponse;
1324
+ }
1325
+ }
1326
+
1327
+ class BatchJob {
1328
+ constructor(client, files, createResponse, options) {
1329
+ this.client = client;
1330
+ this.files = files;
1331
+ this.createResponse = createResponse;
1332
+ this.jobId = createResponse.job_id;
1333
+ this.jobStatus = createResponse.job_status;
1334
+ this.model = createResponse.model || options.model || null;
1335
+ this.uploadsByReferenceId = new Map(
1336
+ normalizeResponseUploads(createResponse).map((entry) => [entry.reference_id, entry.upload]),
1337
+ );
1338
+ this.options = options;
1339
+ }
1340
+
1341
+ async upload({ onProgress } = {}) {
1342
+ if (!this.uploadsByReferenceId.size) {
1343
+ return this.createResponse;
1344
+ }
1345
+
1346
+ const progress = onProgress || this.options.onProgress;
1347
+ const totalBytes = this.files.reduce((sum, item) => sum + Number(item?.file?.size || 0), 0);
1348
+ const loadedByReferenceId = new Map();
1349
+ const uploadableFiles = this.files.filter((item) => item.file);
1350
+
1351
+ emitProgress(progress, {
1352
+ event: "upload_started",
1353
+ jobId: this.jobId,
1354
+ totalFiles: this.files.length,
1355
+ uploadFiles: uploadableFiles.length,
1356
+ totalBytes,
1357
+ });
1358
+
1359
+ for (const item of this.files) {
1360
+ if (!item.file) {
1361
+ continue;
1362
+ }
1363
+ const upload = this.uploadsByReferenceId.get(item.referenceId);
1364
+ if (!upload) {
1365
+ throw new TranscribeAPIError(`Missing upload instructions for \`${item.referenceId}\`.`, {
1366
+ code: "missing_upload_instructions",
1367
+ });
1368
+ }
1369
+ await uploadUsingInstructions(upload, item.file, {
1370
+ onProgress: (event) => {
1371
+ const fileLoaded = Math.max(0, Math.min(Number(event?.loaded || 0), Number(item.file.size || 0)));
1372
+ loadedByReferenceId.set(item.referenceId, fileLoaded);
1373
+ const batchLoaded = Array.from(loadedByReferenceId.values())
1374
+ .reduce((sum, value) => sum + Number(value || 0), 0);
1375
+ emitProgress(progress, {
1376
+ ...event,
1377
+ event: event?.event || "upload_progress",
1378
+ referenceId: item.referenceId,
1379
+ fileLoaded,
1380
+ fileTotal: Number(item.file.size || 0),
1381
+ batchLoaded,
1382
+ batchTotal: totalBytes,
1383
+ totalFiles: this.files.length,
1384
+ uploadFiles: uploadableFiles.length,
1385
+ });
1386
+ },
1387
+ });
1388
+ }
1389
+ const completion = await this.client.jobs.complete(this.jobId);
1390
+ emitProgress(progress, {
1391
+ event: "upload_completed",
1392
+ jobId: this.jobId,
1393
+ response: completion,
1394
+ batchLoaded: totalBytes,
1395
+ batchTotal: totalBytes,
1396
+ totalFiles: this.files.length,
1397
+ uploadFiles: uploadableFiles.length,
1398
+ suppressLog: Boolean(this.client.polling),
1399
+ });
1400
+ return completion;
1401
+ }
1402
+ }
1403
+
1
1404
  export class TranscribeAPI {
2
- constructor({ apiKey, baseUrl = "https://api.transcribeapi.com/v1" }) {
1405
+ constructor({
1406
+ apiKey,
1407
+ baseUrl = DEFAULT_BASE_URL,
1408
+ multipartConcurrency = DEFAULT_MULTIPART_CONCURRENCY,
1409
+ showLogs = false,
1410
+ logger = console,
1411
+ polling = null,
1412
+ } = {}) {
3
1413
  if (!apiKey) {
4
1414
  throw new Error("Missing API key");
5
1415
  }
6
1416
 
7
1417
  this.apiKey = apiKey;
8
1418
  this.baseUrl = baseUrl.replace(/\/+$/, "");
1419
+ this.multipartConcurrency = normalizeMultipartConcurrency(multipartConcurrency);
1420
+ this.showLogs = Boolean(showLogs);
1421
+ this.logger = logger || console;
1422
+ this.polling = normalizePollingConfig(polling);
1423
+ this.batch = {
1424
+ transcribe: (options) => this.transcribeMany(options),
1425
+ };
1426
+ this.jobs = {
1427
+ createBigFile: (options) => this.createBigFileJob(options),
1428
+ createBatch: (options) => this.createBatchJob(options),
1429
+ uploadCompleted: (jobId) => this.requestJson(`/transcribe/${jobId}/upload-completed`, { method: "POST", retryable: true }),
1430
+ complete: (jobId) => this.requestJson(`/transcribe/${jobId}/upload-completed`, { method: "POST", retryable: true }),
1431
+ get: (jobId) => this.requestJson(`/transcribe/${jobId}`),
1432
+ result: (jobId) => this.requestJson(`/transcribe/${jobId}`),
1433
+ };
1434
+ }
1435
+
1436
+ async requestJson(path, { method = "GET", body = null, retryable = false } = {}) {
1437
+ const run = async () => {
1438
+ const response = await fetch(`${this.baseUrl}${path}`, {
1439
+ method,
1440
+ headers: {
1441
+ Authorization: `Bearer ${this.apiKey}`,
1442
+ ...(body ? { "Content-Type": "application/json" } : {}),
1443
+ },
1444
+ body: body ? JSON.stringify(body) : null,
1445
+ });
1446
+ return parseApiResponse(response);
1447
+ };
1448
+ return retryable ? retry(run) : run();
1449
+ }
1450
+
1451
+ async refreshBigFileUpload(jobId, { upload } = {}) {
1452
+ const response = await this.requestJson(`/jobs/${jobId}/resume-upload`, {
1453
+ method: "POST",
1454
+ body: { upload },
1455
+ retryable: true,
1456
+ });
1457
+ return response.upload;
9
1458
  }
10
1459
 
11
- async transcribe() {
12
- throw new Error(
13
- "The Transcribe API JavaScript SDK is coming soon. For now, use the REST API docs at https://transcribeapi.com."
14
- );
1460
+ async waitForJobCompletion(jobId, {
1461
+ polling = this.polling,
1462
+ showLogs = this.showLogs,
1463
+ logger = this.logger,
1464
+ initialJob = null,
1465
+ } = {}) {
1466
+ const effectivePolling = normalizePollingConfig(polling);
1467
+ if (!effectivePolling) {
1468
+ return this.jobs.get(jobId);
1469
+ }
1470
+
1471
+ const startedAt = Date.now();
1472
+ const spinner = showLogs ? createSdkPollingLogger(logger) : null;
1473
+ let lastJob = initialJob || null;
1474
+ spinner?.start({
1475
+ jobId,
1476
+ jobStatus: lastJob?.job_status || "waiting",
1477
+ });
1478
+
1479
+ while (!TERMINAL_JOB_STATUSES.has(String(lastJob?.job_status || ""))) {
1480
+ if (effectivePolling.timeout !== null && (Date.now() - startedAt) >= effectivePolling.timeout * 1000) {
1481
+ spinner?.timeout({
1482
+ timeoutSeconds: effectivePolling.timeout,
1483
+ jobStatus: lastJob?.job_status || "unknown",
1484
+ });
1485
+ throw new TranscribeAPIError(`Polling timed out after ${effectivePolling.timeout} seconds.`, {
1486
+ code: "polling_timeout",
1487
+ extra: {
1488
+ job_id: jobId,
1489
+ job_status: lastJob?.job_status || null,
1490
+ last_job: lastJob,
1491
+ },
1492
+ response: lastJob,
1493
+ });
1494
+ }
1495
+
1496
+ await sleep(effectivePolling.interval * 1000);
1497
+ lastJob = await this.jobs.get(jobId);
1498
+ spinner?.update({
1499
+ jobStatus: lastJob?.job_status || "unknown",
1500
+ });
1501
+ }
1502
+
1503
+ spinner?.finish({
1504
+ jobStatus: lastJob?.job_status || "unknown",
1505
+ resultUrl: lastJob?.result_url || null,
1506
+ });
1507
+ return lastJob;
15
1508
  }
16
- }
1509
+
1510
+ async transcribe({
1511
+ file,
1512
+ webhookUrl,
1513
+ onProgress,
1514
+ showLogs,
1515
+ logger,
1516
+ language,
1517
+ task,
1518
+ vadFilter,
1519
+ initialPrompt,
1520
+ vttGranularity,
1521
+ exclude,
1522
+ multipartConcurrency,
1523
+ } = {}) {
1524
+ const progress = composeProgressHandler({
1525
+ onProgress,
1526
+ showLogs: showLogs ?? this.showLogs,
1527
+ logger: logger ?? this.logger,
1528
+ });
1529
+ if (isRemoteFileInput(file)) {
1530
+ const job = await this.createBigFileJob({
1531
+ file,
1532
+ webhookUrl,
1533
+ onProgress: progress,
1534
+ showLogs: false,
1535
+ multipartConcurrency,
1536
+ });
1537
+ const result = await job.upload({ onProgress: progress });
1538
+ if (this.polling && !TERMINAL_JOB_STATUSES.has(String(result?.job_status || ""))) {
1539
+ return this.waitForJobCompletion(result.job_id, {
1540
+ polling: this.polling,
1541
+ showLogs: showLogs ?? this.showLogs,
1542
+ logger: logger ?? this.logger,
1543
+ initialJob: result,
1544
+ });
1545
+ }
1546
+ return result;
1547
+ }
1548
+
1549
+ const normalizedFile = await normalizeFile(file);
1550
+ const estimatedDurationSec = await estimateDurationFromFile(normalizedFile);
1551
+ if (normalizedFile.size > MAX_SYNC_AUDIO_BYTES || estimatedDurationSec > MAX_SYNC_AUDIO_SECONDS) {
1552
+ const job = await this.createBigFileJob({
1553
+ file: normalizedFile,
1554
+ webhookUrl,
1555
+ durationEstimateSec: estimatedDurationSec,
1556
+ onProgress: progress,
1557
+ showLogs: false,
1558
+ multipartConcurrency,
1559
+ });
1560
+ const result = await job.upload({ onProgress: progress });
1561
+ if (this.polling && !TERMINAL_JOB_STATUSES.has(String(result?.job_status || ""))) {
1562
+ return this.waitForJobCompletion(result.job_id, {
1563
+ polling: this.polling,
1564
+ showLogs: showLogs ?? this.showLogs,
1565
+ logger: logger ?? this.logger,
1566
+ initialJob: result,
1567
+ });
1568
+ }
1569
+ return result;
1570
+ }
1571
+
1572
+ return this.transcribeDirect({
1573
+ file: normalizedFile,
1574
+ language,
1575
+ task,
1576
+ vadFilter,
1577
+ initialPrompt,
1578
+ vttGranularity,
1579
+ exclude,
1580
+ });
1581
+ }
1582
+
1583
+ async transcribeDirect({
1584
+ file,
1585
+ language,
1586
+ task,
1587
+ vadFilter,
1588
+ initialPrompt,
1589
+ vttGranularity,
1590
+ exclude,
1591
+ } = {}) {
1592
+ const normalizedFile = await normalizeFile(file);
1593
+ const directFile = normalizedFile.path
1594
+ ? makeFile([await normalizedFile.readSlice(0, normalizedFile.size)], normalizedFile.name, normalizedFile.type)
1595
+ : normalizedFile;
1596
+ const form = new FormData();
1597
+ form.set("file", directFile, directFile.name);
1598
+ if (language) form.set("language", language);
1599
+ if (task) form.set("task", task);
1600
+ if (vadFilter !== undefined) form.set("vad_filter", String(Boolean(vadFilter)));
1601
+ if (initialPrompt) form.set("initial_prompt", initialPrompt);
1602
+ if (vttGranularity) form.set("vtt_granularity", vttGranularity);
1603
+ if (exclude) form.set("exclude", Array.isArray(exclude) ? exclude.join(",") : exclude);
1604
+
1605
+ const response = await fetch(`${this.baseUrl}/transcribe`, {
1606
+ method: "POST",
1607
+ headers: {
1608
+ Authorization: `Bearer ${this.apiKey}`,
1609
+ },
1610
+ body: form,
1611
+ });
1612
+ return parseApiResponse(response);
1613
+ }
1614
+
1615
+ async transcribeMany({
1616
+ files,
1617
+ webhookUrl,
1618
+ durationEstimateSec,
1619
+ onProgress,
1620
+ showLogs,
1621
+ logger,
1622
+ multipartConcurrency,
1623
+ } = {}) {
1624
+ const progress = composeProgressHandler({
1625
+ onProgress,
1626
+ showLogs: showLogs ?? this.showLogs,
1627
+ logger: logger ?? this.logger,
1628
+ });
1629
+ const job = await this.createBatchJob({
1630
+ files,
1631
+ webhookUrl,
1632
+ durationEstimateSec,
1633
+ onProgress: progress,
1634
+ showLogs: false,
1635
+ multipartConcurrency,
1636
+ });
1637
+ const result = await job.upload({ onProgress: progress });
1638
+ if (this.polling && !TERMINAL_JOB_STATUSES.has(String(result?.job_status || ""))) {
1639
+ return this.waitForJobCompletion(result.job_id, {
1640
+ polling: this.polling,
1641
+ showLogs: showLogs ?? this.showLogs,
1642
+ logger: logger ?? this.logger,
1643
+ initialJob: result,
1644
+ });
1645
+ }
1646
+ return result;
1647
+ }
1648
+
1649
+ async createBigFileJob({
1650
+ file,
1651
+ webhookUrl,
1652
+ durationEstimateSec,
1653
+ onProgress,
1654
+ showLogs,
1655
+ logger,
1656
+ multipartConcurrency,
1657
+ } = {}) {
1658
+ const progress = composeProgressHandler({
1659
+ onProgress,
1660
+ showLogs: showLogs ?? this.showLogs,
1661
+ logger: logger ?? this.logger,
1662
+ });
1663
+ const normalizedConcurrency = normalizeMultipartConcurrency(multipartConcurrency ?? this.multipartConcurrency);
1664
+ if (isRemoteFileInput(file)) {
1665
+ const response = await this.requestJson("/transcribe", {
1666
+ method: "POST",
1667
+ body: {
1668
+ files: [{
1669
+ reference_id: defaultReferenceId(0),
1670
+ url: file.url,
1671
+ }],
1672
+ ...(webhookUrl ? { webhook_url: webhookUrl } : {}),
1673
+ },
1674
+ retryable: true,
1675
+ });
1676
+ return new RemoteBigFileJob(this, response, {
1677
+ onProgress: progress,
1678
+ multipartConcurrency: normalizedConcurrency,
1679
+ });
1680
+ }
1681
+
1682
+ const normalizedFile = await normalizeFile(file);
1683
+ const resumeState = await readMultipartResumeState(normalizedFile, { baseUrl: this.baseUrl });
1684
+ if (resumeState?.job_id && resumeState.upload?.type === "multipart") {
1685
+ try {
1686
+ const refreshedUpload = await this.refreshBigFileUpload(resumeState.job_id, {
1687
+ upload: resumeState.upload,
1688
+ });
1689
+ const existingJob = await this.requestJson(`/transcribe/${resumeState.job_id}`, { retryable: true });
1690
+ const currentStatus = existingJob?.job_status || "uploading";
1691
+ return new BigFileJob(this, normalizedFile, {
1692
+ job_id: resumeState.job_id,
1693
+ job_status: currentStatus,
1694
+ model: existingJob?.model || resumeState.model || null,
1695
+ upload: currentStatus === "uploading" ? refreshedUpload : null,
1696
+ }, {
1697
+ onProgress: progress,
1698
+ multipartConcurrency: normalizedConcurrency,
1699
+ referenceId: defaultReferenceId(0),
1700
+ resumeState,
1701
+ });
1702
+ } catch (error) {
1703
+ if (error?.status === 404 || error?.code === "not_found") {
1704
+ await deleteMultipartResumeState(normalizedFile);
1705
+ } else {
1706
+ throw error;
1707
+ }
1708
+ }
1709
+ }
1710
+ const estimate = durationEstimateSec || await estimateDurationFromFile(normalizedFile);
1711
+ const response = await this.requestJson("/transcribe", {
1712
+ method: "POST",
1713
+ body: {
1714
+ files: [
1715
+ estimate > MAX_SYNC_AUDIO_SECONDS || normalizedFile.size > MAX_SYNC_AUDIO_BYTES
1716
+ ? uploadDescriptorForFile(defaultReferenceId(0), normalizedFile)
1717
+ : { reference_id: defaultReferenceId(0) },
1718
+ ],
1719
+ ...(webhookUrl ? { webhook_url: webhookUrl } : {}),
1720
+ },
1721
+ retryable: true,
1722
+ });
1723
+ emitProgress(progress, {
1724
+ event: "upload_urls_received",
1725
+ jobId: response.job_id,
1726
+ jobStatus: response.job_status,
1727
+ uploadCount: normalizeResponseUploads(response).length,
1728
+ totalFiles: 1,
1729
+ });
1730
+ const job = new BigFileJob(this, normalizedFile, response, {
1731
+ onProgress: progress,
1732
+ multipartConcurrency: normalizedConcurrency,
1733
+ referenceId: defaultReferenceId(0),
1734
+ });
1735
+ if (uploadFromResponse(response, defaultReferenceId(0))?.type === "multipart" && isNodePathFile(normalizedFile)) {
1736
+ await job.persistResumeState([]);
1737
+ }
1738
+ return job;
1739
+ }
1740
+
1741
+ async createBatchJob({
1742
+ files,
1743
+ webhookUrl,
1744
+ durationEstimateSec,
1745
+ onProgress,
1746
+ showLogs,
1747
+ logger,
1748
+ multipartConcurrency,
1749
+ } = {}) {
1750
+ const progress = composeProgressHandler({
1751
+ onProgress,
1752
+ showLogs: showLogs ?? this.showLogs,
1753
+ logger: logger ?? this.logger,
1754
+ });
1755
+ if (!Array.isArray(files) || files.length === 0) {
1756
+ throw new TranscribeAPIError("Batch upload requires at least one file.", { code: "invalid_files" });
1757
+ }
1758
+ assertBatchLimits(files.length, 0);
1759
+ const normalizedItems = files.map((item, index) => normalizeBatchInputItem(item, index));
1760
+
1761
+ const normalizedBatchItems = [];
1762
+ for (let index = 0; index < normalizedItems.length; index += 1) {
1763
+ const item = normalizedItems[index];
1764
+ if (isRemoteBatchItem(item)) {
1765
+ normalizedBatchItems.push({
1766
+ file: null,
1767
+ referenceId: item.referenceId,
1768
+ url: item.url,
1769
+ durationEstimateSec: item.durationEstimateSec || durationEstimateSec || null,
1770
+ });
1771
+ continue;
1772
+ }
1773
+
1774
+ const file = await normalizeFile(item.file, `file_${String(index + 1).padStart(6, "0")}.mp3`);
1775
+ assertSupportedBatchFormat(file);
1776
+ normalizedBatchItems.push({
1777
+ file,
1778
+ referenceId: item.referenceId,
1779
+ url: null,
1780
+ durationEstimateSec: item.durationEstimateSec || await estimateDurationFromFile(file),
1781
+ });
1782
+ }
1783
+
1784
+ const totalSizeBytes = normalizedBatchItems.reduce((total, item) => total + Number(item.file?.size || 0), 0);
1785
+ assertBatchLimits(normalizedBatchItems.length, totalSizeBytes);
1786
+ const response = await this.requestJson("/transcribe", {
1787
+ method: "POST",
1788
+ body: {
1789
+ files: normalizedBatchItems.map((item) => (
1790
+ item.url
1791
+ ? {
1792
+ reference_id: item.referenceId,
1793
+ url: item.url,
1794
+ }
1795
+ : uploadDescriptorForFile(item.referenceId, item.file)
1796
+ )),
1797
+ ...(webhookUrl ? { webhook_url: webhookUrl } : {}),
1798
+ },
1799
+ retryable: true,
1800
+ });
1801
+ emitProgress(progress, {
1802
+ event: "upload_urls_received",
1803
+ jobId: response.job_id,
1804
+ jobStatus: response.job_status,
1805
+ uploadCount: normalizeResponseUploads(response).length,
1806
+ totalFiles: normalizedBatchItems.length,
1807
+ });
1808
+ return new BatchJob(this, normalizedBatchItems, response, {
1809
+ onProgress: progress,
1810
+ webhookUrl,
1811
+ multipartConcurrency: normalizeMultipartConcurrency(multipartConcurrency ?? this.multipartConcurrency),
1812
+ });
1813
+ }
1814
+ }
1815
+
1816
+ export default TranscribeAPI;