// @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(); 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(); } });