Files

109 lines
4.1 KiB
TypeScript
Raw Permalink Normal View History

2026-10-14 12:12:13 +00:00
// @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();
}
});