Newer
Older
rpg / apps / game / tools / aiaudio / index.mjs
/**
 * Пайплайн AI-аудио (работа A1) — манифест-driven, аналог `npm run aiart`.
 *
 * Команды:
 *   npm run aiaudio list                     — таблица позиций манифеста (gate/сиды/promote)
 *   npm run aiaudio gen [id[:seed,seed]...]  — генерация AudioLDM2 + пост-обработка
 *     (без аргументов — все сиды манифеста; sfx — без лупа, остальное — луп + --check)
 *   npm run aiaudio post <file.wav> [...]    — только пост-обработка (pass-through post.py)
 *   npm run aiaudio promote <id> <seed>      — апрув: review/<id>_<seed>_loop.wav →
 *     assets/audio/<promote>.wav + gate=approved в манифесте (sfx не promote-ится —
 *     SFX_KEYS пуст, спек-синтез; нужен файловый SFX-путь в движке)
 *
 * Модель/окружение — см. шапку manifest.json; python — venv ~/.cache/rpg-ai/venv-aiaudio.
 */
import { spawnSync } from 'node:child_process';
import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';

const require = createRequire(import.meta.url);
const HERE = path.dirname(require.resolve('./manifest.json'));
const ASSETS = path.resolve(HERE, '../../assets'); // apps/game/assets
const PY = path.join(process.env.HOME ?? '', '.cache/rpg-ai/venv-aiaudio/bin/python');
const USAGE = 'npm run aiaudio <list|gen [id[:seed,seed]...]|post <file>...|promote <id> <seed>>';

function fail(msg) {
    console.error(msg);
    process.exit(1);
}

function readManifest() {
    return JSON.parse(readFileSync(path.join(HERE, 'manifest.json'), 'utf8'));
}

/** Цели генерации: без аргументов — все сиды манифеста, иначе id[:seed,seed]. */
function parseTargets(args, manifest) {
    if (args.length === 0) {
        return manifest.items.flatMap((it) => (it.seeds ?? [1]).map((seed) => ({ it, seed })));
    }
    const out = [];
    for (const arg of args) {
        const [id, seeds] = arg.split(':');
        const it = manifest.items.find((x) => x.id === id);
        if (!it) fail(`неизвестный id ${id}; доступно: ${manifest.items.map((x) => x.id).join(', ')}`);
        for (const seed of (seeds ? seeds.split(',') : it.seeds ?? [1]).map(Number)) {
            out.push({ it, seed });
        }
    }
    return out;
}

function run(cmd, pyArgs) {
    if (!existsSync(PY)) fail(`нет venv-питона: ${PY} (см. шапку manifest.json)`);
    const r = spawnSync(PY, [cmd, ...pyArgs], { cwd: HERE, stdio: 'inherit' });
    if (r.status !== 0) fail(`${cmd} завершился с ошибкой`);
}

function cmdGen(args) {
    const manifest = readManifest();
    const targets = parseTargets(args, manifest);
    console.log(`целей: ${targets.length} (${targets.map((t) => `${t.it.id}_${t.seed}`).join(', ')})`);
    run('gen_audioldm2.py', targets.map((t) => `${t.it.id}:${t.seed}`));
    // Пост-обработка сразу: sfx — без лупа (_fx), остальное — луп + проверка шва.
    for (const t of targets) {
        const flags = t.it.kind === 'sfx' ? ['--noloop'] : ['--check'];
        if (t.it.peak) flags.push('--peak', String(t.it.peak));
        run('post.py', [`review/${t.it.id}_${t.seed}.wav`, ...flags]);
    }
}

function cmdPromote(args) {
    const [id, seedStr] = args;
    if (!id || !seedStr) fail(USAGE);
    const manifest = readManifest();
    const it = manifest.items.find((x) => x.id === id);
    if (!it) fail(`неизвестный id ${id}`);
    if (!it.promote) fail(`${id}: позиция без promote (SFX_KEYS пуст — файловых sfx пока нет)`);
    const src = path.join(HERE, 'review', `${id}_${seedStr}_loop.wav`);
    if (!existsSync(src)) fail(`нет файла ${src} (сначала npm run aiaudio gen ${id}:${seedStr})`);
    // Промотируем только ключ, известный реестрам игры (audio.ts).
    const audioTs = readFileSync(path.resolve(HERE, '../../src/data/audio.ts'), 'utf8');
    if (!audioTs.includes(`'${it.promote}'`)) fail(`ключ '${it.promote}' не объявлен в data/audio.ts — добавь сначала`);
    const dst = path.join(ASSETS, 'audio', `${it.promote}.wav`);
    copyFileSync(src, dst);
    it.gate = 'approved';
    it.approved_seed = Number(seedStr);
    it.date = new Date().toISOString().slice(0, 10);
    writeFileSync(path.join(HERE, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
    console.log(`промотировано: ${it.promote}.wav (seed ${seedStr}, gate=approved)`);
}

function cmdList() {
    const manifest = readManifest();
    for (const it of manifest.items) {
        const gate = it.gate === 'approved' ? `approved(seed ${it.approved_seed})` : it.gate ?? '—';
        console.log(`${it.id.padEnd(20)} ${it.kind.padEnd(8)} сиды ${(it.seeds ?? [1]).join(',').padEnd(8)} ${gate.padEnd(20)} ${it.promote ?? ''}`);
    }
}

const [cmd, ...args] = process.argv.slice(2);
if (cmd === 'list') cmdList();
else if (cmd === 'gen') cmdGen(args);
else if (cmd === 'post') run('post.py', args);
else if (cmd === 'promote') cmdPromote(args);
else fail(`использование: ${USAGE}`);