@happyvertical/speech 0.96.0 → 0.97.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT.md +14 -1
- package/README.md +59 -2
- package/dist/adapters/openai-compatible-transcriber.d.ts +23 -0
- package/dist/adapters/openai-compatible-transcriber.d.ts.map +1 -0
- package/dist/adapters/studio-server.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +581 -54
- package/dist/index.js.map +1 -1
- package/dist/shared/audio.d.ts +47 -0
- package/dist/shared/audio.d.ts.map +1 -0
- package/dist/shared/env.d.ts +70 -0
- package/dist/shared/env.d.ts.map +1 -0
- package/dist/shared/errors.d.ts +3 -0
- package/dist/shared/errors.d.ts.map +1 -1
- package/dist/shared/factory.d.ts +1 -4
- package/dist/shared/factory.d.ts.map +1 -1
- package/dist/shared/http.d.ts +29 -3
- package/dist/shared/http.d.ts.map +1 -1
- package/dist/shared/retry.d.ts +31 -0
- package/dist/shared/retry.d.ts.map +1 -0
- package/dist/shared/types.d.ts +65 -5
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/shared/usage.d.ts +35 -0
- package/dist/shared/usage.d.ts.map +1 -0
- package/metadata.json +1 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -24,22 +24,416 @@ var InvalidSpeechAdapterError = class extends SpeechError {
|
|
|
24
24
|
var SpeechProviderError = class extends SpeechError {
|
|
25
25
|
status;
|
|
26
26
|
responseBody;
|
|
27
|
+
/** Parsed `Retry-After` delay in milliseconds, when the provider sent one. */
|
|
28
|
+
retryAfterMs;
|
|
27
29
|
constructor(adapter, message, options = {}) {
|
|
28
30
|
super(message, "SPEECH_PROVIDER_ERROR", adapter);
|
|
29
31
|
this.name = "SpeechProviderError";
|
|
30
32
|
this.status = options.status;
|
|
31
33
|
this.responseBody = options.responseBody;
|
|
34
|
+
this.retryAfterMs = options.retryAfterMs;
|
|
32
35
|
if (options.cause !== void 0) this.cause = options.cause;
|
|
33
36
|
}
|
|
34
37
|
};
|
|
35
38
|
//#endregion
|
|
39
|
+
//#region src/shared/audio.ts
|
|
40
|
+
/**
|
|
41
|
+
* Audio input normalisation shared by speech adapters: accepts the input
|
|
42
|
+
* shapes callers actually have (Blob, Buffer, Uint8Array, ArrayBuffer,
|
|
43
|
+
* ReadableStream), enforces byte limits while buffering, and derives a
|
|
44
|
+
* multipart filename whose extension matches the MIME type.
|
|
45
|
+
*/
|
|
46
|
+
/** OpenAI caps `/audio/transcriptions` uploads at 25 MB. */
|
|
47
|
+
var DEFAULT_MAX_AUDIO_BYTES = 25 * 1024 * 1024;
|
|
48
|
+
var MIME_EXTENSIONS = {
|
|
49
|
+
"audio/webm": "webm",
|
|
50
|
+
"video/webm": "webm",
|
|
51
|
+
"audio/mp4": "m4a",
|
|
52
|
+
"audio/m4a": "m4a",
|
|
53
|
+
"audio/x-m4a": "m4a",
|
|
54
|
+
"video/mp4": "mp4",
|
|
55
|
+
"audio/wav": "wav",
|
|
56
|
+
"audio/wave": "wav",
|
|
57
|
+
"audio/x-wav": "wav",
|
|
58
|
+
"audio/vnd.wave": "wav",
|
|
59
|
+
"audio/ogg": "ogg",
|
|
60
|
+
"application/ogg": "ogg",
|
|
61
|
+
"audio/mpeg": "mp3",
|
|
62
|
+
"audio/mp3": "mp3",
|
|
63
|
+
"audio/mpga": "mp3",
|
|
64
|
+
"audio/flac": "flac",
|
|
65
|
+
"audio/x-flac": "flac"
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Strips parameters (e.g. `;codecs=opus`) and lower-cases a MIME type.
|
|
69
|
+
* Returns `undefined` for blank input.
|
|
70
|
+
*/
|
|
71
|
+
function normalizeMimeType(mimeType) {
|
|
72
|
+
const essence = mimeType?.split(";")[0]?.trim().toLowerCase();
|
|
73
|
+
return essence ? essence : void 0;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Maps an audio MIME type to the file extension OpenAI-compatible servers use
|
|
77
|
+
* to detect the container format. Codec parameters are ignored, so
|
|
78
|
+
* `audio/webm;codecs=opus` maps to `webm`. Returns `undefined` when unknown.
|
|
79
|
+
*/
|
|
80
|
+
function mimeTypeToAudioExtension(mimeType) {
|
|
81
|
+
const essence = normalizeMimeType(mimeType);
|
|
82
|
+
return essence ? MIME_EXTENSIONS[essence] : void 0;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Accepts either an {@link AudioInput} wrapper or a bare {@link AudioSource}
|
|
86
|
+
* and returns a size-checked Blob plus the metadata needed for multipart
|
|
87
|
+
* uploads.
|
|
88
|
+
*/
|
|
89
|
+
async function normalizeAudioInput(input, options = {}) {
|
|
90
|
+
const wrapped = isAudioInput(input) ? input : { data: input };
|
|
91
|
+
const { adapter, signal } = options;
|
|
92
|
+
signal?.throwIfAborted();
|
|
93
|
+
const maxBytes = resolveMaxBytes(options.maxBytes, adapter);
|
|
94
|
+
const data = wrapped.data;
|
|
95
|
+
const blobType = typeof Blob !== "undefined" && data instanceof Blob ? data.type : void 0;
|
|
96
|
+
const mimeType = options.mimeType ?? wrapped.mimeType ?? wrapped.contentType ?? (blobType || void 0) ?? "application/octet-stream";
|
|
97
|
+
let blob;
|
|
98
|
+
if (typeof Blob !== "undefined" && data instanceof Blob) {
|
|
99
|
+
assertWithinLimit(data.size, maxBytes, adapter);
|
|
100
|
+
blob = data.type === mimeType ? data : new Blob([data], { type: mimeType });
|
|
101
|
+
} else if (data instanceof Uint8Array) {
|
|
102
|
+
assertWithinLimit(data.byteLength, maxBytes, adapter);
|
|
103
|
+
blob = new Blob([copyBytes(data)], { type: mimeType });
|
|
104
|
+
} else if (data instanceof ArrayBuffer) {
|
|
105
|
+
assertWithinLimit(data.byteLength, maxBytes, adapter);
|
|
106
|
+
blob = new Blob([data], { type: mimeType });
|
|
107
|
+
} else if (isReadableStream(data)) {
|
|
108
|
+
const chunks = await readStreamWithLimit(data, maxBytes, adapter, signal);
|
|
109
|
+
blob = new Blob(chunks, { type: mimeType });
|
|
110
|
+
} else throw new SpeechConfigurationError("Unsupported audio input: expected Blob, Buffer, Uint8Array, ArrayBuffer, or ReadableStream", adapter);
|
|
111
|
+
if (blob.size === 0) throw new SpeechConfigurationError("Audio input is empty", adapter);
|
|
112
|
+
const extension = options.deriveExtension === false ? void 0 : mimeTypeToAudioExtension(mimeType);
|
|
113
|
+
const basename = options.defaultBasename ?? "audio";
|
|
114
|
+
const filename = wrapped.filename ?? (extension ? `${basename}.${extension}` : basename);
|
|
115
|
+
return {
|
|
116
|
+
blob,
|
|
117
|
+
mimeType,
|
|
118
|
+
bytes: blob.size,
|
|
119
|
+
filename,
|
|
120
|
+
durationSeconds: wrapped.durationSeconds
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function isAudioInput(value) {
|
|
124
|
+
return typeof value === "object" && value !== null && "data" in value && !(typeof Blob !== "undefined" && value instanceof Blob) && !(value instanceof Uint8Array) && !(value instanceof ArrayBuffer) && !isReadableStream(value);
|
|
125
|
+
}
|
|
126
|
+
function isReadableStream(value) {
|
|
127
|
+
return typeof value === "object" && value !== null && typeof value.getReader === "function";
|
|
128
|
+
}
|
|
129
|
+
function resolveMaxBytes(maxBytes, adapter) {
|
|
130
|
+
if (maxBytes === void 0) return Number.POSITIVE_INFINITY;
|
|
131
|
+
if (Number.isNaN(maxBytes) || maxBytes <= 0) throw new SpeechConfigurationError("maxBytes must be a positive number", adapter);
|
|
132
|
+
return maxBytes;
|
|
133
|
+
}
|
|
134
|
+
function assertWithinLimit(bytes, maxBytes, adapter) {
|
|
135
|
+
if (bytes > maxBytes) throw new SpeechConfigurationError(`Audio input exceeds maxBytes (${bytes} > ${maxBytes} bytes)`, adapter);
|
|
136
|
+
}
|
|
137
|
+
async function readStreamWithLimit(stream, maxBytes, adapter, signal) {
|
|
138
|
+
const reader = stream.getReader();
|
|
139
|
+
const chunks = [];
|
|
140
|
+
let total = 0;
|
|
141
|
+
const onAbort = () => {
|
|
142
|
+
reader.cancel(signal?.reason).catch(() => void 0);
|
|
143
|
+
};
|
|
144
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
145
|
+
try {
|
|
146
|
+
for (;;) {
|
|
147
|
+
const { done, value } = await reader.read();
|
|
148
|
+
signal?.throwIfAborted();
|
|
149
|
+
if (done) break;
|
|
150
|
+
total += chunkByteLength(value, adapter);
|
|
151
|
+
if (total > maxBytes) {
|
|
152
|
+
await reader.cancel().catch(() => void 0);
|
|
153
|
+
throw new SpeechConfigurationError(`Audio stream exceeds maxBytes (more than ${maxBytes} bytes)`, adapter);
|
|
154
|
+
}
|
|
155
|
+
chunks.push(toBytes(value));
|
|
156
|
+
}
|
|
157
|
+
} finally {
|
|
158
|
+
signal?.removeEventListener("abort", onAbort);
|
|
159
|
+
reader.releaseLock();
|
|
160
|
+
}
|
|
161
|
+
return chunks;
|
|
162
|
+
}
|
|
163
|
+
function chunkByteLength(value, adapter) {
|
|
164
|
+
if (value instanceof Uint8Array || value instanceof ArrayBuffer) return value.byteLength;
|
|
165
|
+
throw new SpeechConfigurationError("Audio stream chunks must be Uint8Array or ArrayBuffer", adapter);
|
|
166
|
+
}
|
|
167
|
+
function toBytes(value) {
|
|
168
|
+
return value instanceof Uint8Array ? copyBytes(value) : new Uint8Array(value.slice(0));
|
|
169
|
+
}
|
|
170
|
+
function copyBytes(bytes) {
|
|
171
|
+
const copy = new Uint8Array(bytes.byteLength);
|
|
172
|
+
copy.set(bytes);
|
|
173
|
+
return copy;
|
|
174
|
+
}
|
|
175
|
+
//#endregion
|
|
176
|
+
//#region src/shared/env.ts
|
|
177
|
+
/**
|
|
178
|
+
* Environment configuration for speech adapters.
|
|
179
|
+
*
|
|
180
|
+
* Transcriber settings resolve in this order (first wins):
|
|
181
|
+
* explicit options → factory context → `HAVE_SPEECH_TRANSCRIBER_*` →
|
|
182
|
+
* legacy `HAVE_SPEECH_STT_*` → gateway `STT_*` aliases.
|
|
183
|
+
*/
|
|
184
|
+
/** Environment variable names read for each transcriber setting, in priority order. */
|
|
185
|
+
var TRANSCRIBER_ENV_KEYS = {
|
|
186
|
+
type: [
|
|
187
|
+
"HAVE_SPEECH_TRANSCRIBER_TYPE",
|
|
188
|
+
"HAVE_SPEECH_STT_TYPE",
|
|
189
|
+
"HAVE_SPEECH_STT_ADAPTER",
|
|
190
|
+
"STT_ADAPTER"
|
|
191
|
+
],
|
|
192
|
+
baseUrl: [
|
|
193
|
+
"HAVE_SPEECH_TRANSCRIBER_BASE_URL",
|
|
194
|
+
"HAVE_SPEECH_STT_BASE_URL",
|
|
195
|
+
"STT_BASE_URL"
|
|
196
|
+
],
|
|
197
|
+
model: [
|
|
198
|
+
"HAVE_SPEECH_TRANSCRIBER_MODEL",
|
|
199
|
+
"HAVE_SPEECH_STT_MODEL",
|
|
200
|
+
"STT_MODEL"
|
|
201
|
+
],
|
|
202
|
+
apiKey: [
|
|
203
|
+
"HAVE_SPEECH_TRANSCRIBER_API_KEY",
|
|
204
|
+
"HAVE_SPEECH_STT_API_KEY",
|
|
205
|
+
"STT_API_KEY",
|
|
206
|
+
"SPEECH_API_KEY"
|
|
207
|
+
],
|
|
208
|
+
timeoutMs: [
|
|
209
|
+
"HAVE_SPEECH_TRANSCRIBER_TIMEOUT",
|
|
210
|
+
"HAVE_SPEECH_TRANSCRIBER_TIMEOUT_MS",
|
|
211
|
+
"HAVE_SPEECH_STT_TIMEOUT_MS",
|
|
212
|
+
"STT_TIMEOUT_MS"
|
|
213
|
+
],
|
|
214
|
+
maxBytes: ["HAVE_SPEECH_TRANSCRIBER_MAX_BYTES", "STT_MAX_BYTES"],
|
|
215
|
+
headers: ["HAVE_SPEECH_TRANSCRIBER_HEADERS", "STT_HEADERS"],
|
|
216
|
+
path: [
|
|
217
|
+
"HAVE_SPEECH_TRANSCRIBER_PATH",
|
|
218
|
+
"HAVE_SPEECH_STT_PATH",
|
|
219
|
+
"STT_PATH"
|
|
220
|
+
]
|
|
221
|
+
};
|
|
222
|
+
function readTranscriberEnv(env) {
|
|
223
|
+
return {
|
|
224
|
+
type: readEnv(env, ...TRANSCRIBER_ENV_KEYS.type),
|
|
225
|
+
baseUrl: readEnv(env, ...TRANSCRIBER_ENV_KEYS.baseUrl),
|
|
226
|
+
model: readEnv(env, ...TRANSCRIBER_ENV_KEYS.model),
|
|
227
|
+
apiKey: readEnv(env, ...TRANSCRIBER_ENV_KEYS.apiKey),
|
|
228
|
+
timeoutMs: parseOptionalInteger(readEnv(env, ...TRANSCRIBER_ENV_KEYS.timeoutMs)),
|
|
229
|
+
maxBytes: parseOptionalInteger(readEnv(env, ...TRANSCRIBER_ENV_KEYS.maxBytes)),
|
|
230
|
+
headers: parseHeadersEnv(env, TRANSCRIBER_ENV_KEYS.headers),
|
|
231
|
+
path: readEnv(env, ...TRANSCRIBER_ENV_KEYS.path)
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
function hasTranscriberEnv(env) {
|
|
235
|
+
return hasAnyEnv(env, ...TRANSCRIBER_ENV_KEYS.type, ...TRANSCRIBER_ENV_KEYS.baseUrl);
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Merges explicit transcriber options with the factory context and the
|
|
239
|
+
* environment. Explicit options always win; headers are merged so a
|
|
240
|
+
* per-deployment env header (e.g. a gateway virtual key) can be combined with
|
|
241
|
+
* explicit ones.
|
|
242
|
+
*/
|
|
243
|
+
function resolveTranscriberConfig(options = {}, context = {}) {
|
|
244
|
+
const fromEnv = readTranscriberEnv(context.env ?? defaultEnv());
|
|
245
|
+
const headers = mergeHeaderInits(fromEnv.headers, context.headers, options.headers);
|
|
246
|
+
return {
|
|
247
|
+
type: options.type ?? fromEnv.type,
|
|
248
|
+
baseUrl: (options.baseUrl ?? fromEnv.baseUrl)?.trim(),
|
|
249
|
+
model: options.model ?? fromEnv.model,
|
|
250
|
+
apiKey: options.apiKey ?? fromEnv.apiKey,
|
|
251
|
+
timeoutMs: options.timeoutMs ?? fromEnv.timeoutMs,
|
|
252
|
+
maxBytes: options.maxBytes ?? fromEnv.maxBytes,
|
|
253
|
+
headers,
|
|
254
|
+
fetch: options.fetch ?? context.fetch,
|
|
255
|
+
path: fromEnv.path
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
/** Merges header sets left to right; later sets win per header name. */
|
|
259
|
+
function mergeHeaderInits(...sets) {
|
|
260
|
+
const present = sets.filter((set) => Boolean(set));
|
|
261
|
+
if (present.length === 0) return;
|
|
262
|
+
const merged = new Headers();
|
|
263
|
+
for (const set of present) new Headers(set).forEach((value, key) => {
|
|
264
|
+
merged.set(key, value);
|
|
265
|
+
});
|
|
266
|
+
return merged;
|
|
267
|
+
}
|
|
268
|
+
function readEnv(env, ...keys) {
|
|
269
|
+
for (const key of keys) {
|
|
270
|
+
const value = env[key];
|
|
271
|
+
if (value?.trim()) return value.trim();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
function hasAnyEnv(env, ...keys) {
|
|
275
|
+
return keys.some((key) => Boolean(env[key]?.trim()));
|
|
276
|
+
}
|
|
277
|
+
function parseOptionalInteger(value) {
|
|
278
|
+
if (!value) return;
|
|
279
|
+
const parsed = Number.parseInt(value, 10);
|
|
280
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
281
|
+
}
|
|
282
|
+
function defaultEnv() {
|
|
283
|
+
return globalThis.process?.env ?? {};
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Parses a JSON object of header names to string values. The raw value is
|
|
287
|
+
* never echoed in errors because it may carry credentials.
|
|
288
|
+
*/
|
|
289
|
+
function parseHeadersEnv(env, keys) {
|
|
290
|
+
for (const key of keys) {
|
|
291
|
+
const raw = env[key]?.trim();
|
|
292
|
+
if (!raw) continue;
|
|
293
|
+
let parsed;
|
|
294
|
+
try {
|
|
295
|
+
parsed = JSON.parse(raw);
|
|
296
|
+
} catch {
|
|
297
|
+
throw new SpeechConfigurationError(`${key} must be a JSON object of header names to string values`);
|
|
298
|
+
}
|
|
299
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || !Object.values(parsed).every((value) => typeof value === "string")) throw new SpeechConfigurationError(`${key} must be a JSON object of header names to string values`);
|
|
300
|
+
return parsed;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
//#endregion
|
|
304
|
+
//#region src/shared/retry.ts
|
|
305
|
+
/**
|
|
306
|
+
* Retry with exponential backoff for speech provider calls. Retries only
|
|
307
|
+
* rate-limit (429) and server (5xx) responses, honours `Retry-After`, and
|
|
308
|
+
* stops immediately when the caller aborts.
|
|
309
|
+
*/
|
|
310
|
+
var DEFAULT_SPEECH_RETRY = {
|
|
311
|
+
maxRetries: 2,
|
|
312
|
+
initialDelayMs: 500,
|
|
313
|
+
maxDelayMs: 3e4
|
|
314
|
+
};
|
|
315
|
+
function resolveRetryOptions(retry, defaults = DEFAULT_SPEECH_RETRY) {
|
|
316
|
+
if (retry === false) return false;
|
|
317
|
+
if (retry === void 0) return defaults;
|
|
318
|
+
const base = defaults || DEFAULT_SPEECH_RETRY;
|
|
319
|
+
return {
|
|
320
|
+
maxRetries: Math.floor(finiteRetryValue("maxRetries", retry.maxRetries, base.maxRetries)),
|
|
321
|
+
initialDelayMs: finiteRetryValue("initialDelayMs", retry.initialDelayMs, base.initialDelayMs),
|
|
322
|
+
maxDelayMs: finiteRetryValue("maxDelayMs", retry.maxDelayMs, base.maxDelayMs)
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Rejects non-finite values (`NaN`, `Infinity`): they would make the retry
|
|
327
|
+
* budget or delay cap unbounded. Negative values clamp to `0`.
|
|
328
|
+
*/
|
|
329
|
+
function finiteRetryValue(name, value, fallback) {
|
|
330
|
+
if (value === void 0) return Math.max(0, fallback);
|
|
331
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new SpeechConfigurationError(`Speech retry option ${name} must be a finite number`);
|
|
332
|
+
return Math.max(0, value);
|
|
333
|
+
}
|
|
334
|
+
function isRetryableStatus(status) {
|
|
335
|
+
return status === 429 || status !== void 0 && status >= 500;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Parses a `Retry-After` header (delta-seconds or HTTP-date) into
|
|
339
|
+
* milliseconds. Returns `undefined` when absent or unparseable.
|
|
340
|
+
*/
|
|
341
|
+
function parseRetryAfter(value, now = Date.now()) {
|
|
342
|
+
if (!value?.trim()) return;
|
|
343
|
+
const trimmed = value.trim();
|
|
344
|
+
if (/^\d+(\.\d+)?$/.test(trimmed)) return Math.round(Number(trimmed) * 1e3);
|
|
345
|
+
const date = Date.parse(trimmed);
|
|
346
|
+
return Number.isNaN(date) ? void 0 : Math.max(0, date - now);
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Runs `attempt` until it succeeds, throws a non-retryable error, or the
|
|
350
|
+
* retry budget is exhausted. Retryable errors are {@link SpeechProviderError}
|
|
351
|
+
* instances whose `status` is 429 or 5xx.
|
|
352
|
+
*/
|
|
353
|
+
async function withSpeechRetry(attempt, retry, signal) {
|
|
354
|
+
const maxRetries = retry ? retry.maxRetries : 0;
|
|
355
|
+
for (let attemptNumber = 0;; attemptNumber += 1) try {
|
|
356
|
+
return await attempt(attemptNumber);
|
|
357
|
+
} catch (error) {
|
|
358
|
+
if (!retry || attemptNumber >= maxRetries || signal?.aborted || !(error instanceof SpeechProviderError) || !isRetryableStatus(error.status)) throw error;
|
|
359
|
+
const backoff = Math.min(retry.maxDelayMs, retry.initialDelayMs * 2 ** attemptNumber);
|
|
360
|
+
const retryAfterMs = error.retryAfterMs;
|
|
361
|
+
if (retryAfterMs !== void 0 && retryAfterMs > retry.maxDelayMs) throw error;
|
|
362
|
+
await sleep(Math.max(backoff, retryAfterMs ?? 0), signal);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
function sleep(ms, signal) {
|
|
366
|
+
return new Promise((resolve, reject) => {
|
|
367
|
+
if (signal?.aborted) {
|
|
368
|
+
reject(signal.reason);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const onAbort = () => {
|
|
372
|
+
clearTimeout(timer);
|
|
373
|
+
reject(signal?.reason);
|
|
374
|
+
};
|
|
375
|
+
const timer = setTimeout(() => {
|
|
376
|
+
signal?.removeEventListener("abort", onAbort);
|
|
377
|
+
resolve();
|
|
378
|
+
}, ms);
|
|
379
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
//#endregion
|
|
36
383
|
//#region src/shared/http.ts
|
|
384
|
+
/**
|
|
385
|
+
* Parses a base URL, dropping any fragment and keeping any query string.
|
|
386
|
+
* Throws {@link SpeechConfigurationError} for a blank or unparseable value.
|
|
387
|
+
* The value is not echoed in the error because it may carry credentials.
|
|
388
|
+
*/
|
|
389
|
+
function parseBaseUrl(baseUrl) {
|
|
390
|
+
const trimmed = baseUrl.trim();
|
|
391
|
+
if (!trimmed) throw new SpeechConfigurationError("Speech adapter baseUrl is required");
|
|
392
|
+
let url;
|
|
393
|
+
try {
|
|
394
|
+
url = new URL(trimmed);
|
|
395
|
+
} catch {
|
|
396
|
+
throw new SpeechConfigurationError("Speech adapter baseUrl is not a valid absolute URL");
|
|
397
|
+
}
|
|
398
|
+
url.hash = "";
|
|
399
|
+
return url;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Returns `baseUrl` with a trailing `/` on its path so relative paths resolve
|
|
403
|
+
* beneath it. The query string is preserved and any fragment is dropped.
|
|
404
|
+
*/
|
|
37
405
|
function normalizeBaseUrl(baseUrl) {
|
|
38
|
-
|
|
39
|
-
|
|
406
|
+
const url = parseBaseUrl(baseUrl);
|
|
407
|
+
if (!url.pathname.endsWith("/")) url.pathname = `${url.pathname}/`;
|
|
408
|
+
return url.toString();
|
|
40
409
|
}
|
|
410
|
+
/**
|
|
411
|
+
* Resolves `path` against `baseUrl`. A relative path without its own query
|
|
412
|
+
* inherits the base URL's query (for example a gateway `api-version`); an
|
|
413
|
+
* absolute URL is returned unchanged.
|
|
414
|
+
*/
|
|
41
415
|
function resolveSpeechUrl(baseUrl, path) {
|
|
42
|
-
|
|
416
|
+
const base = new URL(normalizeBaseUrl(baseUrl));
|
|
417
|
+
const isAbsolute = /^[a-z][a-z\d+.-]*:/i.test(path);
|
|
418
|
+
const resolved = new URL(isAbsolute ? path : path.replace(/^\//, ""), base);
|
|
419
|
+
if (!isAbsolute && !resolved.search && base.search) resolved.search = base.search;
|
|
420
|
+
return resolved.toString();
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Resolves an OpenAI-compatible endpoint from a base URL. Accepts a server
|
|
424
|
+
* root (`https://api.openai.com` → `/v1/<resource>`), an API root ending in a
|
|
425
|
+
* version segment (`http://gateway/stt/v1` → `/<resource>`), or the full
|
|
426
|
+
* endpoint URL (used as-is). Only the path is rewritten: the query string is
|
|
427
|
+
* preserved and any fragment is dropped.
|
|
428
|
+
*/
|
|
429
|
+
function resolveOpenAICompatibleUrl(baseUrl, resource) {
|
|
430
|
+
const trimmedResource = resource.replace(/^\/+|\/+$/g, "");
|
|
431
|
+
const url = parseBaseUrl(baseUrl);
|
|
432
|
+
const pathname = url.pathname.replace(/\/+$/, "");
|
|
433
|
+
if (pathname.endsWith(`/${trimmedResource}`)) url.pathname = pathname;
|
|
434
|
+
else if (/\/v\d+$/.test(pathname)) url.pathname = `${pathname}/${trimmedResource}`;
|
|
435
|
+
else url.pathname = `${pathname}/v1/${trimmedResource}`;
|
|
436
|
+
return url.toString();
|
|
43
437
|
}
|
|
44
438
|
function resolveFetch(fetchOverride) {
|
|
45
439
|
if (fetchOverride) return fetchOverride;
|
|
@@ -68,11 +462,21 @@ var HttpSpeechAdapter = class {
|
|
|
68
462
|
this.headers = options.headers;
|
|
69
463
|
this.timeoutMs = options.timeoutMs;
|
|
70
464
|
}
|
|
71
|
-
|
|
465
|
+
/**
|
|
466
|
+
* POSTs to `path` (relative to `baseUrl`, or an absolute URL). The optional
|
|
467
|
+
* `retry` policy retries 429/5xx responses; `timeoutMs` applies per attempt.
|
|
468
|
+
* The request body must be re-sendable (string, Blob, FormData) when
|
|
469
|
+
* retries are enabled.
|
|
470
|
+
*/
|
|
471
|
+
async post(adapterName, path, init, readResponse, retry = false) {
|
|
472
|
+
return withSpeechRetry(() => this.postOnce(adapterName, path, init, readResponse), retry, init.signal ?? void 0);
|
|
473
|
+
}
|
|
474
|
+
async postOnce(adapterName, path, init, readResponse) {
|
|
72
475
|
const timeoutController = this.timeoutMs && this.timeoutMs > 0 ? new AbortController() : void 0;
|
|
73
476
|
const timeout = timeoutController && this.timeoutMs ? setTimeout(() => timeoutController.abort(), this.timeoutMs) : void 0;
|
|
74
477
|
const requestSignal = composeAbortSignals(init.signal, timeoutController?.signal);
|
|
75
478
|
try {
|
|
479
|
+
requestSignal.signal?.throwIfAborted();
|
|
76
480
|
const response = await this.fetchImpl(resolveSpeechUrl(this.baseUrl, path), {
|
|
77
481
|
...init,
|
|
78
482
|
method: "POST",
|
|
@@ -82,7 +486,7 @@ var HttpSpeechAdapter = class {
|
|
|
82
486
|
}, init.headers),
|
|
83
487
|
signal: requestSignal.signal
|
|
84
488
|
});
|
|
85
|
-
await assertOk(response, adapterName);
|
|
489
|
+
await assertOk(response, adapterName, this.apiKey);
|
|
86
490
|
return await readResponse(response);
|
|
87
491
|
} finally {
|
|
88
492
|
if (timeout) clearTimeout(timeout);
|
|
@@ -122,26 +526,19 @@ function composeAbortSignals(...signals) {
|
|
|
122
526
|
cleanup
|
|
123
527
|
};
|
|
124
528
|
}
|
|
125
|
-
async function assertOk(response, adapterName) {
|
|
529
|
+
async function assertOk(response, adapterName, apiKey) {
|
|
126
530
|
if (response.ok) return;
|
|
127
531
|
const responseBody = await response.text().catch(() => void 0);
|
|
128
532
|
throw new SpeechProviderError(adapterName, `${adapterName} speech request failed with HTTP ${response.status}`, {
|
|
129
533
|
status: response.status,
|
|
130
|
-
responseBody
|
|
534
|
+
responseBody: redactSecret(responseBody, apiKey),
|
|
535
|
+
retryAfterMs: parseRetryAfter(response.headers.get("retry-after"))
|
|
131
536
|
});
|
|
132
537
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const contentType = audio.contentType ?? (data.type || "application/octet-stream");
|
|
138
|
-
const blob = data.type === contentType ? data : new Blob([data], { type: contentType });
|
|
139
|
-
form.append(fieldName, blob, filename);
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
const contentType = audio.contentType ?? "application/octet-stream";
|
|
143
|
-
const blobPart = data instanceof Uint8Array ? arrayBufferFromBytes(data) : data;
|
|
144
|
-
form.append(fieldName, new Blob([blobPart], { type: contentType }), filename);
|
|
538
|
+
/** Removes an API key from provider-supplied text before it is surfaced. */
|
|
539
|
+
function redactSecret(text, secret) {
|
|
540
|
+
if (!text || !secret) return text;
|
|
541
|
+
return text.split(secret).join("[REDACTED]");
|
|
145
542
|
}
|
|
146
543
|
function appendOptionalFormValue(form, name, value) {
|
|
147
544
|
if (value === void 0 || value === null) return;
|
|
@@ -314,6 +711,137 @@ var OpenAICompatibleSpeechSynthesizer = class extends HttpSpeechAdapter {
|
|
|
314
711
|
}
|
|
315
712
|
};
|
|
316
713
|
//#endregion
|
|
714
|
+
//#region src/shared/usage.ts
|
|
715
|
+
/**
|
|
716
|
+
* Invokes every configured usage callback in order (adapter-level first, then
|
|
717
|
+
* request-level). Callback errors propagate to the caller so that billing
|
|
718
|
+
* hooks never fail silently.
|
|
719
|
+
*/
|
|
720
|
+
async function reportSpeechUsage(usage, ...callbacks) {
|
|
721
|
+
for (const callback of callbacks) if (callback) await callback(usage);
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Extracts audio seconds from an OpenAI-style `usage` block
|
|
725
|
+
* (`{ type: 'duration', seconds }`).
|
|
726
|
+
*/
|
|
727
|
+
function audioSecondsFromProviderUsage(providerUsage) {
|
|
728
|
+
if (!providerUsage) return;
|
|
729
|
+
const seconds = providerUsage.seconds;
|
|
730
|
+
return typeof seconds === "number" && Number.isFinite(seconds) ? seconds : void 0;
|
|
731
|
+
}
|
|
732
|
+
//#endregion
|
|
733
|
+
//#region src/adapters/openai-compatible-transcriber.ts
|
|
734
|
+
var DEFAULT_MODEL = "whisper-1";
|
|
735
|
+
/**
|
|
736
|
+
* Models that only accept `json`/`text` and reject `verbose_json` and
|
|
737
|
+
* `timestamp_granularities[]` (e.g. `gpt-4o-transcribe`,
|
|
738
|
+
* `gpt-4o-mini-transcribe`, optionally gateway-prefixed like
|
|
739
|
+
* `openai/gpt-4o-transcribe`).
|
|
740
|
+
*/
|
|
741
|
+
var JSON_ONLY_MODEL = /(^|\/)gpt-4o[\w.-]*-transcribe/i;
|
|
742
|
+
/** Provider 400/422 bodies that indicate an unsupported format option. */
|
|
743
|
+
var UNSUPPORTED_FORMAT_ERROR = /verbose_json|response_format|timestamp_granularit/i;
|
|
744
|
+
function isJsonOnlyTranscriptionModel(model) {
|
|
745
|
+
return JSON_ONLY_MODEL.test(model);
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* OpenAI-compatible speech-to-text adapter (`POST <base>/audio/transcriptions`).
|
|
749
|
+
* Works with OpenAI, Groq, Fireworks, LiteLLM/Bifrost gateways, vLLM,
|
|
750
|
+
* speaches/faster-whisper-server, and whisper.cpp server.
|
|
751
|
+
*
|
|
752
|
+
* Server-side only: the adapter holds the API key.
|
|
753
|
+
*/
|
|
754
|
+
var OpenAICompatibleTranscriber = class extends HttpSpeechAdapter {
|
|
755
|
+
type = "openai-compatible";
|
|
756
|
+
endpoint;
|
|
757
|
+
model;
|
|
758
|
+
responseFormat;
|
|
759
|
+
maxBytes;
|
|
760
|
+
retry;
|
|
761
|
+
onUsage;
|
|
762
|
+
constructor(options) {
|
|
763
|
+
super(options);
|
|
764
|
+
this.endpoint = resolveOpenAICompatibleUrl(options.baseUrl, "audio/transcriptions");
|
|
765
|
+
this.model = options.model?.trim() || DEFAULT_MODEL;
|
|
766
|
+
this.responseFormat = options.responseFormat;
|
|
767
|
+
this.maxBytes = options.maxBytes ?? 26214400;
|
|
768
|
+
this.retry = resolveRetryOptions(options.retry);
|
|
769
|
+
this.onUsage = options.onUsage;
|
|
770
|
+
}
|
|
771
|
+
async transcribe(request) {
|
|
772
|
+
const audio = await normalizeAudioInput(request.audio, {
|
|
773
|
+
mimeType: request.mimeType,
|
|
774
|
+
maxBytes: request.maxBytes ?? this.maxBytes,
|
|
775
|
+
adapter: this.type,
|
|
776
|
+
signal: request.signal
|
|
777
|
+
});
|
|
778
|
+
const wire = this.resolveWireOptions(request);
|
|
779
|
+
const send = (options) => {
|
|
780
|
+
const form = new FormData();
|
|
781
|
+
form.append("file", audio.blob, audio.filename);
|
|
782
|
+
form.set("model", options.model);
|
|
783
|
+
appendOptionalFormValue(form, "language", request.language);
|
|
784
|
+
appendOptionalFormValue(form, "prompt", request.prompt);
|
|
785
|
+
appendOptionalFormValue(form, "temperature", request.temperature);
|
|
786
|
+
form.set("response_format", options.responseFormat);
|
|
787
|
+
for (const granularity of options.timestampGranularities ?? []) form.append("timestamp_granularities[]", granularity);
|
|
788
|
+
return this.post(this.type, this.endpoint, {
|
|
789
|
+
body: form,
|
|
790
|
+
headers: request.headers,
|
|
791
|
+
signal: request.signal
|
|
792
|
+
}, (response) => readTranscriptResponse(response, this.type), this.retry);
|
|
793
|
+
};
|
|
794
|
+
let transcript;
|
|
795
|
+
try {
|
|
796
|
+
transcript = await send(wire);
|
|
797
|
+
} catch (error) {
|
|
798
|
+
if (!isUnsupportedFormatError(error, wire)) throw error;
|
|
799
|
+
transcript = await send({
|
|
800
|
+
model: wire.model,
|
|
801
|
+
responseFormat: "json"
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
const providerUsage = extractProviderUsage(transcript.raw);
|
|
805
|
+
const usage = {
|
|
806
|
+
operation: "transcription",
|
|
807
|
+
provider: this.type,
|
|
808
|
+
model: transcript.model ?? wire.model,
|
|
809
|
+
audioSeconds: transcript.durationSeconds ?? audioSecondsFromProviderUsage(providerUsage) ?? audio.durationSeconds,
|
|
810
|
+
bytes: audio.bytes,
|
|
811
|
+
providerUsage
|
|
812
|
+
};
|
|
813
|
+
const result = {
|
|
814
|
+
...transcript,
|
|
815
|
+
text: transcript.raw === void 0 ? transcript.text.trim() : transcript.text,
|
|
816
|
+
provider: this.type,
|
|
817
|
+
model: transcript.model ?? wire.model,
|
|
818
|
+
usage
|
|
819
|
+
};
|
|
820
|
+
await reportSpeechUsage(usage, this.onUsage, request.onUsage);
|
|
821
|
+
return result;
|
|
822
|
+
}
|
|
823
|
+
resolveWireOptions(request) {
|
|
824
|
+
const model = request.model?.trim() || this.model;
|
|
825
|
+
const jsonOnly = isJsonOnlyTranscriptionModel(model);
|
|
826
|
+
let responseFormat = request.responseFormat ?? this.responseFormat ?? (jsonOnly ? "json" : "verbose_json");
|
|
827
|
+
if (jsonOnly && responseFormat === "verbose_json") responseFormat = "json";
|
|
828
|
+
const timestampGranularities = responseFormat === "verbose_json" && request.timestampGranularities?.length ? [...new Set(request.timestampGranularities)] : void 0;
|
|
829
|
+
return {
|
|
830
|
+
model,
|
|
831
|
+
responseFormat,
|
|
832
|
+
timestampGranularities
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
};
|
|
836
|
+
function isUnsupportedFormatError(error, wire) {
|
|
837
|
+
return error instanceof SpeechProviderError && (error.status === 400 || error.status === 422) && (wire.responseFormat === "verbose_json" || Boolean(wire.timestampGranularities?.length)) && UNSUPPORTED_FORMAT_ERROR.test(error.responseBody ?? "");
|
|
838
|
+
}
|
|
839
|
+
function extractProviderUsage(raw) {
|
|
840
|
+
if (!raw || typeof raw !== "object") return;
|
|
841
|
+
const usage = raw.usage;
|
|
842
|
+
return usage && typeof usage === "object" && !Array.isArray(usage) ? usage : void 0;
|
|
843
|
+
}
|
|
844
|
+
//#endregion
|
|
317
845
|
//#region src/adapters/qwen3.ts
|
|
318
846
|
var Qwen3SpeechSynthesizer = class extends HttpSpeechAdapter {
|
|
319
847
|
type = "qwen3-tts";
|
|
@@ -349,11 +877,19 @@ var StudioServerTranscriber = class extends HttpSpeechAdapter {
|
|
|
349
877
|
this.transcribePath = options.transcribePath ?? "/v1/transcribe";
|
|
350
878
|
}
|
|
351
879
|
async transcribe(request) {
|
|
880
|
+
const audio = await normalizeAudioInput(request.audio, {
|
|
881
|
+
mimeType: request.mimeType,
|
|
882
|
+
maxBytes: request.maxBytes,
|
|
883
|
+
adapter: this.type,
|
|
884
|
+
signal: request.signal,
|
|
885
|
+
deriveExtension: false
|
|
886
|
+
});
|
|
352
887
|
const form = new FormData();
|
|
353
|
-
|
|
888
|
+
form.append("audio", audio.blob, audio.filename);
|
|
354
889
|
appendOptionalFormValue(form, "language", request.language);
|
|
355
890
|
return this.post(this.type, this.transcribePath, {
|
|
356
891
|
body: form,
|
|
892
|
+
headers: request.headers,
|
|
357
893
|
signal: request.signal
|
|
358
894
|
}, (response) => readTranscriptResponse(response, this.type));
|
|
359
895
|
}
|
|
@@ -404,6 +940,7 @@ async function getTranscriber(options = {}, context = {}) {
|
|
|
404
940
|
const resolved = normalizeTranscriberOptions(options, context);
|
|
405
941
|
switch (resolved.type) {
|
|
406
942
|
case "studio-server": return new StudioServerTranscriber(resolved);
|
|
943
|
+
case "openai-compatible": return new OpenAICompatibleTranscriber(resolved);
|
|
407
944
|
default: throw new InvalidSpeechAdapterError(resolved.type ?? "unknown", "STT");
|
|
408
945
|
}
|
|
409
946
|
}
|
|
@@ -418,7 +955,7 @@ async function getSpeechSynthesizer(options, context = {}) {
|
|
|
418
955
|
}
|
|
419
956
|
function getAvailableSpeechAdapters() {
|
|
420
957
|
return {
|
|
421
|
-
transcribers: ["studio-server"],
|
|
958
|
+
transcribers: ["studio-server", "openai-compatible"],
|
|
422
959
|
synthesizers: [
|
|
423
960
|
"studio-server",
|
|
424
961
|
"qwen3-tts",
|
|
@@ -435,20 +972,30 @@ async function getOptionalSpeechSynthesizer(options, context) {
|
|
|
435
972
|
return getSpeechSynthesizer(options, context);
|
|
436
973
|
}
|
|
437
974
|
function normalizeTranscriberOptions(options = {}, context) {
|
|
438
|
-
const
|
|
439
|
-
const type =
|
|
440
|
-
|
|
441
|
-
if (
|
|
442
|
-
|
|
975
|
+
const resolved = resolveTranscriberConfig(options, context);
|
|
976
|
+
const type = resolved.type ?? "studio-server";
|
|
977
|
+
if (type !== "studio-server" && type !== "openai-compatible") throw new InvalidSpeechAdapterError(type, "STT");
|
|
978
|
+
if (!resolved.baseUrl) throw new SpeechConfigurationError("STT baseUrl is required", type);
|
|
979
|
+
const shared = {
|
|
980
|
+
baseUrl: resolved.baseUrl,
|
|
981
|
+
fetch: resolved.fetch,
|
|
982
|
+
headers: resolved.headers,
|
|
983
|
+
apiKey: resolved.apiKey,
|
|
984
|
+
timeoutMs: resolved.timeoutMs
|
|
985
|
+
};
|
|
986
|
+
if (type === "openai-compatible") return {
|
|
987
|
+
...shared,
|
|
988
|
+
type,
|
|
989
|
+
model: resolved.model,
|
|
990
|
+
responseFormat: options.responseFormat,
|
|
991
|
+
maxBytes: resolved.maxBytes,
|
|
992
|
+
retry: options.retry,
|
|
993
|
+
onUsage: options.onUsage
|
|
994
|
+
};
|
|
443
995
|
return {
|
|
444
|
-
...
|
|
996
|
+
...shared,
|
|
445
997
|
type,
|
|
446
|
-
|
|
447
|
-
fetch: options.fetch ?? context.fetch,
|
|
448
|
-
headers: options.headers ?? context.headers,
|
|
449
|
-
apiKey: options.apiKey ?? readEnv(env, "HAVE_SPEECH_STT_API_KEY", "STT_API_KEY", "SPEECH_API_KEY"),
|
|
450
|
-
timeoutMs: options.timeoutMs ?? parseOptionalInteger(readEnv(env, "HAVE_SPEECH_STT_TIMEOUT_MS", "STT_TIMEOUT_MS")),
|
|
451
|
-
transcribePath: options.transcribePath ?? readEnv(env, "HAVE_SPEECH_STT_PATH", "STT_PATH")
|
|
998
|
+
transcribePath: options.transcribePath ?? resolved.path
|
|
452
999
|
};
|
|
453
1000
|
}
|
|
454
1001
|
function normalizeSpeechSynthesizerOptions(options, context) {
|
|
@@ -488,30 +1035,10 @@ function normalizeSpeechSynthesizerOptions(options, context) {
|
|
|
488
1035
|
};
|
|
489
1036
|
throw new InvalidSpeechAdapterError(type, "TTS");
|
|
490
1037
|
}
|
|
491
|
-
function hasTranscriberEnv(env) {
|
|
492
|
-
return hasAnyEnv(env, "HAVE_SPEECH_STT_TYPE", "HAVE_SPEECH_STT_ADAPTER", "HAVE_SPEECH_STT_BASE_URL", "STT_ADAPTER", "STT_BASE_URL");
|
|
493
|
-
}
|
|
494
1038
|
function hasSynthesizerEnv(env) {
|
|
495
1039
|
return hasAnyEnv(env, "HAVE_SPEECH_TTS_TYPE", "HAVE_SPEECH_TTS_ADAPTER", "HAVE_SPEECH_TTS_BASE_URL", "TTS_ADAPTER", "TTS_BASE_URL");
|
|
496
1040
|
}
|
|
497
|
-
function readEnv(env, ...keys) {
|
|
498
|
-
for (const key of keys) {
|
|
499
|
-
const value = env[key];
|
|
500
|
-
if (value?.trim()) return value.trim();
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
function hasAnyEnv(env, ...keys) {
|
|
504
|
-
return keys.some((key) => Boolean(env[key]?.trim()));
|
|
505
|
-
}
|
|
506
|
-
function parseOptionalInteger(value) {
|
|
507
|
-
if (!value) return;
|
|
508
|
-
const parsed = Number.parseInt(value, 10);
|
|
509
|
-
return Number.isFinite(parsed) ? parsed : void 0;
|
|
510
|
-
}
|
|
511
|
-
function defaultEnv() {
|
|
512
|
-
return globalThis.process?.env ?? {};
|
|
513
|
-
}
|
|
514
1041
|
//#endregion
|
|
515
|
-
export { InvalidSpeechAdapterError, SpeechConfigurationError, SpeechError, SpeechProviderError, getAvailableSpeechAdapters, getSpeech, getSpeechSynthesizer, getTranscriber };
|
|
1042
|
+
export { DEFAULT_MAX_AUDIO_BYTES, InvalidSpeechAdapterError, SpeechConfigurationError, SpeechError, SpeechProviderError, TRANSCRIBER_ENV_KEYS, getAvailableSpeechAdapters, getSpeech, getSpeechSynthesizer, getTranscriber, mimeTypeToAudioExtension };
|
|
516
1043
|
|
|
517
1044
|
//# sourceMappingURL=index.js.map
|