/** * Music generation via Google Vertex AI Lyria (predict endpoint). * * VERIFIED LIVE against `lyria-002` (Lyria 2, GA) on 2026-06-01 — the request * body and response field below match the real API. INERT / unavailable * without a Vertex project (GOOGLE_CLOUD_PROJECT or VERTEX_PROJECT must be set). * * POST https://{REGION}-aiplatform.googleapis.com/v1/projects/{PROJECT}/ * locations/{REGION}/publishers/google/models/{MODEL}:predict * Authorization: Bearer * Body: { instances: [{ prompt: string }], parameters: { sampleCount: 1 } } * Response: { predictions: [{ bytesBase64Encoded: string (WAV), mimeType }] } * * MODEL (via LYRIA_MODEL env, default lyria-002): * - lyria-002 Lyria 2, GA, 30s, text-only ($0.06/30s) * - lyria-3-clip-preview Lyria 3, 30s, text+image (PREVIEW — needs project allowlist) * - lyria-3-pro-preview Lyria 3 Pro, ~184s full song, text+image (PREVIEW — allowlist) * NOTE: the Lyria 3 image-input (multimodal) request shape is NOT yet wired or * verified here — it needs preview allowlist access; add + verify once granted. * * Auth (tried in order): GOOGLE_ACCESS_TOKEN env -> GCE metadata server -> * `gcloud auth print-access-token` (local dev, dependency-free). For prod/CI, * set GOOGLE_ACCESS_TOKEN or run on a GCE/Cloud Run service account. * requiresVertex: true. */ import { mkdir, writeFile } from 'node:fs/promises'; import { dirname } from 'node:path'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { VclawError } from '../errors.js'; import type { MusicBackend, MusicGenInput, MusicGenResult } from './types.js'; const execFileAsync = promisify(execFile); const DEFAULT_LYRIA_REGION = 'us-central1'; const DEFAULT_LYRIA_MODEL = 'lyria-002'; /** * True duration (ms) of a RIFF/WAVE buffer, read from its fmt byteRate + data * chunk size. Lyria returns a full WAV, so this reports the real clip length * (~32.8s for lyria-002) rather than the requested-duration default. Returns * null for a non-WAV / unparseable buffer (caller falls back to the request). */ export function wavDurationMs(buf: Buffer): number | null { if ( buf.length < 44 || buf.toString('ascii', 0, 4) !== 'RIFF' || buf.toString('ascii', 8, 12) !== 'WAVE' ) { return null; } let byteRate = 0; let dataSize = 0; let off = 12; while (off + 8 <= buf.length) { const id = buf.toString('ascii', off, off + 4); const size = buf.readUInt32LE(off + 4); if (id === 'fmt ' && off + 24 <= buf.length) { byteRate = buf.readUInt32LE(off + 16); // fmt body: +8 fmt/chan, +12 rate, +16 byteRate } else if (id === 'data') { dataSize = size; break; } off += 8 + size + (size % 2); // chunks are word-aligned } if (byteRate > 0 && dataSize > 0) return Math.round((dataSize / byteRate) * 1000); return null; } /** * Resolve the Vertex project id from explicit env or well-known fallback vars. */ function resolveVertexProject(env: NodeJS.ProcessEnv): string | undefined { return env['GOOGLE_CLOUD_PROJECT'] ?? env['VERTEX_PROJECT'] ?? env['GCLOUD_PROJECT']; } /** * Obtain a Vertex bearer token. Tries GOOGLE_APPLICATION_CREDENTIALS / ADC * via the metadata server token endpoint; falls back to the * GOOGLE_ACCESS_TOKEN env var for local-dev / CI injection. * * SCAFFOLD: In production this should use the Google Auth Library * (e.g. google-auth-library) to obtain a proper access token via ADC. * The current implementation accepts a pre-resolved token via env var * GOOGLE_ACCESS_TOKEN for testability without a real service account. */ async function resolveVertexToken( env: NodeJS.ProcessEnv, fetcher: typeof fetch, ): Promise { const explicit = env['GOOGLE_ACCESS_TOKEN']; if (explicit) return explicit; // Attempt GCE metadata server (works on Cloud Run / GCE VMs). try { const metaResp = await fetcher( 'http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token', { headers: { 'Metadata-Flavor': 'Google' } }, ); if (metaResp.ok) { const json = (await metaResp.json()) as { access_token?: string }; if (json.access_token) return json.access_token; } } catch { // Not on GCE — fall through. } // Local-dev fallback: mint a token via the gcloud CLI if present. Keeps the // Lyria backend dependency-free (no google-auth-library) while removing the // need to refresh GOOGLE_ACCESS_TOKEN by hand. videoclaw already shells out // to provider CLIs elsewhere (e.g. the veo-useapi Bun transport). try { const { stdout } = await execFileAsync('gcloud', ['auth', 'print-access-token'], { timeout: 15_000, }); const tok = stdout.trim(); if (tok) return tok; } catch { // gcloud absent / not authenticated — fall through to the clear error. } throw new VclawError( 'env_var_missing', 'No Vertex access token available. Set GOOGLE_ACCESS_TOKEN, run on a GCE/Cloud Run service account, or `gcloud auth login` (the CLI fallback).', { backend: 'lyria' }, ); } export const lyriaBackend: MusicBackend = { id: 'lyria', kind: 'music', displayName: 'Lyria (Vertex AI)', requiredEnv: [], requiresVertex: true, summary: 'Music generation via Google Vertex AI Lyria. Default lyria-002 (verified); LYRIA_MODEL can target lyria-3-clip-preview / lyria-3-pro-preview (preview, allowlist). Requires GOOGLE_CLOUD_PROJECT / VERTEX_PROJECT + a Vertex token.', async generate(input: MusicGenInput): Promise { const env = input.env ?? process.env; const fetcher = input.fetcher ?? fetch; const durationSec = input.durationSec ?? 30; if (!input.prompt.trim()) { throw new VclawError('music_gen_failed', 'Cannot generate music with an empty prompt.', { backend: 'lyria', }); } const project = resolveVertexProject(env); if (!project) { throw new VclawError( 'env_var_missing', 'GOOGLE_CLOUD_PROJECT (or VERTEX_PROJECT) is not set. The Lyria backend requires a Vertex project.', { backend: 'lyria' }, ); } const region = env['VERTEX_REGION'] ?? DEFAULT_LYRIA_REGION; const model = env['LYRIA_MODEL'] ?? DEFAULT_LYRIA_MODEL; if (input.dryRun) { // Write an empty placeholder so downstream existsSync() stays consistent // with every other audio backend's dry-run path (lyria3/elevenlabs/etc.). const dir = dirname(input.outputPath); if (dir) await mkdir(dir, { recursive: true }); await writeFile(input.outputPath, Buffer.alloc(0)); return { path: input.outputPath, durationMs: durationSec * 1000, backendId: 'lyria' }; } const token = await resolveVertexToken(env, fetcher); const endpoint = `https://${region}-aiplatform.googleapis.com/v1/projects/${project}` + `/locations/${region}/publishers/google/models/${model}:predict`; // Verified against lyria-002 (Lyria 2). Lyria 3 adds optional image input. const body = { instances: [{ prompt: input.prompt }], parameters: { sampleCount: 1 }, }; const resp = await fetcher(endpoint, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify(body), }); if (!resp.ok) { let detail = ''; try { detail = await resp.text(); } catch { /* ignore */ } throw new VclawError( 'music_gen_failed', `Vertex Lyria predict request failed: ${resp.status} ${resp.statusText}`, { status: resp.status, detail: detail.slice(0, 300), backend: 'lyria' }, ); } // Verified response shape (lyria-002): predictions[0].bytesBase64Encoded (WAV). const json = (await resp.json()) as { predictions?: Array<{ bytesBase64Encoded?: string; mimeType?: string }>; }; const prediction = json.predictions?.[0]; const b64 = prediction?.bytesBase64Encoded; if (!b64) { throw new VclawError( 'music_gen_failed', 'Vertex Lyria response did not include bytesBase64Encoded audio data.', { backend: 'lyria' }, ); } const bytes = Buffer.from(b64, 'base64'); const dir = dirname(input.outputPath); if (dir) await mkdir(dir, { recursive: true }); await writeFile(input.outputPath, bytes); // Report the WAV's true length when parseable (Lyria clips are ~32.8s), // falling back to the requested duration only if the header is unreadable. const durationMs = wavDurationMs(bytes) ?? durationSec * 1000; return { path: input.outputPath, durationMs, backendId: 'lyria' }; }, };