fix: normalize ambience loudness for v0.2.5

This commit is contained in:
oz
2026-10-14 12:12:13 +00:00
parent 812540c743
commit 91aff66445
390 changed files with 97709 additions and 257 deletions
+6 -6
View File
@@ -105,11 +105,11 @@ describe("시작부터 결과까지 사용자 흐름", () => {
const questions = createQuestions(42, "choice");
// 양수·0·음수를 각각 명시해 부호만 바꾸고 설명은 그대로인 회귀도 잡는다.
const levels = [
["SNR +6 dB", "음성이 배경보다 6 dB 커요."],
["SNR +3 dB", "음성이 배경보다 3 dB 커요."],
["SNR 0 dB", "음성과 배경의 크기가 같아요."],
["SNR -3 dB", "음성이 배경보다 3 dB 작아요."],
["SNR -6 dB", "음성이 배경보다 6 dB 작아요."],
["SNR +6 dB", "음성이 기준보다 6 dB 커요."],
["SNR +3 dB", "음성이 기준보다 3 dB 커요."],
["SNR 0 dB", "음성을 기준 크기로 들려줘요."],
["SNR -3 dB", "음성이 기준보다 3 dB 작아요."],
["SNR -6 dB", "음성이 기준보다 6 dB 작아요."],
] as const;
await f.user.click(screen.getByRole("radio", { name: "보기에서 선택" }));
await f.user.click(
@@ -203,7 +203,7 @@ describe("시작부터 결과까지 사용자 흐름", () => {
exact: true,
}),
).toBeInTheDocument();
expect(screen.getByText("v0.2.3")).toBeInTheDocument();
expect(screen.getByText("v0.2.5")).toBeInTheDocument();
expect(f.audio.play).not.toHaveBeenCalled();
expect(
screen.getByRole("button", { name: "게임 시작 · 소리 듣기" }),
+167 -9
View File
@@ -4,12 +4,23 @@ import { readFileSync, readdirSync } from "node:fs";
import { expect, it } from "vitest";
import { ENVIRONMENTS } from "../src/content";
// 과거 ASR/보존 검사는 해당 버전의 정확한 바이트를 읽는다. 새 음원의 인수로 재사용하지 않는다.
const readHistoricalBytes = (path: string) =>
readFileSync(
path.startsWith("public/ambience/")
? path.replace(
"public/ambience/",
".agent/evidence-v0.2.5/baseline/ambience/",
)
: path,
);
// 생성기 자기 보고 대신 배포 바이트의 형식·파형·권리 추적 정보를 독립 측정한다.
it("여덟 현장 녹음은 고유 PCM16 WAV·짧은 fade·완전한 provenance/notice를 가진다", () => {
const manifest = JSON.parse(
readFileSync("public/ambience/manifest.json", "utf8"),
);
expect(manifest.version).toBe("0.2.3");
expect(manifest.version).toBe("0.2.5");
expect(
manifest.files.map((f: { environmentId: string }) => f.environmentId),
).toEqual(ENVIRONMENTS.map((e) => e.id));
@@ -177,13 +188,16 @@ it("선택 구간은 승인 계획의 불변 provenance이며 manifest와 고지
}
});
it("최종 ASR 증거는 정확한 WAV와 연결되며 청취·법적 인수로 주장하지 않는다", () => {
it("v0.2.3 최종 ASR 증거는 정확한 WAV와 연결되며 청취·법적 인수로 주장하지 않는다", () => {
const directory = ".agent/evidence-v0.2.3/privacy-screening";
const screening = JSON.parse(
readFileSync(`${directory}/final-asr.json`, "utf8"),
);
const manifest = JSON.parse(
readFileSync("public/ambience/manifest.json", "utf8"),
readFileSync(
".agent/evidence-v0.2.5/baseline/ambience/manifest.json",
"utf8",
),
);
expect(screening.tool).toMatchObject({
name: "faster-whisper",
@@ -212,7 +226,9 @@ it("최종 ASR 증거는 정확한 WAV와 연결되며 청취·법적 인수로
});
expect(screening.method.manifestSha256).toBe(
createHash("sha256")
.update(readFileSync("public/ambience/manifest.json"))
.update(
readFileSync(".agent/evidence-v0.2.5/baseline/ambience/manifest.json"),
)
.digest("hex"),
);
expect(screening.limitations).toMatchObject({
@@ -261,7 +277,7 @@ it("최종 ASR 증거는 정확한 WAV와 연결되며 청취·법적 인수로
).toBe(record.sha256);
});
it("101개 음성·다섯 현장음 및 이전 evidence 바이트를 보존한다", () => {
it("v0.2.3 역사 snapshot은 당시 101개 음성·다섯 현장음 보존을 증명한다", () => {
const baseline = JSON.parse(
readFileSync(
".agent/evidence-v0.2.3/privacy-screening/baseline.json",
@@ -273,7 +289,7 @@ it("101개 음성·다섯 현장음 및 이전 evidence 바이트를 보존한
changedAmbience = 0;
for (const [path, before] of Object.entries(baseline.wavSha256)) {
const actual = createHash("sha256")
.update(readFileSync(path))
.update(readHistoricalBytes(path))
.digest("hex");
if (/public\/ambience\/(mall|market|cafe)\.wav$/.test(path)) {
expect(actual).not.toBe(before);
@@ -288,19 +304,19 @@ it("101개 음성·다섯 현장음 및 이전 evidence 바이트를 보존한
for (const [path, before] of Object.entries(baseline.priorAgentFiles)) {
if (baseline.mutableAgentFiles.includes(path)) continue;
expect(
createHash("sha256").update(readFileSync(path)).digest("hex"),
createHash("sha256").update(readHistoricalBytes(path)).digest("hex"),
path,
).toBe(before);
}
});
it("재시도는 market/cafe만 바꾸고 mall 및 실패·no-VAD 증거를 바이트 그대로 보존한다", () => {
it("v0.2.3 재시도는 market/cafe만 바꾸고 mall 및 실패·no-VAD 증거를 바이트 그대로 보존한다", () => {
const directory = ".agent/evidence-v0.2.3/privacy-screening";
const baseline = JSON.parse(
readFileSync(`${directory}/retry-market24-cafe0/baseline.json`, "utf8"),
);
const hash = (path: string) =>
createHash("sha256").update(readFileSync(path)).digest("hex");
createHash("sha256").update(readHistoricalBytes(path)).digest("hex");
const prior = `${directory}/prior-attempt-market60-cafe180`;
expect(hash(`${prior}/final-asr.json`)).toBe(
"01c8a92fab9f8df7a5f778c5f7e478ce96bcedc33e11a1055bd29154a08b706b",
@@ -352,3 +368,145 @@ it("재시도는 market/cafe만 바꾸고 mall 및 실패·no-VAD 증거를 바
]);
expect([voices, preservedAmbience]).toEqual([101, 6]);
});
// 이번 릴리스는 8 배경을 모두 바꾸되 원본·선택 구간·권리와 101 voice/660 역사 파일을 보존한다.
it("v0.2.5는 이전 증거·음성 바이트와 모든 출처 필드를 그대로 보존한다", () => {
const baseline = JSON.parse(
readFileSync(".agent/evidence-v0.2.5/baseline.json", "utf8"),
);
for (const [path, expected] of Object.entries({
...baseline.immutable,
...baseline.voiceWavs,
}))
expect(
createHash("sha256").update(readFileSync(path)).digest("hex"),
path,
).toBe(expected);
const prior = JSON.parse(
readFileSync(
".agent/evidence-v0.2.5/baseline/ambience/manifest.json",
"utf8",
),
);
const current = JSON.parse(
readFileSync("public/ambience/manifest.json", "utf8"),
);
for (const [index, before] of prior.files.entries()) {
const after = current.files[index];
for (const key of [
"environmentId",
"pageUrl",
"directUrl",
"creator",
"license",
"licenseUrl",
"attribution",
"expectedSourceSha256",
"sourceBytes",
"sourceSha256",
"directMediaDurationSeconds",
"sourceVerification",
"selectedSegmentStartSeconds",
"outputLicense",
])
expect(after[key], key).toEqual(before[key]);
expect(after.sha256).not.toBe(before.sha256);
}
});
// 새 바이트의 ASR은 과거 승인으로 대체하지 않는다. 성공을 강제하려고 결과를 지우면 실패한다.
it("거부된 loudnorm WAV의 ASR 실패와 privacy 인수 경계를 보존한다", () => {
const screening = JSON.parse(
readFileSync(".agent/evidence-v0.2.5/final-asr.json", "utf8"),
);
const manifestBytes = readFileSync(
".agent/evidence-v0.2.5/review-fixes/rejected-loudnorm-bundle/manifest.json",
);
const manifest = JSON.parse(manifestBytes.toString());
expect(screening.method.manifestSha256).toBe(
createHash("sha256").update(manifestBytes).digest("hex"),
);
expect(screening.requiredEmptyPassed).toBe(false);
expect(screening.limitations).toMatchObject({
humanListeningAcceptance: false,
legalPrivacyClearance: false,
textIsVerifiedTranscript: false,
});
for (const entry of manifest.files) {
const record = screening.files.find(
(r: { environmentId: string }) => r.environmentId === entry.environmentId,
);
expect(record).toMatchObject({
sha256: entry.sha256,
sourceSha256: entry.sourceSha256,
selectedSegmentStartSeconds: entry.selectedSegmentStartSeconds,
});
expect(record.segments).toHaveLength(
entry.environmentId === "market" ? 1 : 0,
);
}
});
// 원래 true 요구 테스트와 FAIL 로그는 release-approval/before-ambience.test.ts.txt 및
// 02-original-ASR-FAIL.log에 보존했다. 현재 검사는 실패 진단의 불변성을 강제한다.
// 실제 릴리스 OR 계약은 release-approval.test.ts에서 별도로 검사한다.
it("v0.2.5 exact WAV의 ASR 실패 진단은 원문 바이트·market segment를 보존한다", () => {
expect(
createHash("sha256")
.update(
readFileSync(".agent/evidence-v0.2.5/review-fixes/final-asr.json"),
)
.digest("hex"),
).toBe("98acdb16bc5dce57bb3609b705b63125e5511c2d5f7b26bdde54f4b7bd9a5e49");
const screening = JSON.parse(
readFileSync(".agent/evidence-v0.2.5/review-fixes/final-asr.json", "utf8"),
);
const prior = JSON.parse(
readFileSync(".agent/evidence-v0.2.5/final-asr.json", "utf8"),
);
const bytes = readFileSync("public/ambience/manifest.json");
const manifest = JSON.parse(bytes.toString());
expect(screening.tool).toEqual(prior.tool);
expect(screening.model).toEqual(prior.model);
for (const key of [
"device",
"computeType",
"cpuThreads",
"numWorkers",
"randomSeed",
"parameters",
"vadModelSha256",
])
expect(screening.method[key]).toEqual(prior.method[key]);
expect(screening.method.manifestSha256).toBe(
createHash("sha256").update(bytes).digest("hex"),
);
expect(screening.requiredEmptyEnvironmentIds).toEqual([
"mall",
"market",
"cafe",
]);
expect(screening.requiredEmptyPassed).toBe(false);
expect(screening.limitations).toMatchObject({
humanListeningAcceptance: false,
legalPrivacyClearance: false,
textIsVerifiedTranscript: false,
});
for (const entry of manifest.files) {
const row = screening.files.find(
(r: { environmentId: string }) => r.environmentId === entry.environmentId,
);
expect(row).toMatchObject({
sha256: entry.sha256,
bytes: entry.bytes,
sourceSha256: entry.sourceSha256,
selectedSegmentStartSeconds: entry.selectedSegmentStartSeconds,
});
expect(row.segments).toHaveLength(entry.environmentId === "market" ? 1 : 0);
if (["mall", "cafe"].includes(entry.environmentId)) {
expect(row.segments).toEqual([]);
expect(row.text).toBe("");
expect(row.info.duration_after_vad).toBe(0);
}
}
});
+2 -2
View File
@@ -35,8 +35,8 @@ const expected = [
// 생성기의 검증 함수를 재사용하지 않고 실제 WAV 바이트를 직접 읽는다.
// 문장·해시·헤더·신호를 묶어 manifest만 갱신하는 거짓 업그레이드를 막는다.
describe("저장한 한국어 neural 음성 자산", () => {
it("0.2.3 neural provider·voice·prosody와 실측 도구 버전을 기록한다", () => {
expect.soft(manifest.version).toBe("0.2.3");
it("0.2.5 neural provider·voice·prosody와 실측 도구 버전을 기록한다", () => {
expect.soft(manifest.version).toBe("0.2.5");
expect.soft(manifest.provider).toBe("Microsoft Edge online TTS");
expect.soft(manifest.voice).toBe("ko-KR-HyunsuMultilingualNeural");
expect.soft(manifest.rate).toBe("-6%");
+44
View File
@@ -0,0 +1,44 @@
// @vitest-environment node
import { readFileSync } from "node:fs";
import { expect, it } from "vitest";
import { verifyAudioAsset } from "../src/audio/integrity";
// 신뢰 기준은 JS 번들에 고정한 manifest다. 요청 파일이 자기 해시를 선언할 수 없다.
it("109개 승인 WAV만 SHA-256·bytes·형식 검증을 통과한다", async () => {
let count = 0;
for (const directory of ["audio", "ambience"]) {
const manifest = JSON.parse(
readFileSync(`public/${directory}/manifest.json`, "utf8"),
);
for (const entry of manifest.files) {
const bytes = Uint8Array.from(
readFileSync(`public/${directory}/${entry.file}`),
).buffer;
await expect(
verifyAudioAsset(`/${directory}/${entry.file}?v=0.2.5`, bytes),
).resolves.toBeUndefined();
count++;
}
}
expect(count).toBe(109);
});
it("변조·다른 파일·외부 URL·이전 캐시·축약 응답은 decode 전에 거부한다", async () => {
const bytes = Uint8Array.from(readFileSync("public/ambience/street.wav"));
for (const url of [
"/ambience/rain.wav?v=0.2.5",
"/ambience/street.wav?v=0.2.3",
"https://external.example/ambience/street.wav?v=0.2.5",
"/ambience/unknown.wav?v=0.2.5",
]) {
await expect(verifyAudioAsset(url, bytes.slice().buffer)).rejects.toThrow();
}
const corrupt = bytes.slice();
corrupt[100] = corrupt[100]! ^ 1;
await expect(
verifyAudioAsset("/ambience/street.wav?v=0.2.5", corrupt.buffer),
).rejects.toThrow();
await expect(
verifyAudioAsset("/ambience/street.wav?v=0.2.5", bytes.slice(0, -2).buffer),
).rejects.toThrow();
});
+5 -5
View File
@@ -3,10 +3,10 @@ import { isAudioRequest } from "./browser/audio-requests";
it("WAV 요청 판정은 query의 버전·순서와 무관하게 pathname을 사용한다", () => {
for (const path of [
"/audio/intro.wav?v=0.2.3",
"/audio/luck-apple.wav?v=0.2.3",
"/audio/luck-apple.wav?retry=1&v=0.2.3",
"/audio/luck-apple.wav?v=0.2.3&retry=1",
"/audio/intro.wav?v=0.2.5",
"/audio/luck-apple.wav?v=0.2.5",
"/audio/luck-apple.wav?retry=1&v=0.2.5",
"/audio/luck-apple.wav?v=0.2.5&retry=1",
"/audio/luck-apple.wav",
]) {
expect
@@ -17,7 +17,7 @@ it("WAV 요청 판정은 query의 버전·순서와 무관하게 pathname을 사
"/audio/manifest.json?file=intro.wav",
"/other/intro.wav",
"/audio-backup/intro.wav",
"/audio/intro.wav.txt?v=0.2.3",
"/audio/intro.wav.txt?v=0.2.5",
]) {
expect
.soft(isAudioRequest(new URL(path, "http://localhost")), path)
+48 -11
View File
@@ -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();
}
});
+7 -7
View File
@@ -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) => ({
+205
View File
@@ -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,
});
});
+5 -5
View File
@@ -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")!;
+1 -1
View File
@@ -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);
+101
View File
@@ -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",
});
});
+1 -1
View File
@@ -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에서 한국어 제목의
+27 -1
View File
@@ -267,6 +267,15 @@ it.each([
"durationSeconds",
"outputLicense",
"operations",
"targetIntegratedLUFS",
"truePeakCeilingDBTP",
"finalLoudness",
"scalarFilter",
"measurementFilter",
"internalTruePeakCeilingDBTP",
"gainDB",
"iterations",
"version",
])(
"현장음 manifest 사본을 함께 바꿔도 승인 %s 계약 위반을 거부한다",
(field) => {
@@ -296,7 +305,24 @@ it.each([
const manifest = JSON.parse(
readFileSync("public/ambience/manifest.json", "utf8"),
);
if (field === "operations") manifest.files[0].conversion.operations = "";
if (field === "targetIntegratedLUFS" || field === "truePeakCeilingDBTP")
manifest.normalization[field] = -1;
else if (field === "finalLoudness")
manifest.files[0].conversion.loudness.final.integratedLUFS = -12;
else if (field === "scalarFilter")
manifest.files[0].conversion.loudness.iterations[0].filter =
"loudnorm=I=-32.5";
else if (field === "measurementFilter")
manifest.normalization.measurementFilter = "loudnorm";
else if (field === "internalTruePeakCeilingDBTP")
manifest.normalization.internalTruePeakCeilingDBTP = -6;
else if (field === "gainDB")
manifest.files[0].conversion.loudness.iterations[0].gainDB += 1;
else if (field === "iterations")
manifest.files[0].conversion.loudness.iterations = [];
else if (field === "version") manifest.version = "0.2.3";
else if (field === "operations")
manifest.files[0].conversion.operations = "";
else if (field === "startSeconds" || field === "durationSeconds") {
const entry = manifest.files[0];
const previous = `source seconds ${entry.conversion.startSeconds}–${entry.conversion.startSeconds + entry.conversion.durationSeconds}.`;
+15 -8
View File
@@ -1,12 +1,18 @@
import { describe, expect, it } from "vitest";
import { ENVIRONMENTS, LEVELS } from "../src/content";
import { dbToGain, normalizeRms, mixingGains } from "../src/audio/dsp";
import {
dbToGain,
normalizeRms,
mixingGains,
validatePcm,
} from "../src/audio/dsp";
// 청감이나 실제 음압은 장치마다 다르므로 테스트 대상은 디지털 RMS와 gain
// 비율이다. 배경·음성을 같은 기준으로 정규화해야 표기한 상대 dB가 성립한다.
// 청감이나 실제 음압은 장치마다 다르므로 디지털 정규화와 gain 비율을 검사한다.
// 배경은 저장된 LUFS를 유지하고 음성만 RMS를 맞춘다. 표시 dB는 기준 대비
// 음성 gain 변화이며 서로 다른 신호의 실측 RMS가 같다는 뜻이 아니다.
const rms = (a: Float32Array) =>
Math.sqrt(a.reduce((sum, n) => sum + n * n, 0) / a.length);
describe("상대 dB와 결정론적 배경 합성", () => {
describe("상대 dB와 고정 현장음 혼합", () => {
it("20 log10(음성 gain / 배경 gain)이 표시 dB와 일치한다", () => {
for (const level of LEVELS)
expect(20 * Math.log10(dbToGain(level.db))).toBeCloseTo(level.db, 10);
@@ -28,21 +34,22 @@ describe("상대 dB와 결정론적 배경 합성", () => {
expect(environment).not.toHaveProperty("seed");
}
});
it("피크가 큰 녹음도 공통 감쇠로 합성 peak 0.8을 넘지 않고 SNR 비율을 보존한다", () => {
it("상한 밖 녹음은 거부하고 허용된 신호는 고정 gain으로 SNR 비율을 보존한다", () => {
const voice = normalizeRms(
new Float32Array([1, ...Array(999).fill(0)]),
0.03,
);
const background = normalizeRms(new Float32Array([0.2, -0.2]), 0.03);
for (const db of [-12, -6, 0, 6, 12]) {
const gains = mixingGains(voice, background, voice, db);
expect(() => validatePcm(voice, "voice")).toThrow("피크");
validatePcm(background, "background");
const gains = mixingGains(db);
expect(20 * Math.log10(gains.voice / gains.background)).toBeCloseTo(
db,
10,
);
expect(
Math.max(...voice) * (gains.voice + gains.intro) +
0.03 * gains.background,
0.2 * (gains.voice + gains.intro) + 0.03 * gains.background,
).toBeLessThanOrEqual(0.800001);
}
});
+1 -1
View File
@@ -29,7 +29,7 @@ describe("출제와 정답 판정", () => {
expect(q.options).toContain(q.word.answer);
versions.add(url.searchParams.get("v"));
}
expect(versions).toEqual(new Set(["0.2.3"]));
expect(versions).toEqual(new Set(["0.2.5"]));
});
it("실제 보기에 있는 경쟁 단어를 문장에 넣고 청취 연산별 순서를 유지한다", () => {
for (let seed = 0; seed < 20; seed++) {
+2 -1
View File
@@ -11,7 +11,8 @@ it("neural 생성기의 실패 보존·retry·PCM·비밀값 부재 계약", ()
{ encoding: "utf8" },
);
expect(result.status, result.stdout + result.stderr).toBe(0);
}, 60_000);
// Python 계약은 실제 FFmpeg 재측정·원자 교환을 포함하므로 전체 suite에 3분을 허용한다.
}, 180_000);
// 콘텐츠에 URL 모듈이 추가되어도 Python 생성기가 사용하는 Node 직접 import가 유지돼야 한다.
it("Node type stripping 경로에서 기존 101문장 원문을 읽을 수 있다", () => {
+108
View File
@@ -0,0 +1,108 @@
// @vitest-environment node
import { readFileSync, readdirSync } from "node:fs";
import { expect, it } from "vitest";
import {
mixingGains,
normalizeRms,
validatePcm,
PEAK_BOUNDS,
FIXED_HEADROOM_GAIN,
} from "../src/audio/dsp";
// 표준 PCM16 데이터를 직접 읽어 manifest 수치나 구현의 peak 계산을 믿지 않는다.
function pcm(path: string) {
const bytes = readFileSync(path);
return Float32Array.from(
{ length: (bytes.length - 44) / 2 },
(_, i) => bytes.readInt16LE(44 + i * 2) / 32768,
);
}
const peak = (samples: Float32Array) =>
samples.reduce((max, x) => Math.max(max, Math.abs(x)), 0);
const rms = (samples: Float32Array) =>
Math.sqrt(samples.reduce((sum, x) => sum + x * x, 0) / samples.length);
it("같은 +6dB에서 여덟 배경의 gain은 파일 피크와 무관하다", () => {
const voice = normalizeRms(pcm("public/audio/luck-apple.wav"), 0.03);
const intro = normalizeRms(pcm("public/audio/intro.wav"), 0.03);
// RED 증거는 기존 RMS 로더로 보존했다. 현재 계약은 저장 PCM을 그대로 사용한다.
const rows = readdirSync("public/ambience")
.filter((f) => f.endsWith(".wav"))
.map((file) => {
const background = pcm(`public/ambience/${file}`);
validatePcm(background, "background");
validatePcm(voice, "voice");
validatePcm(intro, "intro");
const gains = mixingGains(6);
return {
file,
backgroundGain: gains.background,
finalRms: rms(background) * gains.background,
};
});
console.log(JSON.stringify(rows, null, 2));
expect(new Set(rows.map((r) => r.backgroundGain)).size).toBe(1);
});
it("101 voice × 8 배경 × 모든 SNR 눈금은 동일 배경 gain과 안전 피크를 가진다", () => {
const voices = readdirSync("public/audio")
.filter((f) => f.endsWith(".wav"))
.map((f) => normalizeRms(pcm(`public/audio/${f}`), 0.03));
const backgrounds = readdirSync("public/ambience")
.filter((f) => f.endsWith(".wav"))
.map((f) => pcm(`public/ambience/${f}`));
const intro = normalizeRms(pcm("public/audio/intro.wav"), 0.03);
const backgroundGains = new Set<number>();
let maximum = 0;
const voicePeaks = voices.map((v) => {
validatePcm(v, "voice");
return peak(v);
});
const backgroundPeaks = backgrounds.map((b) => {
validatePcm(b, "background");
return peak(b);
});
validatePcm(intro, "intro");
const introPeak = peak(intro);
for (const v of voicePeaks)
for (const b of backgroundPeaks)
for (let db = -12; db <= 12; db++) {
const gains = mixingGains(db);
backgroundGains.add(gains.background);
expect(Object.values(gains).every(Number.isFinite)).toBe(true);
expect(20 * Math.log10(gains.voice / gains.background)).toBeCloseTo(
db,
10,
);
// 삼각부등식은 모든 위상·loop offset·중첩 시간의 PCM 합성 peak를 상계한다.
const ceiling =
b * gains.background + v * gains.voice + introPeak * gains.intro;
maximum = Math.max(maximum, ceiling);
expect(ceiling).toBeLessThanOrEqual(0.800001);
}
console.log(
JSON.stringify({
combinations: voices.length * backgrounds.length * 25,
maximum,
voicePeak: Math.max(...voicePeaks),
backgroundPeak: Math.max(...backgroundPeaks),
introPeak,
bounds: PEAK_BOUNDS,
fixedGain: FIXED_HEADROOM_GAIN,
backgroundGains: [...backgroundGains],
}),
);
expect(backgroundGains.size).toBe(1);
}, 60000);
// 경계 밖 입력은 gain 조절로 숨기지 않고 실패한다. 빈 PCM과 비유한 값도 같은 계약이다.
it("SNR과 모든 경로의 peak 상한 위반은 fail closed한다", () => {
for (const db of [NaN, Infinity, -Infinity, -13, 13])
expect(() => mixingGains(db)).toThrow();
for (const kind of ["background", "voice", "intro"] as const) {
for (const value of [NaN, Infinity, PEAK_BOUNDS[kind] + 0.01])
expect(() => validatePcm(new Float32Array([value]), kind)).toThrow();
expect(() => validatePcm(new Float32Array(0), kind)).toThrow();
expect(() => validatePcm(new Float32Array(4), kind)).toThrow();
}
});
+9 -9
View File
@@ -3,12 +3,12 @@ import { readFileSync } from "node:fs";
import { expect, it } from "vitest";
// 배포 표기와 재현 명령을 함께 고정한다. 과거 인수 기록은 검사·수정하지 않는다.
it("0.2.3 버전을 package·lock·UI·index·문서에 일치시킨다", () => {
it("0.2.5 버전을 package·lock·UI·index·문서에 일치시킨다", () => {
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
const lock = JSON.parse(readFileSync("package-lock.json", "utf8"));
expect.soft(pkg.version).toBe("0.2.3");
expect.soft(lock.version).toBe("0.2.3");
expect.soft(lock.packages[""].version).toBe("0.2.3");
expect.soft(pkg.version).toBe("0.2.5");
expect.soft(lock.version).toBe("0.2.5");
expect.soft(lock.packages[""].version).toBe("0.2.5");
for (const path of [
"src/App.tsx",
"index.html",
@@ -16,7 +16,7 @@ it("0.2.3 버전을 package·lock·UI·index·문서에 일치시킨다", () =>
"LICENSE",
"docs/AUDIO_PROVENANCE.md",
]) {
expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.3");
expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.5");
}
});
@@ -44,7 +44,7 @@ it("고정 venv 생성 명령과 provider·도구·출력물 권리의 경계를
});
// 현재 문서는 같은 릴리스를 설명하고 변경 이력은 이전 근거를 보존해야 한다.
it("현재 문서·manifest·CHANGELOG는 0.2.3 동작과 역사 보존을 설명한다", () => {
it("현재 문서·manifest·CHANGELOG는 0.2.5 동작과 역사 보존을 설명한다", () => {
for (const path of [
"docs/ARCHITECTURE.md",
"docs/DEPLOYMENT.md",
@@ -52,9 +52,9 @@ it("현재 문서·manifest·CHANGELOG는 0.2.3 동작과 역사 보존을 설
"public/audio/manifest.json",
"src/audio/assets.ts",
])
expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.3");
expect.soft(readFileSync(path, "utf8"), path).toContain("0.2.5");
const history = readFileSync("CHANGELOG.md", "utf8");
for (const version of ["0.2.3", "0.1.1", "0.1.0"])
for (const version of ["0.2.5", "0.2.3", "0.1.1", "0.1.0"])
expect(history).toContain(version);
for (const change of [
"SNR",
@@ -69,6 +69,6 @@ it("현재 문서·manifest·CHANGELOG는 0.2.3 동작과 역사 보존을 설
it("README는 현재 리뷰 증거 경로와 정확한 라운드 보너스를 안내한다", () => {
const doc = readFileSync("README.md", "utf8");
expect.soft(doc).toContain(".agent/evidence-v0.2.3/review-fixes/");
expect.soft(doc).toContain(".agent/evidence-v0.2.5/");
expect.soft(doc).toContain("문항별 최대 20점, 라운드 합계 최대 30점");
});
+26 -1
View File
@@ -71,7 +71,7 @@ class AmbienceConversion(unittest.TestCase):
self.assertEqual(actual["conversion"]["seekMethod"], "full decode then sample trim")
expected["conversion"]["seekMethod"] = actual["conversion"]["seekMethod"]
self.assertEqual(actual, expected)
self.assertEqual(len(calls), 2)
self.assertEqual(len(calls), 5)
self.assertLess(calls[0].index("-ss"), calls[0].index("-i"))
self.assertNotIn("-ss", calls[1])
self.assertIn("aresample=24000,atrim=start_sample=24000:end_sample=312000,asetpts=N/SR/TB", calls[1])
@@ -286,6 +286,31 @@ class AtomicPublication(unittest.TestCase):
with self.assertRaises(ValueError): self.run_generate()
self.assert_prior()
def test_real_loudness_measurement_failure_preserves_prior_directory(self):
"""최종 실측 FFmpeg만 실패시켜 원자 교환 전 실패 전파를 확인한다."""
import subprocess
from unittest.mock import patch
run = subprocess.run
def fail_measurement(command, **kwargs):
if any("ebur128=" in str(arg) for arg in command):
raise subprocess.CalledProcessError(1, command, stderr=b"injected measurement failure")
return run(command, **kwargs)
with patch.object(audio.subprocess, "run", side_effect=fail_measurement):
with self.assertRaises(subprocess.CalledProcessError): self.run_generate()
self.assert_prior()
def test_actual_target_or_true_peak_violation_preserves_prior_directory(self):
"""manifest가 정상이라고 주장해도 별도 실측 위반은 공개하지 않는다."""
from unittest.mock import patch
for metric, value in (("integratedLUFS", -34.0), ("truePeakDBTP", -5.0), ("integratedLUFS", float("nan"))):
with self.subTest(metric=metric):
stats = {"integratedLUFS": -32.5, "truePeakDBTP": -7.0,
"loudnessRangeLU": 1.0, "thresholdLUFS": -42.5}
stats[metric] = value
with patch.object(audio, "measure_loudness", return_value=stats, create=True):
with self.assertRaises(ValueError): self.run_generate()
self.assert_prior()
def test_exchange_failure_preserves_prior_directory(self):
from unittest.mock import patch
import ctypes
+78
View File
@@ -0,0 +1,78 @@
"""선형 gain만 허용하고 과도 peak·비수렴·측정 실패는 공개 전에 거부한다."""
from array import array
import math
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
import wave
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'scripts'))
import ambience_audio as audio
def stats(i=-32.5, tp=-15):
return dict(integratedLUFS=i, truePeakDBTP=tp, loudnessRangeLU=1.0, thresholdLUFS=i-10)
class LinearNormalization(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.target = Path(self.temporary.name) / 'staged.wav'
values = [0.03 * math.sin(i / 20) for i in range(24000 * 12)]
values[0] = values[-1] = 0
self.samples = array('f', values)
self.pcm = self.samples.tobytes()
def test_real_output_is_only_scalar_gain_with_pcm16_rounding(self):
result = audio.normalize_pcm(self.pcm, self.target)
self.assertEqual(len(result['iterations']), 1)
gain = 10 ** (result['iterations'][0]['gainDB'] / 20)
with wave.open(str(self.target)) as stream:
actual = array('h', stream.readframes(stream.getnframes()))
# 리미터/컴프레서 없이 전 sample이 동일 gain이며 PCM16 반올림 오차만 허용한다.
self.assertEqual(len(actual), len(self.samples))
self.assertLessEqual(max(abs(x / 32768 - y * gain) for x, y in zip(actual, self.samples)), 0.5001 / 32768)
self.assertLessEqual(abs(result['final']['integratedLUFS'] + 32.5), 0.2 + 1e-9)
self.assertLessEqual(result['final']['truePeakDBTP'], -6.2)
def test_unsafe_predicted_peak_is_rejected_before_volume_conversion(self):
with patch.object(audio, 'measure_loudness', return_value=stats(-40, -10)), patch.object(audio.subprocess, 'run') as run:
with self.assertRaisesRegex(ValueError, 'peak'):
audio.normalize_pcm(self.pcm, self.target)
run.assert_not_called()
self.assertFalse(self.target.exists())
def test_correction_restarts_from_identical_source_and_records_both_passes(self):
real_run = subprocess.run
with patch.object(audio, 'measure_loudness', side_effect=[stats(-35), stats(-32.9), stats()]), patch.object(audio.subprocess, 'run', wraps=real_run) as run:
result = audio.normalize_pcm(self.pcm, self.target)
self.assertEqual(len(result['iterations']), 2)
self.assertEqual([r['gainDB'] for r in result['iterations']], [2.5, 2.9])
self.assertEqual([c.kwargs['input'] for c in run.call_args_list], [self.pcm, self.pcm])
for call, expected in zip(run.call_args_list, ['volume=2.500000000000dB:precision=double', 'volume=2.900000000000dB:precision=double']):
self.assertIn(expected, call.args[0])
def test_nonconvergence_is_bounded_and_fails(self):
with patch.object(audio, 'measure_loudness', side_effect=[stats(-35), stats(-33), stats(-33), stats(-33)]) as measure:
with self.assertRaisesRegex(ValueError, '수렴'):
audio.normalize_pcm(self.pcm, self.target)
self.assertEqual(measure.call_count, 4)
def test_each_corrective_gain_is_peak_checked_before_conversion(self):
with patch.object(audio, 'measure_loudness', side_effect=[stats(-35, -9), stats(-33)]), patch.object(audio.subprocess, 'run', wraps=subprocess.run) as run:
with self.assertRaisesRegex(ValueError, 'peak'):
audio.normalize_pcm(self.pcm, self.target)
self.assertEqual(run.call_count, 1)
def test_final_peak_and_nonfinite_measurement_are_rejected(self):
for invalid in [stats(tp=-6.1), stats(i=float('nan')), stats(tp=float('inf'))]:
with self.subTest(invalid=invalid), patch.object(audio, 'measure_loudness', side_effect=[stats(-35), invalid]):
with self.assertRaises(ValueError): audio.normalize_pcm(self.pcm, self.target)
def test_parser_rejects_missing_nonfinite_or_partial_summary(self):
for text in ['', 'Summary:\nI: -32.5 LUFS', 'Summary:\nI: nan LUFS\nThreshold: -42.5 LUFS\nLRA: 0.0 LU\nPeak: -10.0 dBFS']:
with self.subTest(text=text), self.assertRaises(ValueError): audio.parse_loudness(text)
+76
View File
@@ -0,0 +1,76 @@
"""생성기와 독립된 새 ebur128 프로세스가 정확한 최종 WAV의 릴리스 계기다."""
import hashlib
import json
import math
from pathlib import Path
import re
import subprocess
import unittest
# manifest가 목표를 바꾸어 실패를 숨길 수 없도록 수용값을 독립적으로 고정한다.
TARGET = -32.5
CEILING = -6.0
ROOT = Path(__file__).resolve().parents[2]
def independent_measure(path):
"""정규화 필터의 자기 보고 없이 저장된 전체 PCM을 전용 EBU 계기로 측정한다."""
command = ["ffmpeg", "-nostdin", "-hide_banner", "-i", str(path), "-af",
"ebur128=peak=true", "-f", "null", "-"]
result = subprocess.run(command, check=True, capture_output=True, text=True, timeout=30)
summary = result.stderr.rsplit("Summary:", 1)[1]
values = {
"integratedLUFS": float(re.search(r"I:\s+([-\d.]+) LUFS", summary)[1]),
"truePeakDBTP": float(re.search(r"Peak:\s+([-\d.]+) dBFS", summary)[1]),
"loudnessRangeLU": float(re.search(r"LRA:\s+([-\d.]+) LU", summary)[1]),
"thresholdLUFS": float(re.search(r"Threshold:\s+([-\d.]+) LUFS", summary)[1]),
}
if not all(math.isfinite(v) for v in values.values()):
raise ValueError("non-finite ebur128 measurement")
return values
class FinalLoudness(unittest.TestCase):
"""8 WAV의 동등성 assertion은 계기 불일치를 이유로 면제하지 않는다."""
@classmethod
def setUpClass(cls):
cls.rows = {p.stem: independent_measure(p) for p in sorted((ROOT / "public/ambience").glob("*.wav"))}
def test_final_wavs_meet_fixed_loudness_and_true_peak(self):
self.assertEqual(len(self.rows), 8)
spread = max(r["integratedLUFS"] for r in self.rows.values()) - min(r["integratedLUFS"] for r in self.rows.values())
print(json.dumps({"measurementFilter": "ebur128=peak=true", "targetLUFS": TARGET,
"truePeakCeilingDBTP": CEILING, "spreadLU": round(spread, 2),
"files": self.rows}, indent=2), flush=True)
for name, row in self.rows.items():
with self.subTest(environment=name):
self.assertLessEqual(abs(row["integratedLUFS"] - TARGET), 0.2 + 1e-9)
self.assertLessEqual(row["truePeakDBTP"], CEILING)
# 삭제되었던 ebur128 integrated 동등성 검사를 릴리스 필수 조건으로 복원한다.
self.assertLessEqual(spread, 0.4 + 1e-9)
def test_manifest_records_linear_gain_and_exact_ebur128_measurements(self):
manifest = json.loads((ROOT / "public/ambience/manifest.json").read_text())
settings = manifest["normalization"]
for key, value in {"targetIntegratedLUFS": TARGET, "truePeakCeilingDBTP": CEILING,
"internalTruePeakCeilingDBTP": -6.2, "toleranceLU": 0.2,
"maximumSpreadLU": 0.4, "measurementFilter": "ebur128=peak=true",
"maximumIterations": 3}.items():
self.assertEqual(settings[key], value)
self.assertIn("ffmpeg version", manifest["toolVersions"]["ffmpeg"])
for entry in manifest["files"]:
norm = entry["conversion"]["loudness"]
self.assertEqual(norm["measurementFilter"], "ebur128=peak=true")
self.assertRegex(norm["fadedPcmSha256"], r"^[a-f0-9]{64}$")
self.assertGreaterEqual(len(norm["iterations"]), 1)
self.assertLessEqual(len(norm["iterations"]), 3)
gain = TARGET - norm["input"]["integratedLUFS"]
for iteration in norm["iterations"]:
self.assertAlmostEqual(iteration["gainDB"], gain, places=9)
self.assertEqual(iteration["filter"], f"volume={gain:.12f}dB:precision=double")
self.assertLessEqual(norm["input"]["truePeakDBTP"] + gain, -6.2 + 1e-9)
gain += TARGET - iteration["output"]["integratedLUFS"]
self.assertEqual(norm["final"], norm["iterations"][-1]["output"])
# manifest 수치는 새 프로세스 실측과 정확히 같아야 한다.
self.assertEqual(norm["final"], self.rows[entry["environmentId"]])
self.assertEqual(entry["sha256"], hashlib.sha256((ROOT / "public/ambience" / entry["file"]).read_bytes()).hexdigest())
+180
View File
@@ -0,0 +1,180 @@
"""실제 승인/ZIP을 임시 사본에서 변조해 fail-closed 릴리스 계약을 검사한다."""
import copy
import json
from pathlib import Path
import shutil
import tempfile
import unittest
from scripts import release_approval as gate
class ReleaseApprovalTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
# 실제 증거에 쓰지 않는다. 자동 성공 fixture도 이 임시 사본에만 존재한다.
for name in (gate.APPROVAL, gate.ASR, gate.PRIOR_ASR, gate.BUNDLE,
*(f"public/ambience/{name}" for name in gate.SCOPE["files"])):
target = self.root / name
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(gate.ROOT / name, target)
self.approval = gate.read_json(self.root / gate.APPROVAL)
def write(self, path, value):
(self.root / path).write_text(json.dumps(value, ensure_ascii=False) + "\n")
def rejected(self):
with self.assertRaises((ValueError, KeyError, TypeError, OSError)):
gate.validate_release(self.root)
def synthetic_empty_screening(self):
"""OR의 자동 분기 검사용 fixture. 실제 보고서는 절대 수정하지 않는다."""
screening = gate.read_json(self.root / gate.ASR)
for row in screening["files"]:
row["segments"] = []
row["text"] = ""
row["info"]["duration_after_vad"] = 0
screening["requiredEmptyPassed"] = True
self.write(gate.ASR, screening)
def test_current_asr_failure_passes_only_through_exact_owner_approval(self):
result = gate.validate_release(self.root)
self.assertEqual(result["acceptancePath"], "exact-byte-owner-listening-approval")
self.assertFalse(result["automatedEmptyAsrPassed"])
self.assertTrue(result["humanListeningAcceptance"])
self.assertFalse(result["legalPrivacyClearance"])
(self.root / gate.APPROVAL).unlink()
self.rejected()
def test_automated_empty_criteria_can_pass_without_owner_approval(self):
self.synthetic_empty_screening()
(self.root / gate.APPROVAL).unlink()
result = gate.validate_release(self.root)
self.assertEqual(result["acceptancePath"], "automated-empty-asr")
self.assertFalse(result["humanListeningAcceptance"])
self.assertFalse(result["legalPrivacyClearance"])
def test_every_missing_approval_field_fails(self):
for key in self.approval:
with self.subTest(key=key):
changed = copy.deepcopy(self.approval)
del changed[key]
self.write(gate.APPROVAL, changed)
self.rejected()
def test_statement_owner_scope_timestamp_and_clearance_cannot_be_reinterpreted(self):
mutations = {
"owner": ["Neo", "oz", ""],
"statement": ["승인합니다", "승인합니다. 이대로 진행하세요 ", ""],
"timestampUtc": ["", "not-a-date", "2026-10-14T11:29:50Z"],
"timestampKst": ["", "2026-10-14T11:29:51+09:00"],
"scope": [{}, {**gate.SCOPE, "version": "0.2.3"}, {**gate.SCOPE, "files": ["market.wav"]}],
"productOwnerReleaseApproval": [False, "true", 1],
"humanListeningAcceptance": [False, "true", 1],
"legalPrivacyClearance": [True, "false", 0, None],
}
for key, values in mutations.items():
for value in values:
with self.subTest(key=key, value=value):
self.write(gate.APPROVAL, {**self.approval, key: value})
self.rejected()
def test_approval_hash_mismatches_and_incomplete_asset_scope_fail(self):
for field in ("reviewBundle", "manifest", "notice"):
with self.subTest(field=field):
changed = copy.deepcopy(self.approval)
changed[field]["sha256"] = "0" * 64
self.write(gate.APPROVAL, changed)
self.rejected()
for index in range(8):
with self.subTest(wav=index):
changed = copy.deepcopy(self.approval)
changed["wavs"][index]["sha256"] = "0" * 64
self.write(gate.APPROVAL, changed)
self.rejected()
for wavs in (self.approval["wavs"][:-1], self.approval["wavs"] + self.approval["wavs"][:1]):
self.write(gate.APPROVAL, {**self.approval, "wavs": wavs})
self.rejected()
def test_current_wavs_manifest_notice_and_bundle_are_bound_to_reviewed_bytes(self):
for name in (gate.BUNDLE, *(f"public/ambience/{name}" for name in gate.SCOPE["files"])):
with self.subTest(file=name):
path = self.root / name
original = path.read_bytes()
path.write_bytes(original + b" ")
self.rejected()
path.write_bytes(original)
def test_manifest_reformat_cannot_be_self_approved_by_updating_all_recorded_hashes(self):
path = self.root / gate.MANIFEST
path.write_bytes(path.read_bytes() + b"\n")
digest = gate.sha256(path.read_bytes())
self.approval["manifest"]["sha256"] = digest
self.write(gate.APPROVAL, self.approval)
screening = gate.read_json(self.root / gate.ASR)
screening["method"]["manifestSha256"] = digest
self.write(gate.ASR, screening)
self.rejected() # 전달 ZIP의 manifest 바이트는 여전히 다르다.
def test_wav_change_cannot_be_self_approved_by_updating_manifest_asr_and_approval(self):
path = self.root / "public/ambience/market.wav"
raw = bytearray(path.read_bytes())
raw[100] ^= 1
path.write_bytes(raw)
digest = gate.sha256(raw)
manifest = gate.read_json(self.root / gate.MANIFEST)
screening = gate.read_json(self.root / gate.ASR)
for rows in (manifest["files"], screening["files"], self.approval["wavs"]):
next(row for row in rows if row["environmentId"] == "market")["sha256"] = digest
self.write(gate.MANIFEST, manifest)
manifest_hash = gate.sha256((self.root / gate.MANIFEST).read_bytes())
screening["method"]["manifestSha256"] = manifest_hash
self.approval["manifest"]["sha256"] = manifest_hash
self.write(gate.ASR, screening)
self.write(gate.APPROVAL, self.approval)
self.rejected()
def test_different_bundle_hash_cannot_be_self_approved(self):
path = self.root / gate.BUNDLE
path.write_bytes(path.read_bytes() + b" ")
self.approval["reviewBundle"]["sha256"] = gate.sha256(path.read_bytes())
self.write(gate.APPROVAL, self.approval)
self.rejected()
def test_invalid_approval_is_not_ignored_even_when_automated_criteria_pass(self):
self.synthetic_empty_screening()
for key, value in (("legalPrivacyClearance", True), ("scope", {}), ("timestampUtc", "")):
self.write(gate.APPROVAL, {**self.approval, key: value})
self.rejected()
def test_nonempty_asr_cannot_be_relabeled_pass(self):
screening = gate.read_json(self.root / gate.ASR)
screening["requiredEmptyPassed"] = True
self.write(gate.ASR, screening)
self.rejected()
def test_stale_asr_settings_and_clearance_overclaims_fail_with_valid_owner_approval(self):
original = gate.read_json(self.root / gate.ASR)
for field, key, value in (("method", "manifestSha256", "0" * 64),
("method", "parameters", {}),
("limitations", "legalPrivacyClearance", True),
("limitations", "textIsVerifiedTranscript", True)):
changed = copy.deepcopy(original)
changed[field][key] = value
self.write(gate.ASR, changed)
self.rejected()
def test_unknown_clearance_claim_and_duplicate_json_keys_fail(self):
self.write(gate.APPROVAL, {**self.approval, "legalClearance": True})
self.rejected()
raw = json.dumps(self.approval)
(self.root / gate.APPROVAL).write_text(raw[:-1] + ', "legalPrivacyClearance": false}')
self.rejected()
(self.root / gate.APPROVAL).write_text('{"statement":')
self.rejected()
if __name__ == "__main__":
unittest.main()
+35
View File
@@ -0,0 +1,35 @@
// @vitest-environment node
import { existsSync, readFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { expect, it } from "vitest";
// 현재 ASR 실패를 유지한 채, 이번 exact-byte 승인 기록이 없으면 먼저 RED가 된다.
it("v0.2.5 exact-byte owner approval record exists with the exact statement", () => {
const path = ".agent/RELEASE-APPROVAL-v0.2.5.json";
expect(existsSync(path), "exact approval record is absent").toBe(true);
const approval = JSON.parse(readFileSync(path, "utf8"));
expect(approval.statement).toBe("승인합니다. 이대로 진행하세요");
expect(approval.owner).toBe("Oz");
expect(approval.timestampUtc).toBe("2026-10-14T11:29:51Z");
expect(approval.productOwnerReleaseApproval).toBe(true);
expect(approval.humanListeningAcceptance).toBe(true);
expect(approval.legalPrivacyClearance).toBe(false);
});
// npm test의 릴리스 판정은 Python의 동일 CLI를 실행하므로 별도 우회 gate가 없다.
it("release contract is green only through valid exact-byte owner listening approval", () => {
const result = spawnSync("python3", ["scripts/release_approval.py"], {
encoding: "utf8",
});
expect(result.status, result.stdout + result.stderr).toBe(0);
expect(JSON.parse(result.stdout)).toMatchObject({
passed: true,
acceptancePath: "exact-byte-owner-listening-approval",
automatedEmptyAsrPassed: false,
productOwnerReleaseApproval: true,
humanListeningAcceptance: true,
legalPrivacyClearance: false,
reviewBundleSha256:
"bb2e974b652df43db4a1f2a59716836e2c521839c724b34d4b71c1b40c3322e5",
});
});