import { mkdir, writeFile, rename } from 'node:fs/promises'; import { dirname, extname } from 'node:path'; import { generateMureka, downloadMurekaAudio, murekaObject, murekaError, type MurekaOptions } from '../providers/mureka-useapi.js'; import { writeTextFileAtomic } from '../atomic-write.js'; import type { MusicBackend, TtsBackend } from './types.js'; function mp3Path(path: string): string { const ext = extname(path); return ext ? `${path.slice(0, -ext.length)}.mp3` : `${path}.mp3`; } async function saveAudio(path: string, bytes: Buffer): Promise { await mkdir(dirname(path), { recursive: true }); await writeFile(`${path}.tmp`, bytes); await rename(`${path}.tmp`, path); } function validateText(text: string, max: number, label: string, options: MurekaOptions): void { if (!text.trim() || text.length > max) throw murekaError(options, `Mureka ${label} must contain 1–${max} characters.`); } function duration(value: unknown, options: MurekaOptions): number { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { throw murekaError(options, 'Mureka returned an invalid audio duration.'); } return Math.round(value); } function trackSubmission(path: string): (jobid: string) => Promise { return async jobid => { await mkdir(dirname(path), { recursive: true }); await writeTextFileAtomic(`${path}.mureka.json`, JSON.stringify({ jobid, status: 'submitted' }, null, 2)); }; } export const murekaMusicBackend: MusicBackend = { id: 'mureka', kind: 'music', displayName: 'Mureka (useapi.net)', requiredEnv: [], requiresUseApi: true, summary: 'Vocal songs and instrumentals via useapi.net. Supports lyrics; optional VCLAW_MUREKA_ACCOUNT and VCLAW_MUREKA_MODEL. Requires a linked Mureka account. Duration is determined by the provider.', async generate(input) { const options: MurekaOptions = { env: input.env, fetcher: input.fetcher }; const env = input.env ?? process.env; const hasLyrics = input.lyrics !== undefined; if (input.instrumental && hasLyrics) throw murekaError(options, 'Choose lyrics or instrumental, not both.'); validateText(input.prompt, input.instrumental || hasLyrics ? 1000 : 3000, 'prompt', options); if (hasLyrics) validateText(input.lyrics!, 5000, 'lyrics', options); const model = env.VCLAW_MUREKA_MODEL?.trim(); if (model && !['V9.5', 'V9', 'V8', 'O2', 'V7.6', 'V7.5'].includes(model)) { throw murekaError(options, 'Unsupported VCLAW_MUREKA_MODEL. Use V9.5, V9, V8, O2, V7.6 or V7.5.'); } const path = mp3Path(input.outputPath); if (input.dryRun) { await saveAudio(path, Buffer.alloc(0)); return { path, durationMs: (input.durationSec ?? 150) * 1000, backendId: 'mureka' }; } const endpoint = input.instrumental ? 'music/create-instrumental' : hasLyrics ? 'music/create-advanced' : 'music/create'; options.onSubmitted = trackSubmission(path); const body = hasLyrics ? { desc: input.prompt, lyrics: input.lyrics } : { prompt: input.prompt }; const { jobid, result } = await generateMureka(endpoint, { ...body, ...(model ? { model } : {}) }, options); // Save all song IDs/URLs before download, retaining the second generated take // and allowing failed downloads to be recovered without another paid request. await mkdir(dirname(path), { recursive: true }); const songs = Array.isArray(result.songs) ? result.songs.map(murekaObject) : []; await writeTextFileAtomic(`${path}.mureka.json`, JSON.stringify({ jobid, songs: songs.map(song => ({ song_id: song.song_id, mp3_url: song.mp3_url, title: song.title, duration_milliseconds: song.duration_milliseconds, })) }, null, 2)); if (!songs.length) throw murekaError(options, 'Mureka completed without songs.', { jobid }); const song = songs[0]; const durationMs = duration(song.duration_milliseconds, options); await saveAudio(path, await downloadMurekaAudio(song.mp3_url, options)); return { path, durationMs, backendId: 'mureka' }; }, }; export const murekaTtsBackend: TtsBackend = { id: 'mureka-tts', kind: 'tts', outputExtension: 'mp3', displayName: 'Mureka speech (useapi.net)', requiredEnv: ['USEAPI_API_TOKEN'], summary: 'Speech and per-character dialogue via Mureka. Set --voice to a numeric Mureka voice ID, or VCLAW_MUREKA_VOICE_ID. Optional VCLAW_MUREKA_ACCOUNT.', async generate(input) { const options: MurekaOptions = { env: input.env, fetcher: input.fetcher, errorCode: 'tts_failed' }; validateText(input.text, 5000, 'speech text', options); const rawVoice = (input.voice ?? (input.env ?? process.env).VCLAW_MUREKA_VOICE_ID)?.trim(); const voice = Number(rawVoice); if (!rawVoice || !/^\d+$/.test(rawVoice) || !Number.isSafeInteger(voice) || voice <= 0) { throw murekaError(options, 'Mureka speech needs a numeric --voice or VCLAW_MUREKA_VOICE_ID from GET /speech/voices.'); } const path = mp3Path(input.outputPath); if (input.dryRun) { await saveAudio(path, Buffer.alloc(0)); return { path, durationMs: Math.round(Math.max(1, input.text.length / 14) * 1000), backendId: 'mureka-tts' }; } options.onSubmitted = trackSubmission(path); const { jobid, result } = await generateMureka('speech', { text: input.text, voice_id: voice }, options); await mkdir(dirname(path), { recursive: true }); await writeTextFileAtomic(`${path}.mureka.json`, JSON.stringify({ jobid, mp3_url: result.mp3_url, duration_milliseconds: result.duration_milliseconds }, null, 2)); const durationMs = duration(result.duration_milliseconds, options); await saveAudio(path, await downloadMurekaAudio(result.mp3_url, options)); return { path, durationMs, backendId: 'mureka-tts' }; }, };