@mevdragon/vidfarm-devcli 0.10.0 → 0.12.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/README.md +19 -1
- package/SKILL.director.md +83 -2
- package/SKILL.platform.md +18 -3
- package/demo/dist/app.js +191 -65
- package/dist/src/app.js +69 -5
- package/dist/src/cli.js +424 -10
- package/dist/src/composition-runtime.js +77 -1
- package/dist/src/devcli/captions.js +310 -0
- package/dist/src/devcli/composition-edit.js +142 -0
- package/dist/src/devcli/speech.js +178 -0
- package/dist/src/editor-chat.js +5 -2
- package/dist/src/hyperframes/composition.js +315 -0
- package/dist/src/primitive-context.js +16 -0
- package/dist/src/primitive-registry.js +243 -0
- package/dist/src/services/captions.js +123 -0
- package/dist/src/services/hyperframes.js +113 -1
- package/dist/src/services/local-dynamo.js +0 -0
- package/dist/src/services/provider-errors.js +77 -0
- package/dist/src/services/providers.js +38 -431
- package/dist/src/services/speech.js +629 -0
- package/package.json +1 -1
|
@@ -5,35 +5,9 @@ import { createId } from "../lib/ids.js";
|
|
|
5
5
|
import { addSeconds } from "../lib/time.js";
|
|
6
6
|
import { ServerlessProviderKeyService } from "./serverless-provider-keys.js";
|
|
7
7
|
import sharp from "sharp";
|
|
8
|
-
|
|
9
|
-
}
|
|
10
|
-
export
|
|
11
|
-
}
|
|
12
|
-
export class ProviderQuotaExceededError extends Error {
|
|
13
|
-
}
|
|
14
|
-
export class ProviderWaitExceededError extends Error {
|
|
15
|
-
}
|
|
16
|
-
export class ProviderLeaseTimeoutError extends Error {
|
|
17
|
-
retryAfterSeconds;
|
|
18
|
-
constructor(message, retryAfterSeconds = 5) {
|
|
19
|
-
super(message);
|
|
20
|
-
this.retryAfterSeconds = retryAfterSeconds;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
export class ProviderRateLimitError extends Error {
|
|
24
|
-
retryAfterSeconds;
|
|
25
|
-
constructor(message, retryAfterSeconds = 60) {
|
|
26
|
-
super(message);
|
|
27
|
-
this.retryAfterSeconds = retryAfterSeconds;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
export class ProviderTransientError extends Error {
|
|
31
|
-
retryAfterSeconds;
|
|
32
|
-
constructor(message, retryAfterSeconds = 5) {
|
|
33
|
-
super(message);
|
|
34
|
-
this.retryAfterSeconds = retryAfterSeconds;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
8
|
+
import { ProviderAuthError, ProviderKeyUnavailableError, ProviderLeaseTimeoutError, ProviderQuotaExceededError, ProviderRateLimitError, ProviderTransientError, ProviderWaitExceededError, SpeechVoiceMismatchError, conciseProviderDetails, parseRetryAfterSeconds, rateLimitOrQuotaError } from "./provider-errors.js";
|
|
9
|
+
import { SUPPORTED_STT_MODELS, SUPPORTED_TTS_MODELS, defaultSpeechModelFor, generateSpeechWithKey, inferSpeechContentType, speechVoiceFamilyFor, speechVoiceMismatch, transcribeSpeechWithKey } from "./speech.js";
|
|
10
|
+
export { ProviderAuthError, ProviderKeyUnavailableError, ProviderLeaseTimeoutError, ProviderQuotaExceededError, ProviderRateLimitError, ProviderTransientError, ProviderWaitExceededError } from "./provider-errors.js";
|
|
37
11
|
const SUPPORTED_TEXT_MODELS = {
|
|
38
12
|
openai: new Set(["gpt-5.4"]),
|
|
39
13
|
gemini: new Set(["gemini-3.1-flash-lite", "gemini-3.1-flash-lite-preview", "gemini-2.5-flash-lite"]),
|
|
@@ -96,16 +70,6 @@ const OPENAI_IMAGE_SIZES = new Set([
|
|
|
96
70
|
"auto"
|
|
97
71
|
]);
|
|
98
72
|
const GEMINI_IMAGE_ASPECT_RATIOS = new Set(["1:1", "16:9", "9:16", "4:3", "3:4", "21:9", "9:21"]);
|
|
99
|
-
const SUPPORTED_TTS_MODELS = {
|
|
100
|
-
openrouter: new Set(["google/gemini-3.1-flash-tts-preview"]),
|
|
101
|
-
gemini: new Set(["gemini-3.1-flash-tts-preview", "gemini-2.5-flash-preview-tts"]),
|
|
102
|
-
openai: new Set(["gpt-4o-mini-tts-2025-12-15"])
|
|
103
|
-
};
|
|
104
|
-
const SUPPORTED_STT_MODELS = {
|
|
105
|
-
openrouter: new Set(["openai/gpt-4o-mini-transcribe"]),
|
|
106
|
-
gemini: new Set(["gemini-3.1-flash-lite-preview", "gemini-2.5-flash-lite"]),
|
|
107
|
-
openai: new Set(["gpt-4o-mini-transcribe-2025-12-15"])
|
|
108
|
-
};
|
|
109
73
|
const PROVIDER_KEY_VALIDATION_MODELS = {
|
|
110
74
|
openai: "gpt-4.1-mini",
|
|
111
75
|
gemini: "gemini-3.1-flash-lite-preview",
|
|
@@ -134,48 +98,9 @@ function formatDurationSeconds(totalSeconds) {
|
|
|
134
98
|
}
|
|
135
99
|
return `${Math.max(1, Math.round(totalSeconds))} second${Math.round(totalSeconds) === 1 ? "" : "s"}`;
|
|
136
100
|
}
|
|
137
|
-
function parseRetryAfterSeconds(value) {
|
|
138
|
-
if (!value) {
|
|
139
|
-
return 60;
|
|
140
|
-
}
|
|
141
|
-
const numeric = Number(value);
|
|
142
|
-
if (Number.isFinite(numeric) && numeric > 0) {
|
|
143
|
-
return Math.max(1, Math.min(Math.ceil(numeric), 60 * 60));
|
|
144
|
-
}
|
|
145
|
-
const parsedDate = Date.parse(value);
|
|
146
|
-
if (Number.isFinite(parsedDate)) {
|
|
147
|
-
return Math.max(1, Math.min(Math.ceil((parsedDate - Date.now()) / 1000), 60 * 60));
|
|
148
|
-
}
|
|
149
|
-
return 60;
|
|
150
|
-
}
|
|
151
|
-
function isHardQuotaError(details) {
|
|
152
|
-
const normalized = details.toLowerCase();
|
|
153
|
-
return [
|
|
154
|
-
"insufficient_quota",
|
|
155
|
-
"quota_exceeded",
|
|
156
|
-
"exceeded your current quota",
|
|
157
|
-
"billing",
|
|
158
|
-
"insufficient credits",
|
|
159
|
-
"not enough credits",
|
|
160
|
-
"credit balance",
|
|
161
|
-
"prepaid credits",
|
|
162
|
-
"resource_exhausted"
|
|
163
|
-
].some((pattern) => normalized.includes(pattern));
|
|
164
|
-
}
|
|
165
|
-
function conciseProviderDetails(details) {
|
|
166
|
-
const compact = details.replace(/\s+/g, " ").trim();
|
|
167
|
-
return compact ? `: ${compact.slice(0, 500)}` : "";
|
|
168
|
-
}
|
|
169
101
|
function conciseProviderResponse(operation, response, details) {
|
|
170
102
|
return `${operation} returned ${response.status}${conciseProviderDetails(details)}`;
|
|
171
103
|
}
|
|
172
|
-
async function rateLimitOrQuotaError(provider, response) {
|
|
173
|
-
const details = await response.text();
|
|
174
|
-
if (isHardQuotaError(details)) {
|
|
175
|
-
return new ProviderQuotaExceededError(`${provider} quota or billing limit was reached${conciseProviderDetails(details)}`);
|
|
176
|
-
}
|
|
177
|
-
return new ProviderRateLimitError(`${provider} rate limited${conciseProviderDetails(details)}`, parseRetryAfterSeconds(response.headers.get("retry-after")));
|
|
178
|
-
}
|
|
179
104
|
function isTransientProviderStatus(status) {
|
|
180
105
|
return status === 408 || status === 409 || status === 425 || status === 500 || status === 502 || status === 503 || status === 504;
|
|
181
106
|
}
|
|
@@ -253,9 +178,6 @@ function defaultModelFor(kind, provider) {
|
|
|
253
178
|
function providerSupportsModelFrom(supported, provider, model) {
|
|
254
179
|
return Boolean(supported[provider]?.has(normalizeProviderModel(model)));
|
|
255
180
|
}
|
|
256
|
-
function defaultSpeechModelFor(kind, provider) {
|
|
257
|
-
return kind === "tts" ? SUPPORTED_TTS_MODELS[provider]?.values().next().value : SUPPORTED_STT_MODELS[provider]?.values().next().value;
|
|
258
|
-
}
|
|
259
181
|
function uniqueProviders(requested, supported) {
|
|
260
182
|
return [
|
|
261
183
|
requested,
|
|
@@ -620,7 +542,9 @@ export class ProviderService {
|
|
|
620
542
|
}
|
|
621
543
|
}
|
|
622
544
|
async generateSpeech(input) {
|
|
623
|
-
|
|
545
|
+
// Provider fallback must stay voice-aware: a Gemini preset routed to an
|
|
546
|
+
// OpenAI key (or the reverse) would 400 opaquely at the provider.
|
|
547
|
+
const attempts = (await providerAttempts({
|
|
624
548
|
providerKeyService: this.serverlessProviderKeys,
|
|
625
549
|
customerId: input.customerId,
|
|
626
550
|
capability: "tts",
|
|
@@ -628,7 +552,11 @@ export class ProviderService {
|
|
|
628
552
|
requestedModel: input.model,
|
|
629
553
|
supportedModels: SUPPORTED_TTS_MODELS,
|
|
630
554
|
defaultModelForProvider: (provider) => defaultSpeechModelFor("tts", provider)
|
|
631
|
-
});
|
|
555
|
+
})).filter((attempt) => !speechVoiceMismatch({ provider: attempt.provider, model: attempt.model, voice: input.voice }));
|
|
556
|
+
if (!attempts.length) {
|
|
557
|
+
throw new SpeechVoiceMismatchError(speechVoiceMismatch({ provider: input.provider, model: input.model, voice: input.voice }) ??
|
|
558
|
+
`Voice "${input.voice}" needs a ${speechVoiceFamilyFor(input.provider, input.model) === "gemini" ? "Gemini-family (gemini, or openrouter with a Gemini TTS model)" : "OpenAI-family (openai)"} provider key, but none is configured. Save a compatible key or pick a different voice.`);
|
|
559
|
+
}
|
|
632
560
|
const retryDeadlineMs = providerLeaseRetryDeadlineMs();
|
|
633
561
|
for (;;) {
|
|
634
562
|
let lastError = null;
|
|
@@ -686,23 +614,15 @@ export class ProviderService {
|
|
|
686
614
|
}
|
|
687
615
|
};
|
|
688
616
|
}
|
|
689
|
-
const result = zeroUsageCost(await this.withLeaseRenewal(lease, () =>
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
provider: input.provider === "openrouter" ? "openrouter" : "openai",
|
|
699
|
-
model: input.model,
|
|
700
|
-
text: input.text,
|
|
701
|
-
voice: input.voice,
|
|
702
|
-
instructions: input.instructions,
|
|
703
|
-
responseFormat: input.responseFormat,
|
|
704
|
-
apiKey: lease.secret
|
|
705
|
-
})));
|
|
617
|
+
const result = zeroUsageCost(await this.withLeaseRenewal(lease, () => generateSpeechWithKey({
|
|
618
|
+
provider: input.provider,
|
|
619
|
+
model: input.model,
|
|
620
|
+
text: input.text,
|
|
621
|
+
voice: input.voice,
|
|
622
|
+
instructions: input.instructions,
|
|
623
|
+
responseFormat: input.responseFormat,
|
|
624
|
+
apiKey: lease.secret
|
|
625
|
+
})));
|
|
706
626
|
await this.serverlessProviderKeys.recordProviderKeyUsage({
|
|
707
627
|
id: createId("usage"),
|
|
708
628
|
keyId: lease.keyId,
|
|
@@ -1612,6 +1532,14 @@ export class ProviderService {
|
|
|
1612
1532
|
await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
|
|
1613
1533
|
return {
|
|
1614
1534
|
text: "Mock transcription output.",
|
|
1535
|
+
language: input.language?.trim() || null,
|
|
1536
|
+
segments: input.diarize
|
|
1537
|
+
? [
|
|
1538
|
+
{ speaker: "speaker_1", start_sec: 0, end_sec: 2, text: "Mock transcription output." },
|
|
1539
|
+
{ speaker: "speaker_2", start_sec: 2, end_sec: 4, text: "Mock second narrator." }
|
|
1540
|
+
]
|
|
1541
|
+
: [{ speaker: "speaker_1", start_sec: null, end_sec: null, text: "Mock transcription output." }],
|
|
1542
|
+
diarization: input.diarize ? "diarized" : "none",
|
|
1615
1543
|
usage: {
|
|
1616
1544
|
inputTokens: 0,
|
|
1617
1545
|
outputTokens: 0,
|
|
@@ -1619,32 +1547,16 @@ export class ProviderService {
|
|
|
1619
1547
|
}
|
|
1620
1548
|
};
|
|
1621
1549
|
}
|
|
1622
|
-
const result = zeroUsageCost(await this.withLeaseRenewal(lease, () =>
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
? this.callOpenRouterSpeechTranscription({
|
|
1633
|
-
model: input.model,
|
|
1634
|
-
audio: input.audio,
|
|
1635
|
-
contentType: input.contentType,
|
|
1636
|
-
prompt: input.prompt,
|
|
1637
|
-
language: input.language,
|
|
1638
|
-
apiKey: lease.secret
|
|
1639
|
-
})
|
|
1640
|
-
: this.callOpenAISpeechTranscription({
|
|
1641
|
-
model: input.model,
|
|
1642
|
-
audio: input.audio,
|
|
1643
|
-
contentType: input.contentType,
|
|
1644
|
-
prompt: input.prompt,
|
|
1645
|
-
language: input.language,
|
|
1646
|
-
apiKey: lease.secret
|
|
1647
|
-
})));
|
|
1550
|
+
const result = zeroUsageCost(await this.withLeaseRenewal(lease, () => transcribeSpeechWithKey({
|
|
1551
|
+
provider: input.provider,
|
|
1552
|
+
model: input.model,
|
|
1553
|
+
audio: input.audio,
|
|
1554
|
+
contentType: input.contentType,
|
|
1555
|
+
prompt: input.prompt,
|
|
1556
|
+
language: input.language,
|
|
1557
|
+
diarize: input.diarize,
|
|
1558
|
+
apiKey: lease.secret
|
|
1559
|
+
})));
|
|
1648
1560
|
await this.serverlessProviderKeys.recordProviderKeyUsage({
|
|
1649
1561
|
id: createId("usage"),
|
|
1650
1562
|
keyId: lease.keyId,
|
|
@@ -1897,118 +1809,6 @@ export class ProviderService {
|
|
|
1897
1809
|
}
|
|
1898
1810
|
return image;
|
|
1899
1811
|
}
|
|
1900
|
-
async callOpenAICompatibleSpeechGeneration(input) {
|
|
1901
|
-
const requestedFormat = input.responseFormat ?? "mp3";
|
|
1902
|
-
const usePcmBridge = input.provider === "openrouter" &&
|
|
1903
|
-
/gemini/i.test(input.model) &&
|
|
1904
|
-
requestedFormat !== "mp3";
|
|
1905
|
-
const responseFormat = usePcmBridge ? "pcm" : requestedFormat;
|
|
1906
|
-
const endpoint = input.provider === "openrouter"
|
|
1907
|
-
? "https://openrouter.ai/api/v1/audio/speech"
|
|
1908
|
-
: "https://api.openai.com/v1/audio/speech";
|
|
1909
|
-
const response = await fetch(endpoint, {
|
|
1910
|
-
method: "POST",
|
|
1911
|
-
headers: {
|
|
1912
|
-
"Content-Type": "application/json",
|
|
1913
|
-
Authorization: `Bearer ${input.apiKey}`
|
|
1914
|
-
},
|
|
1915
|
-
body: JSON.stringify({
|
|
1916
|
-
model: input.model,
|
|
1917
|
-
input: input.text,
|
|
1918
|
-
voice: input.voice ?? "alloy",
|
|
1919
|
-
instructions: input.instructions,
|
|
1920
|
-
response_format: responseFormat
|
|
1921
|
-
})
|
|
1922
|
-
});
|
|
1923
|
-
if (response.status === 401 || response.status === 403) {
|
|
1924
|
-
throw new ProviderAuthError(`${input.provider} authentication failed`);
|
|
1925
|
-
}
|
|
1926
|
-
if (response.status === 429) {
|
|
1927
|
-
throw await rateLimitOrQuotaError(input.provider, response);
|
|
1928
|
-
}
|
|
1929
|
-
if (!response.ok) {
|
|
1930
|
-
const details = await response.text();
|
|
1931
|
-
throw new Error(`${input.provider} speech generation returned ${response.status}${details ? `: ${details}` : ""}`);
|
|
1932
|
-
}
|
|
1933
|
-
const rawBytes = Buffer.from(await response.arrayBuffer());
|
|
1934
|
-
const bytes = usePcmBridge ? pcm16MonoToWav(rawBytes, 24000) : rawBytes;
|
|
1935
|
-
const contentType = usePcmBridge
|
|
1936
|
-
? "audio/wav"
|
|
1937
|
-
: response.headers.get("content-type")?.split(";")[0]?.trim() || inferSpeechContentType(responseFormat);
|
|
1938
|
-
return {
|
|
1939
|
-
bytes,
|
|
1940
|
-
contentType,
|
|
1941
|
-
usage: {
|
|
1942
|
-
inputTokens: 0,
|
|
1943
|
-
outputTokens: 0,
|
|
1944
|
-
costUsd: 0
|
|
1945
|
-
}
|
|
1946
|
-
};
|
|
1947
|
-
}
|
|
1948
|
-
async callGeminiSpeechGeneration(input) {
|
|
1949
|
-
const prompt = input.instructions?.trim()
|
|
1950
|
-
? `${input.instructions.trim()}\n\nSpeak exactly the following text:\n${input.text}`
|
|
1951
|
-
: input.text;
|
|
1952
|
-
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${input.model}:generateContent?key=${input.apiKey}`, {
|
|
1953
|
-
method: "POST",
|
|
1954
|
-
headers: {
|
|
1955
|
-
"Content-Type": "application/json"
|
|
1956
|
-
},
|
|
1957
|
-
body: JSON.stringify({
|
|
1958
|
-
contents: [{ parts: [{ text: prompt }] }],
|
|
1959
|
-
generationConfig: {
|
|
1960
|
-
responseModalities: ["AUDIO"],
|
|
1961
|
-
speechConfig: {
|
|
1962
|
-
voiceConfig: {
|
|
1963
|
-
prebuiltVoiceConfig: {
|
|
1964
|
-
voiceName: input.voice ?? "Kore"
|
|
1965
|
-
}
|
|
1966
|
-
}
|
|
1967
|
-
}
|
|
1968
|
-
}
|
|
1969
|
-
})
|
|
1970
|
-
});
|
|
1971
|
-
if (response.status === 401 || response.status === 403) {
|
|
1972
|
-
throw new ProviderAuthError("gemini authentication failed");
|
|
1973
|
-
}
|
|
1974
|
-
if (response.status === 429) {
|
|
1975
|
-
throw await rateLimitOrQuotaError("gemini", response);
|
|
1976
|
-
}
|
|
1977
|
-
if (!response.ok) {
|
|
1978
|
-
const details = await response.text();
|
|
1979
|
-
throw new Error(`gemini speech generation returned ${response.status}${details ? `: ${details}` : ""}`);
|
|
1980
|
-
}
|
|
1981
|
-
const data = await response.json();
|
|
1982
|
-
const part = data.candidates?.[0]?.content?.parts?.find((item) => item.inlineData?.data || item.inline_data?.data);
|
|
1983
|
-
const encoded = part?.inlineData?.data ?? part?.inline_data?.data;
|
|
1984
|
-
if (!encoded) {
|
|
1985
|
-
throw new Error("gemini speech generation returned no audio payload");
|
|
1986
|
-
}
|
|
1987
|
-
const rawBytes = Buffer.from(encoded, "base64");
|
|
1988
|
-
const rawContentType = String(part?.inlineData?.mimeType ?? part?.inline_data?.mime_type ?? "audio/L16;rate=24000");
|
|
1989
|
-
const pcmRateMatch = /audio\/l16;?\s*rate=(\d+)/i.exec(rawContentType);
|
|
1990
|
-
const looksLikeWav = isWavBuffer(rawBytes);
|
|
1991
|
-
const looksLikeMp3 = isLikelyMp3Buffer(rawBytes);
|
|
1992
|
-
const shouldWrapPcm = Boolean(pcmRateMatch ||
|
|
1993
|
-
(!looksLikeWav && !looksLikeMp3));
|
|
1994
|
-
const bytes = shouldWrapPcm
|
|
1995
|
-
? pcm16MonoToWav(rawBytes, Number(pcmRateMatch?.[1] || "24000"))
|
|
1996
|
-
: rawBytes;
|
|
1997
|
-
const contentType = shouldWrapPcm
|
|
1998
|
-
? "audio/wav"
|
|
1999
|
-
: looksLikeWav
|
|
2000
|
-
? "audio/wav"
|
|
2001
|
-
: "audio/mpeg";
|
|
2002
|
-
return {
|
|
2003
|
-
bytes,
|
|
2004
|
-
contentType,
|
|
2005
|
-
usage: {
|
|
2006
|
-
inputTokens: Number(data.usageMetadata?.promptTokenCount ?? 0),
|
|
2007
|
-
outputTokens: Number(data.usageMetadata?.candidatesTokenCount ?? 0),
|
|
2008
|
-
costUsd: 0
|
|
2009
|
-
}
|
|
2010
|
-
};
|
|
2011
|
-
}
|
|
2012
1812
|
async callOpenAICompatibleLayoutAnalysis(input) {
|
|
2013
1813
|
const endpoint = input.provider === "openrouter"
|
|
2014
1814
|
? "https://openrouter.ai/api/v1/chat/completions"
|
|
@@ -2112,128 +1912,6 @@ export class ProviderService {
|
|
|
2112
1912
|
const text = data.candidates?.[0]?.content?.parts?.map((part) => part.text ?? "").join("\n") ?? "";
|
|
2113
1913
|
return String(text);
|
|
2114
1914
|
}
|
|
2115
|
-
async callOpenAISpeechTranscription(input) {
|
|
2116
|
-
const contentType = input.contentType ?? "audio/mpeg";
|
|
2117
|
-
const form = new FormData();
|
|
2118
|
-
form.set("file", new Blob([Buffer.from(input.audio)], { type: contentType }), `audio.${audioExtensionFromContentType(contentType)}`);
|
|
2119
|
-
form.set("model", input.model);
|
|
2120
|
-
form.set("response_format", "json");
|
|
2121
|
-
if (input.prompt) {
|
|
2122
|
-
form.set("prompt", input.prompt);
|
|
2123
|
-
}
|
|
2124
|
-
if (input.language) {
|
|
2125
|
-
form.set("language", input.language);
|
|
2126
|
-
}
|
|
2127
|
-
const response = await fetch("https://api.openai.com/v1/audio/transcriptions", {
|
|
2128
|
-
method: "POST",
|
|
2129
|
-
headers: {
|
|
2130
|
-
Authorization: `Bearer ${input.apiKey}`
|
|
2131
|
-
},
|
|
2132
|
-
body: form
|
|
2133
|
-
});
|
|
2134
|
-
if (response.status === 401 || response.status === 403) {
|
|
2135
|
-
throw new ProviderAuthError("openai authentication failed");
|
|
2136
|
-
}
|
|
2137
|
-
if (response.status === 429) {
|
|
2138
|
-
throw await rateLimitOrQuotaError("openai", response);
|
|
2139
|
-
}
|
|
2140
|
-
if (!response.ok) {
|
|
2141
|
-
const details = await response.text();
|
|
2142
|
-
throw new Error(`openai transcription returned ${response.status}${details ? `: ${details}` : ""}`);
|
|
2143
|
-
}
|
|
2144
|
-
const data = await response.json();
|
|
2145
|
-
return {
|
|
2146
|
-
text: String(data.text ?? ""),
|
|
2147
|
-
usage: {
|
|
2148
|
-
inputTokens: Number(data.usage?.input_tokens ?? 0),
|
|
2149
|
-
outputTokens: Number(data.usage?.output_tokens ?? 0),
|
|
2150
|
-
costUsd: Number(data.usage?.cost ?? 0)
|
|
2151
|
-
}
|
|
2152
|
-
};
|
|
2153
|
-
}
|
|
2154
|
-
async callOpenRouterSpeechTranscription(input) {
|
|
2155
|
-
const response = await fetch("https://openrouter.ai/api/v1/audio/transcriptions", {
|
|
2156
|
-
method: "POST",
|
|
2157
|
-
headers: {
|
|
2158
|
-
"Content-Type": "application/json",
|
|
2159
|
-
Authorization: `Bearer ${input.apiKey}`
|
|
2160
|
-
},
|
|
2161
|
-
body: JSON.stringify({
|
|
2162
|
-
model: input.model,
|
|
2163
|
-
input_audio: {
|
|
2164
|
-
data: Buffer.from(input.audio).toString("base64"),
|
|
2165
|
-
format: audioFormatFromContentType(input.contentType)
|
|
2166
|
-
},
|
|
2167
|
-
...(input.prompt ? { prompt: input.prompt } : {}),
|
|
2168
|
-
...(input.language ? { language: input.language } : {})
|
|
2169
|
-
})
|
|
2170
|
-
});
|
|
2171
|
-
if (response.status === 401 || response.status === 403) {
|
|
2172
|
-
throw new ProviderAuthError("openrouter authentication failed");
|
|
2173
|
-
}
|
|
2174
|
-
if (response.status === 429) {
|
|
2175
|
-
throw await rateLimitOrQuotaError("openrouter", response);
|
|
2176
|
-
}
|
|
2177
|
-
if (!response.ok) {
|
|
2178
|
-
const details = await response.text();
|
|
2179
|
-
throw new Error(`openrouter transcription returned ${response.status}${details ? `: ${details}` : ""}`);
|
|
2180
|
-
}
|
|
2181
|
-
const data = await response.json();
|
|
2182
|
-
return {
|
|
2183
|
-
text: String(data.text ?? ""),
|
|
2184
|
-
usage: {
|
|
2185
|
-
inputTokens: Number(data.usage?.input_tokens ?? data.usage?.prompt_tokens ?? 0),
|
|
2186
|
-
outputTokens: Number(data.usage?.output_tokens ?? data.usage?.completion_tokens ?? 0),
|
|
2187
|
-
costUsd: Number(data.usage?.cost ?? 0)
|
|
2188
|
-
}
|
|
2189
|
-
};
|
|
2190
|
-
}
|
|
2191
|
-
async callGeminiSpeechTranscription(input) {
|
|
2192
|
-
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${input.model}:generateContent?key=${input.apiKey}`, {
|
|
2193
|
-
method: "POST",
|
|
2194
|
-
headers: {
|
|
2195
|
-
"Content-Type": "application/json"
|
|
2196
|
-
},
|
|
2197
|
-
body: JSON.stringify({
|
|
2198
|
-
contents: [{
|
|
2199
|
-
parts: [
|
|
2200
|
-
{
|
|
2201
|
-
inline_data: {
|
|
2202
|
-
mime_type: input.contentType ?? "audio/mpeg",
|
|
2203
|
-
data: Buffer.from(input.audio).toString("base64")
|
|
2204
|
-
}
|
|
2205
|
-
},
|
|
2206
|
-
{
|
|
2207
|
-
text: buildGeminiTranscriptionPrompt(input.prompt, input.language)
|
|
2208
|
-
}
|
|
2209
|
-
]
|
|
2210
|
-
}],
|
|
2211
|
-
generationConfig: {
|
|
2212
|
-
temperature: 0,
|
|
2213
|
-
responseMimeType: "text/plain"
|
|
2214
|
-
}
|
|
2215
|
-
})
|
|
2216
|
-
});
|
|
2217
|
-
if (response.status === 401 || response.status === 403) {
|
|
2218
|
-
throw new ProviderAuthError("gemini authentication failed");
|
|
2219
|
-
}
|
|
2220
|
-
if (response.status === 429) {
|
|
2221
|
-
throw await rateLimitOrQuotaError("gemini", response);
|
|
2222
|
-
}
|
|
2223
|
-
if (!response.ok) {
|
|
2224
|
-
const details = await response.text();
|
|
2225
|
-
throw new Error(`gemini transcription returned ${response.status}${details ? `: ${details}` : ""}`);
|
|
2226
|
-
}
|
|
2227
|
-
const data = await response.json();
|
|
2228
|
-
return {
|
|
2229
|
-
text: extractGeminiText(data),
|
|
2230
|
-
usage: {
|
|
2231
|
-
inputTokens: Number(data.usageMetadata?.promptTokenCount ?? 0),
|
|
2232
|
-
outputTokens: Number(data.usageMetadata?.candidatesTokenCount ?? 0),
|
|
2233
|
-
costUsd: 0
|
|
2234
|
-
}
|
|
2235
|
-
};
|
|
2236
|
-
}
|
|
2237
1915
|
async callProvider(input) {
|
|
2238
1916
|
if (input.provider === "gemini") {
|
|
2239
1917
|
return this.callGemini({
|
|
@@ -2643,77 +2321,6 @@ function assertSupportedSpeechModel(kind, provider, model) {
|
|
|
2643
2321
|
function normalizeProviderModel(model) {
|
|
2644
2322
|
return model.trim().replace(/^models\//, "");
|
|
2645
2323
|
}
|
|
2646
|
-
function inferSpeechContentType(format) {
|
|
2647
|
-
return format === "wav" ? "audio/wav" : "audio/mpeg";
|
|
2648
|
-
}
|
|
2649
|
-
function pcm16MonoToWav(pcmBuffer, sampleRate) {
|
|
2650
|
-
const header = Buffer.alloc(44);
|
|
2651
|
-
header.write("RIFF", 0, 4, "ascii");
|
|
2652
|
-
header.writeUInt32LE(36 + pcmBuffer.byteLength, 4);
|
|
2653
|
-
header.write("WAVE", 8, 4, "ascii");
|
|
2654
|
-
header.write("fmt ", 12, 4, "ascii");
|
|
2655
|
-
header.writeUInt32LE(16, 16);
|
|
2656
|
-
header.writeUInt16LE(1, 20);
|
|
2657
|
-
header.writeUInt16LE(1, 22);
|
|
2658
|
-
header.writeUInt32LE(sampleRate, 24);
|
|
2659
|
-
header.writeUInt32LE(sampleRate * 2, 28);
|
|
2660
|
-
header.writeUInt16LE(2, 32);
|
|
2661
|
-
header.writeUInt16LE(16, 34);
|
|
2662
|
-
header.write("data", 36, 4, "ascii");
|
|
2663
|
-
header.writeUInt32LE(pcmBuffer.byteLength, 40);
|
|
2664
|
-
return Buffer.concat([header, pcmBuffer]);
|
|
2665
|
-
}
|
|
2666
|
-
function isWavBuffer(buffer) {
|
|
2667
|
-
return buffer.byteLength >= 12
|
|
2668
|
-
&& buffer.subarray(0, 4).toString("ascii") === "RIFF"
|
|
2669
|
-
&& buffer.subarray(8, 12).toString("ascii") === "WAVE";
|
|
2670
|
-
}
|
|
2671
|
-
function isLikelyMp3Buffer(buffer) {
|
|
2672
|
-
if (buffer.byteLength < 3) {
|
|
2673
|
-
return false;
|
|
2674
|
-
}
|
|
2675
|
-
if (buffer.subarray(0, 3).toString("ascii") === "ID3") {
|
|
2676
|
-
return true;
|
|
2677
|
-
}
|
|
2678
|
-
return buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0;
|
|
2679
|
-
}
|
|
2680
|
-
function audioFormatFromContentType(contentType) {
|
|
2681
|
-
const normalized = contentType?.split(";")[0]?.trim().toLowerCase();
|
|
2682
|
-
switch (normalized) {
|
|
2683
|
-
case "audio/wav":
|
|
2684
|
-
case "audio/x-wav":
|
|
2685
|
-
return "wav";
|
|
2686
|
-
case "audio/mp4":
|
|
2687
|
-
case "audio/m4a":
|
|
2688
|
-
return "mp4";
|
|
2689
|
-
case "audio/webm":
|
|
2690
|
-
return "webm";
|
|
2691
|
-
case "audio/ogg":
|
|
2692
|
-
return "ogg";
|
|
2693
|
-
default:
|
|
2694
|
-
return "mp3";
|
|
2695
|
-
}
|
|
2696
|
-
}
|
|
2697
|
-
function audioExtensionFromContentType(contentType) {
|
|
2698
|
-
switch (audioFormatFromContentType(contentType)) {
|
|
2699
|
-
case "wav":
|
|
2700
|
-
return "wav";
|
|
2701
|
-
case "mp4":
|
|
2702
|
-
return "m4a";
|
|
2703
|
-
case "webm":
|
|
2704
|
-
return "webm";
|
|
2705
|
-
case "ogg":
|
|
2706
|
-
return "ogg";
|
|
2707
|
-
default:
|
|
2708
|
-
return "mp3";
|
|
2709
|
-
}
|
|
2710
|
-
}
|
|
2711
|
-
function buildGeminiTranscriptionPrompt(prompt, language) {
|
|
2712
|
-
const basePrompt = prompt?.trim() || "Transcribe this audio verbatim. Return plain text only.";
|
|
2713
|
-
return language?.trim()
|
|
2714
|
-
? `${basePrompt}\n\nExpected language: ${language.trim()}.`
|
|
2715
|
-
: basePrompt;
|
|
2716
|
-
}
|
|
2717
2324
|
function extractGeminiText(data) {
|
|
2718
2325
|
return String(data.candidates?.[0]?.content?.parts?.map((part) => part.text ?? "").join("\n") ?? "");
|
|
2719
2326
|
}
|