Files
Find-Word/tests/mixing-loudness.test.ts

109 lines
4.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment node
import { readFileSync, readdirSync } from "node:fs";
import { expect, it } from "vitest";
import {
mixingGains,
normalizeRms,
validatePcm,
PEAK_BOUNDS,
FIXED_HEADROOM_GAIN,
} from "../src/audio/dsp";
// 표준 PCM16 데이터를 직접 읽어 manifest 수치나 구현의 peak 계산을 믿지 않는다.
function pcm(path: string) {
const bytes = readFileSync(path);
return Float32Array.from(
{ length: (bytes.length - 44) / 2 },
(_, i) => bytes.readInt16LE(44 + i * 2) / 32768,
);
}
const peak = (samples: Float32Array) =>
samples.reduce((max, x) => Math.max(max, Math.abs(x)), 0);
const rms = (samples: Float32Array) =>
Math.sqrt(samples.reduce((sum, x) => sum + x * x, 0) / samples.length);
it("같은 +6dB에서 여덟 배경의 gain은 파일 피크와 무관하다", () => {
const voice = normalizeRms(pcm("public/audio/luck-apple.wav"), 0.03);
const intro = normalizeRms(pcm("public/audio/intro.wav"), 0.03);
// RED 증거는 기존 RMS 로더로 보존했다. 현재 계약은 저장 PCM을 그대로 사용한다.
const rows = readdirSync("public/ambience")
.filter((f) => f.endsWith(".wav"))
.map((file) => {
const background = pcm(`public/ambience/${file}`);
validatePcm(background, "background");
validatePcm(voice, "voice");
validatePcm(intro, "intro");
const gains = mixingGains(6);
return {
file,
backgroundGain: gains.background,
finalRms: rms(background) * gains.background,
};
});
console.log(JSON.stringify(rows, null, 2));
expect(new Set(rows.map((r) => r.backgroundGain)).size).toBe(1);
});
it("101 voice × 8 배경 × 모든 SNR 눈금은 동일 배경 gain과 안전 피크를 가진다", () => {
const voices = readdirSync("public/audio")
.filter((f) => f.endsWith(".wav"))
.map((f) => normalizeRms(pcm(`public/audio/${f}`), 0.03));
const backgrounds = readdirSync("public/ambience")
.filter((f) => f.endsWith(".wav"))
.map((f) => pcm(`public/ambience/${f}`));
const intro = normalizeRms(pcm("public/audio/intro.wav"), 0.03);
const backgroundGains = new Set<number>();
let maximum = 0;
const voicePeaks = voices.map((v) => {
validatePcm(v, "voice");
return peak(v);
});
const backgroundPeaks = backgrounds.map((b) => {
validatePcm(b, "background");
return peak(b);
});
validatePcm(intro, "intro");
const introPeak = peak(intro);
for (const v of voicePeaks)
for (const b of backgroundPeaks)
for (let db = -12; db <= 12; db++) {
const gains = mixingGains(db);
backgroundGains.add(gains.background);
expect(Object.values(gains).every(Number.isFinite)).toBe(true);
expect(20 * Math.log10(gains.voice / gains.background)).toBeCloseTo(
db,
10,
);
// 삼각부등식은 모든 위상·loop offset·중첩 시간의 PCM 합성 peak를 상계한다.
const ceiling =
b * gains.background + v * gains.voice + introPeak * gains.intro;
maximum = Math.max(maximum, ceiling);
expect(ceiling).toBeLessThanOrEqual(0.800001);
}
console.log(
JSON.stringify({
combinations: voices.length * backgrounds.length * 25,
maximum,
voicePeak: Math.max(...voicePeaks),
backgroundPeak: Math.max(...backgroundPeaks),
introPeak,
bounds: PEAK_BOUNDS,
fixedGain: FIXED_HEADROOM_GAIN,
backgroundGains: [...backgroundGains],
}),
);
expect(backgroundGains.size).toBe(1);
}, 60000);
// 경계 밖 입력은 gain 조절로 숨기지 않고 실패한다. 빈 PCM과 비유한 값도 같은 계약이다.
it("SNR과 모든 경로의 peak 상한 위반은 fail closed한다", () => {
for (const db of [NaN, Infinity, -Infinity, -13, 13])
expect(() => mixingGains(db)).toThrow();
for (const kind of ["background", "voice", "intro"] as const) {
for (const value of [NaN, Infinity, PEAK_BOUNDS[kind] + 0.01])
expect(() => validatePcm(new Float32Array([value]), kind)).toThrow();
expect(() => validatePcm(new Float32Array(0), kind)).toThrow();
expect(() => validatePcm(new Float32Array(4), kind)).toThrow();
}
});