@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.
@@ -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.10.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": {