fix: normalize ambience loudness for v0.2.5
This commit is contained in:
+6
-6
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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%");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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에서 한국어 제목의
|
||||
|
||||
+27
-1
@@ -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
@@ -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
@@ -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++) {
|
||||
|
||||
@@ -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문장 원문을 읽을 수 있다", () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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점");
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user