206 lines
7.3 KiB
TypeScript
206 lines
7.3 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
import { ENVIRONMENTS } from "../../src/content";
|
|
import { FIXED_HEADROOM_GAIN, PEAK_BOUNDS } from "../../src/audio/dsp";
|
|
|
|
// native connect/start/AudioParam을 그대로 실행하며 실제 그래프의 연결과 예약값을 관찰한다.
|
|
// 배경 plateau는 일정 시간 뒤 GainNode.gain.value를 직접 읽어 스케줄 기록과 교차 검사한다.
|
|
declare global {
|
|
interface Window {
|
|
mixerProbe: {
|
|
sources: {
|
|
loop: boolean;
|
|
duration: number;
|
|
peak: number;
|
|
rms: number;
|
|
rate: number;
|
|
scheduled: number;
|
|
observed?: number;
|
|
scheduledAt: number;
|
|
observedAt?: number;
|
|
master: number;
|
|
}[];
|
|
};
|
|
}
|
|
}
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.addInitScript(() => {
|
|
window.mixerProbe = { sources: [] };
|
|
const edges = new WeakMap<AudioNode, AudioNode>();
|
|
const settings = new WeakMap<AudioParam, number>();
|
|
const connect = AudioNode.prototype.connect;
|
|
const set = AudioParam.prototype.setValueAtTime;
|
|
const start = AudioBufferSourceNode.prototype.start;
|
|
AudioNode.prototype.connect = function (
|
|
this: AudioNode,
|
|
destination: AudioNode,
|
|
output?: number,
|
|
input?: number,
|
|
) {
|
|
const result = Reflect.apply(connect, this, [
|
|
destination,
|
|
output ?? 0,
|
|
input ?? 0,
|
|
]);
|
|
edges.set(this, destination);
|
|
return result;
|
|
} as typeof connect;
|
|
AudioParam.prototype.setValueAtTime = function (value, time) {
|
|
const result = set.call(this, value, time);
|
|
if (value > 0) settings.set(this, value);
|
|
return result;
|
|
};
|
|
AudioBufferSourceNode.prototype.start = function (...args) {
|
|
const gain = edges.get(this) as GainNode;
|
|
const master = edges.get(gain) as GainNode;
|
|
const data = this.buffer!.getChannelData(0);
|
|
const record = {
|
|
loop: this.loop,
|
|
duration: this.buffer!.duration,
|
|
rate: this.buffer!.sampleRate,
|
|
peak: data.reduce((max, x) => Math.max(max, Math.abs(x)), 0),
|
|
rms: Math.sqrt(data.reduce((sum, x) => sum + x * x, 0) / data.length),
|
|
scheduled: settings.get(gain.gain)!,
|
|
master: master.gain.value,
|
|
observed: undefined as number | undefined,
|
|
scheduledAt: args[0] ?? this.context.currentTime,
|
|
observedAt: undefined as number | undefined,
|
|
};
|
|
window.mixerProbe.sources.push(record);
|
|
if (this.loop) {
|
|
// 장치 초기화 중에는 wall clock이 지나도 AudioContext 시계가 진행되지 않을 수 있다.
|
|
// 값이 맞아질 때까지 기다리지 않고, 실제 fade 종료 시점에 도달한 뒤 딱 한 번 읽는다.
|
|
const observe = () => {
|
|
if (this.context.currentTime < record.scheduledAt + 0.2) {
|
|
setTimeout(observe, 25);
|
|
return;
|
|
}
|
|
record.observedAt = this.context.currentTime;
|
|
record.observed = gain.gain.value;
|
|
};
|
|
setTimeout(observe, 25);
|
|
}
|
|
return start.apply(this, args);
|
|
};
|
|
});
|
|
});
|
|
|
|
test("실제 GainNode의 배경 배율은 8환경 × SNR 경계·0에서 동일하다", async ({
|
|
page,
|
|
browser,
|
|
}, info) => {
|
|
const errors: string[] = [],
|
|
failed: string[] = [],
|
|
external: string[] = [];
|
|
page.on("pageerror", (e) => errors.push(e.message));
|
|
page.on("console", (m) => {
|
|
if (m.type() === "error") errors.push(m.text());
|
|
});
|
|
page.on("requestfailed", (r) => failed.push(r.url()));
|
|
page.on("request", (r) => {
|
|
if (new URL(r.url()).origin !== "http://127.0.0.1:4173")
|
|
external.push(r.url());
|
|
});
|
|
await page.goto("/");
|
|
// master=1에서도 같은 안전 상한을 만족하는지 확인한다.
|
|
await page.getByRole("slider", { name: "전체 음량" }).fill("100");
|
|
const rows = [];
|
|
for (const environment of ENVIRONMENTS)
|
|
for (const db of [-12, 0, 12]) {
|
|
await page.getByLabel("배경 환경").selectOption(environment.id);
|
|
await page
|
|
.getByRole("slider", { name: "목표 음성 SNR" })
|
|
.fill(String(db));
|
|
await page.evaluate(() => {
|
|
window.mixerProbe.sources = [];
|
|
});
|
|
await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click();
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
() =>
|
|
window.mixerProbe.sources.filter((s) => s.observed !== undefined)
|
|
.length,
|
|
),
|
|
)
|
|
.toBe(1);
|
|
const sources = await page.evaluate(() => window.mixerProbe.sources);
|
|
expect(sources).toHaveLength(3);
|
|
const [background, intro, voice] = sources;
|
|
expect(background!.loop).toBe(true);
|
|
expect(background!.duration).toBe(12);
|
|
expect(background!.scheduled).toBe(FIXED_HEADROOM_GAIN);
|
|
expect(
|
|
background!.observedAt! - background!.scheduledAt,
|
|
).toBeGreaterThanOrEqual(0.2);
|
|
expect(background!.observed).toBeCloseTo(FIXED_HEADROOM_GAIN, 6);
|
|
expect(voice!.scheduled / background!.scheduled).toBeCloseTo(
|
|
10 ** (db / 20),
|
|
10,
|
|
);
|
|
expect(intro!.scheduled).toBeCloseTo(FIXED_HEADROOM_GAIN * 1.8, 10);
|
|
expect(voice!.rms).toBeCloseTo(0.03, 6);
|
|
expect(intro!.rms).toBeCloseTo(0.03, 6);
|
|
for (const [kind, source] of [
|
|
["background", background],
|
|
["voice", voice],
|
|
["intro", intro],
|
|
] as const) {
|
|
expect(source!.peak).toBeLessThanOrEqual(PEAK_BOUNDS[kind]);
|
|
expect(source!.master).toBe(1);
|
|
}
|
|
rows.push({ environment: environment.id, db, sources });
|
|
await page.getByRole("button", { name: "문제 풀이 종료" }).click();
|
|
await page.getByRole("button", { name: "처음 화면으로" }).click();
|
|
}
|
|
// 같은 PCM의 RMS도 모든 SNR에서 보존된다. 배경 재정규화나 gain 보상이 없어야 한다.
|
|
for (const environment of ENVIRONMENTS) {
|
|
const rms = rows
|
|
.filter((r) => r.environment === environment.id)
|
|
.map((r) => r.sources[0]!.rms);
|
|
expect(new Set(rms).size).toBe(1);
|
|
}
|
|
expect({ errors, failed, external }).toEqual({
|
|
errors: [],
|
|
failed: [],
|
|
external: [],
|
|
});
|
|
await info.attach("actual-gain-nodes", {
|
|
body: JSON.stringify(
|
|
{
|
|
chrome: browser.version(),
|
|
fixedGain: FIXED_HEADROOM_GAIN,
|
|
rows,
|
|
errors,
|
|
failed,
|
|
external,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
contentType: "application/json",
|
|
});
|
|
});
|
|
|
|
// HTTP 200으로 반환되는 실제 WAV 한 sample만 바꾼다. decoder가 허용하는 손상도 hash가 차단해야 한다.
|
|
test("실제 WAV 변조는 소스 시작 전에 거부하고 원본 재시도로 회복한다", async ({
|
|
page,
|
|
}) => {
|
|
await page.route(/\/ambience\/.*\.wav/, async (route) => {
|
|
const response = await route.fetch();
|
|
const body = await response.body();
|
|
body[100] = body[100]! ^ 1;
|
|
await route.fulfill({ response, body });
|
|
});
|
|
await page.goto("/");
|
|
await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click();
|
|
await expect(page.getByRole("alert")).toContainText(
|
|
"소리를 재생하지 못했어요",
|
|
);
|
|
expect(await page.evaluate(() => window.mixerProbe.sources)).toEqual([]);
|
|
await page.unroute(/\/ambience\/.*\.wav/);
|
|
await page.getByRole("button", { name: "다시 시도" }).click();
|
|
await expect(page.getByRole("group", { name: "답 선택" })).toBeVisible({
|
|
timeout: 30000,
|
|
});
|
|
});
|