fix: normalize ambience loudness for v0.2.5
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
// @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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user