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(); const settings = new WeakMap(); 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, }); });