fix: normalize ambience loudness for v0.2.5
This commit is contained in:
@@ -24,14 +24,14 @@ test("음성·현장음 109개 versioned WAV를 실제 Chrome에서 해시·디
|
||||
page.on("requestfailed", (request) => failed.push(request.url()));
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.goto("/");
|
||||
await expect(page).toHaveTitle("단어를 맞춰라 · Find-Word v0.2.3");
|
||||
await expect(page.locator(".version")).toHaveText("v0.2.3");
|
||||
await expect(page).toHaveTitle("단어를 맞춰라 · Find-Word v0.2.5");
|
||||
await expect(page.locator(".version")).toHaveText("v0.2.5");
|
||||
const plan = JSON.parse(
|
||||
readFileSync(".agent/AMBIENCE-SOURCES-v0.2.3.json", "utf8"),
|
||||
);
|
||||
const published = await page.evaluate(async () => ({
|
||||
manifest: await (await fetch("/ambience/manifest.json?v=0.2.3")).json(),
|
||||
notice: await (await fetch("/ambience/AMBIENCE_NOTICES.md?v=0.2.3")).text(),
|
||||
manifest: await (await fetch("/ambience/manifest.json?v=0.2.5")).json(),
|
||||
notice: await (await fetch("/ambience/AMBIENCE_NOTICES.md?v=0.2.5")).text(),
|
||||
}));
|
||||
expect(published.manifest).toEqual(
|
||||
JSON.parse(readFileSync("public/ambience/manifest.json", "utf8")),
|
||||
@@ -80,13 +80,13 @@ test("음성·현장음 109개 versioned WAV를 실제 Chrome에서 해시·디
|
||||
introUrl,
|
||||
...ambience.files.map(
|
||||
(entry: { file: string }) =>
|
||||
new URL(`/ambience/${entry.file}?v=0.2.3`, page.url()).href,
|
||||
new URL(`/ambience/${entry.file}?v=0.2.5`, page.url()).href,
|
||||
),
|
||||
...targetUrls.map((url) => new URL(url, page.url()).href),
|
||||
];
|
||||
expect(
|
||||
new Set(urls.map((url) => new URL(url).searchParams.get("v"))),
|
||||
).toEqual(new Set(["0.2.3"]));
|
||||
).toEqual(new Set(["0.2.5"]));
|
||||
expect(
|
||||
urls.every((url) => new URL(url).origin === new URL(page.url()).origin),
|
||||
).toBe(true);
|
||||
@@ -96,7 +96,7 @@ test("음성·현장음 109개 versioned WAV를 실제 Chrome에서 해시·디
|
||||
version: string;
|
||||
files: { file: string; sha256: string; bytes: number; duration: number }[];
|
||||
};
|
||||
expect(manifest.version).toBe("0.2.3");
|
||||
expect(manifest.version).toBe("0.2.5");
|
||||
// 두 manifest의 파일명 충돌 없이 경로를 명시해 109개 응답을 독립 대조한다.
|
||||
const entries = [
|
||||
...manifest.files.map((entry) => ({
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
@@ -41,8 +41,8 @@ test("실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부
|
||||
await page.getByRole("button", { name: "게임 시작 · 소리 듣기" }).click();
|
||||
await expect.poll(() => audioUrls.length).toBe(2);
|
||||
expect(audioUrls.map((url) => url.searchParams.get("v"))).toEqual([
|
||||
"0.2.3",
|
||||
"0.2.3",
|
||||
"0.2.5",
|
||||
"0.2.5",
|
||||
]);
|
||||
await expect(page.getByRole("button", { name: /듣는 중/ })).toBeDisabled();
|
||||
await expect(page.locator(".sound-wave.active i").first()).toHaveCSS(
|
||||
@@ -117,7 +117,7 @@ test("실제 오디오로 혼합 모드 완주·레이아웃·접근성·외부
|
||||
page.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
|
||||
).toBeVisible();
|
||||
expect(new Set(audioUrls.map((url) => url.searchParams.get("v")))).toEqual(
|
||||
new Set(["0.2.3"]),
|
||||
new Set(["0.2.5"]),
|
||||
);
|
||||
expect(
|
||||
audioUrls.filter((url) => url.pathname !== "/audio/intro.wav"),
|
||||
@@ -150,7 +150,7 @@ for (const failed of ["intro", "target"]) {
|
||||
"소리를 재생하지 못했어요",
|
||||
);
|
||||
expect(missing).toHaveLength(1);
|
||||
expect(new URL(missing[0]!).searchParams.get("v")).toBe("0.2.3");
|
||||
expect(new URL(missing[0]!).searchParams.get("v")).toBe("0.2.5");
|
||||
expect(audioUrls).toHaveLength(2);
|
||||
await page.unroute(matchesFailure);
|
||||
await page.getByRole("button", { name: "다시 시도" }).click();
|
||||
@@ -162,7 +162,7 @@ for (const failed of ["intro", "target"]) {
|
||||
expect(audioUrls.slice(2).sort()).toEqual(audioUrls.slice(0, 2).sort());
|
||||
expect(
|
||||
new Set(audioUrls.map((url) => new URL(url).searchParams.get("v"))),
|
||||
).toEqual(new Set(["0.2.3"]));
|
||||
).toEqual(new Set(["0.2.5"]));
|
||||
const targetPath = audioUrls
|
||||
.map((url) => new URL(url).pathname)
|
||||
.find((path) => path !== "/audio/intro.wav")!;
|
||||
|
||||
@@ -25,7 +25,7 @@ test("누락 음성·환경음은 query와 HTML accept에도 404이고 출처
|
||||
request,
|
||||
}) => {
|
||||
for (const directory of ["audio", "ambience"]) {
|
||||
const response = await request.get(`/${directory}/not-found.wav?v=0.2.3`, {
|
||||
const response = await request.get(`/${directory}/not-found.wav?v=0.2.5`, {
|
||||
headers: { Accept: "text/html" },
|
||||
});
|
||||
expect(response.status()).toBe(404);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
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",
|
||||
});
|
||||
});
|
||||
@@ -60,7 +60,7 @@ test("난이도 설명은 모바일에서도 보인다", async ({ page }) => {
|
||||
.toBeVisible({ timeout: 1000 });
|
||||
await expect
|
||||
.soft(page.locator("#snr-help"))
|
||||
.toContainText("음성이 배경보다 6 dB 커요.", { timeout: 1000 });
|
||||
.toContainText("음성이 기준보다 6 dB 커요.", { timeout: 1000 });
|
||||
});
|
||||
|
||||
// jsdom은 줄바꿈을 계산하지 않는다. 실제 Chrome에서 한국어 제목의
|
||||
|
||||
Reference in New Issue
Block a user