@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
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
// Speech provider layer (TTS + STT) shared by cloud and devcli. ProviderService
|
|
2
|
+
// wraps these calls with BYOK key leasing/usage metering; the devcli `tts`/`stt`
|
|
3
|
+
// commands call them DIRECTLY with a local env key (GEMINI_API_KEY /
|
|
4
|
+
// OPENAI_API_KEY / OPENROUTER_API_KEY) so speech runs local-first without the
|
|
5
|
+
// REST primitive route (which stays the explicit `--cloud` backup). Keep the
|
|
6
|
+
// request/response shapes here — one implementation, zero cloud/devcli drift.
|
|
7
|
+
//
|
|
8
|
+
// Diarization (multi-narration): only the Gemini path can label speakers — it
|
|
9
|
+
// is an audio-native LLM, so we ask for speaker-attributed JSON segments in one
|
|
10
|
+
// call. OpenAI/OpenRouter transcription endpoints return plain text only and
|
|
11
|
+
// degrade to a single unlabeled segment.
|
|
12
|
+
import { ProviderAuthError, SpeechVoiceMismatchError, rateLimitOrQuotaError } from "./provider-errors.js";
|
|
13
|
+
export const SUPPORTED_TTS_MODELS = {
|
|
14
|
+
openrouter: new Set(["google/gemini-3.1-flash-tts-preview"]),
|
|
15
|
+
gemini: new Set(["gemini-3.1-flash-tts-preview", "gemini-2.5-flash-preview-tts"]),
|
|
16
|
+
openai: new Set(["gpt-4o-mini-tts-2025-12-15"])
|
|
17
|
+
};
|
|
18
|
+
export const SUPPORTED_STT_MODELS = {
|
|
19
|
+
openrouter: new Set(["openai/gpt-4o-mini-transcribe"]),
|
|
20
|
+
gemini: new Set(["gemini-3.1-flash-lite-preview", "gemini-2.5-flash-lite"]),
|
|
21
|
+
openai: new Set(["gpt-4o-mini-transcribe-2025-12-15", "whisper-1"])
|
|
22
|
+
};
|
|
23
|
+
// The only STT model that returns real word-level timestamps
|
|
24
|
+
// (verbose_json + timestamp_granularities). Word-timestamp requests on the
|
|
25
|
+
// openai path are routed here regardless of the configured chat-grade model.
|
|
26
|
+
export const OPENAI_WORD_TIMESTAMP_STT_MODEL = "whisper-1";
|
|
27
|
+
export function defaultSpeechModelFor(kind, provider) {
|
|
28
|
+
return kind === "tts" ? SUPPORTED_TTS_MODELS[provider]?.values().next().value : SUPPORTED_STT_MODELS[provider]?.values().next().value;
|
|
29
|
+
}
|
|
30
|
+
// Known TTS voice presets, in the exact casing each provider accepts (OpenAI is
|
|
31
|
+
// lowercase; Gemini prebuilt voices are case-sensitive TitleCase). These lists
|
|
32
|
+
// exist to guard, not to gate: a voice that clearly belongs to the OTHER family
|
|
33
|
+
// is rejected with an actionable message (the provider would 400 opaquely), a
|
|
34
|
+
// known voice has its casing normalized, and an UNKNOWN voice passes through
|
|
35
|
+
// untouched so new provider presets keep working without a code change.
|
|
36
|
+
export const OPENAI_TTS_VOICES = [
|
|
37
|
+
"alloy", "ash", "ballad", "cedar", "coral", "echo", "fable", "marin", "nova", "onyx", "sage", "shimmer", "verse"
|
|
38
|
+
];
|
|
39
|
+
export const GEMINI_TTS_VOICES = [
|
|
40
|
+
"Achernar", "Achird", "Algenib", "Algieba", "Alnilam", "Aoede", "Autonoe", "Callirrhoe", "Charon", "Despina",
|
|
41
|
+
"Enceladus", "Erinome", "Fenrir", "Gacrux", "Iapetus", "Kore", "Laomedeia", "Leda", "Orus", "Puck",
|
|
42
|
+
"Pulcherrima", "Rasalgethi", "Sadachbia", "Sadaltager", "Schedar", "Sulafat", "Umbriel", "Vindemiatrix", "Zephyr", "Zubenelgenubi"
|
|
43
|
+
];
|
|
44
|
+
const OPENAI_VOICES_BY_KEY = new Map(OPENAI_TTS_VOICES.map((voice) => [voice.toLowerCase(), voice]));
|
|
45
|
+
const GEMINI_VOICES_BY_KEY = new Map(GEMINI_TTS_VOICES.map((voice) => [voice.toLowerCase(), voice]));
|
|
46
|
+
/** Which voice family a provider/model pair expects; openrouter follows its (typically Gemini) TTS model. */
|
|
47
|
+
export function speechVoiceFamilyFor(provider, model) {
|
|
48
|
+
if (provider === "gemini") {
|
|
49
|
+
return "gemini";
|
|
50
|
+
}
|
|
51
|
+
if (provider === "openrouter") {
|
|
52
|
+
const resolvedModel = model?.trim() || defaultSpeechModelFor("tts", provider) || "";
|
|
53
|
+
return /gemini/i.test(resolvedModel) ? "gemini" : "openai";
|
|
54
|
+
}
|
|
55
|
+
return "openai";
|
|
56
|
+
}
|
|
57
|
+
/** Which provider a bare voice preset implies (null when the name is not a known preset). */
|
|
58
|
+
export function inferSpeechProviderForVoice(voice) {
|
|
59
|
+
const key = voice?.trim().toLowerCase();
|
|
60
|
+
if (!key) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
if (OPENAI_VOICES_BY_KEY.has(key)) {
|
|
64
|
+
return "openai";
|
|
65
|
+
}
|
|
66
|
+
if (GEMINI_VOICES_BY_KEY.has(key)) {
|
|
67
|
+
return "gemini";
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
/** Actionable mismatch message when the voice belongs to the other provider family, else null (usable). */
|
|
72
|
+
export function speechVoiceMismatch(input) {
|
|
73
|
+
const key = input.voice?.trim().toLowerCase();
|
|
74
|
+
if (!key) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const family = speechVoiceFamilyFor(input.provider, input.model);
|
|
78
|
+
if ((family === "gemini" ? GEMINI_VOICES_BY_KEY : OPENAI_VOICES_BY_KEY).has(key)) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
const foreign = (family === "gemini" ? OPENAI_VOICES_BY_KEY : GEMINI_VOICES_BY_KEY).get(key);
|
|
82
|
+
if (!foreign) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return family === "gemini"
|
|
86
|
+
? `Voice "${foreign}" is an OpenAI preset, but provider ${input.provider} expects a Gemini voice (e.g. Kore, Puck, Charon). Pick a Gemini voice or set provider to "openai".`
|
|
87
|
+
: `Voice "${foreign}" is a Gemini preset, but provider ${input.provider} expects an OpenAI voice (e.g. alloy, ash, coral). Pick an OpenAI voice or set provider to "gemini".`;
|
|
88
|
+
}
|
|
89
|
+
/** Canonicalize a requested voice for the target provider/model (casing fixed, cross-family presets rejected). */
|
|
90
|
+
export function resolveSpeechVoice(input) {
|
|
91
|
+
const raw = input.voice?.trim();
|
|
92
|
+
if (!raw) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
const mismatch = speechVoiceMismatch(input);
|
|
96
|
+
if (mismatch) {
|
|
97
|
+
throw new SpeechVoiceMismatchError(mismatch);
|
|
98
|
+
}
|
|
99
|
+
const family = speechVoiceFamilyFor(input.provider, input.model);
|
|
100
|
+
return (family === "gemini" ? GEMINI_VOICES_BY_KEY : OPENAI_VOICES_BY_KEY).get(raw.toLowerCase()) ?? raw;
|
|
101
|
+
}
|
|
102
|
+
/** Generate speech audio with a raw provider key. Voice style rides on `instructions`. */
|
|
103
|
+
export async function generateSpeechWithKey(input) {
|
|
104
|
+
const voice = resolveSpeechVoice(input);
|
|
105
|
+
return input.provider === "gemini"
|
|
106
|
+
? callGeminiSpeechGeneration({ ...input, voice })
|
|
107
|
+
: callOpenAICompatibleSpeechGeneration({
|
|
108
|
+
...input,
|
|
109
|
+
voice,
|
|
110
|
+
provider: input.provider === "openrouter" ? "openrouter" : "openai"
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/** Transcribe speech audio with a raw provider key; diarizes on the Gemini path.
|
|
114
|
+
* wordTimestamps requests word-level timings — honored only on the openai path
|
|
115
|
+
* (routed to whisper-1 verbose_json); other providers return their usual
|
|
116
|
+
* segment-level output and callers estimate word windows from text. */
|
|
117
|
+
export async function transcribeSpeechWithKey(input) {
|
|
118
|
+
if (input.provider === "gemini") {
|
|
119
|
+
return callGeminiSpeechTranscription(input);
|
|
120
|
+
}
|
|
121
|
+
if (input.provider === "openai" && input.wordTimestamps) {
|
|
122
|
+
return callOpenAIVerboseSpeechTranscription(input);
|
|
123
|
+
}
|
|
124
|
+
const plain = input.provider === "openrouter"
|
|
125
|
+
? await callOpenRouterSpeechTranscription(input)
|
|
126
|
+
: await callOpenAISpeechTranscription(input);
|
|
127
|
+
return {
|
|
128
|
+
text: plain.text,
|
|
129
|
+
language: input.language?.trim() || null,
|
|
130
|
+
segments: plain.text.trim()
|
|
131
|
+
? [{ speaker: "speaker_1", start_sec: null, end_sec: null, text: plain.text.trim() }]
|
|
132
|
+
: [],
|
|
133
|
+
diarization: "none",
|
|
134
|
+
usage: plain.usage
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
async function callOpenAICompatibleSpeechGeneration(input) {
|
|
138
|
+
const requestedFormat = input.responseFormat ?? "mp3";
|
|
139
|
+
const usePcmBridge = input.provider === "openrouter" &&
|
|
140
|
+
/gemini/i.test(input.model) &&
|
|
141
|
+
requestedFormat !== "mp3";
|
|
142
|
+
const responseFormat = usePcmBridge ? "pcm" : requestedFormat;
|
|
143
|
+
const endpoint = input.provider === "openrouter"
|
|
144
|
+
? "https://openrouter.ai/api/v1/audio/speech"
|
|
145
|
+
: "https://api.openai.com/v1/audio/speech";
|
|
146
|
+
const response = await fetch(endpoint, {
|
|
147
|
+
method: "POST",
|
|
148
|
+
headers: {
|
|
149
|
+
"Content-Type": "application/json",
|
|
150
|
+
Authorization: `Bearer ${input.apiKey}`
|
|
151
|
+
},
|
|
152
|
+
body: JSON.stringify({
|
|
153
|
+
model: input.model,
|
|
154
|
+
input: input.text,
|
|
155
|
+
voice: input.voice ?? "alloy",
|
|
156
|
+
instructions: input.instructions,
|
|
157
|
+
response_format: responseFormat
|
|
158
|
+
})
|
|
159
|
+
});
|
|
160
|
+
if (response.status === 401 || response.status === 403) {
|
|
161
|
+
throw new ProviderAuthError(`${input.provider} authentication failed`);
|
|
162
|
+
}
|
|
163
|
+
if (response.status === 429) {
|
|
164
|
+
throw await rateLimitOrQuotaError(input.provider, response);
|
|
165
|
+
}
|
|
166
|
+
if (!response.ok) {
|
|
167
|
+
const details = await response.text();
|
|
168
|
+
throw new Error(`${input.provider} speech generation returned ${response.status}${details ? `: ${details}` : ""}`);
|
|
169
|
+
}
|
|
170
|
+
const rawBytes = Buffer.from(await response.arrayBuffer());
|
|
171
|
+
const bytes = usePcmBridge ? pcm16MonoToWav(rawBytes, 24000) : rawBytes;
|
|
172
|
+
const contentType = usePcmBridge
|
|
173
|
+
? "audio/wav"
|
|
174
|
+
: response.headers.get("content-type")?.split(";")[0]?.trim() || inferSpeechContentType(responseFormat);
|
|
175
|
+
return {
|
|
176
|
+
bytes,
|
|
177
|
+
contentType,
|
|
178
|
+
usage: {
|
|
179
|
+
inputTokens: 0,
|
|
180
|
+
outputTokens: 0,
|
|
181
|
+
costUsd: 0
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
async function callGeminiSpeechGeneration(input) {
|
|
186
|
+
const prompt = input.instructions?.trim()
|
|
187
|
+
? `${input.instructions.trim()}\n\nSpeak exactly the following text:\n${input.text}`
|
|
188
|
+
: input.text;
|
|
189
|
+
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${input.model}:generateContent?key=${input.apiKey}`, {
|
|
190
|
+
method: "POST",
|
|
191
|
+
headers: {
|
|
192
|
+
"Content-Type": "application/json"
|
|
193
|
+
},
|
|
194
|
+
body: JSON.stringify({
|
|
195
|
+
contents: [{ parts: [{ text: prompt }] }],
|
|
196
|
+
generationConfig: {
|
|
197
|
+
responseModalities: ["AUDIO"],
|
|
198
|
+
speechConfig: {
|
|
199
|
+
voiceConfig: {
|
|
200
|
+
prebuiltVoiceConfig: {
|
|
201
|
+
voiceName: input.voice ?? "Kore"
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
});
|
|
208
|
+
if (response.status === 401 || response.status === 403) {
|
|
209
|
+
throw new ProviderAuthError("gemini authentication failed");
|
|
210
|
+
}
|
|
211
|
+
if (response.status === 429) {
|
|
212
|
+
throw await rateLimitOrQuotaError("gemini", response);
|
|
213
|
+
}
|
|
214
|
+
if (!response.ok) {
|
|
215
|
+
const details = await response.text();
|
|
216
|
+
throw new Error(`gemini speech generation returned ${response.status}${details ? `: ${details}` : ""}`);
|
|
217
|
+
}
|
|
218
|
+
const data = await response.json();
|
|
219
|
+
const part = data.candidates?.[0]?.content?.parts?.find((item) => item.inlineData?.data || item.inline_data?.data);
|
|
220
|
+
const encoded = part?.inlineData?.data ?? part?.inline_data?.data;
|
|
221
|
+
if (!encoded) {
|
|
222
|
+
throw new Error("gemini speech generation returned no audio payload");
|
|
223
|
+
}
|
|
224
|
+
const rawBytes = Buffer.from(encoded, "base64");
|
|
225
|
+
const rawContentType = String(part?.inlineData?.mimeType ?? part?.inline_data?.mime_type ?? "audio/L16;rate=24000");
|
|
226
|
+
const pcmRateMatch = /audio\/l16;?\s*rate=(\d+)/i.exec(rawContentType);
|
|
227
|
+
const looksLikeWav = isWavBuffer(rawBytes);
|
|
228
|
+
const looksLikeMp3 = isLikelyMp3Buffer(rawBytes);
|
|
229
|
+
const shouldWrapPcm = Boolean(pcmRateMatch ||
|
|
230
|
+
(!looksLikeWav && !looksLikeMp3));
|
|
231
|
+
const bytes = shouldWrapPcm
|
|
232
|
+
? pcm16MonoToWav(rawBytes, Number(pcmRateMatch?.[1] || "24000"))
|
|
233
|
+
: rawBytes;
|
|
234
|
+
const contentType = shouldWrapPcm
|
|
235
|
+
? "audio/wav"
|
|
236
|
+
: looksLikeWav
|
|
237
|
+
? "audio/wav"
|
|
238
|
+
: "audio/mpeg";
|
|
239
|
+
return {
|
|
240
|
+
bytes,
|
|
241
|
+
contentType,
|
|
242
|
+
usage: {
|
|
243
|
+
inputTokens: Number(data.usageMetadata?.promptTokenCount ?? 0),
|
|
244
|
+
outputTokens: Number(data.usageMetadata?.candidatesTokenCount ?? 0),
|
|
245
|
+
costUsd: 0
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
async function callOpenAISpeechTranscription(input) {
|
|
250
|
+
const contentType = input.contentType ?? "audio/mpeg";
|
|
251
|
+
const form = new FormData();
|
|
252
|
+
form.set("file", new Blob([Buffer.from(input.audio)], { type: contentType }), `audio.${audioExtensionFromContentType(contentType)}`);
|
|
253
|
+
form.set("model", input.model);
|
|
254
|
+
form.set("response_format", "json");
|
|
255
|
+
if (input.prompt) {
|
|
256
|
+
form.set("prompt", input.prompt);
|
|
257
|
+
}
|
|
258
|
+
if (input.language) {
|
|
259
|
+
form.set("language", input.language);
|
|
260
|
+
}
|
|
261
|
+
const response = await fetch("https://api.openai.com/v1/audio/transcriptions", {
|
|
262
|
+
method: "POST",
|
|
263
|
+
headers: {
|
|
264
|
+
Authorization: `Bearer ${input.apiKey}`
|
|
265
|
+
},
|
|
266
|
+
body: form
|
|
267
|
+
});
|
|
268
|
+
if (response.status === 401 || response.status === 403) {
|
|
269
|
+
throw new ProviderAuthError("openai authentication failed");
|
|
270
|
+
}
|
|
271
|
+
if (response.status === 429) {
|
|
272
|
+
throw await rateLimitOrQuotaError("openai", response);
|
|
273
|
+
}
|
|
274
|
+
if (!response.ok) {
|
|
275
|
+
const details = await response.text();
|
|
276
|
+
throw new Error(`openai transcription returned ${response.status}${details ? `: ${details}` : ""}`);
|
|
277
|
+
}
|
|
278
|
+
const data = await response.json();
|
|
279
|
+
return {
|
|
280
|
+
text: String(data.text ?? ""),
|
|
281
|
+
usage: {
|
|
282
|
+
inputTokens: Number(data.usage?.input_tokens ?? 0),
|
|
283
|
+
outputTokens: Number(data.usage?.output_tokens ?? 0),
|
|
284
|
+
costUsd: Number(data.usage?.cost ?? 0)
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
// whisper-1 verbose_json: the one provider path with real (non-estimated) word
|
|
289
|
+
// timestamps. Returns timed segments with their word windows attached, ready
|
|
290
|
+
// for animated-caption cue building.
|
|
291
|
+
async function callOpenAIVerboseSpeechTranscription(input) {
|
|
292
|
+
const contentType = input.contentType ?? "audio/mpeg";
|
|
293
|
+
const model = /^whisper/i.test(input.model) ? input.model : OPENAI_WORD_TIMESTAMP_STT_MODEL;
|
|
294
|
+
const form = new FormData();
|
|
295
|
+
form.set("file", new Blob([Buffer.from(input.audio)], { type: contentType }), `audio.${audioExtensionFromContentType(contentType)}`);
|
|
296
|
+
form.set("model", model);
|
|
297
|
+
form.set("response_format", "verbose_json");
|
|
298
|
+
form.append("timestamp_granularities[]", "word");
|
|
299
|
+
form.append("timestamp_granularities[]", "segment");
|
|
300
|
+
if (input.prompt) {
|
|
301
|
+
form.set("prompt", input.prompt);
|
|
302
|
+
}
|
|
303
|
+
if (input.language) {
|
|
304
|
+
form.set("language", input.language);
|
|
305
|
+
}
|
|
306
|
+
const response = await fetch("https://api.openai.com/v1/audio/transcriptions", {
|
|
307
|
+
method: "POST",
|
|
308
|
+
headers: {
|
|
309
|
+
Authorization: `Bearer ${input.apiKey}`
|
|
310
|
+
},
|
|
311
|
+
body: form
|
|
312
|
+
});
|
|
313
|
+
if (response.status === 401 || response.status === 403) {
|
|
314
|
+
throw new ProviderAuthError("openai authentication failed");
|
|
315
|
+
}
|
|
316
|
+
if (response.status === 429) {
|
|
317
|
+
throw await rateLimitOrQuotaError("openai", response);
|
|
318
|
+
}
|
|
319
|
+
if (!response.ok) {
|
|
320
|
+
const details = await response.text();
|
|
321
|
+
throw new Error(`openai transcription returned ${response.status}${details ? `: ${details}` : ""}`);
|
|
322
|
+
}
|
|
323
|
+
const data = await response.json();
|
|
324
|
+
const words = Array.isArray(data.words)
|
|
325
|
+
? data.words
|
|
326
|
+
.map((entry) => ({
|
|
327
|
+
text: String(entry.word ?? "").trim(),
|
|
328
|
+
start_sec: Number(entry.start),
|
|
329
|
+
end_sec: Number(entry.end)
|
|
330
|
+
}))
|
|
331
|
+
.filter((word) => word.text && Number.isFinite(word.start_sec) && Number.isFinite(word.end_sec) && word.end_sec > word.start_sec)
|
|
332
|
+
: [];
|
|
333
|
+
const rawSegments = Array.isArray(data.segments) ? data.segments : [];
|
|
334
|
+
const segments = [];
|
|
335
|
+
for (const entry of rawSegments) {
|
|
336
|
+
const start = Number(entry.start);
|
|
337
|
+
const end = Number(entry.end);
|
|
338
|
+
const text = String(entry.text ?? "").trim();
|
|
339
|
+
if (!text || !Number.isFinite(start) || !Number.isFinite(end))
|
|
340
|
+
continue;
|
|
341
|
+
const segmentWords = words.filter((word) => word.start_sec >= start - 0.05 && word.start_sec < end + 0.05);
|
|
342
|
+
segments.push({
|
|
343
|
+
speaker: "speaker_1",
|
|
344
|
+
start_sec: start,
|
|
345
|
+
end_sec: end,
|
|
346
|
+
text,
|
|
347
|
+
words: segmentWords.length ? segmentWords : undefined
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
const text = String(data.text ?? "").trim();
|
|
351
|
+
if (!segments.length && text) {
|
|
352
|
+
segments.push({ speaker: "speaker_1", start_sec: null, end_sec: null, text, words: words.length ? words : undefined });
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
text,
|
|
356
|
+
language: typeof data.language === "string" && data.language.trim() ? data.language.trim() : input.language?.trim() || null,
|
|
357
|
+
segments,
|
|
358
|
+
diarization: "none",
|
|
359
|
+
usage: {
|
|
360
|
+
inputTokens: Number(data.usage?.input_tokens ?? 0),
|
|
361
|
+
outputTokens: Number(data.usage?.output_tokens ?? 0),
|
|
362
|
+
costUsd: Number(data.usage?.cost ?? 0)
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
async function callOpenRouterSpeechTranscription(input) {
|
|
367
|
+
const response = await fetch("https://openrouter.ai/api/v1/audio/transcriptions", {
|
|
368
|
+
method: "POST",
|
|
369
|
+
headers: {
|
|
370
|
+
"Content-Type": "application/json",
|
|
371
|
+
Authorization: `Bearer ${input.apiKey}`
|
|
372
|
+
},
|
|
373
|
+
body: JSON.stringify({
|
|
374
|
+
model: input.model,
|
|
375
|
+
input_audio: {
|
|
376
|
+
data: Buffer.from(input.audio).toString("base64"),
|
|
377
|
+
format: audioFormatFromContentType(input.contentType)
|
|
378
|
+
},
|
|
379
|
+
...(input.prompt ? { prompt: input.prompt } : {}),
|
|
380
|
+
...(input.language ? { language: input.language } : {})
|
|
381
|
+
})
|
|
382
|
+
});
|
|
383
|
+
if (response.status === 401 || response.status === 403) {
|
|
384
|
+
throw new ProviderAuthError("openrouter authentication failed");
|
|
385
|
+
}
|
|
386
|
+
if (response.status === 429) {
|
|
387
|
+
throw await rateLimitOrQuotaError("openrouter", response);
|
|
388
|
+
}
|
|
389
|
+
if (!response.ok) {
|
|
390
|
+
const details = await response.text();
|
|
391
|
+
throw new Error(`openrouter transcription returned ${response.status}${details ? `: ${details}` : ""}`);
|
|
392
|
+
}
|
|
393
|
+
const data = await response.json();
|
|
394
|
+
return {
|
|
395
|
+
text: String(data.text ?? ""),
|
|
396
|
+
usage: {
|
|
397
|
+
inputTokens: Number(data.usage?.input_tokens ?? data.usage?.prompt_tokens ?? 0),
|
|
398
|
+
outputTokens: Number(data.usage?.output_tokens ?? data.usage?.completion_tokens ?? 0),
|
|
399
|
+
costUsd: Number(data.usage?.cost ?? 0)
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
async function callGeminiSpeechTranscription(input) {
|
|
404
|
+
const diarize = input.diarize !== false;
|
|
405
|
+
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${input.model}:generateContent?key=${input.apiKey}`, {
|
|
406
|
+
method: "POST",
|
|
407
|
+
headers: {
|
|
408
|
+
"Content-Type": "application/json"
|
|
409
|
+
},
|
|
410
|
+
body: JSON.stringify({
|
|
411
|
+
contents: [{
|
|
412
|
+
parts: [
|
|
413
|
+
{
|
|
414
|
+
inline_data: {
|
|
415
|
+
mime_type: input.contentType ?? "audio/mpeg",
|
|
416
|
+
data: Buffer.from(input.audio).toString("base64")
|
|
417
|
+
}
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
text: diarize
|
|
421
|
+
? buildGeminiDiarizedTranscriptionPrompt(input.prompt, input.language)
|
|
422
|
+
: buildGeminiTranscriptionPrompt(input.prompt, input.language)
|
|
423
|
+
}
|
|
424
|
+
]
|
|
425
|
+
}],
|
|
426
|
+
generationConfig: {
|
|
427
|
+
temperature: 0,
|
|
428
|
+
responseMimeType: diarize ? "application/json" : "text/plain"
|
|
429
|
+
}
|
|
430
|
+
})
|
|
431
|
+
});
|
|
432
|
+
if (response.status === 401 || response.status === 403) {
|
|
433
|
+
throw new ProviderAuthError("gemini authentication failed");
|
|
434
|
+
}
|
|
435
|
+
if (response.status === 429) {
|
|
436
|
+
throw await rateLimitOrQuotaError("gemini", response);
|
|
437
|
+
}
|
|
438
|
+
if (!response.ok) {
|
|
439
|
+
const details = await response.text();
|
|
440
|
+
throw new Error(`gemini transcription returned ${response.status}${details ? `: ${details}` : ""}`);
|
|
441
|
+
}
|
|
442
|
+
const data = await response.json();
|
|
443
|
+
const usage = {
|
|
444
|
+
inputTokens: Number(data.usageMetadata?.promptTokenCount ?? 0),
|
|
445
|
+
outputTokens: Number(data.usageMetadata?.candidatesTokenCount ?? 0),
|
|
446
|
+
costUsd: 0
|
|
447
|
+
};
|
|
448
|
+
const rawText = extractGeminiText(data);
|
|
449
|
+
if (diarize) {
|
|
450
|
+
const parsed = parseDiarizedTranscription(rawText);
|
|
451
|
+
if (parsed) {
|
|
452
|
+
return { ...parsed, usage };
|
|
453
|
+
}
|
|
454
|
+
// JSON mode misbehaved — degrade to the raw text rather than failing the job.
|
|
455
|
+
}
|
|
456
|
+
return {
|
|
457
|
+
text: rawText.trim(),
|
|
458
|
+
language: input.language?.trim() || null,
|
|
459
|
+
segments: rawText.trim()
|
|
460
|
+
? [{ speaker: "speaker_1", start_sec: null, end_sec: null, text: rawText.trim() }]
|
|
461
|
+
: [],
|
|
462
|
+
diarization: "none",
|
|
463
|
+
usage
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
function buildGeminiTranscriptionPrompt(prompt, language) {
|
|
467
|
+
const basePrompt = prompt?.trim() || "Transcribe this audio verbatim. Return plain text only.";
|
|
468
|
+
return language?.trim()
|
|
469
|
+
? `${basePrompt}\n\nExpected language: ${language.trim()}.`
|
|
470
|
+
: basePrompt;
|
|
471
|
+
}
|
|
472
|
+
function buildGeminiDiarizedTranscriptionPrompt(prompt, language) {
|
|
473
|
+
return [
|
|
474
|
+
"Transcribe this audio verbatim with speaker diarization.",
|
|
475
|
+
"Return ONLY a JSON object with this exact shape:",
|
|
476
|
+
`{"language": "<BCP-47 code or null>", "segments": [{"speaker": "<label>", "start_sec": <number>, "end_sec": <number>, "text": "<verbatim words>"}]}`,
|
|
477
|
+
"Rules:",
|
|
478
|
+
"- Split segments on speaker turns; keep each segment's text verbatim.",
|
|
479
|
+
"- Label speakers consistently across the whole audio (speaker_1, speaker_2, …), or use a real name/role when the audio makes it unambiguous (e.g. it is spoken aloud).",
|
|
480
|
+
"- If only one voice speaks, return all segments under speaker_1.",
|
|
481
|
+
"- start_sec/end_sec are seconds from the start of the audio; use your best estimate.",
|
|
482
|
+
"- Non-speech audio (music, silence, sfx) is not a segment.",
|
|
483
|
+
...(language?.trim() ? [`- Expected language: ${language.trim()}.`] : []),
|
|
484
|
+
...(prompt?.trim() ? [`Context from the caller: ${prompt.trim()}`] : [])
|
|
485
|
+
].join("\n");
|
|
486
|
+
}
|
|
487
|
+
function parseDiarizedTranscription(raw) {
|
|
488
|
+
const trimmed = (raw ?? "").trim();
|
|
489
|
+
if (!trimmed)
|
|
490
|
+
return null;
|
|
491
|
+
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(trimmed);
|
|
492
|
+
for (const candidate of [fenced?.[1], trimmed]) {
|
|
493
|
+
if (!candidate)
|
|
494
|
+
continue;
|
|
495
|
+
const start = candidate.indexOf("{");
|
|
496
|
+
const end = candidate.lastIndexOf("}");
|
|
497
|
+
if (start < 0 || end <= start)
|
|
498
|
+
continue;
|
|
499
|
+
let parsed;
|
|
500
|
+
try {
|
|
501
|
+
parsed = JSON.parse(candidate.slice(start, end + 1));
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.segments))
|
|
507
|
+
continue;
|
|
508
|
+
const record = parsed;
|
|
509
|
+
const segments = record.segments
|
|
510
|
+
.filter((entry) => Boolean(entry) && typeof entry === "object")
|
|
511
|
+
.map((entry, index) => ({
|
|
512
|
+
speaker: typeof entry.speaker === "string" && entry.speaker.trim() ? entry.speaker.trim() : `speaker_${index + 1}`,
|
|
513
|
+
start_sec: Number.isFinite(Number(entry.start_sec)) ? Number(entry.start_sec) : null,
|
|
514
|
+
end_sec: Number.isFinite(Number(entry.end_sec)) ? Number(entry.end_sec) : null,
|
|
515
|
+
text: String(entry.text ?? "").trim()
|
|
516
|
+
}))
|
|
517
|
+
.filter((segment) => segment.text);
|
|
518
|
+
if (!segments.length)
|
|
519
|
+
continue;
|
|
520
|
+
const speakers = new Set(segments.map((segment) => segment.speaker));
|
|
521
|
+
return {
|
|
522
|
+
text: segments.map((segment) => segment.text).join(" ").trim(),
|
|
523
|
+
language: typeof record.language === "string" && record.language.trim() ? record.language.trim() : null,
|
|
524
|
+
segments,
|
|
525
|
+
diarization: speakers.size >= 1 ? "diarized" : "none"
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
function extractGeminiText(data) {
|
|
531
|
+
return String(data.candidates?.[0]?.content?.parts?.map((part) => part.text ?? "").join("\n") ?? "");
|
|
532
|
+
}
|
|
533
|
+
function formatSrtTimestamp(totalSeconds) {
|
|
534
|
+
const clamped = Math.max(0, totalSeconds);
|
|
535
|
+
const hours = Math.floor(clamped / 3600);
|
|
536
|
+
const minutes = Math.floor((clamped % 3600) / 60);
|
|
537
|
+
const seconds = Math.floor(clamped % 60);
|
|
538
|
+
const millis = Math.floor((clamped * 1000) % 1000);
|
|
539
|
+
const pad = (value, length = 2) => String(value).padStart(length, "0");
|
|
540
|
+
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)},${pad(millis, 3)}`;
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Simple subtitle rendering of a transcript: one plain-text cue per timed
|
|
544
|
+
* segment, no speaker labels. Returns null when the provider gave no timings
|
|
545
|
+
* (the plain-text transcript still covers that case).
|
|
546
|
+
*/
|
|
547
|
+
export function buildSrtFromSegments(segments) {
|
|
548
|
+
const timed = segments.filter((segment) => typeof segment.start_sec === "number" && Number.isFinite(segment.start_sec));
|
|
549
|
+
if (!timed.length) {
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
const cues = timed.map((segment, index) => {
|
|
553
|
+
const start = segment.start_sec;
|
|
554
|
+
const nextStart = timed[index + 1]?.start_sec;
|
|
555
|
+
const end = typeof segment.end_sec === "number" && segment.end_sec > start
|
|
556
|
+
? segment.end_sec
|
|
557
|
+
: typeof nextStart === "number" && nextStart > start
|
|
558
|
+
? nextStart
|
|
559
|
+
: start + 4;
|
|
560
|
+
return `${index + 1}\n${formatSrtTimestamp(start)} --> ${formatSrtTimestamp(end)}\n${segment.text}`;
|
|
561
|
+
});
|
|
562
|
+
return `${cues.join("\n\n")}\n`;
|
|
563
|
+
}
|
|
564
|
+
export function inferSpeechContentType(format) {
|
|
565
|
+
return format === "wav" ? "audio/wav" : "audio/mpeg";
|
|
566
|
+
}
|
|
567
|
+
export function pcm16MonoToWav(pcmBuffer, sampleRate) {
|
|
568
|
+
const header = Buffer.alloc(44);
|
|
569
|
+
header.write("RIFF", 0, 4, "ascii");
|
|
570
|
+
header.writeUInt32LE(36 + pcmBuffer.byteLength, 4);
|
|
571
|
+
header.write("WAVE", 8, 4, "ascii");
|
|
572
|
+
header.write("fmt ", 12, 4, "ascii");
|
|
573
|
+
header.writeUInt32LE(16, 16);
|
|
574
|
+
header.writeUInt16LE(1, 20);
|
|
575
|
+
header.writeUInt16LE(1, 22);
|
|
576
|
+
header.writeUInt32LE(sampleRate, 24);
|
|
577
|
+
header.writeUInt32LE(sampleRate * 2, 28);
|
|
578
|
+
header.writeUInt16LE(2, 32);
|
|
579
|
+
header.writeUInt16LE(16, 34);
|
|
580
|
+
header.write("data", 36, 4, "ascii");
|
|
581
|
+
header.writeUInt32LE(pcmBuffer.byteLength, 40);
|
|
582
|
+
return Buffer.concat([header, pcmBuffer]);
|
|
583
|
+
}
|
|
584
|
+
function isWavBuffer(buffer) {
|
|
585
|
+
return buffer.byteLength >= 12
|
|
586
|
+
&& buffer.subarray(0, 4).toString("ascii") === "RIFF"
|
|
587
|
+
&& buffer.subarray(8, 12).toString("ascii") === "WAVE";
|
|
588
|
+
}
|
|
589
|
+
function isLikelyMp3Buffer(buffer) {
|
|
590
|
+
if (buffer.byteLength < 3) {
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
if (buffer.subarray(0, 3).toString("ascii") === "ID3") {
|
|
594
|
+
return true;
|
|
595
|
+
}
|
|
596
|
+
return buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0;
|
|
597
|
+
}
|
|
598
|
+
export function audioFormatFromContentType(contentType) {
|
|
599
|
+
const normalized = contentType?.split(";")[0]?.trim().toLowerCase();
|
|
600
|
+
switch (normalized) {
|
|
601
|
+
case "audio/wav":
|
|
602
|
+
case "audio/x-wav":
|
|
603
|
+
return "wav";
|
|
604
|
+
case "audio/mp4":
|
|
605
|
+
case "audio/m4a":
|
|
606
|
+
return "mp4";
|
|
607
|
+
case "audio/webm":
|
|
608
|
+
return "webm";
|
|
609
|
+
case "audio/ogg":
|
|
610
|
+
return "ogg";
|
|
611
|
+
default:
|
|
612
|
+
return "mp3";
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
export function audioExtensionFromContentType(contentType) {
|
|
616
|
+
switch (audioFormatFromContentType(contentType)) {
|
|
617
|
+
case "wav":
|
|
618
|
+
return "wav";
|
|
619
|
+
case "mp4":
|
|
620
|
+
return "m4a";
|
|
621
|
+
case "webm":
|
|
622
|
+
return "webm";
|
|
623
|
+
case "ogg":
|
|
624
|
+
return "ogg";
|
|
625
|
+
default:
|
|
626
|
+
return "mp3";
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
//# sourceMappingURL=speech.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mevdragon/vidfarm-devcli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Local bridge for the Vidfarm Trackpad Editor. `vidfarm serve <template_id>` boots the FULL editor on localhost (disk-backed records/storage, free in-process render); edit composition.html on disk (Claude Code, Codex, etc.) and the browser live-morphs it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|