/** * FlowMusic music backend — Google Lyria 3 Pro vocal songs via useapi.net. * * Unlike the instrumental-only Lyria backends (native-lyria / native-lyria3), * FlowMusic generates FULL VOCAL SONGS (and instrumentals): it honors * user-supplied `lyrics` and an `instrumental` toggle. It is an async, job-polled * provider, so this backend wraps the providers/flowmusic-useapi.ts transport: * submit (mode:async) → poll GET /jobs/{jobid} until terminal → download the * first clip's mp3 via the auth'd /music/download endpoint → write `.mp3`. * * Availability reuses the shared USEAPI_API_TOKEN (the same token as the * dreamina/runway routes) — gated in the registry via `requiresUseApi`, not a * backend-specific requiredEnv. Optional: VCLAW_FLOWMUSIC_ACCOUNT pins the * flowmusic.app account; VCLAW_FLOWMUSIC_GHOSTWRITER ('standard'|'pro') picks the * lyrics-writer when the model writes lyrics. The poll cadence is configurable * via VCLAW_FLOWMUSIC_POLL_INTERVAL_MS / VCLAW_FLOWMUSIC_POLL_MAX_ATTEMPTS * (tests set the interval to 0 to stay fast + offline). */ import { mkdir, writeFile, rename } from 'node:fs/promises'; import { dirname, extname } from 'node:path'; import { VclawError } from '../errors.js'; import { submitFlowMusicJob, pollFlowMusicJob, downloadFlowMusicClipMp3, type FlowMusicFetchLike, type FlowMusicGhostwriter, } from '../providers/flowmusic-useapi.js'; import type { MusicBackend, MusicGenInput, MusicGenResult } from './types.js'; /** Lyria 3 Pro full songs land ~40–150s; used only as a fallback duration estimate. */ const DEFAULT_DURATION_SEC = 150; const DEFAULT_POLL_INTERVAL_MS = 5000; const DEFAULT_POLL_MAX_ATTEMPTS = 90; // ~7.5 min ceiling at 5s /** Force a `.mp3` extension on the output path (we download mp3 bytes). */ function forceMp3Path(p: string): string { const ext = extname(p); if (ext.toLowerCase() === '.mp3') return p; return ext ? `${p.slice(0, p.length - ext.length)}.mp3` : `${p}.mp3`; } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } function isGhostwriter(v: string | undefined): v is FlowMusicGhostwriter { return v === 'standard' || v === 'pro'; } function positiveIntFromEnv(raw: string | undefined, fallback: number): number { if (raw === undefined) return fallback; const n = Number(raw); return Number.isFinite(n) && n >= 0 ? n : fallback; } export const flowMusicBackend: MusicBackend = { id: 'flowmusic', kind: 'music', displayName: 'FlowMusic (Lyria 3 Pro, useapi.net)', // Reuses the shared USEAPI_API_TOKEN; gated in the registry via requiresUseApi // rather than a backend-specific requiredEnv (no new token is introduced). requiredEnv: [], requiresUseApi: true, summary: 'Full vocal songs (and instrumentals) via Google Lyria 3 Pro on useapi.net FlowMusic. ' + 'Honors --lyrics and --instrumental. Async submit → poll → mp3 download. Reuses USEAPI_API_TOKEN; ' + 'optional VCLAW_FLOWMUSIC_ACCOUNT / VCLAW_FLOWMUSIC_GHOSTWRITER.', async generate(input: MusicGenInput): Promise { const env = input.env ?? process.env; const outputPath = forceMp3Path(input.outputPath); const fallbackDurationMs = (input.durationSec ?? DEFAULT_DURATION_SEC) * 1000; if (!input.prompt.trim()) { throw new VclawError('music_gen_failed', 'Cannot generate music with an empty prompt.', { backend: 'flowmusic', }); } // Dry-run: estimate duration, write a placeholder mp3, touch no network. if (input.dryRun) { await mkdir(dirname(outputPath), { recursive: true }); await writeFile(outputPath, Buffer.alloc(0)); return { path: outputPath, durationMs: fallbackDurationMs, backendId: 'flowmusic' }; } const apiToken = (env['USEAPI_API_TOKEN'] ?? '').trim(); if (!apiToken) { throw new VclawError('music_gen_failed', 'FlowMusic requires USEAPI_API_TOKEN.', { backend: 'flowmusic', }); } const account = env['VCLAW_FLOWMUSIC_ACCOUNT']?.trim() || undefined; const ghostwriterRaw = env['VCLAW_FLOWMUSIC_GHOSTWRITER']?.trim(); const ghostwriter = isGhostwriter(ghostwriterRaw) ? ghostwriterRaw : undefined; const fetchImpl = input.fetcher as unknown as FlowMusicFetchLike | undefined; const pollIntervalMs = positiveIntFromEnv(env['VCLAW_FLOWMUSIC_POLL_INTERVAL_MS'], DEFAULT_POLL_INTERVAL_MS); const maxAttempts = positiveIntFromEnv(env['VCLAW_FLOWMUSIC_POLL_MAX_ATTEMPTS'], DEFAULT_POLL_MAX_ATTEMPTS); const submit = await submitFlowMusicJob({ apiToken, prompt: input.prompt, ...(account ? { account } : {}), ...(input.instrumental !== undefined ? { instrumental: input.instrumental } : {}), ...(input.lyrics ? { lyrics: input.lyrics } : {}), ...(ghostwriter ? { ghostwriter } : {}), ...(fetchImpl ? { fetchImpl } : {}), }); // Poll to a terminal state (bounded). The submit may already be terminal in // some responses; the loop body re-polls on 'pending'. let poll = await pollFlowMusicJob({ apiToken, jobid: submit.jobid, ...(fetchImpl ? { fetchImpl } : {}), }); for (let attempt = 0; poll.status === 'pending' && attempt < maxAttempts; attempt += 1) { if (pollIntervalMs > 0) await sleep(pollIntervalMs); poll = await pollFlowMusicJob({ apiToken, jobid: submit.jobid, ...(fetchImpl ? { fetchImpl } : {}), }); } if (poll.status === 'failed') { throw new VclawError( 'music_gen_failed', `FlowMusic generation failed: ${poll.error ?? 'unknown error'}`, { backend: 'flowmusic', jobid: submit.jobid, error: poll.error }, ); } if (poll.status !== 'completed' || poll.clips.length === 0) { throw new VclawError( 'music_gen_failed', 'FlowMusic job did not complete in time (no clips returned).', { backend: 'flowmusic', jobid: submit.jobid, status: poll.status }, ); } const clip = poll.clips[0]; // A/B pair → MVP takes the first clip. const bytes = await downloadFlowMusicClipMp3({ apiToken, clip: clip.clip, ...(fetchImpl ? { fetchImpl } : {}), }); if (bytes.length === 0) { throw new VclawError('music_gen_failed', 'FlowMusic returned an empty audio payload.', { backend: 'flowmusic', jobid: submit.jobid, }); } await mkdir(dirname(outputPath), { recursive: true }); // Atomic write: tmp → rename (mirrors native-dreamina's downloadToFile). const tmp = `${outputPath}.tmp`; await writeFile(tmp, bytes); await rename(tmp, outputPath); // Prefer the clip's real duration; fall back to the request estimate. const durationMs = clip.durationS != null ? Math.round(clip.durationS * 1000) : fallbackDurationMs; return { path: outputPath, durationMs, backendId: 'flowmusic' }; }, };