fix: normalize ambience loudness for v0.2.5
This commit is contained in:
+81
-1
@@ -2,6 +2,7 @@ import { readFileSync, readdirSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { AUDIO_ASSET_VERSION } from "../src/audio/assets.ts";
|
||||
import { ENVIRONMENTS, PATTERNS, WORDS } from "../src/content.ts";
|
||||
|
||||
// 빌드가 성공해도 public 파일이 빠지면 게임은 실행 중 실패한다. 따라서 배포
|
||||
@@ -35,7 +36,10 @@ export function verifyBuild(directory) {
|
||||
const builtManifest = JSON.parse(
|
||||
readFileSync(resolve(directory, "audio/manifest.json"), "utf8"),
|
||||
);
|
||||
if (JSON.stringify(manifest) !== JSON.stringify(builtManifest))
|
||||
if (
|
||||
manifest.version !== AUDIO_ASSET_VERSION ||
|
||||
JSON.stringify(manifest) !== JSON.stringify(builtManifest)
|
||||
)
|
||||
throw new Error("빌드 음성 목록이 원본과 다릅니다.");
|
||||
// manifest끼리 일치해도 함께 축약될 수 있으므로 앱 콘텐츠에서 독립 재구성한다.
|
||||
const expectedVoice = [
|
||||
@@ -71,6 +75,22 @@ export function verifyBuild(directory) {
|
||||
)
|
||||
throw new Error("manifest 불일치");
|
||||
const ambience = JSON.parse(source);
|
||||
// manifest 사본끼리 같아도 버전·청감 계약을 함께 바꿀 수 있으므로 릴리스 고정값과 대조한다.
|
||||
// 실제 파일의 독립 FFmpeg 실측은 Python suite가 수행하고 빌드는 그 바이트 해시를 보존한다.
|
||||
const normalization = ambience.normalization;
|
||||
if (
|
||||
ambience.version !== AUDIO_ASSET_VERSION ||
|
||||
normalization?.targetIntegratedLUFS !== -32.5 ||
|
||||
normalization?.truePeakCeilingDBTP !== -6 ||
|
||||
normalization?.internalTruePeakCeilingDBTP !== -6.2 ||
|
||||
normalization?.measurementFilter !== "ebur128=peak=true" ||
|
||||
normalization?.maximumIterations !== 3 ||
|
||||
normalization?.toleranceLU !== 0.2 ||
|
||||
normalization?.maximumSpreadLU !== 0.4 ||
|
||||
!ambience.toolVersions?.ffmpeg?.startsWith("ffmpeg version ")
|
||||
)
|
||||
throw new Error("현장음 버전/정규화 계약 불일치");
|
||||
const integrated = []; // 최종 저장 WAV의 전용 ebur128 실측만 릴리스 통계로 인정한다.
|
||||
const plan = JSON.parse(
|
||||
readFileSync(".agent/AMBIENCE-SOURCES-v0.2.3.json", "utf8"),
|
||||
);
|
||||
@@ -135,6 +155,64 @@ export function verifyBuild(directory) {
|
||||
!entry.conversion.operations.trim()
|
||||
)
|
||||
throw new Error(`승인 출처/변경 메타데이터 불일치: ${entry.file}`);
|
||||
const loudness = entry.conversion.loudness;
|
||||
const keys = [
|
||||
"integratedLUFS",
|
||||
"truePeakDBTP",
|
||||
"loudnessRangeLU",
|
||||
"thresholdLUFS",
|
||||
];
|
||||
const finiteStats = (stats) =>
|
||||
stats &&
|
||||
keys.every(
|
||||
(key) =>
|
||||
typeof stats[key] === "number" && Number.isFinite(stats[key]),
|
||||
);
|
||||
if (
|
||||
!loudness ||
|
||||
loudness.measurementFilter !== "ebur128=peak=true" ||
|
||||
!/^[a-f0-9]{64}$/.test(loudness.fadedPcmSha256) ||
|
||||
!finiteStats(loudness.input) ||
|
||||
!finiteStats(loudness.final) ||
|
||||
!Array.isArray(loudness.iterations) ||
|
||||
loudness.iterations.length < 1 ||
|
||||
loudness.iterations.length > 3
|
||||
)
|
||||
throw new Error(`선형 ebur128 provenance 누락: ${entry.file}`);
|
||||
let gain = -32.5 - loudness.input.integratedLUFS;
|
||||
for (const [index, iteration] of loudness.iterations.entries()) {
|
||||
const predicted = loudness.input.truePeakDBTP + gain;
|
||||
if (
|
||||
typeof iteration.gainDB !== "number" ||
|
||||
!Number.isFinite(iteration.gainDB) ||
|
||||
Math.abs(iteration.gainDB - gain) > 1e-9 ||
|
||||
iteration.filter !==
|
||||
`volume=${gain.toFixed(12)}dB:precision=double` ||
|
||||
typeof iteration.predictedTruePeakDBTP !== "number" ||
|
||||
!Number.isFinite(iteration.predictedTruePeakDBTP) ||
|
||||
Math.abs(iteration.predictedTruePeakDBTP - predicted) > 1e-9 ||
|
||||
predicted > -6.2 + 1e-9 ||
|
||||
!finiteStats(iteration.output) ||
|
||||
iteration.output.truePeakDBTP > -6.2
|
||||
)
|
||||
throw new Error(`선형 gain/peak provenance 불일치: ${entry.file}`);
|
||||
const error = -32.5 - iteration.output.integratedLUFS;
|
||||
if (
|
||||
index < loudness.iterations.length - 1 &&
|
||||
Math.abs(error) <= 0.2 + 1e-9
|
||||
)
|
||||
throw new Error(`불필요한 보정 반복: ${entry.file}`);
|
||||
gain += error;
|
||||
}
|
||||
const last = loudness.iterations.at(-1).output;
|
||||
const measuredI = loudness.final.integratedLUFS;
|
||||
if (
|
||||
keys.some((key) => last[key] !== loudness.final[key]) ||
|
||||
Math.abs(measuredI + 32.5) > 0.2 + 1e-9 ||
|
||||
loudness.final.truePeakDBTP > -6
|
||||
)
|
||||
throw new Error(`최종 ebur128 loudness/true peak 위반: ${entry.file}`);
|
||||
integrated.push(measuredI);
|
||||
// 다른 항목에 같은 라이선스 URL이 있어도 이 항목의 누락을 가리지 못한다.
|
||||
const sections = notice
|
||||
.split("## ")
|
||||
@@ -156,6 +234,8 @@ export function verifyBuild(directory) {
|
||||
)
|
||||
throw new Error(`출처 고지 누락: ${entry.file}`);
|
||||
}
|
||||
if (Math.max(...integrated) - Math.min(...integrated) > 0.4 + 1e-9)
|
||||
throw new Error("현장음 loudness spread 위반");
|
||||
} catch (cause) {
|
||||
throw new Error("현장음 자산/manifest/notice 누락 또는 불일치", { cause });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user