/**
* Спектро-лист: «взгляд» ИИ-агента на 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)}`);
}