fix: normalize ambience loudness for v0.2.5

This commit is contained in:
oz
2026-10-14 12:12:13 +00:00
parent 812540c743
commit 91aff66445
390 changed files with 97709 additions and 257 deletions
+48 -11
View File
@@ -1,7 +1,13 @@
import { describe, expect, it, vi } from "vitest";
import { FIXED_HEADROOM_GAIN } from "../src/audio/dsp";
import { AudioEngine } from "../src/audio/engine";
import { createQuestions } from "../src/game";
// 무결성은 실제 바이트 전수 검사와 Chrome 변조 테스트에서 검증한다. 이 파일은 노드 수명에 집중한다.
vi.mock("../src/audio/integrity", () => ({
verifyAudioAsset: vi.fn(async () => {}),
}));
// 녹음 재생 성공만 흉내 내는 대신 Web Audio 그래프의 노드·예약 시간·정리
// 호출을 기록한다. HTTP와 장치 경계를 주입하여 경합을 결정론적으로 재현한다.
function fixture() {
@@ -67,7 +73,7 @@ function fixture() {
createBufferSource: makeSource,
createBuffer: (_channels: number, length: number, rate: number) =>
buffer(length, rate),
decodeAudioData: vi.fn(async () => buffer()),
decodeAudioData: vi.fn(async () => buffer(16000 * 12)),
};
const fetcher = vi.fn<typeof fetch>(
async () =>
@@ -85,7 +91,7 @@ function fixture() {
}
const question = createQuestions(42, "choice")[0]!;
const flush = async () => {
for (let i = 0; i < 12; i++) await Promise.resolve();
for (let i = 0; i < 24; i++) await Promise.resolve();
};
describe("오디오 재생 수명과 믹서", () => {
@@ -96,8 +102,8 @@ describe("오디오 재생 수명과 믹서", () => {
f.sources.at(-1)!.onended?.();
await done;
expect(f.fetcher.mock.calls.map(([url]) => url)).toEqual([
"/audio/intro.wav?v=0.2.3",
`/audio/${question.id}.wav?v=0.2.3`,
"/audio/intro.wav?v=0.2.5",
`/audio/${question.id}.wav?v=0.2.5`,
question.environment.audioUrl,
]);
f.engine.dispose();
@@ -130,11 +136,11 @@ describe("오디오 재생 수명과 믹서", () => {
f.sources.at(-1)!.onended?.();
await done;
expect(f.fetcher.mock.calls.map(([url]) => url)).toEqual([
"/audio/intro.wav?v=0.2.3",
`/audio/${question.id}.wav?v=0.2.3`,
"/audio/intro.wav?v=0.2.5",
`/audio/${question.id}.wav?v=0.2.5`,
question.environment.audioUrl,
"/audio/intro.wav?v=0.2.3",
`/audio/${question.id}.wav?v=0.2.3`,
"/audio/intro.wav?v=0.2.5",
`/audio/${question.id}.wav?v=0.2.5`,
question.environment.audioUrl,
]);
f.engine.dispose();
@@ -161,16 +167,16 @@ describe("오디오 재생 수명과 믹서", () => {
]);
// master, 배경, 목표, 안내의 gain을 분리한다. 안내는 점차 소음에 묻힌다.
expect(f.gains[1]!.gain.setValueAtTime).toHaveBeenCalledWith(
1,
FIXED_HEADROOM_GAIN,
expect.any(Number),
);
expect(f.gains[2]!.gain.setValueAtTime).toHaveBeenCalledWith(
10 ** (question.db / 20),
FIXED_HEADROOM_GAIN * 10 ** (question.db / 20),
expect.any(Number),
);
expect(
f.gains[3]!.gain.linearRampToValueAtTime.mock.calls[0]![0],
).toBeCloseTo(0.12, 10);
).toBeCloseTo(FIXED_HEADROOM_GAIN * 0.12, 10);
expect(f.sources[2]!.start.mock.calls[0]![0]!).toBeGreaterThan(
f.sources[1]!.start.mock.calls[0]![0]!,
);
@@ -319,3 +325,34 @@ it("로드 시간초과는 fetch를 취소하고 늦은 디코드가 소스를
vi.useRealTimers();
}
});
// ebur128 실측 스칼라 gain으로 저장한 배경의 진폭을 로더가 다시 RMS .03으로 바꾸면 청감 보정이 사라진다.
it("배경 PCM은 그대로 복사하고 voice/intro만 RMS .03을 유지한다", async () => {
const f = fixture();
const done = f.engine.play(question, true, vi.fn());
await flush();
const energy = (data: Float32Array) =>
Math.sqrt(data.reduce((sum, x) => sum + x * x, 0) / data.length);
expect(energy(f.sources[0]!.buffer!.getChannelData(0))).toBeGreaterThan(0.1);
expect(energy(f.sources[1]!.buffer!.getChannelData(0))).toBeCloseTo(0.03, 6);
expect(energy(f.sources[2]!.buffer!.getChannelData(0))).toBeCloseTo(0.03, 6);
f.sources.at(-1)!.onended?.();
await done;
f.engine.dispose();
});
it("배경의 과도 peak·NaN·무음은 소스 시작 전 거부하고 다시 시도할 수 있다", async () => {
for (const sample of [0.9, NaN, 0]) {
const f = fixture();
const decoded = await f.context.decodeAudioData();
f.context.decodeAudioData
.mockResolvedValueOnce(decoded)
.mockResolvedValueOnce({
...decoded,
getChannelData: () => new Float32Array([sample, -sample]),
});
await expect(f.engine.play(question, false, vi.fn())).rejects.toThrow();
expect(f.sources).toHaveLength(0);
f.engine.dispose();
}
});