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