Newer
Older
rpg / packages / engine / tools / spectro.mjs
/**
 * Спектро-лист: «взгляд» ИИ-агента на WAV без слушания (аудио-гейты работ
 * AI-контента). Жанронезависимо: вход — сэмплы, выход — PNG. На каждый клип —
 * спектрограмма (лог-частота, dB-палитра), огибающая RMS и метки времени;
 * для лупов — маркер шва (стык конца в начало). Модуль не знает контента игры.
 */
import { readFileSync, writeFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
import { encodePng } from './png.mjs';
import { decodeWav } from './wav.mjs';

const EPS = 1e-10;
/** Порог динамики спектрограммы, dB ниже максимума записи. */
const DB_FLOOR = 66;

/** Палитра dB→цвет: чёрный → синева → бирюза → золото → белый (жар). */
const HEAT = [
    [12, 14, 20],
    [26, 34, 62],
    [38, 92, 108],
    [70, 160, 130],
    [158, 202, 96],
    [222, 198, 74],
    [252, 246, 220]
];

/** Итеративный radix-2 FFT (in-place, re/im длиной в степень двойки). */
function fft(re, im) {
    const n = re.length;
    for (let i = 1, j = 0; i < n; i++) {
        let bit = n >> 1;
        for (; j & bit; bit >>= 1) j ^= bit;
        j ^= bit;
        if (i < j) {
            [re[i], re[j]] = [re[j], re[i]];
            [im[i], im[j]] = [im[j], im[i]];
        }
    }
    for (let len = 2; len <= n; len <<= 1) {
        const ang = (-2 * Math.PI) / len;
        const wr = Math.cos(ang);
        const wi = Math.sin(ang);
        for (let i = 0; i < n; i += len) {
            let cr = 1;
            let ci = 0;
            for (let k = 0; k < len / 2; k++) {
                const ur = re[i + k];
                const ui = im[i + k];
                const vr = re[i + k + len / 2] * cr - im[i + k + len / 2] * ci;
                const vi = re[i + k + len / 2] * ci + im[i + k + len / 2] * cr;
                re[i + k] = ur + vr;
                im[i + k] = ui + vi;
                re[i + k + len / 2] = ur - vr;
                im[i + k + len / 2] = ui - vi;
                const ncr = cr * wr - ci * wi;
                ci = cr * wi + ci * wr;
                cr = ncr;
            }
        }
    }
}

/**
 * Спектрограмма: окна Ханна win с шагом hop, |FFT| в dB. Возвращает
 * { cols, rows, db: Float32Array (cols*rows, частота: строка 0 — верх/высокая) }.
 */
export function spectrogram(samples, { win = 1024, hop = 512 } = {}) {
    const cols = Math.max(1, Math.floor((samples.length - win) / hop) + 1);
    const rows = win / 2;
    const db = new Float32Array(cols * rows);
    const re = new Float32Array(win);
    const im = new Float32Array(win);
    let max = -Infinity;
    for (let c = 0; c < cols; c++) {
        for (let i = 0; i < win; i++) {
            const w = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / win);
            re[i] = (samples[c * hop + i] ?? 0) * w;
            im[i] = 0;
        }
        fft(re, im);
        for (let r = 0; r < rows; r++) {
            const mag = Math.hypot(re[r], im[r]); // нижняя половина спектра
            const v = 20 * Math.log10(mag + EPS);
            db[c * rows + r] = v;
            if (v > max) max = v;
        }
    }
    return { cols, rows, db, max };
}

/** Огибающая RMS (0..1): окно winMs, шаг окно/2 — для полосы «дыхания». */
export function envelope(samples, rate, winMs = 60) {
    const win = Math.max(1, Math.round((winMs / 1000) * rate));
    const cols = Math.max(1, Math.floor(samples.length / win));
    const out = new Float32Array(cols);
    let max = 0;
    for (let c = 0; c < cols; c++) {
        let sum = 0;
        for (let i = 0; i < win; i++) {
            const s = samples[c * win + i] ?? 0;
            sum += s * s;
        }
        out[c] = Math.sqrt(sum / win);
        if (out[c] > max) max = out[c];
    }
    if (max > 0) for (let c = 0; c < cols; c++) out[c] /= max;
    return out;
}

/** dB→цвет палитры (0 = тише DB_FLOOR ниже максимума записи). */
function heatIndex(db, max) {
    const t = Math.max(0, Math.min(1, (db - (max - DB_FLOOR)) / DB_FLOOR));
    const seg = t * (HEAT.length - 1);
    const i = Math.min(HEAT.length - 2, Math.floor(seg));
    const f = seg - i;
    const mix = (c) => Math.round(HEAT[i][c] + (HEAT[i + 1][c] - HEAT[i][c]) * f);
    return [mix(0), mix(1), mix(2)];
}

/** Крошечный шрифт цифр 3×5 (для номеров строк листа). */
const DIGITS = ['111101101101111', '010110010010111', '111001111100111', '111001111001111', '101101111001001', '111100111001111', '111100111101111', '111001010100100', '111101111101111', '111101111001111'];

/** Нарисовать цифру (индекс строки) в 3×5 с масштабом. */
function drawNumber(px, x, y, n, scale = 2, color = [160, 168, 180]) {
    for (const ch of String(n)) {
        const g = DIGITS[Number(ch)];
        for (let i = 0; i < 15; i++) {
            if (g[i] === '1') {
                const gx = i % 3;
                const gy = (i / 3) | 0;
                for (let dy = 0; dy < scale; dy++) {
                    for (let dx = 0; dx < scale; dx++) {
                        const px0 = (y + gy * scale + dy) * px.width + x + gx * scale + dx;
                        px.data.set(color, px0 * 4);
                    }
                }
            }
        }
        x += 4 * scale;
    }
}

/**
 * Лист клипов: спектро (log-частота) + огибающая + ось времени на строку.
 * clips: { name, samples, rate, loop? }. loop=true — красный маркер шва
 * (конец→начало) на правом краю спектро. Пишет PNG в path, возвращает путь.
 */
export function spectroSheet(clips, path, { width = 720, specH = 140, envH = 22 } = {}) {
    const labelH = 12;
    const rowH = labelH + specH + envH + 10;
    const W = width;
    const H = clips.length * rowH + 8;
    const px = { width: W, height: H, data: Buffer.alloc(W * H * 4, 255) };
    const bg = [235, 236, 240];
    for (let i = 0; i < W * H; i++) px.data.set(bg, i * 4);

    clips.forEach((clip, row) => {
        const y0 = row * rowH + 4;
        const spec = spectrogram(clip.samples, { win: 1024, hop: Math.max(1, Math.floor(clip.samples.length / (W - 16))) });
        const fmin = 40;
        const fmax = clip.rate / 2;
        const x0 = 20;
        const gw = W - x0 - 12;
        for (let x = 0; x < gw; x++) {
            const col = Math.min(spec.cols - 1, Math.floor((x / gw) * spec.cols));
            for (let y = 0; y < specH; y++) {
                const f = fmax * Math.pow(fmin / fmax, y / specH); // лог-частота сверху вниз
                const binIdx = Math.min(spec.rows - 1, Math.round((f / fmax) * spec.rows));
                const [r, g, b] = heatIndex(spec.db[col * spec.rows + binIdx], spec.max);
                const pi = ((y0 + labelH + y) * W + x0 + x) * 4;
                px.data.set([r, g, b], pi);
            }
        }
        // Огибающая RMS: заполненная полоса под спектро.
        const env = envelope(clip.samples, clip.rate);
        const ey = y0 + labelH + specH + 4;
        for (let x = 0; x < gw; x++) {
            const v = env[Math.min(env.length - 1, Math.floor((x / gw) * env.length))] ?? 0;
            const h = Math.round(v * (envH - 4));
            for (let y = 0; y < h; y++) {
                const pi = ((ey + envH - 4 - y) * W + x0 + x) * 4;
                px.data.set([70, 110, 140], pi);
            }
        }
        // Секундные тики времени под огибающей.
        const totalSec = clip.samples.length / clip.rate;
        for (let s = 0; s <= totalSec; s++) {
            const x = Math.round((s / totalSec) * (gw - 1));
            for (let y = 0; y < 4; y++) {
                const pi = ((ey + envH + y) * W + x0 + x) * 4;
                px.data.set([120, 126, 138], pi);
            }
        }
        // Маркер шва лупа: красные кромки на стыке конец→начало.
        if (clip.loop) {
            for (let y = 0; y < specH; y++) {
                const pl = ((y0 + labelH + y) * W + x0) * 4;
                const pr = ((y0 + labelH + y) * W + x0 + gw - 1) * 4;
                px.data.set([200, 60, 50], pl);
                px.data.set([200, 60, 50], pr);
            }
        }
        drawNumber(px, 6, y0 + labelH / 2 - 4, row + 1);
    });

    writeFileSync(path, encodePng(W, H, px.data));
    return path;
}

// CLI: node spectro.mjs <out.png> <clip.wav[:loop]> [clip.wav[:loop]...]
// :loop — пометить шов; порядок файлов = номера строк на листе.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
    const [out, ...files] = process.argv.slice(2);
    if (!out || files.length === 0) {
        console.error('использование: node spectro.mjs <out.png> <clip.wav[:loop]> [...]');
        process.exit(1);
    }
    const clips = files.map((f, i) => {
        const loop = f.endsWith(':loop');
        const file = f.replace(/:loop$/, '');
        const buf = readFileSync(file);
        const { samples, rate } = decodeWav(buf);
        console.log(`${i + 1}: ${file} (${(samples.length / rate).toFixed(1)} с, ${rate} Гц)${loop ? ' [луп]' : ''}`);
        return { name: file, samples, rate, loop };
    });
    console.log(`лист: ${spectroSheet(clips, out)}`);
}