/** * SFX / foley clip generation orchestrator. * * Thin wrapper over the audio-platform SFX backend registry (ElevenLabs Sound * Effects today): generates one sound-effect clip from a text prompt, writes it * to `projects//artifacts/audio/sfx-.mp3`, and persists an `sfx.json` * artifact listing every clip generated for the project (append-on-repeat). * * Pure-ish: `env`/`fetcher` are threaded through so tests run fully offline. * Inert (throws a clear error) when no SFX backend is configured. */ import { mkdir, readFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { dirname, join, relative, resolve } from 'node:path'; import { resolveProjectWorkspace } from './workspace.js'; import { appendProjectEvent } from './events.js'; import { writeTextFileAtomic } from './atomic-write.js'; import { VclawError } from './errors.js'; import { getSfxBackend, listAvailableSfxBackends } from './audio-platform/registry.js'; import type { SfxBackend } from './audio-platform/types.js'; export const SFX_SCHEMA_VERSION = 1; export interface SfxClip { prompt: string; /** Project-relative path to the generated clip. */ path: string; durationMs: number; backendId: string; } export interface SfxArtifact { schemaVersion: typeof SFX_SCHEMA_VERSION; projectSlug: string; generatedAt: string; clips: SfxClip[]; } export interface GenerateSfxOptions { workspaceRoot: string; slug: string; prompt: string; durationSec?: number; promptInfluence?: number; /** Restrict to this backend id (defaults to the first AVAILABLE SFX backend). */ backendId?: string; dryRun?: boolean; env?: NodeJS.ProcessEnv; fetcher?: typeof fetch; } export function sfxArtifactPathFor(root: string, slug: string): string { return join(resolveProjectWorkspace(slug, root).projectDir, 'artifacts', 'sfx.json'); } export function sfxAudioDirFor(root: string, slug: string): string { return join(resolveProjectWorkspace(slug, root).projectDir, 'artifacts', 'audio'); } export async function readSfxArtifact(root: string, slug: string): Promise { const path = sfxArtifactPathFor(root, slug); if (!existsSync(path)) return null; return JSON.parse(await readFile(path, 'utf-8')) as SfxArtifact; } function pickBackend(backendId: string | undefined, available: SfxBackend[]): SfxBackend { if (backendId) { const backend = getSfxBackend(backendId); // throws on unknown id if (!available.some((b) => b.id === backend.id)) { throw new VclawError( 'music_gen_failed', `SFX backend '${backendId}' is not available (missing credentials).`, { backendId, available: available.map((b) => b.id) }, ); } return backend; } if (available.length === 0) { throw new VclawError( 'music_gen_failed', 'No SFX backend is available. Set credentials for one (e.g. ELEVENLABS_API_KEY for elevenlabs-sfx).', {}, ); } return available[0]; } /** * Generate one SFX clip and append it to the project's sfx.json. Returns the * new clip plus the full updated artifact. */ export async function generateSfx( options: GenerateSfxOptions, ): Promise<{ clip: SfxClip; artifact: SfxArtifact }> { const env = options.env ?? process.env; if (!options.prompt.trim()) { throw new VclawError('music_gen_failed', 'Cannot generate an SFX clip with an empty prompt.', { slug: options.slug, }); } const backend = pickBackend(options.backendId, listAvailableSfxBackends(env)); const workspace = resolveProjectWorkspace(options.slug, options.workspaceRoot); const projectRoot = resolve(workspace.projectDir); const audioDir = sfxAudioDirFor(options.workspaceRoot, options.slug); await mkdir(audioDir, { recursive: true }); // Append-friendly index: continue numbering after any existing clips. const prior = await readSfxArtifact(options.workspaceRoot, options.slug); const index = prior?.clips.length ?? 0; const outputPath = join(audioDir, `sfx-${index}.mp3`); const result = await backend.generate({ text: options.prompt, ...(options.durationSec !== undefined ? { durationSec: options.durationSec } : {}), ...(options.promptInfluence !== undefined ? { promptInfluence: options.promptInfluence } : {}), outputPath, ...(options.dryRun !== undefined ? { dryRun: options.dryRun } : {}), env, ...(options.fetcher ? { fetcher: options.fetcher } : {}), }); // In dry-run the backend returns a path without real bytes; write a // placeholder so the artifact stays consistent with disk (downstream // discovery guards with existsSync). if (options.dryRun && !existsSync(result.path)) { await mkdir(dirname(result.path), { recursive: true }); await writeTextFileAtomic(result.path, ''); } const clip: SfxClip = { prompt: options.prompt, path: relative(projectRoot, resolve(result.path)).replaceAll('\\', '/'), durationMs: result.durationMs, backendId: result.backendId, }; const artifact: SfxArtifact = { schemaVersion: SFX_SCHEMA_VERSION, projectSlug: options.slug, generatedAt: new Date().toISOString(), clips: [...(prior?.clips ?? []), clip], }; await mkdir(dirname(sfxArtifactPathFor(options.workspaceRoot, options.slug)), { recursive: true }); await writeTextFileAtomic( sfxArtifactPathFor(options.workspaceRoot, options.slug), `${JSON.stringify(artifact, null, 2)}\n`, ); if (existsSync(workspace.eventsPath) || existsSync(workspace.eventsDir)) { await appendProjectEvent(workspace, { type: 'sfx.generated', payload: { backendId: clip.backendId, prompt: clip.prompt, dryRun: options.dryRun ?? false }, }); } return { clip, artifact }; }