import { readFileSync } from "node:fs"; import { expect, test } from "@playwright/test"; import { PEAK_BOUNDS } from "../../src/audio/dsp"; // 장치 rate 변경에 따른 intersample peak 증가를 실제 Chrome 디코더로 전수 확인한다. // gain 안전성의 입력 상한을 검증하며 Web Audio나 오디오 데이터는 mock하지 않는다. test("109 WAV의 24·44.1·48·96kHz 디코드는 고정 peak 상한 안에 있다", async ({ page, browser, }, info) => { const entries = ["audio", "ambience"].flatMap((directory) => JSON.parse( readFileSync(`public/${directory}/manifest.json`, "utf8"), ).files.map((entry: { file: string }) => ({ url: `/${directory}/${entry.file}?v=0.2.5`, kind: directory === "ambience" ? "background" : entry.file === "intro.wav" ? "intro" : "voice", })), ); 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("/"); const rows = await page.evaluate( async ({ entries, bounds }) => { const contexts = [24000, 44100, 48000, 96000].map( (rate) => new OfflineAudioContext(1, 1, rate), ); const rows = []; for (const entry of entries) { const response = await fetch(entry.url); if (!response.ok) throw new Error("WAV fetch failed"); const bytes = await response.arrayBuffer(); for (const context of contexts) { const buffer = await context.decodeAudioData(bytes.slice(0)); const samples = buffer.getChannelData(0); const rms = Math.sqrt( samples.reduce((sum, x) => sum + x * x, 0) / samples.length, ); const peak = samples.reduce( (max, x) => Math.max(max, Math.abs(x)), 0, ); // 배경은 저장 진폭 그대로, voice/intro만 엔진의 RMS .03 정규화를 반영한다. const normalizedPeak = entry.kind === "background" ? peak : (peak * 0.03) / rms; if ( !Number.isFinite(normalizedPeak) || normalizedPeak > bounds[entry.kind as keyof typeof bounds] ) throw new Error( `${entry.url}: ${context.sampleRate} Hz peak out of bounds`, ); rows.push({ ...entry, rate: context.sampleRate, rms, peak, normalizedPeak, }); } } return rows; }, { entries, bounds: PEAK_BOUNDS }, ); expect(rows).toHaveLength(109 * 4); expect({ errors, failed, external }).toEqual({ errors: [], failed: [], external: [], }); await info.attach("all-voices-resampled-bounds", { body: JSON.stringify( { chrome: browser.version(), bounds: PEAK_BOUNDS, rows, errors, failed, external, }, null, 2, ), contentType: "application/json", }); });