109 lines
4.1 KiB
TypeScript
109 lines
4.1 KiB
TypeScript
// @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();
|
|||
|
|
}
|
|||
|
|
});
|