Newer
Older
rpg / packages / engine / tools / __tests__ / spectro.test.mjs
import { describe, expect, it } from 'vitest';
import { writeFileSync, readFileSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { encodeWav, decodeWav } from '../wav.mjs';
import { spectrogram, envelope, spectroSheet } from '../spectro.mjs';

/** Синус 440 Гц амплитуды 0.5 (2 с — за пределами окна анализа). */
const tone = (rate = 22050) => {
    const n = rate * 2;
    const s = new Float32Array(n);
    for (let i = 0; i < n; i++) s[i] = 0.5 * Math.sin((2 * Math.PI * 440 * i) / rate);
    return s;
};

describe('decodeWav', () => {
    it('читает обратно то, что написал encodeWav', () => {
        const src = tone();
        const { samples, rate } = decodeWav(encodeWav(src, 22050));
        expect(rate).toBe(22050);
        expect(samples.length).toBe(src.length);
        // 16-бит квантование: расхождение с оригиналом микроскопическое.
        let err = 0;
        for (let i = 0; i < samples.length; i++) err = Math.max(err, Math.abs(samples[i] - src[i]));
        expect(err).toBeLessThan(1e-4);
    });

    it('падает с внятной ошибкой на не-WAV', () => {
        expect(() => decodeWav(Buffer.from('not a wav file at all'))).toThrow(/не WAV/);
    });
});

describe('spectrogram/envelope', () => {
    it('пик спектрограммы тона попадает на его частоту', () => {
        const rate = 22050;
        const spec = spectrogram(tone(rate), { win: 1024, hop: 512 });
        expect(spec.cols).toBeGreaterThan(0);
        expect(Number.isFinite(spec.max)).toBe(true);
        // 440 Гц → бин 440/22050*1024 ≈ 20 (из 512).
        const bin = Math.round((440 / rate) * 1024);
        let best = -Infinity;
        let bestRow = 0;
        for (let r = 0; r < spec.rows; r++) {
            if (spec.db[r] > best) {
                best = spec.db[r];
                bestRow = r;
            }
        }
        expect(Math.abs(bestRow - bin)).toBeLessThanOrEqual(2);
    });

    it('огибающая нормализована в 0..1', () => {
        const env = envelope(tone(), 22050);
        for (const v of env) {
            expect(v).toBeGreaterThanOrEqual(0);
            expect(v).toBeLessThanOrEqual(1);
        }
        expect(env.some((v) => v > 0.9)).toBe(true); // максимум достижим
    });
});

describe('spectroSheet', () => {
    it('пишет PNG-лист по нескольким клипам', () => {
        const dir = mkdtempSync(join(tmpdir(), 'spectro-'));
        try {
            const out = join(dir, 'sheet.png');
            const s = tone();
            spectroSheet(
                [
                    { name: 'a', samples: s, rate: 22050, loop: true },
                    { name: 'b', samples: s, rate: 22050, loop: false }
                ],
                out
            );
            const buf = readFileSync(out);
            expect(buf.subarray(1, 4).toString()).toBe('PNG');
        } finally {
            rmSync(dir, { recursive: true, force: true });
        }
    });
});