@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/worker.js ADDED
@@ -0,0 +1,1358 @@
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 MIN_POLLING_INTERVAL_SECONDS = 10;
12
+ const TERMINAL_JOB_STATUSES = new Set(["completed", "failed", "insufficient_funds"]);
13
+ const BATCH_MP4_UNSUPPORTED_MESSAGE = "Batch uploads do not support .mp4 for MVP. Supported batch audio formats: mp3, mpeg, mpga, m4a, wav, webm.";
14
+ const BATCH_UNSUPPORTED_MESSAGE = "Unsupported batch audio format. Supported batch audio formats: mp3, mpeg, mpga, m4a, wav, webm.";
15
+
16
+ export class TranscribeAPIError extends Error {
17
+ constructor(error, { status = null, code = null, extra = null, response = null } = {}) {
18
+ super(error);
19
+ this.name = "TranscribeAPIError";
20
+ this.status = status;
21
+ this.code = code;
22
+ this.error = error;
23
+ this.response = response;
24
+ if (extra && typeof extra === "object") {
25
+ for (const [key, value] of Object.entries(extra)) {
26
+ if (key === "status" || key === "stack" || key === "name") {
27
+ continue;
28
+ }
29
+ this[key] = value;
30
+ }
31
+ }
32
+ }
33
+ }
34
+
35
+ function sleep(ms) {
36
+ return new Promise((resolve) => setTimeout(resolve, ms));
37
+ }
38
+
39
+ function extractErrorInfo(error) {
40
+ return {
41
+ name: error?.name || null,
42
+ message: error?.message || String(error),
43
+ code: error?.code || error?.cause?.code || null,
44
+ status: error?.status || null,
45
+ cause_message: error?.cause?.message || null,
46
+ };
47
+ }
48
+
49
+ function isRetryableError(error) {
50
+ const code = String(error?.code || error?.cause?.code || "");
51
+ if (
52
+ code === "ECONNRESET"
53
+ || code === "ETIMEDOUT"
54
+ || code === "ECONNREFUSED"
55
+ || code === "EPIPE"
56
+ || code === "UND_ERR_SOCKET"
57
+ || code === "UND_ERR_CONNECT_TIMEOUT"
58
+ || code === "ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC"
59
+ ) {
60
+ return true;
61
+ }
62
+ if (String(error?.message || "").includes("fetch failed")) {
63
+ return true;
64
+ }
65
+ return !error?.status || error.status === 429 || error.status >= 500;
66
+ }
67
+
68
+ async function retry(operation, { attempts = 3, baseDelayMs = 250 } = {}) {
69
+ let lastError;
70
+ for (let index = 0; index < attempts; index += 1) {
71
+ try {
72
+ return await operation(index);
73
+ } catch (error) {
74
+ lastError = error;
75
+ if (!isRetryableError(error) || index === attempts - 1) {
76
+ throw error;
77
+ }
78
+ await sleep(baseDelayMs * (2 ** index));
79
+ }
80
+ }
81
+ throw lastError;
82
+ }
83
+
84
+ function normalizeMultipartConcurrency(value) {
85
+ if (value === undefined || value === null || value === "") {
86
+ return DEFAULT_MULTIPART_CONCURRENCY;
87
+ }
88
+ const parsed = Number.parseInt(String(value), 10);
89
+ if (!Number.isInteger(parsed) || parsed < 1) {
90
+ throw new TranscribeAPIError("`multipartConcurrency` must be an integer >= 1.", {
91
+ code: "invalid_multipart_concurrency",
92
+ });
93
+ }
94
+ return Math.min(parsed, MAX_MULTIPART_CONCURRENCY);
95
+ }
96
+
97
+ function normalizePollingConfig(polling) {
98
+ if (polling === undefined || polling === null || polling === false) {
99
+ return null;
100
+ }
101
+ if (typeof polling !== "object" || Array.isArray(polling)) {
102
+ throw new TranscribeAPIError("`polling` must be an object with `interval` in seconds.", {
103
+ code: "invalid_polling_config",
104
+ });
105
+ }
106
+
107
+ const interval = Number.parseInt(String(polling.interval ?? ""), 10);
108
+ if (!Number.isInteger(interval) || interval < MIN_POLLING_INTERVAL_SECONDS) {
109
+ throw new TranscribeAPIError(`\`polling.interval\` must be an integer >= ${MIN_POLLING_INTERVAL_SECONDS} seconds.`, {
110
+ code: "invalid_polling_interval",
111
+ });
112
+ }
113
+
114
+ if (polling.timeout === undefined || polling.timeout === null || polling.timeout === "") {
115
+ return {
116
+ interval,
117
+ timeout: null,
118
+ };
119
+ }
120
+
121
+ const timeout = Number.parseInt(String(polling.timeout), 10);
122
+ if (!Number.isInteger(timeout) || timeout < interval) {
123
+ throw new TranscribeAPIError("`polling.timeout` must be an integer >= `polling.interval` in seconds.", {
124
+ code: "invalid_polling_timeout",
125
+ });
126
+ }
127
+
128
+ return {
129
+ interval,
130
+ timeout,
131
+ };
132
+ }
133
+
134
+ function contentTypeFromName(name = "") {
135
+ const lower = String(name).toLowerCase();
136
+ if (lower.endsWith(".mp3")) return "audio/mpeg";
137
+ if (lower.endsWith(".mpeg")) return "audio/mpeg";
138
+ if (lower.endsWith(".mpga")) return "audio/mpeg";
139
+ if (lower.endsWith(".wav")) return "audio/wav";
140
+ if (lower.endsWith(".m4a")) return "audio/mp4";
141
+ if (lower.endsWith(".webm")) return "audio/webm";
142
+ return "application/octet-stream";
143
+ }
144
+
145
+ function isRemoteFileInput(input) {
146
+ return Boolean(input && typeof input === "object" && typeof input.url === "string");
147
+ }
148
+
149
+ function isRemoteBatchItem(input) {
150
+ return Boolean(input && typeof input === "object" && typeof input.url === "string");
151
+ }
152
+
153
+ function defaultReferenceId(index) {
154
+ return `file_${String(index + 1).padStart(6, "0")}`;
155
+ }
156
+
157
+ function normalizeBatchInputItem(item, index) {
158
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
159
+ throw new TranscribeAPIError("Each batch item must be an object with `reference_id` and either `file` or `url`.", {
160
+ code: "invalid_batch_item",
161
+ });
162
+ }
163
+
164
+ const referenceId = String(item.reference_id || "").trim();
165
+ if (!referenceId) {
166
+ throw new TranscribeAPIError(`files[${index}].reference_id is required.`, {
167
+ code: "missing_reference_id",
168
+ });
169
+ }
170
+
171
+ const hasFile = Object.prototype.hasOwnProperty.call(item, "file");
172
+ const hasUrl = typeof item.url === "string" && item.url.trim();
173
+ if (hasFile && hasUrl) {
174
+ throw new TranscribeAPIError(`files[${index}] must include either \`file\` or \`url\`, not both.`, {
175
+ code: "invalid_batch_item",
176
+ });
177
+ }
178
+ if (!hasFile && !hasUrl) {
179
+ throw new TranscribeAPIError(`files[${index}] must include either \`file\` or \`url\`.`, {
180
+ code: "invalid_batch_item",
181
+ });
182
+ }
183
+
184
+ return {
185
+ referenceId,
186
+ file: hasFile ? item.file : null,
187
+ url: hasUrl ? item.url.trim() : null,
188
+ durationEstimateSec: item.durationEstimateSec || item.duration_estimate_sec || null,
189
+ };
190
+ }
191
+
192
+ function uploadDescriptorForFile(referenceId, file) {
193
+ const descriptor = { reference_id: referenceId };
194
+ if (Number(file?.size || 0) >= MULTIPART_UPLOAD_THRESHOLD_BYTES) {
195
+ descriptor.size_bytes = Number(file.size || 0);
196
+ }
197
+ return descriptor;
198
+ }
199
+
200
+ function normalizeResponseUploads(response) {
201
+ if (Array.isArray(response?.uploads)) {
202
+ return response.uploads;
203
+ }
204
+ if (response?.upload) {
205
+ return [{
206
+ reference_id: response.reference_id || defaultReferenceId(0),
207
+ upload: response.upload,
208
+ }];
209
+ }
210
+ return [];
211
+ }
212
+
213
+ function uploadFromResponse(response, referenceId) {
214
+ if (response?.upload) {
215
+ return response.upload;
216
+ }
217
+ const entry = normalizeResponseUploads(response)
218
+ .find((candidate) => candidate.reference_id === referenceId);
219
+ return entry?.upload || null;
220
+ }
221
+
222
+ function emitProgress(handler, event) {
223
+ if (typeof handler === "function") {
224
+ handler(event);
225
+ }
226
+ }
227
+
228
+ function renderProgressBar(loaded, total, width = 30) {
229
+ if (!total) {
230
+ return `[${".".repeat(width)}]`;
231
+ }
232
+ const ratio = Math.max(0, Math.min(1, Number(loaded || 0) / Number(total || 1)));
233
+ const filled = Math.round(ratio * width);
234
+ return `[${"|".repeat(filled)}${".".repeat(Math.max(0, width - filled))}]`;
235
+ }
236
+
237
+ function formatBytes(bytes) {
238
+ const value = Number(bytes || 0);
239
+ if (value < 1024) return `${value} B`;
240
+ if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KB`;
241
+ if (value < 1024 ** 3) return `${(value / 1024 ** 2).toFixed(1)} MB`;
242
+ return `${(value / 1024 ** 3).toFixed(2)} GB`;
243
+ }
244
+
245
+ function createSdkLoggerProgressHandler(logger = console) {
246
+ return (event) => {
247
+ if (!event || typeof event !== "object" || typeof logger?.log !== "function") {
248
+ return;
249
+ }
250
+ if (event.event === "upload_started") {
251
+ logger.log(`Uploading ${event.uploadFiles}/${event.totalFiles} local file(s) for ${event.jobId}, ${formatBytes(event.totalBytes)} total`);
252
+ return;
253
+ }
254
+ if (event.event === "upload_progress") {
255
+ const loaded = Number(event.batchLoaded ?? event.loaded ?? 0);
256
+ const total = Number(event.batchTotal ?? event.total ?? 0);
257
+ const percent = total ? ((loaded / total) * 100).toFixed(1) : "0.0";
258
+ logger.log(`Uploading ${renderProgressBar(loaded, total)} ${percent}% (${formatBytes(loaded)} / ${formatBytes(total)})`);
259
+ return;
260
+ }
261
+ if (event.event === "upload_completed" && !event.suppressLog) {
262
+ logger.log(`Uploaded completed: ${JSON.stringify(event.response, null, 2)}`);
263
+ }
264
+ };
265
+ }
266
+
267
+ function createSdkPollingLogger(logger = console) {
268
+ let lastStatus = null;
269
+ return {
270
+ start({ jobId, jobStatus }) {
271
+ lastStatus = jobStatus || null;
272
+ if (typeof logger?.log === "function") {
273
+ logger.log(`Polling ${jobId} (${jobStatus || "waiting"})`);
274
+ }
275
+ },
276
+ update({ jobId, jobStatus }) {
277
+ if (jobStatus === lastStatus) {
278
+ return;
279
+ }
280
+ lastStatus = jobStatus || null;
281
+ if (typeof logger?.log === "function") {
282
+ logger.log(`Polling ${jobId} (${jobStatus || "unknown"})`);
283
+ }
284
+ },
285
+ finish({ jobStatus, resultUrl }) {
286
+ if (typeof logger?.log === "function") {
287
+ logger.log(resultUrl
288
+ ? `Polling complete: ${jobStatus} - ${resultUrl}`
289
+ : `Polling complete: ${jobStatus}`);
290
+ }
291
+ },
292
+ timeout({ timeoutSeconds, jobStatus }) {
293
+ if (typeof logger?.log === "function") {
294
+ logger.log(`Polling stopped: timeout after ${timeoutSeconds}s (${jobStatus})`);
295
+ }
296
+ },
297
+ };
298
+ }
299
+
300
+ function composeProgressHandler({ onProgress, showLogs = false, logger = console } = {}) {
301
+ const logHandler = showLogs ? createSdkLoggerProgressHandler(logger) : null;
302
+ if (!logHandler) {
303
+ return typeof onProgress === "function" ? onProgress : null;
304
+ }
305
+ return (event) => {
306
+ emitProgress(logHandler, event);
307
+ emitProgress(onProgress, event);
308
+ };
309
+ }
310
+
311
+ function assertSupportedBatchFormat(file) {
312
+ const lowerName = String(file?.name || "").toLowerCase();
313
+ const lowerType = String(file?.type || contentTypeFromName(file?.name)).toLowerCase();
314
+
315
+ if (lowerName.endsWith(".mp4") || lowerType === "video/mp4") {
316
+ throw new TranscribeAPIError(BATCH_MP4_UNSUPPORTED_MESSAGE, { code: "unsupported_batch_format" });
317
+ }
318
+
319
+ if (
320
+ lowerName.endsWith(".mp3")
321
+ || lowerName.endsWith(".mpeg")
322
+ || lowerName.endsWith(".mpga")
323
+ || lowerName.endsWith(".m4a")
324
+ || lowerName.endsWith(".wav")
325
+ || lowerName.endsWith(".webm")
326
+ || lowerType.includes("audio/mpeg")
327
+ || lowerType.includes("mpga")
328
+ || lowerType.includes("audio/mp4")
329
+ || lowerType.includes("audio/x-m4a")
330
+ || lowerType.includes("audio/wav")
331
+ || lowerType.includes("audio/wave")
332
+ || lowerType.includes("audio/webm")
333
+ || lowerType.includes("video/webm")
334
+ ) {
335
+ return;
336
+ }
337
+
338
+ throw new TranscribeAPIError(BATCH_UNSUPPORTED_MESSAGE, { code: "unsupported_batch_format" });
339
+ }
340
+
341
+ function estimateDurationFromSize(sizeBytes) {
342
+ return Math.max(1, Math.ceil(Number(sizeBytes || 0) / 16000));
343
+ }
344
+
345
+ function ascii(bytes, offset, length) {
346
+ return Array.from(bytes.slice(offset, offset + length), (byte) => String.fromCharCode(byte)).join("");
347
+ }
348
+
349
+ function readUint32(bytes, offset, littleEndian = false) {
350
+ return new DataView(bytes.buffer, bytes.byteOffset + offset, 4).getUint32(0, littleEndian);
351
+ }
352
+
353
+ function readUint64(bytes, offset) {
354
+ const high = readUint32(bytes, offset);
355
+ const low = readUint32(bytes, offset + 4);
356
+ return high * 2 ** 32 + low;
357
+ }
358
+
359
+ function syncSafeInteger(bytes, offset) {
360
+ return ((bytes[offset] & 0x7f) << 21)
361
+ | ((bytes[offset + 1] & 0x7f) << 14)
362
+ | ((bytes[offset + 2] & 0x7f) << 7)
363
+ | (bytes[offset + 3] & 0x7f);
364
+ }
365
+
366
+ function id3Offset(bytes) {
367
+ if (bytes.length >= 10 && ascii(bytes, 0, 3) === "ID3") {
368
+ return 10 + syncSafeInteger(bytes, 6);
369
+ }
370
+ return 0;
371
+ }
372
+
373
+ const MP3_BITRATES = {
374
+ V1L3: [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320],
375
+ V2L3: [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160],
376
+ };
377
+
378
+ const MP3_SAMPLE_RATES = {
379
+ 3: [44100, 48000, 32000],
380
+ 2: [22050, 24000, 16000],
381
+ 0: [11025, 12000, 8000],
382
+ };
383
+
384
+ function mp3FrameInfoAt(bytes, offset) {
385
+ if (offset + 4 > bytes.length || bytes[offset] !== 0xff || (bytes[offset + 1] & 0xe0) !== 0xe0) {
386
+ return null;
387
+ }
388
+
389
+ const versionBits = (bytes[offset + 1] >> 3) & 0x03;
390
+ const layerBits = (bytes[offset + 1] >> 1) & 0x03;
391
+ const bitrateIndex = (bytes[offset + 2] >> 4) & 0x0f;
392
+ const sampleRateIndex = (bytes[offset + 2] >> 2) & 0x03;
393
+ const padding = (bytes[offset + 2] >> 1) & 0x01;
394
+ if (versionBits === 1 || layerBits !== 1 || bitrateIndex === 0 || bitrateIndex === 15 || sampleRateIndex === 3) {
395
+ return null;
396
+ }
397
+
398
+ const sampleRate = MP3_SAMPLE_RATES[versionBits]?.[sampleRateIndex];
399
+ const bitrateKbps = versionBits === 3
400
+ ? MP3_BITRATES.V1L3[bitrateIndex]
401
+ : MP3_BITRATES.V2L3[bitrateIndex];
402
+ if (!sampleRate || !bitrateKbps) {
403
+ return null;
404
+ }
405
+
406
+ const bitrate = bitrateKbps * 1000;
407
+ const frameLength = Math.floor(((versionBits === 3 ? 144 : 72) * bitrate) / sampleRate + padding);
408
+ if (frameLength <= 0) {
409
+ return null;
410
+ }
411
+
412
+ return {
413
+ bitrate,
414
+ sampleRate,
415
+ frameLength,
416
+ };
417
+ }
418
+
419
+ function findMp3Frame(bytes, startOffset = 0) {
420
+ for (let offset = Math.max(0, startOffset); offset < bytes.length - 4; offset += 1) {
421
+ const info = mp3FrameInfoAt(bytes, offset);
422
+ if (info) {
423
+ return { offset, ...info };
424
+ }
425
+ }
426
+ return null;
427
+ }
428
+
429
+ function parseMp3Duration(bytes, objectSize) {
430
+ const offset = id3Offset(bytes);
431
+ const firstFrame = findMp3Frame(bytes, offset);
432
+ if (!firstFrame) {
433
+ return null;
434
+ }
435
+ const audioBytes = Math.max(0, Number(objectSize || bytes.length) - firstFrame.offset);
436
+ const duration = (audioBytes * 8) / firstFrame.bitrate;
437
+ return Number.isFinite(duration) && duration > 0 ? duration : null;
438
+ }
439
+
440
+ function parseWavDuration(bytes) {
441
+ if (bytes.length < 44 || ascii(bytes, 0, 4) !== "RIFF" || ascii(bytes, 8, 4) !== "WAVE") {
442
+ return null;
443
+ }
444
+
445
+ let offset = 12;
446
+ let byteRate = 0;
447
+ let dataSize = 0;
448
+ while (offset + 8 <= bytes.length) {
449
+ const chunkId = ascii(bytes, offset, 4);
450
+ const chunkSize = readUint32(bytes, offset + 4, true);
451
+ if (chunkId === "fmt " && offset + 20 <= bytes.length) {
452
+ byteRate = readUint32(bytes, offset + 16, true);
453
+ } else if (chunkId === "data") {
454
+ dataSize = chunkSize;
455
+ break;
456
+ }
457
+ offset += 8 + chunkSize + (chunkSize % 2);
458
+ }
459
+
460
+ if (!byteRate || !dataSize) {
461
+ return null;
462
+ }
463
+ return dataSize / byteRate;
464
+ }
465
+
466
+ function parseMp4Duration(bytes) {
467
+ for (let offset = 0; offset + 32 < bytes.length; offset += 1) {
468
+ if (ascii(bytes, offset + 4, 4) !== "mvhd") {
469
+ continue;
470
+ }
471
+ const size = readUint32(bytes, offset);
472
+ if (size < 32 || offset + size > bytes.length + 8) {
473
+ continue;
474
+ }
475
+ const version = bytes[offset + 8];
476
+ if (version === 0 && offset + 28 <= bytes.length) {
477
+ const timescale = readUint32(bytes, offset + 20);
478
+ const duration = readUint32(bytes, offset + 24);
479
+ return timescale > 0 ? duration / timescale : null;
480
+ }
481
+ if (version === 1 && offset + 40 <= bytes.length) {
482
+ const timescale = readUint32(bytes, offset + 28);
483
+ const duration = readUint64(bytes, offset + 32);
484
+ return timescale > 0 ? duration / timescale : null;
485
+ }
486
+ }
487
+ return null;
488
+ }
489
+
490
+ function typeFromNameOrContentType(name = "", contentType = "") {
491
+ const lowerName = String(name || "").toLowerCase();
492
+ const lowerType = String(contentType || "").toLowerCase();
493
+ if (lowerType.includes("wav") || lowerName.endsWith(".wav")) {
494
+ return "wav";
495
+ }
496
+ if (
497
+ lowerType.includes("mp4")
498
+ || lowerType.includes("m4a")
499
+ || lowerType.includes("audio/x-m4a")
500
+ || lowerName.endsWith(".m4a")
501
+ ) {
502
+ return "m4a";
503
+ }
504
+ return "mp3";
505
+ }
506
+
507
+ async function estimateDurationFromFile(file) {
508
+ const headerLength = Math.min(Number(file?.size || 0), 2 * 1024 * 1024);
509
+ if (!headerLength) {
510
+ return estimateDurationFromSize(file?.size);
511
+ }
512
+
513
+ try {
514
+ const bytes = new Uint8Array(await file.slice(0, headerLength).arrayBuffer());
515
+ const type = typeFromNameOrContentType(file?.name, file?.type || contentTypeFromName(file?.name));
516
+ const duration = type === "wav"
517
+ ? parseWavDuration(bytes)
518
+ : type === "m4a"
519
+ ? parseMp4Duration(bytes)
520
+ : parseMp3Duration(bytes, file.size);
521
+ if (Number.isFinite(duration) && duration > 0) {
522
+ return Math.max(1, Math.ceil(duration));
523
+ }
524
+ } catch {
525
+ // Fall back to the coarse size heuristic when metadata parsing fails.
526
+ }
527
+
528
+ return estimateDurationFromSize(file?.size);
529
+ }
530
+
531
+ function makeFile(parts, name, type) {
532
+ if (typeof File !== "undefined") {
533
+ return new File(parts, name, { type });
534
+ }
535
+ const blob = new Blob(parts, { type });
536
+ Object.defineProperty(blob, "name", { value: name });
537
+ return blob;
538
+ }
539
+
540
+ async function normalizeFile(input, fallbackName = "audio.mp3") {
541
+ if (
542
+ input
543
+ && typeof input === "object"
544
+ && typeof input.name === "string"
545
+ && Number.isFinite(Number(input.size))
546
+ && typeof input.type === "string"
547
+ && typeof input.slice === "function"
548
+ ) {
549
+ return input;
550
+ }
551
+ if (typeof input === "string") {
552
+ throw new TranscribeAPIError("Worker SDK does not support local file paths. Use a Blob, File, bytes, or remote URL.", {
553
+ code: "unsupported_file_path",
554
+ });
555
+ }
556
+ if (typeof File !== "undefined" && input instanceof File) {
557
+ return input;
558
+ }
559
+ if (input instanceof Blob) {
560
+ return input.name ? input : makeFile([input], fallbackName, input.type || contentTypeFromName(fallbackName));
561
+ }
562
+ if (input?.data instanceof Uint8Array || input?.data instanceof ArrayBuffer) {
563
+ return makeFile([input.data], input.name || fallbackName, input.type || contentTypeFromName(input.name || fallbackName));
564
+ }
565
+ throw new TranscribeAPIError("Invalid file. Provide a File, Blob, or { data, name, type }.");
566
+ }
567
+
568
+ async function parseApiResponse(response) {
569
+ const text = await response.text();
570
+ let data = {};
571
+ try {
572
+ data = text ? JSON.parse(text) : {};
573
+ } catch {
574
+ data = { raw: text };
575
+ }
576
+ if (!response.ok) {
577
+ throw new TranscribeAPIError(data.error || text || `HTTP ${response.status}`, {
578
+ status: response.status,
579
+ code: data.code || null,
580
+ extra: data,
581
+ response: data,
582
+ });
583
+ }
584
+ return data;
585
+ }
586
+
587
+ function normalizeHeaders(headers = {}) {
588
+ return Object.fromEntries(
589
+ Object.entries(headers)
590
+ .filter(([, value]) => value !== undefined && value !== null)
591
+ .map(([key, value]) => [key, String(value)]),
592
+ );
593
+ }
594
+
595
+ async function putObjectWithRetry(upload, body, {
596
+ onProgress,
597
+ loadedOffset = 0,
598
+ totalBytes = 0,
599
+ contentLength = null,
600
+ progressMeta = null,
601
+ debugContext = null,
602
+ } = {}) {
603
+ const response = await retry(async () => {
604
+ const resolvedBody = typeof body === "function" ? await body() : body;
605
+ const putResponse = await fetch(upload.url || upload, {
606
+ method: "PUT",
607
+ headers: normalizeHeaders({
608
+ ...(upload.headers || {}),
609
+ ...(contentLength !== null ? { "Content-Length": contentLength } : {}),
610
+ }),
611
+ body: resolvedBody,
612
+ });
613
+ if (!putResponse.ok) {
614
+ throw new TranscribeAPIError(`R2 upload failed with HTTP ${putResponse.status}.`, {
615
+ status: putResponse.status,
616
+ code: "upload_failed",
617
+ extra: {
618
+ ...(progressMeta || {}),
619
+ ...(debugContext || {}),
620
+ content_length: contentLength,
621
+ },
622
+ });
623
+ }
624
+ return putResponse;
625
+ }, {
626
+ attempts: 5,
627
+ baseDelayMs: 1000,
628
+ });
629
+
630
+ const transferredBytes = Number(
631
+ contentLength
632
+ ?? body?.size
633
+ ?? body?.byteLength
634
+ ?? 0,
635
+ );
636
+ const loaded = loadedOffset + transferredBytes;
637
+ if (onProgress) {
638
+ onProgress({
639
+ loaded,
640
+ total: totalBytes || loaded,
641
+ ...(progressMeta || {}),
642
+ });
643
+ }
644
+ return response;
645
+ }
646
+
647
+ async function runWithConcurrency(items, concurrency, worker) {
648
+ let index = 0;
649
+ const workerCount = Math.max(1, Math.min(items.length || 1, concurrency));
650
+ const workers = Array.from({ length: workerCount }, async () => {
651
+ while (true) {
652
+ const current = index;
653
+ index += 1;
654
+ if (current >= items.length) {
655
+ return;
656
+ }
657
+ await worker(items[current], current);
658
+ }
659
+ });
660
+ await Promise.all(workers);
661
+ }
662
+
663
+ function multipartCompleteXml(parts) {
664
+ const rows = parts
665
+ .sort((a, b) => a.partNumber - b.partNumber)
666
+ .map((part) => `<Part><PartNumber>${part.partNumber}</PartNumber><ETag>${part.etag}</ETag></Part>`)
667
+ .join("");
668
+ return `<CompleteMultipartUpload>${rows}</CompleteMultipartUpload>`;
669
+ }
670
+
671
+ async function uploadMultipart(upload, file, {
672
+ onProgress,
673
+ multipartConcurrency = DEFAULT_MULTIPART_CONCURRENCY,
674
+ onConcurrencyChange = null,
675
+ } = {}) {
676
+ const completed = [];
677
+ const completedPartNumbers = new Set();
678
+ let completedBytes = 0;
679
+ const totalParts = upload.parts.length;
680
+ const concurrency = normalizeMultipartConcurrency(multipartConcurrency);
681
+ let targetConcurrency = concurrency;
682
+ let activeWorkers = 0;
683
+ let fatalError = null;
684
+ const pendingParts = [...upload.parts];
685
+ const partAttempts = new Map();
686
+
687
+ const workers = Array.from({ length: concurrency }, async (_, workerIndex) => {
688
+ while (true) {
689
+ if (fatalError) {
690
+ return;
691
+ }
692
+ if (completedPartNumbers.size >= totalParts) {
693
+ return;
694
+ }
695
+ if (workerIndex >= targetConcurrency) {
696
+ await sleep(MULTIPART_IDLE_WAIT_MS);
697
+ continue;
698
+ }
699
+
700
+ const part = pendingParts.shift();
701
+ if (!part) {
702
+ if (!activeWorkers) {
703
+ return;
704
+ }
705
+ await sleep(MULTIPART_IDLE_WAIT_MS);
706
+ continue;
707
+ }
708
+
709
+ activeWorkers += 1;
710
+ const start = (part.part_number - 1) * upload.part_size;
711
+ const end = Math.min(file.size, start + upload.part_size);
712
+ const chunkSize = end - start;
713
+ try {
714
+ const response = await putObjectWithRetry(
715
+ { url: part.url },
716
+ file.slice(start, end),
717
+ {
718
+ onProgress: null,
719
+ loadedOffset: 0,
720
+ totalBytes: file.size,
721
+ contentLength: chunkSize,
722
+ progressMeta: {
723
+ uploadType: "multipart",
724
+ partNumber: part.part_number,
725
+ totalParts,
726
+ chunkBytes: chunkSize,
727
+ },
728
+ debugContext: {
729
+ file_name: file?.name || null,
730
+ file_size: file?.size || null,
731
+ range_start: start,
732
+ range_end_exclusive: end,
733
+ },
734
+ },
735
+ );
736
+ const etag = response.headers.get("ETag") || response.headers.get("etag");
737
+ completed.push({
738
+ partNumber: part.part_number,
739
+ etag,
740
+ });
741
+ completedPartNumbers.add(part.part_number);
742
+ completedBytes += chunkSize;
743
+ if (onProgress) {
744
+ onProgress({
745
+ loaded: completedBytes,
746
+ total: file.size,
747
+ uploadType: "multipart",
748
+ partNumber: part.part_number,
749
+ totalParts,
750
+ chunkBytes: chunkSize,
751
+ multipartConcurrency: targetConcurrency,
752
+ });
753
+ }
754
+ } catch (error) {
755
+ const attempts = (partAttempts.get(part.part_number) || 0) + 1;
756
+ partAttempts.set(part.part_number, attempts);
757
+ if (isRetryableError(error) && attempts < MAX_MULTIPART_ADAPTIVE_ATTEMPTS) {
758
+ const previousConcurrency = targetConcurrency;
759
+ targetConcurrency = Math.max(1, Math.floor(targetConcurrency / 2));
760
+ pendingParts.push(part);
761
+ if (onConcurrencyChange && targetConcurrency !== previousConcurrency) {
762
+ try {
763
+ onConcurrencyChange({
764
+ previousConcurrency,
765
+ nextConcurrency: targetConcurrency,
766
+ partNumber: part.part_number,
767
+ attempts,
768
+ error: extractErrorInfo(error),
769
+ });
770
+ } catch {
771
+ // Ignore observer failures.
772
+ }
773
+ }
774
+ activeWorkers -= 1;
775
+ await sleep(Math.min(10000, 1000 * (2 ** Math.min(attempts - 1, 3))));
776
+ continue;
777
+ }
778
+ fatalError = new TranscribeAPIError(`Multipart upload failed for part ${part.part_number}/${totalParts}.`, {
779
+ status: error?.status || null,
780
+ code: error?.code || "multipart_part_upload_failed",
781
+ extra: {
782
+ cause_error: error?.error || error?.message || String(error),
783
+ cause_code: error?.code || error?.cause?.code || null,
784
+ cause_status: error?.status || null,
785
+ },
786
+ });
787
+ activeWorkers -= 1;
788
+ return;
789
+ }
790
+
791
+ activeWorkers -= 1;
792
+ }
793
+ });
794
+
795
+ await Promise.all(workers);
796
+ if (fatalError) {
797
+ throw fatalError;
798
+ }
799
+
800
+ const completeResponse = await retry(async () => {
801
+ const response = await fetch(upload.complete_url, {
802
+ method: "POST",
803
+ headers: { "Content-Type": "application/xml" },
804
+ body: multipartCompleteXml(completed),
805
+ });
806
+ if (!response.ok) {
807
+ throw new TranscribeAPIError(`R2 multipart complete failed with HTTP ${response.status}.`, {
808
+ status: response.status,
809
+ code: "multipart_complete_failed",
810
+ });
811
+ }
812
+ return response;
813
+ });
814
+ return completeResponse;
815
+ }
816
+
817
+ async function uploadUsingInstructions(upload, file, options = {}) {
818
+ if (upload.type === "multipart") {
819
+ return uploadMultipart(upload, file, options);
820
+ }
821
+ return putObjectWithRetry(upload, file, {
822
+ onProgress: options.onProgress,
823
+ loadedOffset: 0,
824
+ totalBytes: file.size,
825
+ contentLength: file.size,
826
+ progressMeta: {
827
+ uploadType: "single_put",
828
+ chunkBytes: file.size,
829
+ },
830
+ debugContext: {
831
+ file_name: file?.name || null,
832
+ file_size: file?.size || null,
833
+ },
834
+ });
835
+ }
836
+
837
+ function assertBatchLimits(totalFiles, totalSizeBytes) {
838
+ if (totalFiles > MAX_BATCH_FILES) {
839
+ throw new TranscribeAPIError(`Batch jobs support up to ${MAX_BATCH_FILES} files.`, {
840
+ code: "too_many_files",
841
+ });
842
+ }
843
+ if (totalSizeBytes > MAX_BATCH_TOTAL_SIZE_BYTES) {
844
+ throw new TranscribeAPIError("Batch jobs support up to 10GB total.", {
845
+ code: "batch_too_large",
846
+ });
847
+ }
848
+ }
849
+
850
+ class BigFileJob {
851
+ constructor(client, file, createResponse, options) {
852
+ this.client = client;
853
+ this.file = file;
854
+ this.createResponse = createResponse;
855
+ this.referenceId = options.referenceId || defaultReferenceId(0);
856
+ this.jobId = createResponse.job_id;
857
+ this.jobStatus = createResponse.job_status;
858
+ this.model = createResponse.model || options.model || null;
859
+ this.uploadInfo = uploadFromResponse(createResponse, this.referenceId);
860
+ this.options = options;
861
+ }
862
+
863
+ async upload({ onProgress } = {}) {
864
+ const progress = onProgress || this.options.onProgress;
865
+ if (!this.uploadInfo) {
866
+ return this.createResponse;
867
+ }
868
+ await uploadUsingInstructions(this.uploadInfo, this.file, {
869
+ onProgress: progress,
870
+ multipartConcurrency: this.options.multipartConcurrency,
871
+ onConcurrencyChange: (event) => {
872
+ if (typeof onProgress === "function" || typeof this.options.onProgress === "function") {
873
+ (onProgress || this.options.onProgress)({
874
+ uploadType: "multipart",
875
+ event: "adaptive_concurrency",
876
+ previousConcurrency: event.previousConcurrency,
877
+ multipartConcurrency: event.nextConcurrency,
878
+ partNumber: event.partNumber,
879
+ attempts: event.attempts,
880
+ error: event.error,
881
+ });
882
+ }
883
+ },
884
+ });
885
+ const completion = await this.client.jobs.complete(this.jobId);
886
+ emitProgress(progress, {
887
+ event: "upload_completed",
888
+ jobId: this.jobId,
889
+ response: completion,
890
+ suppressLog: Boolean(this.client.polling),
891
+ });
892
+ return completion;
893
+ }
894
+ }
895
+
896
+ class RemoteBigFileJob {
897
+ constructor(client, createResponse, options) {
898
+ this.client = client;
899
+ this.createResponse = createResponse;
900
+ this.jobId = createResponse.job_id;
901
+ this.jobStatus = createResponse.job_status;
902
+ this.model = createResponse.model || options.model || null;
903
+ }
904
+
905
+ async upload() {
906
+ return this.createResponse;
907
+ }
908
+ }
909
+
910
+ class BatchJob {
911
+ constructor(client, files, createResponse, options) {
912
+ this.client = client;
913
+ this.files = files;
914
+ this.createResponse = createResponse;
915
+ this.jobId = createResponse.job_id;
916
+ this.jobStatus = createResponse.job_status;
917
+ this.model = createResponse.model || options.model || null;
918
+ this.uploadsByReferenceId = new Map(
919
+ normalizeResponseUploads(createResponse).map((entry) => [entry.reference_id, entry.upload]),
920
+ );
921
+ this.options = options;
922
+ }
923
+
924
+ async upload({ onProgress } = {}) {
925
+ if (!this.uploadsByReferenceId.size) {
926
+ return this.createResponse;
927
+ }
928
+
929
+ const progress = onProgress || this.options.onProgress;
930
+ const totalBytes = this.files.reduce((sum, item) => sum + Number(item?.file?.size || 0), 0);
931
+ const loadedByReferenceId = new Map();
932
+ const uploadableFiles = this.files.filter((item) => item.file);
933
+
934
+ emitProgress(progress, {
935
+ event: "upload_started",
936
+ jobId: this.jobId,
937
+ totalFiles: this.files.length,
938
+ uploadFiles: uploadableFiles.length,
939
+ totalBytes,
940
+ });
941
+
942
+ for (const item of this.files) {
943
+ if (!item.file) {
944
+ continue;
945
+ }
946
+ const upload = this.uploadsByReferenceId.get(item.referenceId);
947
+ if (!upload) {
948
+ throw new TranscribeAPIError(`Missing upload instructions for \`${item.referenceId}\`.`, {
949
+ code: "missing_upload_instructions",
950
+ });
951
+ }
952
+ await uploadUsingInstructions(upload, item.file, {
953
+ onProgress: (event) => {
954
+ const fileLoaded = Math.max(0, Math.min(Number(event?.loaded || 0), Number(item.file.size || 0)));
955
+ loadedByReferenceId.set(item.referenceId, fileLoaded);
956
+ const batchLoaded = Array.from(loadedByReferenceId.values())
957
+ .reduce((sum, value) => sum + Number(value || 0), 0);
958
+ emitProgress(progress, {
959
+ ...event,
960
+ event: event?.event || "upload_progress",
961
+ referenceId: item.referenceId,
962
+ fileLoaded,
963
+ fileTotal: Number(item.file.size || 0),
964
+ batchLoaded,
965
+ batchTotal: totalBytes,
966
+ totalFiles: this.files.length,
967
+ uploadFiles: uploadableFiles.length,
968
+ });
969
+ },
970
+ });
971
+ }
972
+
973
+ const completion = await this.client.jobs.complete(this.jobId);
974
+ emitProgress(progress, {
975
+ event: "upload_completed",
976
+ jobId: this.jobId,
977
+ response: completion,
978
+ batchLoaded: totalBytes,
979
+ batchTotal: totalBytes,
980
+ totalFiles: this.files.length,
981
+ uploadFiles: uploadableFiles.length,
982
+ suppressLog: Boolean(this.client.polling),
983
+ });
984
+ return completion;
985
+ }
986
+ }
987
+
988
+ export class TranscribeAPI {
989
+ constructor({
990
+ apiKey,
991
+ baseUrl = DEFAULT_BASE_URL,
992
+ multipartConcurrency = DEFAULT_MULTIPART_CONCURRENCY,
993
+ showLogs = false,
994
+ logger = console,
995
+ polling = null,
996
+ } = {}) {
997
+ if (!apiKey) {
998
+ throw new Error("Missing API key");
999
+ }
1000
+
1001
+ this.apiKey = apiKey;
1002
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
1003
+ this.multipartConcurrency = normalizeMultipartConcurrency(multipartConcurrency);
1004
+ this.showLogs = Boolean(showLogs);
1005
+ this.logger = logger || console;
1006
+ this.polling = normalizePollingConfig(polling);
1007
+ this.batch = {
1008
+ transcribe: (options) => this.transcribeMany(options),
1009
+ };
1010
+ this.jobs = {
1011
+ createBigFile: (options) => this.createBigFileJob(options),
1012
+ createBatch: (options) => this.createBatchJob(options),
1013
+ uploadCompleted: (jobId) => this.requestJson(`/transcribe/${jobId}/upload-completed`, { method: "POST", retryable: true }),
1014
+ complete: (jobId) => this.requestJson(`/transcribe/${jobId}/upload-completed`, { method: "POST", retryable: true }),
1015
+ get: (jobId) => this.requestJson(`/transcribe/${jobId}`),
1016
+ result: (jobId) => this.requestJson(`/transcribe/${jobId}`),
1017
+ };
1018
+ }
1019
+
1020
+ async requestJson(path, { method = "GET", body = null, retryable = false } = {}) {
1021
+ const run = async () => {
1022
+ const response = await fetch(`${this.baseUrl}${path}`, {
1023
+ method,
1024
+ headers: {
1025
+ Authorization: `Bearer ${this.apiKey}`,
1026
+ ...(body ? { "Content-Type": "application/json" } : {}),
1027
+ },
1028
+ body: body ? JSON.stringify(body) : null,
1029
+ });
1030
+ return parseApiResponse(response);
1031
+ };
1032
+ return retryable ? retry(run) : run();
1033
+ }
1034
+
1035
+ async waitForJobCompletion(jobId, {
1036
+ polling = this.polling,
1037
+ showLogs = this.showLogs,
1038
+ logger = this.logger,
1039
+ initialJob = null,
1040
+ } = {}) {
1041
+ const effectivePolling = normalizePollingConfig(polling);
1042
+ if (!effectivePolling) {
1043
+ return this.jobs.get(jobId);
1044
+ }
1045
+
1046
+ const startedAt = Date.now();
1047
+ const pollingLogger = showLogs ? createSdkPollingLogger(logger) : null;
1048
+ let lastJob = initialJob || null;
1049
+ pollingLogger?.start({
1050
+ jobId,
1051
+ jobStatus: lastJob?.job_status || "waiting",
1052
+ });
1053
+
1054
+ while (!TERMINAL_JOB_STATUSES.has(String(lastJob?.job_status || ""))) {
1055
+ if (effectivePolling.timeout !== null && (Date.now() - startedAt) >= effectivePolling.timeout * 1000) {
1056
+ pollingLogger?.timeout({
1057
+ timeoutSeconds: effectivePolling.timeout,
1058
+ jobStatus: lastJob?.job_status || "unknown",
1059
+ });
1060
+ throw new TranscribeAPIError(`Polling timed out after ${effectivePolling.timeout} seconds.`, {
1061
+ code: "polling_timeout",
1062
+ extra: {
1063
+ job_id: jobId,
1064
+ job_status: lastJob?.job_status || null,
1065
+ last_job: lastJob,
1066
+ },
1067
+ response: lastJob,
1068
+ });
1069
+ }
1070
+
1071
+ await sleep(effectivePolling.interval * 1000);
1072
+ lastJob = await this.jobs.get(jobId);
1073
+ pollingLogger?.update({
1074
+ jobId,
1075
+ jobStatus: lastJob?.job_status || "unknown",
1076
+ });
1077
+ }
1078
+
1079
+ pollingLogger?.finish({
1080
+ jobStatus: lastJob?.job_status || "unknown",
1081
+ resultUrl: lastJob?.result_url || null,
1082
+ });
1083
+ return lastJob;
1084
+ }
1085
+
1086
+ async transcribe({
1087
+ file,
1088
+ webhookUrl,
1089
+ onProgress,
1090
+ showLogs,
1091
+ logger,
1092
+ language,
1093
+ task,
1094
+ vadFilter,
1095
+ initialPrompt,
1096
+ vttGranularity,
1097
+ exclude,
1098
+ multipartConcurrency,
1099
+ } = {}) {
1100
+ const progress = composeProgressHandler({
1101
+ onProgress,
1102
+ showLogs: showLogs ?? this.showLogs,
1103
+ logger: logger ?? this.logger,
1104
+ });
1105
+ if (isRemoteFileInput(file)) {
1106
+ const job = await this.createBigFileJob({
1107
+ file,
1108
+ webhookUrl,
1109
+ onProgress: progress,
1110
+ showLogs: false,
1111
+ multipartConcurrency,
1112
+ });
1113
+ const result = await job.upload({ onProgress: progress });
1114
+ if (this.polling && !TERMINAL_JOB_STATUSES.has(String(result?.job_status || ""))) {
1115
+ return this.waitForJobCompletion(result.job_id, {
1116
+ polling: this.polling,
1117
+ showLogs: showLogs ?? this.showLogs,
1118
+ logger: logger ?? this.logger,
1119
+ initialJob: result,
1120
+ });
1121
+ }
1122
+ return result;
1123
+ }
1124
+
1125
+ const normalizedFile = await normalizeFile(file);
1126
+ const estimatedDurationSec = await estimateDurationFromFile(normalizedFile);
1127
+ if (normalizedFile.size > MAX_SYNC_AUDIO_BYTES || estimatedDurationSec > MAX_SYNC_AUDIO_SECONDS) {
1128
+ const job = await this.createBigFileJob({
1129
+ file: normalizedFile,
1130
+ webhookUrl,
1131
+ durationEstimateSec: estimatedDurationSec,
1132
+ onProgress: progress,
1133
+ showLogs: false,
1134
+ multipartConcurrency,
1135
+ });
1136
+ const result = await job.upload({ onProgress: progress });
1137
+ if (this.polling && !TERMINAL_JOB_STATUSES.has(String(result?.job_status || ""))) {
1138
+ return this.waitForJobCompletion(result.job_id, {
1139
+ polling: this.polling,
1140
+ showLogs: showLogs ?? this.showLogs,
1141
+ logger: logger ?? this.logger,
1142
+ initialJob: result,
1143
+ });
1144
+ }
1145
+ return result;
1146
+ }
1147
+
1148
+ return this.transcribeDirect({
1149
+ file: normalizedFile,
1150
+ language,
1151
+ task,
1152
+ vadFilter,
1153
+ initialPrompt,
1154
+ vttGranularity,
1155
+ exclude,
1156
+ });
1157
+ }
1158
+
1159
+ async transcribeDirect({
1160
+ file,
1161
+ language,
1162
+ task,
1163
+ vadFilter,
1164
+ initialPrompt,
1165
+ vttGranularity,
1166
+ exclude,
1167
+ } = {}) {
1168
+ const normalizedFile = await normalizeFile(file);
1169
+ const form = new FormData();
1170
+ form.set("file", normalizedFile, normalizedFile.name);
1171
+ if (language) form.set("language", language);
1172
+ if (task) form.set("task", task);
1173
+ if (vadFilter !== undefined) form.set("vad_filter", String(Boolean(vadFilter)));
1174
+ if (initialPrompt) form.set("initial_prompt", initialPrompt);
1175
+ if (vttGranularity) form.set("vtt_granularity", vttGranularity);
1176
+ if (exclude) form.set("exclude", Array.isArray(exclude) ? exclude.join(",") : exclude);
1177
+
1178
+ const response = await fetch(`${this.baseUrl}/transcribe`, {
1179
+ method: "POST",
1180
+ headers: {
1181
+ Authorization: `Bearer ${this.apiKey}`,
1182
+ },
1183
+ body: form,
1184
+ });
1185
+ return parseApiResponse(response);
1186
+ }
1187
+
1188
+ async transcribeMany({
1189
+ files,
1190
+ webhookUrl,
1191
+ durationEstimateSec,
1192
+ onProgress,
1193
+ showLogs,
1194
+ logger,
1195
+ multipartConcurrency,
1196
+ } = {}) {
1197
+ const progress = composeProgressHandler({
1198
+ onProgress,
1199
+ showLogs: showLogs ?? this.showLogs,
1200
+ logger: logger ?? this.logger,
1201
+ });
1202
+ const job = await this.createBatchJob({
1203
+ files,
1204
+ webhookUrl,
1205
+ durationEstimateSec,
1206
+ onProgress: progress,
1207
+ showLogs: false,
1208
+ multipartConcurrency,
1209
+ });
1210
+ const result = await job.upload({ onProgress: progress });
1211
+ if (this.polling && !TERMINAL_JOB_STATUSES.has(String(result?.job_status || ""))) {
1212
+ return this.waitForJobCompletion(result.job_id, {
1213
+ polling: this.polling,
1214
+ showLogs: showLogs ?? this.showLogs,
1215
+ logger: logger ?? this.logger,
1216
+ initialJob: result,
1217
+ });
1218
+ }
1219
+ return result;
1220
+ }
1221
+
1222
+ async createBigFileJob({
1223
+ file,
1224
+ webhookUrl,
1225
+ durationEstimateSec,
1226
+ onProgress,
1227
+ showLogs,
1228
+ logger,
1229
+ multipartConcurrency,
1230
+ } = {}) {
1231
+ const progress = composeProgressHandler({
1232
+ onProgress,
1233
+ showLogs: showLogs ?? this.showLogs,
1234
+ logger: logger ?? this.logger,
1235
+ });
1236
+ const normalizedConcurrency = normalizeMultipartConcurrency(multipartConcurrency ?? this.multipartConcurrency);
1237
+ if (isRemoteFileInput(file)) {
1238
+ const response = await this.requestJson("/transcribe", {
1239
+ method: "POST",
1240
+ body: {
1241
+ files: [{
1242
+ reference_id: defaultReferenceId(0),
1243
+ url: file.url,
1244
+ }],
1245
+ ...(webhookUrl ? { webhook_url: webhookUrl } : {}),
1246
+ },
1247
+ retryable: true,
1248
+ });
1249
+ return new RemoteBigFileJob(this, response, {
1250
+ onProgress: progress,
1251
+ multipartConcurrency: normalizedConcurrency,
1252
+ });
1253
+ }
1254
+
1255
+ const normalizedFile = await normalizeFile(file);
1256
+ const estimate = durationEstimateSec || await estimateDurationFromFile(normalizedFile);
1257
+ const response = await this.requestJson("/transcribe", {
1258
+ method: "POST",
1259
+ body: {
1260
+ files: [
1261
+ estimate > MAX_SYNC_AUDIO_SECONDS || normalizedFile.size > MAX_SYNC_AUDIO_BYTES
1262
+ ? uploadDescriptorForFile(defaultReferenceId(0), normalizedFile)
1263
+ : { reference_id: defaultReferenceId(0) },
1264
+ ],
1265
+ ...(webhookUrl ? { webhook_url: webhookUrl } : {}),
1266
+ },
1267
+ retryable: true,
1268
+ });
1269
+ emitProgress(progress, {
1270
+ event: "upload_urls_received",
1271
+ jobId: response.job_id,
1272
+ jobStatus: response.job_status,
1273
+ uploadCount: normalizeResponseUploads(response).length,
1274
+ totalFiles: 1,
1275
+ });
1276
+ return new BigFileJob(this, normalizedFile, response, {
1277
+ onProgress: progress,
1278
+ multipartConcurrency: normalizedConcurrency,
1279
+ referenceId: defaultReferenceId(0),
1280
+ });
1281
+ }
1282
+
1283
+ async createBatchJob({
1284
+ files,
1285
+ webhookUrl,
1286
+ durationEstimateSec,
1287
+ onProgress,
1288
+ showLogs,
1289
+ logger,
1290
+ multipartConcurrency,
1291
+ } = {}) {
1292
+ const progress = composeProgressHandler({
1293
+ onProgress,
1294
+ showLogs: showLogs ?? this.showLogs,
1295
+ logger: logger ?? this.logger,
1296
+ });
1297
+ if (!Array.isArray(files) || files.length === 0) {
1298
+ throw new TranscribeAPIError("Batch upload requires at least one file.", { code: "invalid_files" });
1299
+ }
1300
+ assertBatchLimits(files.length, 0);
1301
+ const normalizedItems = files.map((item, index) => normalizeBatchInputItem(item, index));
1302
+
1303
+ const normalizedBatchItems = [];
1304
+ for (let index = 0; index < normalizedItems.length; index += 1) {
1305
+ const item = normalizedItems[index];
1306
+ if (isRemoteBatchItem(item)) {
1307
+ normalizedBatchItems.push({
1308
+ file: null,
1309
+ referenceId: item.referenceId,
1310
+ url: item.url,
1311
+ durationEstimateSec: item.durationEstimateSec || durationEstimateSec || null,
1312
+ });
1313
+ continue;
1314
+ }
1315
+
1316
+ const fileValue = await normalizeFile(item.file, `file_${String(index + 1).padStart(6, "0")}.mp3`);
1317
+ assertSupportedBatchFormat(fileValue);
1318
+ normalizedBatchItems.push({
1319
+ file: fileValue,
1320
+ referenceId: item.referenceId,
1321
+ url: null,
1322
+ durationEstimateSec: item.durationEstimateSec || await estimateDurationFromFile(fileValue),
1323
+ });
1324
+ }
1325
+
1326
+ const totalSizeBytes = normalizedBatchItems.reduce((total, item) => total + Number(item.file?.size || 0), 0);
1327
+ assertBatchLimits(normalizedBatchItems.length, totalSizeBytes);
1328
+ const response = await this.requestJson("/transcribe", {
1329
+ method: "POST",
1330
+ body: {
1331
+ files: normalizedBatchItems.map((item) => (
1332
+ item.url
1333
+ ? {
1334
+ reference_id: item.referenceId,
1335
+ url: item.url,
1336
+ }
1337
+ : uploadDescriptorForFile(item.referenceId, item.file)
1338
+ )),
1339
+ ...(webhookUrl ? { webhook_url: webhookUrl } : {}),
1340
+ },
1341
+ retryable: true,
1342
+ });
1343
+ emitProgress(progress, {
1344
+ event: "upload_urls_received",
1345
+ jobId: response.job_id,
1346
+ jobStatus: response.job_status,
1347
+ uploadCount: normalizeResponseUploads(response).length,
1348
+ totalFiles: normalizedBatchItems.length,
1349
+ });
1350
+ return new BatchJob(this, normalizedBatchItems, response, {
1351
+ onProgress: progress,
1352
+ webhookUrl,
1353
+ multipartConcurrency: normalizeMultipartConcurrency(multipartConcurrency ?? this.multipartConcurrency),
1354
+ });
1355
+ }
1356
+ }
1357
+
1358
+ export default TranscribeAPI;