Newer
Older
rpg / packages / engine / src / audio / __tests__ / Music.test.ts
import { describe, expect, it } from 'vitest';
import { renderMusic, type MusicSpec } from '../Music';

const RATE = 22050;
const spb = (bpm: number): number => 60 / bpm;

const same = (a: Float32Array, b: Float32Array): boolean =>
    a.length === b.length && a.every((v, i) => v === b[i]);

const peak = (samples: Float32Array): number => Math.max(...[...samples].map(Math.abs));

/** Одна нота на долю — простейшая партитура. */
const one = (over: Partial<MusicSpec> = {}): MusicSpec => ({
    bpm: 120,
    beats: 4,
    tracks: [{ instrument: 'pluck', notes: [{ beat: 0, dur: 1, midi: 69 }] }],
    ...over
});

describe('renderMusic', () => {
    it('детерминизм: одинаковые спеки → одинаковые сэмплы', () => {
        expect(same(renderMusic(one()), renderMusic(one()))).toBe(true);
    });

    it('длина лупа — сырая минус кроссфейд (фейд = min(0.5с, 25% длины))', () => {
        const t1 = 4 * spb(120) * RATE; // 2 с
        expect(renderMusic(one({ bpm: 120, beats: 4 })).length).toBe(t1 - 0.5 * RATE);
        const t2 = 8 * spb(60) * RATE; // 8 с — фейд упёрся в 0.5 с
        expect(renderMusic(one({ bpm: 60, beats: 8 })).length).toBe(t2 - 0.5 * RATE);
    });

    it('высота ноты доезжает: другой midi — другие сэмплы', () => {
        const a = renderMusic(one());
        const b = renderMusic(one({ tracks: [{ instrument: 'pluck', notes: [{ beat: 0, dur: 1, midi: 71 }] }] }));
        expect(same(a, b)).toBe(false);
    });

    it('vel и gain меняют микс (пик после нормализации — 0.8 вне кроссфейд-зоны)', () => {
        const beat1 = one({ tracks: [{ instrument: 'pluck', notes: [{ beat: 1, dur: 1, midi: 69 }] }] });
        expect(peak(renderMusic(beat1))).toBeCloseTo(0.8, 2); // нормализация

        // gain одной партии при опорной второй стереть нормализацией нельзя:
        const mix = (g: number): MusicSpec => ({
            bpm: 120,
            beats: 4,
            tracks: [
                { instrument: 'pad', notes: [{ beat: 0, dur: 4, midi: 57 }] },
                { instrument: 'pluck', notes: [{ beat: 1, dur: 1, midi: 69 }], gain: g }
            ]
        });
        expect(same(renderMusic(mix(1)), renderMusic(mix(0.3)))).toBe(false);
    });

    it('ноты за краем лупа обрезаются, пустой трек — тишина нормализована в 0', () => {
        const empty = renderMusic(one({ tracks: [{ instrument: 'pad', notes: [] }] }));
        expect(peak(empty)).toBe(0);

        const clipped = renderMusic(one({ beats: 1, tracks: [{ instrument: 'pluck', notes: [{ beat: 0.5, dur: 2, midi: 69 }] }] }));
        expect(clipped.length).toBeLessThanOrEqual(1 * spb(120) * RATE);
        expect(peak(clipped)).toBeGreaterThan(0);
    });
});