diff --git a/packages/ai-providers/src/elevenlabs/ElevenLabsProvider.ts b/packages/ai-providers/src/elevenlabs/ElevenLabsProvider.ts index d7c67298..a1ec025a 100644 --- a/packages/ai-providers/src/elevenlabs/ElevenLabsProvider.ts +++ b/packages/ai-providers/src/elevenlabs/ElevenLabsProvider.ts @@ -5,7 +5,8 @@ import type { } from "../interface/types.js"; import { defaultModel } from "../catalog/catalog.js"; import type { SpeechGenerator, SpeechRequest, SpeechResult } from "../speech/contract.js"; -import { ProviderError } from "../shared/errors.js"; +import { ProviderError, isProviderError, type ProviderErrorKind } from "../shared/errors.js"; +import { audioFilename } from "../transcription/contract.js"; import { providerRequest } from "../shared/http.js"; import { resolveCatalogModel } from "../video/models.js"; @@ -32,6 +33,8 @@ export interface VoiceCloneResult { voiceId?: string; /** Error message if failed */ error?: string; + /** Why the provider failed (`auth`, `quota`, `moderation`, ...). */ + errorKind?: ProviderErrorKind; } /** @@ -55,6 +58,8 @@ export interface MusicResult { audioBuffer?: Buffer; /** Error message if failed */ error?: string; + /** Why the provider failed (`auth`, `quota`, `moderation`, ...). */ + errorKind?: ProviderErrorKind; } /** @@ -76,6 +81,8 @@ export interface SoundEffectResult { audioBuffer?: Buffer; /** Error message if failed */ error?: string; + /** Why the provider failed (`auth`, `quota`, `moderation`, ...). */ + errorKind?: ProviderErrorKind; } /** @@ -87,6 +94,8 @@ export interface AudioIsolationResult { audioBuffer?: Buffer; /** Error message if failed */ error?: string; + /** Why the provider failed (`auth`, `quota`, `moderation`, ...). */ + errorKind?: ProviderErrorKind; } /** @@ -371,6 +380,23 @@ export class ElevenLabsProvider implements AIProvider, SpeechGenerator { } } + /** An ElevenLabs API call through the shared HTTP layer (retries, classified errors). */ + private request(path: string, init: RequestInit): Promise { + return providerRequest(this.speechProvider, `${this.baseUrl}${path}`, { + ...init, + headers: { "xi-api-key": this.apiKey!, ...(init.headers as Record | undefined) }, + }); + } + + /** A failed result carrying the provider error kind. */ + private failure(error: unknown): { success: false; error: string; errorKind?: ProviderErrorKind } { + return { + success: false, + error: errorText(error), + ...(isProviderError(error) ? { errorKind: error.kind } : {}), + }; + } + /** * Generate sound effect from text prompt */ @@ -398,24 +424,15 @@ export class ElevenLabsProvider implements AIProvider, SpeechGenerator { body.duration_seconds = duration; } - const response = await fetch(`${this.baseUrl}/sound-generation`, { + const response = await this.request(`/sound-generation`, { method: "POST", headers: { - "xi-api-key": this.apiKey, "Content-Type": "application/json", Accept: "audio/mpeg", }, body: JSON.stringify(body), }); - if (!response.ok) { - const error = await response.text(); - return { - success: false, - error: `Sound generation failed: ${error}`, - }; - } - const arrayBuffer = await response.arrayBuffer(); const audioBuffer = Buffer.from(arrayBuffer); @@ -424,10 +441,7 @@ export class ElevenLabsProvider implements AIProvider, SpeechGenerator { audioBuffer, }; } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : "Unknown error", - }; + return this.failure(error); } } @@ -465,24 +479,15 @@ export class ElevenLabsProvider implements AIProvider, SpeechGenerator { body.seed = options.seed; } - const response = await fetch(`${this.baseUrl}/music`, { + const response = await this.request(`/music`, { method: "POST", headers: { - "xi-api-key": this.apiKey, "Content-Type": "application/json", Accept: "audio/mpeg", }, body: JSON.stringify(body), }); - if (!response.ok) { - const error = await response.text(); - return { - success: false, - error: `Music generation failed: ${error}`, - }; - } - const arrayBuffer = await response.arrayBuffer(); const audioBuffer = Buffer.from(arrayBuffer); @@ -491,10 +496,7 @@ export class ElevenLabsProvider implements AIProvider, SpeechGenerator { audioBuffer, }; } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : "Unknown error", - }; + return this.failure(error); } } @@ -516,25 +518,16 @@ export class ElevenLabsProvider implements AIProvider, SpeechGenerator { const audioBlob = Buffer.isBuffer(audioData) ? new Blob([new Uint8Array(audioData)]) : audioData; - formData.append("audio", audioBlob, "audio.mp3"); + formData.append("audio", audioBlob, audioFilename(new Uint8Array(await audioBlob.arrayBuffer()))); - const response = await fetch(`${this.baseUrl}/audio-isolation`, { + const response = await this.request(`/audio-isolation`, { method: "POST", headers: { - "xi-api-key": this.apiKey, Accept: "audio/mpeg", }, body: formData, }); - if (!response.ok) { - const error = await response.text(); - return { - success: false, - error: `Audio isolation failed: ${error}`, - }; - } - const arrayBuffer = await response.arrayBuffer(); const audioBuffer = Buffer.from(arrayBuffer); @@ -543,10 +536,7 @@ export class ElevenLabsProvider implements AIProvider, SpeechGenerator { audioBuffer, }; } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : "Unknown error", - }; + return this.failure(error); } } @@ -601,22 +591,11 @@ export class ElevenLabsProvider implements AIProvider, SpeechGenerator { formData.append("files", blob, `sample_${i + 1}.mp3`); } - const response = await fetch(`${this.baseUrl}/voices/add`, { + const response = await this.request(`/voices/add`, { method: "POST", - headers: { - "xi-api-key": this.apiKey, - }, body: formData, }); - if (!response.ok) { - const error = await response.text(); - return { - success: false, - error: `Voice clone failed: ${error}`, - }; - } - const data = (await response.json()) as { voice_id: string }; return { @@ -624,10 +603,7 @@ export class ElevenLabsProvider implements AIProvider, SpeechGenerator { voiceId: data.voice_id, }; } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : "Unknown error", - }; + return this.failure(error); } } @@ -643,27 +619,11 @@ export class ElevenLabsProvider implements AIProvider, SpeechGenerator { } try { - const response = await fetch(`${this.baseUrl}/voices/${voiceId}`, { - method: "DELETE", - headers: { - "xi-api-key": this.apiKey, - }, - }); - - if (!response.ok) { - const error = await response.text(); - return { - success: false, - error: `Voice deletion failed: ${error}`, - }; - } + await this.request(`/voices/${voiceId}`, { method: "DELETE" }); return { success: true }; } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : "Unknown error", - }; + return this.failure(error); } } } diff --git a/packages/ai-providers/src/replicate/ReplicateProvider.ts b/packages/ai-providers/src/replicate/ReplicateProvider.ts index 323809bf..986839c8 100644 --- a/packages/ai-providers/src/replicate/ReplicateProvider.ts +++ b/packages/ai-providers/src/replicate/ReplicateProvider.ts @@ -4,6 +4,7 @@ import type { ProviderConfig, VideoResult, } from "../interface/types.js"; +import { providerRequest } from "../shared/http.js"; /** * Video upscale options @@ -133,6 +134,12 @@ export class ReplicateProvider implements AIProvider { return !!this.apiToken; } + /** A Replicate API call through the shared HTTP layer; throws a classified `ProviderError`. */ + private request(url: string, init: RequestInit = {}): Promise { + return providerRequest(this.id, url, init); + } + + /** * Upscale video using Real-ESRGAN */ @@ -160,7 +167,7 @@ export class ReplicateProvider implements AIProvider { const version = modelVersions[model] || modelVersions["real-esrgan"]; - const response = await fetch(`${this.baseUrl}/predictions`, { + const response = await this.request(`${this.baseUrl}/predictions`, { method: "POST", headers: { "Content-Type": "application/json", @@ -175,22 +182,6 @@ export class ReplicateProvider implements AIProvider { }), }); - if (!response.ok) { - const errorText = await response.text(); - let errorMessage: string; - try { - const errorData = JSON.parse(errorText); - errorMessage = errorData.detail || errorData.error || errorText; - } catch { - errorMessage = errorText; - } - return { - id: "", - status: "failed", - error: `API error (${response.status}): ${errorMessage}`, - }; - } - const prediction = (await response.json()) as ReplicatePrediction; return { @@ -252,7 +243,7 @@ export class ReplicateProvider implements AIProvider { }; } - const response = await fetch(`${this.baseUrl}/predictions`, { + const response = await this.request(`${this.baseUrl}/predictions`, { method: "POST", headers: { "Content-Type": "application/json", @@ -264,22 +255,6 @@ export class ReplicateProvider implements AIProvider { }), }); - if (!response.ok) { - const errorText = await response.text(); - let errorMessage: string; - try { - const errorData = JSON.parse(errorText); - errorMessage = errorData.detail || errorData.error || errorText; - } catch { - errorMessage = errorText; - } - return { - id: "", - status: "failed", - error: `API error (${response.status}): ${errorMessage}`, - }; - } - const prediction = (await response.json()) as ReplicatePrediction; return { @@ -309,21 +284,12 @@ export class ReplicateProvider implements AIProvider { } try { - const response = await fetch(`${this.baseUrl}/predictions/${id}`, { + const response = await this.request(`${this.baseUrl}/predictions/${id}`, { headers: { Authorization: `Bearer ${this.apiToken}`, }, }); - if (!response.ok) { - const errorText = await response.text(); - return { - id, - status: "failed", - error: `Failed to get status: ${errorText}`, - }; - } - const prediction = (await response.json()) as ReplicatePrediction; const statusMap: Record = { @@ -401,7 +367,7 @@ export class ReplicateProvider implements AIProvider { } try { - const response = await fetch(`${this.baseUrl}/predictions/${id}/cancel`, { + const response = await this.request(`${this.baseUrl}/predictions/${id}/cancel`, { method: "POST", headers: { Authorization: `Bearer ${this.apiToken}`, @@ -460,7 +426,7 @@ export class ReplicateProvider implements AIProvider { input.temperature = options.temperature; } - const response = await fetch(`${this.baseUrl}/predictions`, { + const response = await this.request(`${this.baseUrl}/predictions`, { method: "POST", headers: { "Content-Type": "application/json", @@ -472,21 +438,6 @@ export class ReplicateProvider implements AIProvider { }), }); - if (!response.ok) { - const errorText = await response.text(); - let errorMessage: string; - try { - const errorData = JSON.parse(errorText); - errorMessage = errorData.detail || errorData.error || errorText; - } catch { - errorMessage = errorText; - } - return { - success: false, - error: `API error (${response.status}): ${errorMessage}`, - }; - } - const prediction = (await response.json()) as ReplicatePrediction; return { @@ -587,7 +538,7 @@ export class ReplicateProvider implements AIProvider { } // Using resemble-enhance model for audio restoration - const response = await fetch(`${this.baseUrl}/predictions`, { + const response = await this.request(`${this.baseUrl}/predictions`, { method: "POST", headers: { "Content-Type": "application/json", @@ -599,21 +550,6 @@ export class ReplicateProvider implements AIProvider { }), }); - if (!response.ok) { - const errorText = await response.text(); - let errorMessage: string; - try { - const errorData = JSON.parse(errorText); - errorMessage = errorData.detail || errorData.error || errorText; - } catch { - errorMessage = errorText; - } - return { - success: false, - error: `API error (${response.status}): ${errorMessage}`, - }; - } - const prediction = (await response.json()) as ReplicatePrediction; return { @@ -727,7 +663,7 @@ export class ReplicateProvider implements AIProvider { }; } - const response = await fetch(`${this.baseUrl}/predictions`, { + const response = await this.request(`${this.baseUrl}/predictions`, { method: "POST", headers: { "Content-Type": "application/json", @@ -739,22 +675,6 @@ export class ReplicateProvider implements AIProvider { }), }); - if (!response.ok) { - const errorText = await response.text(); - let errorMessage: string; - try { - const errorData = JSON.parse(errorText); - errorMessage = errorData.detail || errorData.error || errorText; - } catch { - errorMessage = errorText; - } - return { - id: "", - status: "failed", - error: `API error (${response.status}): ${errorMessage}`, - }; - } - const prediction = (await response.json()) as ReplicatePrediction; return { @@ -821,7 +741,7 @@ export class ReplicateProvider implements AIProvider { }; } - const response = await fetch(`${this.baseUrl}/predictions`, { + const response = await this.request(`${this.baseUrl}/predictions`, { method: "POST", headers: { "Content-Type": "application/json", @@ -835,22 +755,6 @@ export class ReplicateProvider implements AIProvider { }), }); - if (!response.ok) { - const errorText = await response.text(); - let errorMessage: string; - try { - const errorData = JSON.parse(errorText); - errorMessage = errorData.detail || errorData.error || errorText; - } catch { - errorMessage = errorText; - } - return { - id: "", - status: "failed", - error: `API error (${response.status}): ${errorMessage}`, - }; - } - const prediction = (await response.json()) as ReplicatePrediction; return { diff --git a/packages/cli/src/commands/_shared/transcribe-narration.ts b/packages/cli/src/commands/_shared/transcribe-narration.ts index 84bbaca0..d7afabbd 100644 --- a/packages/cli/src/commands/_shared/transcribe-narration.ts +++ b/packages/cli/src/commands/_shared/transcribe-narration.ts @@ -15,7 +15,6 @@ import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; - import type { SceneTranscriptWord } from "./scene-html-emit.js"; import { transcribeAudioFile } from "./transcription.js"; diff --git a/packages/cli/src/commands/ai-audio.ts b/packages/cli/src/commands/ai-audio.ts index c75b9dd0..171d763e 100644 --- a/packages/cli/src/commands/ai-audio.ts +++ b/packages/cli/src/commands/ai-audio.ts @@ -23,6 +23,8 @@ import { import { execSafe, execSafeSync, commandExists } from "../utils/exec-safe.js"; import { detectFormat, formatTranscript } from "../utils/subtitle.js"; import { transcribeAudioFile } from "./_shared/transcription.js"; +import { getConfiguredApiKey } from "../utils/api-key.js"; +import type { ProviderErrorKind } from "@vibeframe/ai-providers"; // ============================================================================ // Transcribe @@ -42,33 +44,33 @@ export interface TranscribeResult { segments?: Array<{ startTime: number; endTime: number; text: string }>; detectedLanguage?: string; outputPath?: string; + /** Subtitle format written to `outputPath` (json, srt, vtt). */ + format?: string; error?: string; + errorKind?: ProviderErrorKind; } export async function executeTranscribe(options: TranscribeOptions): Promise { const { audioPath, language, output, format, apiKey } = options; try { - const key = apiKey || process.env.OPENAI_API_KEY; - if (!key) return { success: false, error: "OPENAI_API_KEY required" }; - const absPath = resolve(process.cwd(), audioPath); - if (!existsSync(absPath)) return { success: false, error: `File not found: ${absPath}` }; - - + if (!existsSync(absPath)) return { success: false, error: `File not found: ${absPath}`, errorKind: "not-found" }; + const key = await getConfiguredApiKey("OPENAI_API_KEY", apiKey); + if (!key) return { success: false, error: "OPENAI_API_KEY required", errorKind: "auth" }; - const result = await transcribeAudioFile(absPath, { apiKey: key, language: language }); + const result = await transcribeAudioFile(absPath, { apiKey: key, language }); if (result.status === "failed") { - return { success: false, error: result.error || "Transcription failed" }; + return { success: false, error: result.error || "Transcription failed", errorKind: result.errorKind }; } let outputPath: string | undefined; + let fmt: ReturnType | undefined; if (output) { outputPath = resolve(process.cwd(), output); - const fmt = detectFormat(output, format); - const content = formatTranscript(result, fmt); - await writeFile(outputPath, content, "utf-8"); + fmt = detectFormat(output, format); + await writeFile(outputPath, formatTranscript(result, fmt), "utf-8"); } return { @@ -77,6 +79,7 @@ export async function executeTranscribe(options: TranscribeOptions): Promise ({ startTime: s.startTime, endTime: s.endTime, text: s.text })), detectedLanguage: result.detectedLanguage, outputPath, + format: fmt, }; } catch (error) { return { success: false, error: `Transcription failed: ${error instanceof Error ? error.message : String(error)}` }; @@ -97,17 +100,17 @@ export interface IsolateResult { success: boolean; outputPath?: string; error?: string; + errorKind?: ProviderErrorKind; } export async function executeIsolate(options: IsolateOptions): Promise { const { audioPath, output = "vocals.mp3", apiKey } = options; try { - const key = apiKey || process.env.ELEVENLABS_API_KEY; - if (!key) return { success: false, error: "ELEVENLABS_API_KEY required" }; - const absPath = resolve(process.cwd(), audioPath); - if (!existsSync(absPath)) return { success: false, error: `File not found: ${absPath}` }; + if (!existsSync(absPath)) return { success: false, error: `File not found: ${absPath}`, errorKind: "not-found" }; + const key = await getConfiguredApiKey("ELEVENLABS_API_KEY", apiKey); + if (!key) return { success: false, error: "ELEVENLABS_API_KEY required", errorKind: "auth" }; const audioBuffer = await readFile(absPath); const elevenlabs = new ElevenLabsProvider(); @@ -115,7 +118,7 @@ export async function executeIsolate(options: IsolateOptions): Promise { const { samplePaths, name, description, labels, removeNoise, apiKey } = options; try { - const key = apiKey || process.env.ELEVENLABS_API_KEY; - if (!key) return { success: false, error: "ELEVENLABS_API_KEY required" }; + const key = await getConfiguredApiKey("ELEVENLABS_API_KEY", apiKey); + if (!key) return { success: false, error: "ELEVENLABS_API_KEY required", errorKind: "auth" }; if (!samplePaths || samplePaths.length === 0) { return { success: false, error: "At least one audio sample is required" }; @@ -161,7 +165,7 @@ export async function executeVoiceClone(options: VoiceCloneOptions): Promise; error?: string; + errorKind?: ProviderErrorKind; } export async function executeDub(options: DubOptions): Promise { @@ -213,9 +220,9 @@ export async function executeDub(options: DubOptions): Promise { const absPath = resolve(process.cwd(), mediaPath); if (!existsSync(absPath)) return { success: false, error: `File not found: ${absPath}` }; - const openaiKey = process.env.OPENAI_API_KEY; - const anthropicKey = process.env.ANTHROPIC_API_KEY; - const elevenlabsKey = process.env.ELEVENLABS_API_KEY; + const openaiKey = await getConfiguredApiKey("OPENAI_API_KEY"); + const anthropicKey = await getConfiguredApiKey("ANTHROPIC_API_KEY"); + const elevenlabsKey = await getConfiguredApiKey("ELEVENLABS_API_KEY"); if (!openaiKey) return { success: false, error: "OPENAI_API_KEY required for Whisper transcription" }; if (!anthropicKey) return { success: false, error: "ANTHROPIC_API_KEY required for Claude translation" }; @@ -234,7 +241,7 @@ export async function executeDub(options: DubOptions): Promise { const transcriptResult = await transcribeAudioFile(audioPath, { apiKey: openaiKey, language: source }); if (transcriptResult.status === "failed" || !transcriptResult.segments) { - return { success: false, error: `Transcription failed: ${transcriptResult.error}` }; + return { success: false, error: `Transcription failed: ${transcriptResult.error}`, errorKind: transcriptResult.errorKind }; } const segments = transcriptResult.segments; @@ -284,6 +291,12 @@ export async function executeDub(options: DubOptions): Promise { sourceLanguage: transcriptResult.detectedLanguage || source || "auto", targetLanguage: language, segmentCount: segments.length, + segments: segments.map((s, i) => ({ + startTime: s.startTime, + endTime: s.endTime, + original: s.text, + translated: translatedSegments[i]?.text || s.text, + })), outputPath: output ? resolve(process.cwd(), output) : undefined, }; } @@ -315,6 +328,12 @@ export async function executeDub(options: DubOptions): Promise { sourceLanguage: transcriptResult.detectedLanguage || source || "auto", targetLanguage: language, segmentCount: segments.length, + segments: segments.map((s, i) => ({ + startTime: s.startTime, + endTime: s.endTime, + original: s.text, + translated: translatedSegments[i]?.text || s.text, + })), }; } catch (error) { return { success: false, error: `Dubbing failed: ${error instanceof Error ? error.message : String(error)}` }; diff --git a/packages/cli/src/commands/ai-highlights.ts b/packages/cli/src/commands/ai-highlights.ts index c64b5785..d2626107 100644 --- a/packages/cli/src/commands/ai-highlights.ts +++ b/packages/cli/src/commands/ai-highlights.ts @@ -274,7 +274,6 @@ Analyze both what is SHOWN (visual cues, actions, expressions) and what is SAID sourceDuration = await ffprobeDuration(absPath); } - const transcriptResult = await transcribeAudioFile(audioPath, { apiKey: openaiApiKey, language: options.language }); if (tempAudioPath && existsSync(tempAudioPath)) { @@ -547,7 +546,6 @@ Analyze both VISUALS (expressions, actions, scene changes) and AUDIO (speech, re const tempAudio = absPath.replace(/(\.[^.]+)$/, "-temp-audio.mp3"); await execSafe("ffmpeg", ["-i", absPath, "-vn", "-acodec", "libmp3lame", "-q:a", "2", tempAudio, "-y"]); - const transcript = await transcribeAudioFile(tempAudio, { apiKey: openaiApiKey, language: options.language }); try { @@ -882,7 +880,6 @@ Analyze both what is SHOWN (visual cues, actions, expressions) and what is SAID const transcribeSpinner = ora("📝 Transcribing with Whisper...").start(); - const transcriptResult = await transcribeAudioFile(audioPath, { apiKey: openaiApiKey, language: options.language }); if (transcriptResult.status === "failed" || !transcriptResult.segments) { @@ -1203,7 +1200,6 @@ Analyze both VISUALS (expressions, actions, scene changes) and AUDIO (speech, re spinner.text = "Transcribing audio..."; - const transcript = await transcribeAudioFile(tempAudio, { apiKey: openaiApiKey, language: options.language }); try { diff --git a/packages/cli/src/commands/audio.ts b/packages/cli/src/commands/audio.ts index 926623b7..f172df86 100644 --- a/packages/cli/src/commands/audio.ts +++ b/packages/cli/src/commands/audio.ts @@ -15,15 +15,13 @@ */ import { Command } from "commander"; -import { resolve, dirname, basename, extname } from "node:path"; -import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; import { existsSync } from "node:fs"; import chalk from "chalk"; import ora from "ora"; -import { ElevenLabsProvider, ClaudeProvider } from "@vibeframe/ai-providers"; -import { getApiKey, requireApiKey } from "../utils/api-key.js"; -import { execSafe, commandExists, execSafeSync } from "../utils/exec-safe.js"; -import { detectFormat, formatTranscript } from "../utils/subtitle.js"; +import { ElevenLabsProvider } from "@vibeframe/ai-providers"; +import { requireApiKey } from "../utils/api-key.js"; +import { execSafe, commandExists } from "../utils/exec-safe.js"; import { applyTiers } from "./_shared/cost-tier.js"; import { formatTime } from "./ai-helpers.js"; import { @@ -34,9 +32,10 @@ import { usageError, apiError, generalError, + providerFailure, } from "./output.js"; import { rejectControlChars, validateOutputPath } from "./validate.js"; -import { transcribeAudioFile } from "./_shared/transcription.js"; +import { executeDub, executeIsolate, executeTranscribe, executeVoiceClone } from "./ai-audio.js"; export const audioCommand = new Command("audio") .alias("au") @@ -78,72 +77,54 @@ audioCommand .option("--format ", "Output format: json, srt, vtt (auto-detected from extension)") .action(async (audioPath: string, options) => { const startedAt = Date.now(); - try { - if (options.output) { - validateOutputPath(options.output); - } - if (!existsSync(resolve(process.cwd(), audioPath))) { - exitWithError(notFoundError(resolve(process.cwd(), audioPath))); - } - - const apiKey = await requireApiKey("OPENAI_API_KEY", "OpenAI", options.apiKey); - - const spinner = ora("Initializing Whisper...").start(); - - - spinner.text = "Reading audio file..."; - const absPath = resolve(process.cwd(), audioPath); - - spinner.text = "Transcribing..."; - const result = await transcribeAudioFile(absPath, { apiKey, language: options.language }); - - if (result.status === "failed") { - spinner.fail("Transcription failed"); - exitWithError(apiError(`Transcription failed: ${result.error}`, true)); - } - - spinner.succeed(chalk.green("Transcription complete")); + if (options.output) validateOutputPath(options.output); + if (!existsSync(resolve(process.cwd(), audioPath))) { + exitWithError(notFoundError(resolve(process.cwd(), audioPath))); + } + const apiKey = await requireApiKey("OPENAI_API_KEY", "OpenAI", options.apiKey); + const spinner = isJsonMode() ? null : ora("Transcribing...").start(); + const result = await executeTranscribe({ + audioPath, + language: options.language, + output: options.output, + format: options.format, + apiKey, + }); + if (!result.success) { + spinner?.fail("Transcription failed"); + exitWithError(providerFailure(`Transcription failed: ${result.error}`, result.errorKind)); + } - if (isJsonMode()) { - outputSuccess({ - command: "audio transcribe", - startedAt, - data: { - fullText: result.fullText, - segments: result.segments, - language: result.detectedLanguage, - outputPath: options.output ? resolve(process.cwd(), options.output) : undefined, - }, - }); - return; - } + if (isJsonMode()) { + outputSuccess({ + command: "audio transcribe", + startedAt, + data: { + fullText: result.text, + segments: result.segments, + language: result.detectedLanguage, + outputPath: result.outputPath, + }, + }); + return; + } - console.log(); - console.log(chalk.bold.cyan("Transcript")); + spinner?.succeed(chalk.green("Transcription complete")); + console.log(); + console.log(chalk.bold.cyan("Transcript")); + console.log(chalk.dim("─".repeat(60))); + console.log(result.text); + console.log(); + if (result.segments && result.segments.length > 0) { + console.log(chalk.bold.cyan("Segments")); console.log(chalk.dim("─".repeat(60))); - console.log(result.fullText); - console.log(); - - if (result.segments && result.segments.length > 0) { - console.log(chalk.bold.cyan("Segments")); - console.log(chalk.dim("─".repeat(60))); - for (const seg of result.segments) { - const time = `[${formatTime(seg.startTime)} - ${formatTime(seg.endTime)}]`; - console.log(`${chalk.dim(time)} ${seg.text}`); - } - console.log(); + for (const seg of result.segments) { + console.log(`${chalk.dim(`[${formatTime(seg.startTime)} - ${formatTime(seg.endTime)}]`)} ${seg.text}`); } - - if (options.output) { - const outputPath = resolve(process.cwd(), options.output); - const format = detectFormat(options.output, options.format); - const content = formatTranscript(result, format); - await writeFile(outputPath, content, "utf-8"); - console.log(chalk.green(`Saved ${format.toUpperCase()} to: ${outputPath}`)); - } - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - exitWithError(apiError(`Transcription failed: ${msg}`, true)); + console.log(); + } + if (result.outputPath) { + console.log(chalk.green(`Saved ${(result.format ?? "json").toUpperCase()} to: ${result.outputPath}`)); } }); @@ -211,60 +192,27 @@ audioCommand .option("--dry-run", "Preview parameters without executing") .action(async (audioPath: string, options) => { const startedAt = Date.now(); - try { - if (options.dryRun) { - outputSuccess({ - command: "audio isolate", - startedAt, - dryRun: true, - data: { params: { audioPath } }, - }); - return; - } - - if (options.output) { - validateOutputPath(options.output); - } - - const apiKey = await requireApiKey("ELEVENLABS_API_KEY", "ElevenLabs", options.apiKey); - - const spinner = ora("Reading audio file...").start(); - - const absPath = resolve(process.cwd(), audioPath); - const audioBuffer = await readFile(absPath); - - spinner.text = "Isolating vocals..."; - - const elevenlabs = new ElevenLabsProvider(); - await elevenlabs.initialize({ apiKey }); - - const result = await elevenlabs.isolateVocals(audioBuffer); - - if (!result.success || !result.audioBuffer) { - spinner.fail(result.error || "Audio isolation failed"); - exitWithError(apiError(result.error || "Audio isolation failed", true)); - } - - const outputPath = resolve(process.cwd(), options.output); - await writeFile(outputPath, result.audioBuffer); - - spinner.succeed(chalk.green("Vocals isolated")); - - if (isJsonMode()) { - outputSuccess({ - command: "audio isolate", - startedAt, - data: { outputPath }, - }); - return; - } - - console.log(chalk.green(`Saved to: ${outputPath}`)); - console.log(); - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - exitWithError(apiError(`Audio isolation failed: ${msg}`, true)); + if (options.dryRun) { + outputSuccess({ command: "audio isolate", startedAt, dryRun: true, data: { params: { audioPath, output: options.output } } }); + return; + } + if (options.output) validateOutputPath(options.output); + if (!existsSync(resolve(process.cwd(), audioPath))) { + exitWithError(notFoundError(resolve(process.cwd(), audioPath))); + } + const apiKey = await requireApiKey("ELEVENLABS_API_KEY", "ElevenLabs", options.apiKey); + const spinner = isJsonMode() ? null : ora("Isolating vocals...").start(); + const result = await executeIsolate({ audioPath, output: options.output, apiKey }); + if (!result.success) { + spinner?.fail(result.error ?? "Audio isolation failed"); + exitWithError(providerFailure(result.error ?? "Audio isolation failed", result.errorKind)); + } + if (isJsonMode()) { + outputSuccess({ command: "audio isolate", startedAt, data: { outputPath: result.outputPath } }); + return; } + spinner?.succeed(chalk.green("Vocals isolated")); + console.log(chalk.green(`Saved to: ${result.outputPath}`)); }); // ── audio clone-voice ────────────────────────────────────────────────── @@ -284,108 +232,76 @@ audioCommand .option("--dry-run", "Preview parameters without executing") .action(async (samples: string[], options) => { const startedAt = Date.now(); - try { - if (options.dryRun) { - outputSuccess({ - command: "audio clone-voice", - startedAt, - dryRun: true, - data: { params: { samples: samples?.length, name: options.name } }, - }); - return; - } - - const apiKey = await requireApiKey("ELEVENLABS_API_KEY", "ElevenLabs", options.apiKey); + if (options.dryRun) { + outputSuccess({ + command: "audio clone-voice", + startedAt, + dryRun: true, + data: { params: { samples: samples?.length, name: options.name } }, + }); + return; + } + const apiKey = await requireApiKey("ELEVENLABS_API_KEY", "ElevenLabs", options.apiKey); + if (options.list) { const elevenlabs = new ElevenLabsProvider(); await elevenlabs.initialize({ apiKey }); - - // List voices mode - if (options.list) { - const spinner = ora("Fetching voices...").start(); - const voices = await elevenlabs.getVoices(); - spinner.succeed(chalk.green(`Found ${voices.length} voices`)); - - console.log(); - console.log(chalk.bold.cyan("Available Voices")); - console.log(chalk.dim("─".repeat(60))); - - for (const voice of voices) { - const category = chalk.dim(`(${voice.category})`); - console.log(`${chalk.bold(voice.name)} ${category}`); - console.log(` ${chalk.dim("ID:")} ${voice.voice_id}`); - if (voice.labels && Object.keys(voice.labels).length > 0) { - console.log(` ${chalk.dim("Labels:")} ${JSON.stringify(voice.labels)}`); - } - console.log(); - } - return; - } - - // Clone voice mode - if (!options.name) { - exitWithError(usageError("Voice name is required. Use --name ")); - } - - rejectControlChars(options.name); - - if (!samples || samples.length === 0) { - exitWithError(usageError("At least one audio sample is required")); - } - - const spinner = ora("Reading audio samples...").start(); - - const audioBuffers: Buffer[] = []; - for (const samplePath of samples) { - const absPath = resolve(process.cwd(), samplePath); - if (!existsSync(absPath)) { - spinner.fail(`File not found: ${samplePath}`); - exitWithError(notFoundError(samplePath)); + const spinner = ora("Fetching voices...").start(); + const voices = await elevenlabs.getVoices(); + spinner.succeed(chalk.green(`Found ${voices.length} voices`)); + console.log(); + console.log(chalk.bold.cyan("Available Voices")); + console.log(chalk.dim("─".repeat(60))); + for (const voice of voices) { + console.log(`${chalk.bold(voice.name)} ${chalk.dim(`(${voice.category})`)}`); + console.log(` ${chalk.dim("ID:")} ${voice.voice_id}`); + if (voice.labels && Object.keys(voice.labels).length > 0) { + console.log(` ${chalk.dim("Labels:")} ${JSON.stringify(voice.labels)}`); } - const buffer = await readFile(absPath); - audioBuffers.push(buffer); - } - - spinner.text = `Cloning voice from ${audioBuffers.length} sample(s)...`; - - const labels = options.labels ? JSON.parse(options.labels) : undefined; - - const result = await elevenlabs.cloneVoice(audioBuffers, { - name: options.name, - description: options.description, - labels, - removeBackgroundNoise: options.removeNoise, - }); - - if (!result.success) { - spinner.fail(result.error || "Voice cloning failed"); - exitWithError(apiError(result.error || "Voice cloning failed", true)); + console.log(); } + return; + } - spinner.succeed(chalk.green("Voice cloned successfully")); - - if (isJsonMode()) { - outputSuccess({ - command: "audio clone-voice", - startedAt, - data: { name: options.name, voiceId: result.voiceId }, - }); - return; - } + if (!options.name) exitWithError(usageError("Voice name is required. Use --name ")); + rejectControlChars(options.name); + if (!samples || samples.length === 0) exitWithError(usageError("At least one audio sample is required")); + for (const samplePath of samples) { + if (!existsSync(resolve(process.cwd(), samplePath))) exitWithError(notFoundError(samplePath)); + } + let labels: Record | undefined; + try { + labels = options.labels ? JSON.parse(options.labels) : undefined; + } catch { + exitWithError(usageError(`--labels is not valid JSON: ${options.labels}`)); + } - console.log(); - console.log(chalk.bold.cyan("Voice Details")); - console.log(chalk.dim("─".repeat(60))); - console.log(`Name: ${chalk.bold(options.name)}`); - console.log(`Voice ID: ${chalk.bold(result.voiceId)}`); - console.log(); - console.log(chalk.dim("Use this voice ID with:")); - console.log(chalk.dim(` pnpm vibe audio tts "Hello world" -v ${result.voiceId}`)); - console.log(); - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - exitWithError(apiError(`Voice cloning failed: ${msg}`, true)); + const spinner = isJsonMode() ? null : ora(`Cloning voice from ${samples.length} sample(s)...`).start(); + const result = await executeVoiceClone({ + samplePaths: samples, + name: options.name, + description: options.description, + labels, + removeNoise: options.removeNoise, + apiKey, + }); + if (!result.success) { + spinner?.fail(result.error ?? "Voice cloning failed"); + exitWithError(providerFailure(result.error ?? "Voice cloning failed", result.errorKind)); + } + if (isJsonMode()) { + outputSuccess({ command: "audio clone-voice", startedAt, data: { name: options.name, voiceId: result.voiceId } }); + return; } + spinner?.succeed(chalk.green("Voice cloned successfully")); + console.log(); + console.log(chalk.bold.cyan("Voice Details")); + console.log(chalk.dim("─".repeat(60))); + console.log(`Name: ${chalk.bold(options.name)}`); + console.log(`Voice ID: ${chalk.bold(result.voiceId)}`); + console.log(); + console.log(chalk.dim("Use this voice with:")); + console.log(chalk.dim(` vibe generate narration "Hello world" -p elevenlabs --voice ${result.voiceId}`)); }); // ── audio dub ────────────────────────────────────────────────────────── @@ -402,261 +318,70 @@ audioCommand .option("--dry-run", "Preview parameters without executing") .action(async (mediaPath: string, options) => { const startedAt = Date.now(); - try { - if (options.dryRun) { - outputSuccess({ - command: "audio dub", - startedAt, - dryRun: true, - data: { - params: { - mediaPath, - targetLanguage: options.language, - sourceLanguage: options.source, - voice: options.voice, - }, - }, - }); - return; - } - - if (options.output) { - validateOutputPath(options.output); - } - - if (!options.language) { - exitWithError(usageError("Target language is required. Use -l or --language")); - } - - const absPath = resolve(process.cwd(), mediaPath); - if (!existsSync(absPath)) { - exitWithError(notFoundError(mediaPath)); - } - - // Check required API keys - const openaiKey = await requireApiKey("OPENAI_API_KEY", "OpenAI"); - const anthropicKey = await requireApiKey("ANTHROPIC_API_KEY", "Anthropic"); - const elevenlabsKey = options.analyzeOnly - ? await getApiKey("ELEVENLABS_API_KEY", "ElevenLabs", undefined) - : await requireApiKey("ELEVENLABS_API_KEY", "ElevenLabs"); - - const spinner = ora("Extracting audio...").start(); - - // Check if input is video - const ext = extname(absPath).toLowerCase(); - const isVideo = [".mp4", ".mov", ".avi", ".mkv", ".webm"].includes(ext); - - // Step 1: Extract audio if video - let audioPath = absPath; - if (isVideo) { - const tempAudioPath = resolve(dirname(absPath), `temp-audio-${Date.now()}.mp3`); - try { - execSafeSync("ffmpeg", ["-i", absPath, "-vn", "-acodec", "mp3", "-y", tempAudioPath]); - audioPath = tempAudioPath; - } catch { - spinner.fail("Failed to extract audio from video"); - exitWithError( - generalError("Failed to extract audio from video", "Ensure FFmpeg is installed.") - ); - } - } - - // Step 2: Transcribe with Whisper - spinner.text = "Transcribing audio..."; - - - const transcriptResult = await transcribeAudioFile(audioPath, { apiKey: openaiKey, language: options.source }); - - if (transcriptResult.status === "failed" || !transcriptResult.segments) { - spinner.fail("Transcription failed"); - exitWithError(apiError(`Transcription failed: ${transcriptResult.error}`, true)); - } - - // Step 3: Translate with Claude - spinner.text = "Translating..."; - const claude = new ClaudeProvider(); - await claude.initialize({ apiKey: anthropicKey }); - - // Build translation prompt - const segments = transcriptResult.segments; - const segmentTexts = segments.map((s, i) => `[${i}] ${s.text}`).join("\n"); - - // Language names for better translation context - const languageNames: Record = { - en: "English", - es: "Spanish", - fr: "French", - de: "German", - it: "Italian", - pt: "Portuguese", - ja: "Japanese", - ko: "Korean", - zh: "Chinese", - ar: "Arabic", - ru: "Russian", - hi: "Hindi", - }; - const targetLangName = languageNames[options.language] || options.language; - - let translatedSegments: Array<{ - index: number; - text: string; - startTime: number; - endTime: number; - }> = []; - - try { - const storyboard = await claude.analyzeContent( - `TRANSLATE to ${targetLangName}. Return the translated text only, preserving segment numbers:\n\n${segmentTexts}`, - segments[segments.length - 1]?.endTime || 60 - ); - - if (storyboard && storyboard.length > 0) { - translatedSegments = segments.map((s, i) => ({ - index: i, - text: storyboard[i]?.description || s.text, - startTime: s.startTime, - endTime: s.endTime, - })); - } else { - translatedSegments = segments.map((s, i) => ({ - index: i, - text: s.text, - startTime: s.startTime, - endTime: s.endTime, - })); - } - } catch { - translatedSegments = segments.map((s, i) => ({ - index: i, - text: s.text, - startTime: s.startTime, - endTime: s.endTime, - })); - } - - spinner.succeed(chalk.green("Transcription and translation complete")); - - // Display timing analysis - console.log(); - console.log(chalk.bold.cyan("Dubbing Analysis")); - console.log(chalk.dim("─".repeat(60))); - console.log( - `Source language: ${transcriptResult.detectedLanguage || options.source || "auto"}` - ); - console.log(`Target language: ${targetLangName}`); - console.log(`Segments: ${segments.length}`); - console.log(); - - console.log(chalk.bold("Segment Timing:")); - for (let i = 0; i < Math.min(5, segments.length); i++) { - const seg = segments[i]; - const time = `[${formatTime(seg.startTime)} - ${formatTime(seg.endTime)}]`; - console.log(`${chalk.dim(time)} ${seg.text}`); - console.log( - `${chalk.dim(" →")} ${chalk.green(translatedSegments[i]?.text || seg.text)}` - ); - console.log(); - } - - if (segments.length > 5) { - console.log(chalk.dim(`... and ${segments.length - 5} more segments`)); - } - - if (options.analyzeOnly) { - console.log(); - console.log(chalk.dim("Use without --analyze-only to generate dubbed audio")); - - if (options.output) { - const timingPath = resolve(process.cwd(), options.output); - const timingData = { - sourcePath: absPath, - sourceLanguage: transcriptResult.detectedLanguage || options.source || "auto", - targetLanguage: options.language, - segments: segments.map((s, i) => ({ - index: i, - startTime: s.startTime, - endTime: s.endTime, - original: s.text, - translated: translatedSegments[i]?.text || s.text, - })), - }; - await writeFile(timingPath, JSON.stringify(timingData, null, 2)); - console.log(`Timing saved to: ${chalk.bold(timingPath)}`); - } - return; - } - - // Step 4: Generate TTS for each segment - spinner.start("Generating dubbed audio..."); - const elevenlabs = new ElevenLabsProvider(); - await elevenlabs.initialize({ apiKey: elevenlabsKey! }); - - const dubbedAudioBuffers: Array<{ buffer: Buffer; startTime: number }> = []; - - for (let i = 0; i < translatedSegments.length; i++) { - spinner.text = `Generating audio segment ${i + 1}/${translatedSegments.length}...`; - const seg = translatedSegments[i]; - - const ttsResult = await elevenlabs.textToSpeech(seg.text, { - voiceId: options.voice, - }); - - if (ttsResult.success && ttsResult.audioBuffer) { - dubbedAudioBuffers.push({ - buffer: ttsResult.audioBuffer, - startTime: seg.startTime, - }); - } - } - - // Step 5: Combine and save - spinner.text = "Combining audio..."; - - const combinedBuffer = Buffer.concat(dubbedAudioBuffers.map((a) => a.buffer)); - - const outputExt = isVideo ? ".mp3" : extname(absPath); - const defaultOutputPath = resolve( - dirname(absPath), - `${basename(absPath, extname(absPath))}-${options.language}${outputExt}` - ); - const finalOutputPath = resolve(process.cwd(), options.output || defaultOutputPath); - - await writeFile(finalOutputPath, combinedBuffer); - - spinner.succeed(chalk.green("Dubbing complete")); - - if (isJsonMode()) { - outputSuccess({ - command: "audio dub", - startedAt, - data: { - sourceLanguage: transcriptResult.detectedLanguage || options.source || "auto", - targetLanguage: options.language, - segmentCount: translatedSegments.length, - outputPath: finalOutputPath, - }, - }); - return; - } - - console.log(); - console.log(`Saved to: ${chalk.bold(finalOutputPath)}`); + if (options.dryRun) { + outputSuccess({ + command: "audio dub", + startedAt, + dryRun: true, + data: { params: { mediaPath, targetLanguage: options.language, sourceLanguage: options.source, voice: options.voice } }, + }); + return; + } + if (options.output) validateOutputPath(options.output); + if (!options.language) exitWithError(usageError("Target language is required. Use -l or --language")); + if (!existsSync(resolve(process.cwd(), mediaPath))) exitWithError(notFoundError(mediaPath)); + // Ask for every key up front, so a run never fails halfway for a missing one. + await requireApiKey("OPENAI_API_KEY", "OpenAI"); + await requireApiKey("ANTHROPIC_API_KEY", "Anthropic"); + if (!options.analyzeOnly) await requireApiKey("ELEVENLABS_API_KEY", "ElevenLabs"); + + const spinner = isJsonMode() ? null : ora("Transcribing, translating, and dubbing...").start(); + const result = await executeDub({ + mediaPath, + language: options.language, + source: options.source, + voice: options.voice, + analyzeOnly: options.analyzeOnly, + output: options.output, + }); + if (!result.success) { + spinner?.fail("Dubbing failed"); + exitWithError(providerFailure(result.error ?? "Dubbing failed", result.errorKind)); + } + if (isJsonMode()) { + outputSuccess({ + command: "audio dub", + startedAt, + data: { + sourceLanguage: result.sourceLanguage, + targetLanguage: result.targetLanguage, + segmentCount: result.segmentCount, + segments: result.segments, + outputPath: result.outputPath, + }, + }); + return; + } + spinner?.succeed(chalk.green(options.analyzeOnly ? "Transcription and translation complete" : "Dubbing complete")); + console.log(); + console.log(chalk.bold.cyan("Dubbing Analysis")); + console.log(chalk.dim("─".repeat(60))); + console.log(`Source language: ${result.sourceLanguage}`); + console.log(`Target language: ${result.targetLanguage}`); + console.log(`Segments: ${result.segmentCount}`); + console.log(); + for (const seg of (result.segments ?? []).slice(0, 5)) { + console.log(`${chalk.dim(`[${formatTime(seg.startTime)} - ${formatTime(seg.endTime)}]`)} ${seg.original}`); + console.log(`${chalk.dim(" →")} ${chalk.green(seg.translated)}`); console.log(); - - // Clean up temp audio if we extracted from video - if (isVideo && audioPath !== absPath) { - try { - const { unlink } = await import("node:fs/promises"); - await unlink(audioPath); - } catch { - // Ignore cleanup errors - } - } - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - exitWithError(apiError(`Dubbing failed: ${msg}`, true)); } + if ((result.segmentCount ?? 0) > 5) console.log(chalk.dim(`... and ${(result.segmentCount ?? 0) - 5} more segments`)); + if (options.analyzeOnly) { + console.log(chalk.dim("Use without --analyze-only to generate dubbed audio")); + if (result.outputPath) console.log(`Timing saved to: ${chalk.bold(result.outputPath)}`); + return; + } + console.log(`Saved to: ${chalk.bold(result.outputPath)}`); }); // ── audio duck ───────────────────────────────────────────────────────── diff --git a/packages/cli/src/commands/edit-cmd.ts b/packages/cli/src/commands/edit-cmd.ts index 34841a39..5f04db07 100644 --- a/packages/cli/src/commands/edit-cmd.ts +++ b/packages/cli/src/commands/edit-cmd.ts @@ -377,7 +377,6 @@ editCommand // Step 2: Transcribe spinner.text = "Transcribing audio..."; - const transcript = await transcribeAudioFile(tempAudio, { apiKey: openaiApiKey, language: options.language }); if (!transcript.segments || transcript.segments.length === 0) { @@ -1003,10 +1002,8 @@ applyTiers(editCommand, { "fill-gaps": "very-high", }); - // ── Exported execute functions ───────────────────────────────────────────── - // ============================================================================ // Color Grade // ============================================================================ @@ -1063,7 +1060,6 @@ export async function executeGrade(options: GradeOptions): Promise } } - // ============================================================================ // Speed Ramp // ============================================================================ @@ -1145,7 +1141,6 @@ export async function executeSpeedRamp(options: SpeedRampOptions): Promise { const startedAt = Date.now(); - try { - rejectControlChars(prompt); - if (options.output) { - validateOutputPath(options.output); - } + rejectControlChars(prompt); + if (options.output) validateOutputPath(options.output); + // Validate before the dry run, so a plan never echoes values a real run would reject. + const requested = parseFloat(options.duration); + if (!Number.isFinite(requested) || requested <= 0 || requested > 600) { + exitWithError(usageError(`Invalid --duration: ${options.duration}`, "Must be a positive number ≤ 600s (ElevenLabs 3-600, Replicate 1-30).")); + } + const provider = String(options.provider || "elevenlabs").toLowerCase(); + if (provider !== "elevenlabs" && provider !== "replicate") { + exitWithError(usageError(`Invalid provider: ${provider}`, "Available providers: elevenlabs, replicate")); + } + if (options.melody) { + if (!existsSync(resolve(process.cwd(), options.melody))) exitWithError(notFoundError(options.melody)); + exitWithError(usageError("Melody conditioning requires a publicly accessible URL", "Please upload your melody file and provide the URL.")); + } - // Validate duration up-front so dry-run rejects nonsense values - // before they're echoed as a "plan" the user could copy and run. - if (options.duration !== undefined) { - const d = parseFloat(options.duration); - if (!Number.isFinite(d) || d <= 0 || d > 600) { - exitWithError(usageError( - `Invalid --duration: ${options.duration}`, - "Must be a positive number ≤ 600s (ElevenLabs 3-600, Replicate 1-30).", - )); - } - } + if (options.dryRun) { + outputSuccess({ + command: "generate music", + startedAt, + dryRun: true, + data: { + params: { prompt, provider, duration: options.duration, model: options.model, output: options.output, instrumental: options.instrumental }, + }, + }); + return; + } - const provider = (options.provider || "elevenlabs").toLowerCase(); + const apiKey = + provider === "elevenlabs" + ? await requireApiKey("ELEVENLABS_API_KEY", "ElevenLabs", options.apiKey) + : await requireApiKey("REPLICATE_API_TOKEN", "Replicate", options.apiKey); + const label = provider === "elevenlabs" ? "ElevenLabs" : "Replicate MusicGen"; + const spinner = isJsonMode() ? null : ora(`Generating music with ${label}...`).start(); + const result = await executeMusic({ + prompt, + provider, + duration: requested, + instrumental: options.instrumental, + model: options.model, + output: options.output, + wait: options.wait, + apiKey, + }); + if (!result.success) { + spinner?.fail(result.error ?? "Music generation failed"); + exitWithError(providerFailure(result.error ?? "Music generation failed", result.errorKind)); + } - if (options.dryRun) { - outputSuccess({ - command: "generate music", - startedAt, - dryRun: true, - data: { - params: { - prompt, - provider, - duration: options.duration, - model: options.model, - output: options.output, - instrumental: options.instrumental, - }, - }, - }); + // Replicate --no-wait: record the job so `vibe status job` can finish it. + if (result.taskId && !result.outputPath) { + const job = await recordMusicNoWaitJob({ provider: "replicate", providerTaskId: result.taskId, prompt }); + if (isJsonMode()) { + outputSuccess({ command: "generate music", startedAt, data: noWaitMusicData("replicate", result.taskId, job) }); return; } + spinner?.succeed(chalk.green(`Music generation started (task ${result.taskId})`)); + console.log(chalk.dim(`Check status with: ${job.retryWith[0]}`)); + return; + } - if (provider === "elevenlabs") { - // ElevenLabs Music API — synchronous, up to 10 minutes - const apiKey = await requireApiKey( - "ELEVENLABS_API_KEY", - "ElevenLabs", - options.apiKey, - ); - - const elevenlabs = new ElevenLabsProvider(); - await elevenlabs.initialize({ apiKey }); - - const duration = Math.max(3, Math.min(600, parseFloat(options.duration))); - const spinner = ora(`Generating music (${duration}s)...`).start(); - - const result = await elevenlabs.generateMusic(prompt, { - duration, - forceInstrumental: options.instrumental || false, - }); - - if (!result.success || !result.audioBuffer) { - spinner.fail(result.error || "Music generation failed"); - exitWithError(apiError(result.error || "Music generation failed", true)); - } - - const outputPath = resolve(process.cwd(), options.output); - await writeFile(outputPath, result.audioBuffer); - - spinner.succeed(chalk.green("Music generated successfully")); - - if (isJsonMode()) { - outputSuccess({ - command: "generate music", - startedAt, - data: { - provider: "elevenlabs", - outputPath, - duration, - }, - }); - return; - } - - console.log(); - console.log(`Saved to: ${chalk.bold(outputPath)}`); - console.log(`Duration: ${duration}s`); - console.log(`Provider: ElevenLabs (${ELEVENLABS_MUSIC_MODEL})`); - if (options.instrumental) console.log(`Mode: Instrumental`); - console.log(); - } else { - // Replicate MusicGen — async, max 30 seconds - const apiKey = await requireApiKey( - "REPLICATE_API_TOKEN", - "Replicate", - options.apiKey, - ); - - const replicate = new ReplicateProvider(); - await replicate.initialize({ apiKey }); - - const spinner = ora("Starting music generation...").start(); - - const duration = Math.max(1, Math.min(30, parseFloat(options.duration))); - - // If melody file provided, upload it first - if (options.melody) { - spinner.text = "Uploading melody reference..."; - const absPath = resolve(process.cwd(), options.melody); - if (!existsSync(absPath)) { - spinner.fail(`Melody file not found: ${options.melody}`); - exitWithError(notFoundError(options.melody)); - } - exitWithError( - usageError( - "Melody conditioning requires a publicly accessible URL", - "Please upload your melody file and provide the URL.", - ), - ); - } - - const result = await replicate.generateMusic(prompt, { - duration, - model: options.model as - | "large" - | "stereo-large" - | "melody-large" - | "stereo-melody-large", - }); - - if (!result.success || !result.taskId) { - spinner.fail(result.error || "Music generation failed"); - exitWithError(apiError(result.error || "Music generation failed", true)); - } - - if (!options.wait) { - const job = await recordMusicNoWaitJob({ - provider: "replicate", - providerTaskId: result.taskId, - prompt, - }); - spinner.succeed(chalk.green("Music generation started")); - if (isJsonMode()) { - outputSuccess({ - command: "generate music", - startedAt, - data: noWaitMusicData("replicate", result.taskId, job), - }); - return; - } - console.log(); - console.log(`Task ID: ${chalk.bold(result.taskId)}`); - console.log(chalk.dim(`Check status with: vibe status job ${job.id} --json`)); - return; - } - - spinner.text = "Generating music (this may take a few minutes)..."; - - const finalResult = await replicate.waitForMusic(result.taskId); - - if (!finalResult.success || !finalResult.audioUrl) { - spinner.fail(finalResult.error || "Music generation failed"); - exitWithError(apiError(finalResult.error || "Music generation failed", true)); - } - - spinner.text = "Downloading generated audio..."; - - const response = await fetch(finalResult.audioUrl); - if (!response.ok) { - spinner.fail("Failed to download generated audio"); - exitWithError(apiError("Failed to download generated audio", true)); - } - - const audioBuffer = Buffer.from(await response.arrayBuffer()); - const outputPath = resolve(process.cwd(), options.output); - await writeFile(outputPath, audioBuffer); - - spinner.succeed(chalk.green("Music generated successfully")); - - if (isJsonMode()) { - outputSuccess({ - command: "generate music", - startedAt, - data: { - provider: "replicate", - taskId: result.taskId, - audioUrl: finalResult.audioUrl, - outputPath, - }, - }); - return; - } - - console.log(); - console.log(`Saved to: ${chalk.bold(outputPath)}`); - console.log(`Duration: ${duration}s`); - console.log(`Model: ${options.model}`); - console.log(); - } - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - exitWithError(apiError(`Music generation failed: ${msg}`, true)); + if (isJsonMode()) { + outputSuccess({ + command: "generate music", + startedAt, + data: { provider: result.provider, outputPath: result.outputPath, duration: result.duration }, + }); + return; } + spinner?.succeed(chalk.green("Music generated successfully")); + console.log(`Saved to: ${chalk.bold(result.outputPath)}`); + console.log(`Duration: ${result.duration}s`); + console.log(provider === "elevenlabs" ? `Provider: ElevenLabs (${ELEVENLABS_MUSIC_MODEL})` : `Provider: Replicate MusicGen (${options.model})`); + if (options.instrumental && provider === "elevenlabs") console.log("Mode: Instrumental"); }); } @@ -374,7 +249,6 @@ function noWaitMusicData(provider: string, taskId: string, job: JobRecord): Reco taskId, status: job.status, jobId: job.id, - statusCommand: `vibe status job ${job.id} --project ${job.projectDir} --json`, - providerStatusCommand: job.retryWith.find((item) => item.startsWith("vibe generate music-status")), + statusCommand: job.retryWith[0], }; } diff --git a/packages/cli/src/commands/generate/sound-effect.ts b/packages/cli/src/commands/generate/sound-effect.ts index d08e00c8..ebfeb3d9 100644 --- a/packages/cli/src/commands/generate/sound-effect.ts +++ b/packages/cli/src/commands/generate/sound-effect.ts @@ -1,7 +1,8 @@ /** * @module generate/sound-effect * @description `vibe generate sound-effect` — ElevenLabs SFX generation. - * Split out of `generate.ts` in v0.69 (Plan G Phase 2). + * The command validates and formats; `executeSoundEffect` (also used by + * pipelines and the MCP tool) does the work. */ import type { Command } from "commander"; @@ -9,9 +10,9 @@ import { resolve } from "node:path"; import { writeFile } from "node:fs/promises"; import chalk from "chalk"; import ora from "ora"; -import { ElevenLabsProvider } from "@vibeframe/ai-providers"; +import { ElevenLabsProvider, type ProviderErrorKind } from "@vibeframe/ai-providers"; import { requireApiKey, getConfiguredApiKey } from "../../utils/api-key.js"; -import { isJsonMode, outputSuccess, exitWithError, apiError } from "../output.js"; +import { isJsonMode, outputSuccess, exitWithError, providerFailure, usageError } from "../output.js"; import { rejectControlChars, validateOutputPath } from "../validate.js"; // ── Library: executeSoundEffect (used by pipeline executor + manifest) ── @@ -19,54 +20,40 @@ import { rejectControlChars, validateOutputPath } from "../validate.js"; export interface ExecuteSoundEffectOptions { prompt: string; output?: string; + /** Seconds (0.5-30); the model picks when omitted. */ duration?: number; + /** 0-1, default 0.3. */ promptInfluence?: number; + apiKey?: string; } + export interface ExecuteSoundEffectResult { success: boolean; outputPath?: string; error?: string; + errorKind?: ProviderErrorKind; } -export async function executeSoundEffect( - options: ExecuteSoundEffectOptions, -): Promise { +export async function executeSoundEffect(options: ExecuteSoundEffectOptions): Promise { try { - const apiKey = await getConfiguredApiKey("ELEVENLABS_API_KEY"); - if (!apiKey) - return { - success: false, - error: - "ElevenLabs API key required. Set ELEVENLABS_API_KEY or run: vibe setup", - }; - + const apiKey = await getConfiguredApiKey("ELEVENLABS_API_KEY", options.apiKey); + if (!apiKey) { + return { success: false, error: "ElevenLabs API key required. Set ELEVENLABS_API_KEY or run: vibe setup", errorKind: "auth" }; + } const elevenlabs = new ElevenLabsProvider(); await elevenlabs.initialize({ apiKey }); - const result = await elevenlabs.generateSoundEffect(options.prompt, { duration: options.duration, promptInfluence: options.promptInfluence, }); - if (!result.success || !result.audioBuffer) { - return { - success: false, - error: result.error || "Sound effect generation failed", - }; + return { success: false, error: result.error || "Sound effect generation failed", errorKind: result.errorKind }; } - - const outputPath = resolve( - process.cwd(), - options.output || "sound-effect.mp3", - ); + const outputPath = resolve(process.cwd(), options.output || "sound-effect.mp3"); await writeFile(outputPath, result.audioBuffer); - return { success: true, outputPath }; } catch (error) { - return { - success: false, - error: `SFX failed: ${error instanceof Error ? error.message : String(error)}`, - }; + return { success: false, error: `SFX failed: ${error instanceof Error ? error.message : String(error)}` }; } } @@ -84,73 +71,39 @@ export function registerSoundEffectCommand(parent: Command): void { .option("--dry-run", "Preview parameters without executing") .action(async (prompt: string, options) => { const startedAt = Date.now(); - try { - rejectControlChars(prompt); - if (options.output) { - validateOutputPath(options.output); - } - - if (options.dryRun) { - outputSuccess({ - command: "generate sound-effect", - startedAt, - dryRun: true, - data: { - params: { - prompt, - duration: options.duration, - promptInfluence: options.promptInfluence, - output: options.output, - }, - }, - }); - return; - } - - const apiKey = await requireApiKey( - "ELEVENLABS_API_KEY", - "ElevenLabs", - options.apiKey, - ); - - const spinner = ora("Generating sound effect...").start(); - - const elevenlabs = new ElevenLabsProvider(); - await elevenlabs.initialize({ apiKey }); + rejectControlChars(prompt); + if (options.output) validateOutputPath(options.output); + const duration = options.duration !== undefined ? parseFloat(options.duration) : undefined; + if (duration !== undefined && !(duration >= 0.5 && duration <= 30)) { + exitWithError(usageError(`Invalid --duration: ${options.duration}`, "Must be between 0.5 and 30 seconds.")); + } + const promptInfluence = options.promptInfluence !== undefined ? parseFloat(options.promptInfluence) : undefined; + if (promptInfluence !== undefined && !(promptInfluence >= 0 && promptInfluence <= 1)) { + exitWithError(usageError(`Invalid --prompt-influence: ${options.promptInfluence}`, "Must be between 0 and 1.")); + } - const result = await elevenlabs.generateSoundEffect(prompt, { - duration: options.duration ? parseFloat(options.duration) : undefined, - promptInfluence: options.promptInfluence - ? parseFloat(options.promptInfluence) - : undefined, + if (options.dryRun) { + outputSuccess({ + command: "generate sound-effect", + startedAt, + dryRun: true, + data: { params: { prompt, duration, promptInfluence, output: options.output } }, }); + return; + } - if (!result.success || !result.audioBuffer) { - spinner.fail(result.error || "Sound effect generation failed"); - exitWithError( - apiError(result.error || "Sound effect generation failed", true), - ); - } - - const outputPath = resolve(process.cwd(), options.output); - await writeFile(outputPath, result.audioBuffer); - - spinner.succeed(chalk.green("Sound effect generated")); - - if (isJsonMode()) { - outputSuccess({ - command: "generate sound-effect", - startedAt, - data: { outputPath }, - }); - return; - } - - console.log(chalk.green(`Saved to: ${outputPath}`)); - console.log(); - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - exitWithError(apiError(`Sound effect generation failed: ${msg}`, true)); + const apiKey = await requireApiKey("ELEVENLABS_API_KEY", "ElevenLabs", options.apiKey); + const spinner = isJsonMode() ? null : ora("Generating sound effect...").start(); + const result = await executeSoundEffect({ prompt, output: options.output, duration, promptInfluence, apiKey }); + if (!result.success) { + spinner?.fail(result.error ?? "Sound effect generation failed"); + exitWithError(providerFailure(result.error ?? "Sound effect generation failed", result.errorKind)); + } + if (isJsonMode()) { + outputSuccess({ command: "generate sound-effect", startedAt, data: { outputPath: result.outputPath } }); + return; } + spinner?.succeed(chalk.green("Sound effect generated")); + console.log(chalk.green(`Saved to: ${result.outputPath}`)); }); }