fix: normalize ambience loudness for v0.2.5
This commit is contained in:
+159
-21
@@ -1,8 +1,8 @@
|
||||
"""Find-Word v0.2.3 현장 녹음 파생본과 기계 판독 provenance를 만든다.
|
||||
"""Find-Word v0.2.5 현장 녹음 파생본과 기계 판독 provenance를 만든다.
|
||||
|
||||
원본은 명시적으로 받은 /tmp 캐시에만 두고 네트워크를 호출하지 않는다.
|
||||
FFmpeg는 구간 선택·다운믹스·리샘플만 담당하며 fade와 선형 gain은 여기서
|
||||
결정한다. 원본/출력 해시와 실제 변환 설정으로 다른 사람이 결과를 추적한다.
|
||||
전용 FFmpeg ebur128 실측에 따른 volume 스칼라만 적용하고 마지막 PCM을 재측정한다.
|
||||
출처·구간·50ms fade는 보존하고 측정 실패나 목표 이탈은 원자 공개 전에 거부한다. 원본/출력 해시와 실제 변환 설정으로 다른 사람이 결과를 추적한다.
|
||||
"""
|
||||
from array import array
|
||||
from datetime import datetime, timezone
|
||||
@@ -11,6 +11,7 @@ import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -25,6 +26,105 @@ EXCERPT_SECONDS = 12
|
||||
SAMPLE_RATE = 24000
|
||||
FADE_SECONDS = 0.05
|
||||
|
||||
# ebur128의 0.1 dB 보고 정밀도와 PCM16 양자화를 위해 내부 ceiling에 0.2 dB 여유를 둔다.
|
||||
TARGET_LUFS = -32.5
|
||||
TRUE_PEAK_DBTP = -6.0
|
||||
INTERNAL_TRUE_PEAK_DBTP = -6.2
|
||||
LOUDNESS_FILTER = "ebur128=peak=true"
|
||||
NORMALIZATION = {
|
||||
"algorithm": "FFmpeg ebur128 measurement; scalar volume in double precision; PCM16; fresh ebur128 verification",
|
||||
"targetIntegratedLUFS": TARGET_LUFS, "truePeakCeilingDBTP": TRUE_PEAK_DBTP,
|
||||
"internalTruePeakCeilingDBTP": INTERNAL_TRUE_PEAK_DBTP,
|
||||
"toleranceLU": 0.2, "maximumSpreadLU": 0.4, "measurementFilter": LOUDNESS_FILTER,
|
||||
"maximumIterations": 3,
|
||||
}
|
||||
STAT_KEYS = ("integratedLUFS", "truePeakDBTP", "loudnessRangeLU", "thresholdLUFS")
|
||||
PCM_INPUT_ARGS = ["ffmpeg", "-nostdin", "-hide_banner", "-f", "f32le", "-ar", str(SAMPLE_RATE),
|
||||
"-ac", "1", "-i", "pipe:0"]
|
||||
|
||||
|
||||
def validate_stats(stats):
|
||||
"""누락/NaN/inf는 통과값으로 대체하지 않는다."""
|
||||
if set(stats) != set(STAT_KEYS) or not all(
|
||||
type(stats[key]) in (int, float) and math.isfinite(stats[key]) for key in STAT_KEYS):
|
||||
raise ValueError("유한한 ebur128 통계가 아닙니다.")
|
||||
|
||||
|
||||
def parse_loudness(stderr):
|
||||
"""프레임 진단값 대신 전용 ebur128의 마지막 전체 구간 Summary만 읽는다."""
|
||||
if isinstance(stderr, bytes):
|
||||
stderr = stderr.decode("utf-8")
|
||||
try:
|
||||
summary = stderr.rsplit("Summary:", 1)[1]
|
||||
patterns = {"integratedLUFS": r"I:\s+([-\d.]+) LUFS",
|
||||
"truePeakDBTP": r"Peak:\s+([-\d.]+) dBFS",
|
||||
"loudnessRangeLU": r"LRA:\s+([-\d.]+) LU",
|
||||
"thresholdLUFS": r"Threshold:\s+([-\d.]+) LUFS"}
|
||||
result = {key: float(re.search(pattern, summary)[1]) for key, pattern in patterns.items()}
|
||||
validate_stats(result)
|
||||
return result
|
||||
except (IndexError, TypeError, ValueError) as error:
|
||||
raise ValueError("FFmpeg ebur128 측정 실패") from error
|
||||
|
||||
|
||||
def measure_loudness(path=None, *, pcm=None):
|
||||
"""매 호출 새 프로세스로 정확한 faded f32 PCM 또는 저장된 WAV 전체를 읽는다."""
|
||||
if (path is None) == (pcm is None):
|
||||
raise ValueError("하나의 측정 입력만 필요합니다.")
|
||||
command = PCM_INPUT_ARGS if pcm is not None else ["ffmpeg", "-nostdin", "-hide_banner", "-i", str(path)]
|
||||
result = subprocess.run([*command, "-af", LOUDNESS_FILTER, "-f", "null", "-"],
|
||||
input=pcm, check=True, capture_output=True, timeout=90)
|
||||
return parse_loudness(result.stderr)
|
||||
|
||||
|
||||
def validate_loudness(stats):
|
||||
"""최종 PCM의 ±0.2 LU 및 -6 dBTP 수용 조건은 자기 보고로 면제하지 않는다."""
|
||||
validate_stats(stats)
|
||||
if (abs(stats["integratedLUFS"] - TARGET_LUFS) > NORMALIZATION["toleranceLU"] + 1e-9
|
||||
or stats["truePeakDBTP"] > TRUE_PEAK_DBTP):
|
||||
raise ValueError("최종 loudness/true peak 계약 위반")
|
||||
|
||||
|
||||
def volume_filter(gain):
|
||||
return f"volume={gain:.12f}dB:precision=double"
|
||||
|
||||
|
||||
def normalize_pcm(faded_pcm, destination):
|
||||
"""각 시도는 같은 원본 PCM에서 시작한다. 누적 재양자화/리미터/압축은 사용하지 않는다.
|
||||
|
||||
입력 peak + gain을 변환 전에 검사하고 양자화 후 새 계기로 재측정한다.
|
||||
목표 이탈 때만 최대 3회까지 스칼라를 보정하며 비수렴은 staging 검증 전에 실패한다.
|
||||
"""
|
||||
source = measure_loudness(pcm=faded_pcm)
|
||||
validate_stats(source)
|
||||
gain = round(TARGET_LUFS - source["integratedLUFS"], 12)
|
||||
iterations = []
|
||||
for _ in range(NORMALIZATION["maximumIterations"]):
|
||||
if source["truePeakDBTP"] + gain > INTERNAL_TRUE_PEAK_DBTP + 1e-9:
|
||||
raise ValueError("예측 true peak가 내부 ceiling을 초과하여 변환 중단")
|
||||
exact_filter = volume_filter(gain)
|
||||
encoded = subprocess.run([*PCM_INPUT_ARGS, "-af", exact_filter, "-c:a", "pcm_s16le",
|
||||
"-f", "s16le", "pipe:1"], input=faded_pcm,
|
||||
check=True, capture_output=True, timeout=90).stdout
|
||||
if len(encoded) != len(faded_pcm) // 2:
|
||||
raise ValueError("정규화 PCM 길이 오류")
|
||||
with wave.open(str(destination), "wb") as stream:
|
||||
stream.setparams((1, 2, SAMPLE_RATE, 0, "NONE", "not compressed"))
|
||||
stream.writeframes(encoded)
|
||||
final = measure_loudness(destination)
|
||||
validate_stats(final)
|
||||
iterations.append({"gainDB": gain, "filter": exact_filter,
|
||||
"predictedTruePeakDBTP": round(source["truePeakDBTP"] + gain, 12),
|
||||
"output": final})
|
||||
if final["truePeakDBTP"] > INTERNAL_TRUE_PEAK_DBTP:
|
||||
raise ValueError("양자화 후 true peak가 내부 ceiling 초과")
|
||||
if abs(final["integratedLUFS"] - TARGET_LUFS) <= NORMALIZATION["toleranceLU"] + 1e-9:
|
||||
validate_loudness(final)
|
||||
return {"measurementFilter": LOUDNESS_FILTER, "fadedPcmSha256": hashlib.sha256(faded_pcm).hexdigest(),
|
||||
"input": source, "iterations": iterations, "final": final}
|
||||
gain = round(gain + TARGET_LUFS - final["integratedLUFS"], 12)
|
||||
raise ValueError("PCM16 loudness 보정 비수렴; 공개 중단")
|
||||
|
||||
|
||||
def digest(path):
|
||||
"""큰 원본도 메모리에 전부 적재하지 않고 SHA-256을 계산한다."""
|
||||
@@ -33,10 +133,10 @@ def digest(path):
|
||||
|
||||
|
||||
def convert(source, destination, start, duration=EXCERPT_SECONDS):
|
||||
"""실제 PCM에 짧은 양끝 fade와 공통 gain을 적용해 24kHz mono PCM16으로 쓴다.
|
||||
"""source의 승인 start/duration 구간을 fade한 뒤 ebur128 실측에 따른 스칼라 gain만 적용한다.
|
||||
|
||||
start/duration은 원본의 초 단위 구간이다. RMS 목표 .08, peak .85,
|
||||
최대 증폭 4배 중 가장 작은 gain으로 과증폭을 막고 파형의 비율을 보존한다.
|
||||
source는 해시 확인한 원본, destination은 비공개 staging 파일이다. RMS 목표나
|
||||
최대 증폭 제한은 쓰지 않으며 true peak/청감 계약을 만족하는 선형 변환만 공개한다.
|
||||
"""
|
||||
if not 8 <= duration <= 20 or not math.isfinite(start) or start < 0:
|
||||
raise ValueError("현장음 구간은 8~20초, 시작점은 0 이상이어야 합니다.")
|
||||
@@ -66,26 +166,27 @@ def convert(source, destination, start, duration=EXCERPT_SECONDS):
|
||||
faded = [x * min(1, i / fade_frames, (len(samples) - 1 - i) / fade_frames)
|
||||
for i, x in enumerate(samples)]
|
||||
rms = math.sqrt(sum(x*x for x in faded) / len(faded))
|
||||
peak = max(abs(x) for x in faded)
|
||||
if rms < 0.001:
|
||||
raise ValueError("무음 또는 지나치게 작은 현장 녹음입니다.")
|
||||
gain = min(0.08 / rms, 0.85 / peak, 4)
|
||||
pcm = array("h", (round(x * gain * 32767) for x in faded))
|
||||
# 측정과 모든 변환 시도에 정확히 같은 faded float PCM을 전달한다.
|
||||
faded_pcm = array("f", faded)
|
||||
if sys.byteorder != "little": faded_pcm.byteswap()
|
||||
loudness = normalize_pcm(faded_pcm.tobytes(), destination)
|
||||
with wave.open(str(destination), "rb") as stream:
|
||||
pcm = array("h", stream.readframes(stream.getnframes()))
|
||||
if sys.byteorder != "little": pcm.byteswap()
|
||||
measured_rms = math.sqrt(sum(x*x for x in pcm) / len(pcm)) / 32768
|
||||
measured_peak = max(abs(x) for x in pcm) / 32768
|
||||
if sys.byteorder != "little":
|
||||
pcm.byteswap()
|
||||
with wave.open(str(destination), "wb") as stream:
|
||||
stream.setparams((1, 2, SAMPLE_RATE, 0, "NONE", "not compressed"))
|
||||
stream.writeframes(pcm.tobytes())
|
||||
if pcm[0] != 0 or pcm[-1] != 0:
|
||||
raise ValueError("정규화 후 fade 경계 손상")
|
||||
return {"sha256": digest(destination), "bytes": Path(destination).stat().st_size,
|
||||
"duration": len(samples) / SAMPLE_RATE, "rms": measured_rms, "peak": measured_peak,
|
||||
"conversion": {"startSeconds": start, "durationSeconds": duration,
|
||||
"seekMethod": seek_method,
|
||||
"fadeSeconds": FADE_SECONDS, "sampleRate": SAMPLE_RATE,
|
||||
"channels": 1, "encoding": "PCM16 little-endian", "linearGain": gain,
|
||||
"targetRms": 0.08, "peakLimit": 0.85, "maxGain": 4,
|
||||
"operations": "excerpt, downmix, resample, linear end fades, bounded linear normalization, WAV encoding"}}
|
||||
"channels": 1, "encoding": "PCM16 little-endian",
|
||||
"loudness": loudness,
|
||||
"operations": "excerpt, downmix, resample, linear end fades, FFmpeg double-precision scalar volume, PCM16 WAV encoding, fresh ebur128 integrated/true-peak measurement"}}
|
||||
|
||||
|
||||
def source_path(directory, environment):
|
||||
@@ -121,7 +222,7 @@ def verify_sources(plan, directory):
|
||||
|
||||
def render_notice(files):
|
||||
"""각 항목 자체에 저작자·두 URL·원본/파생 라이선스·변경 고지를 완결한다."""
|
||||
lines = ["# Find-Word v0.2.3 — Field recording notices", "",
|
||||
lines = ["# Find-Word v0.2.5 — Field recording notices", "",
|
||||
"These third-party recordings retain their own licenses. The private project license does not apply to them.",
|
||||
"All files are modified excerpts: trimmed, faded, downmixed, resampled, normalized and converted to WAV.",
|
||||
"The market derivative remains CC BY-SA 4.0. No additional restrictions are imposed on that audio.", ""]
|
||||
@@ -147,10 +248,14 @@ def validate_bundle(directory, plan):
|
||||
raise ValueError("현장음 파일 누락·추가·symlink")
|
||||
manifest = json.loads((directory / "manifest.json").read_text())
|
||||
entries = manifest["files"]
|
||||
if (manifest["version"] != "0.2.3" or len(entries) != 8
|
||||
if (manifest["version"] != "0.2.5" or len(entries) != 8
|
||||
or {e["environmentId"] for e in entries} != ENVIRONMENT_IDS
|
||||
or len({e["sha256"] for e in entries}) != 8):
|
||||
raise ValueError("고유한 승인 현장음 8개가 필요합니다.")
|
||||
if (manifest["normalization"] != NORMALIZATION
|
||||
or not manifest["toolVersions"]["ffmpeg"].startswith("ffmpeg version ")):
|
||||
raise ValueError("정규화 계약 또는 도구 버전 불일치")
|
||||
measured_values = [] # 독립 재측정된 8개의 LUFS로 bundle 전체 spread를 검사한다.
|
||||
approved = {a["environmentId"]: a for a in plan["assets"]}
|
||||
for entry in entries:
|
||||
environment = entry["environmentId"]
|
||||
@@ -176,19 +281,51 @@ def validate_bundle(directory, plan):
|
||||
raise ValueError("12초 PCM 또는 fade 경계 불일치")
|
||||
rms = math.sqrt(sum(x*x for x in samples) / frames) / 32768
|
||||
peak = max(abs(x) for x in samples) / 32768
|
||||
if (rms <= 0.003 or peak > 0.85 or not math.isclose(rms, entry["rms"], abs_tol=1e-12)
|
||||
if (rms <= 0.003 or peak > 10 ** (TRUE_PEAK_DBTP / 20) or not math.isclose(rms, entry["rms"], abs_tol=1e-12)
|
||||
or not math.isclose(peak, entry["peak"], abs_tol=1e-12)
|
||||
or entry["duration"] != frames / SAMPLE_RATE
|
||||
or entry["conversion"]["startSeconds"] != asset["selectedSegmentStartSeconds"]
|
||||
or entry["conversion"]["durationSeconds"] != EXCERPT_SECONDS
|
||||
or entry["conversion"]["fadeSeconds"] != FADE_SECONDS):
|
||||
raise ValueError("현장음 실측값/변환 불일치")
|
||||
actual = measure_loudness(path)
|
||||
validate_loudness(actual)
|
||||
loudness = entry["conversion"]["loudness"]
|
||||
validate_provenance(loudness, actual)
|
||||
measured_values.append(actual["integratedLUFS"])
|
||||
if max(measured_values) - min(measured_values) > 0.4 + 1e-9:
|
||||
raise ValueError("환경 간 loudness spread 초과")
|
||||
if (directory / "AMBIENCE_NOTICES.md").read_text() != render_notice(entries):
|
||||
raise ValueError("항목별 출처/변경 고지 불일치")
|
||||
except (KeyError, TypeError, json.JSONDecodeError, wave.Error, EOFError) as error:
|
||||
raise ValueError("현장음 bundle 검증 실패") from error
|
||||
|
||||
|
||||
def validate_provenance(loudness, actual):
|
||||
"""고정 계기·gain 공식·모든 시도·최종 실측을 서로 대조한다."""
|
||||
source = loudness["input"]
|
||||
validate_stats(source)
|
||||
iterations = loudness["iterations"]
|
||||
if (loudness["measurementFilter"] != LOUDNESS_FILTER
|
||||
or not re.fullmatch(r"[a-f0-9]{64}", loudness["fadedPcmSha256"])
|
||||
or not 1 <= len(iterations) <= NORMALIZATION["maximumIterations"]):
|
||||
raise ValueError("선형 정규화 provenance 누락")
|
||||
gain = round(TARGET_LUFS - source["integratedLUFS"], 12)
|
||||
for index, iteration in enumerate(iterations):
|
||||
validate_stats(iteration["output"])
|
||||
if (iteration["gainDB"] != gain or iteration["filter"] != volume_filter(gain)
|
||||
or iteration["predictedTruePeakDBTP"] != round(source["truePeakDBTP"] + gain, 12)
|
||||
or source["truePeakDBTP"] + gain > INTERNAL_TRUE_PEAK_DBTP + 1e-9
|
||||
or iteration["output"]["truePeakDBTP"] > INTERNAL_TRUE_PEAK_DBTP):
|
||||
raise ValueError("선형 gain/peak provenance 불일치")
|
||||
error = TARGET_LUFS - iteration["output"]["integratedLUFS"]
|
||||
if index < len(iterations) - 1 and abs(error) <= NORMALIZATION["toleranceLU"] + 1e-9:
|
||||
raise ValueError("불필요한 보정 반복")
|
||||
gain = round(gain + error, 12)
|
||||
if loudness["final"] != actual or iterations[-1]["output"] != actual:
|
||||
raise ValueError("최종 ebur128 실측/provenance 불일치")
|
||||
|
||||
|
||||
def atomic_publish(staged, output):
|
||||
"""Linux의 단일 renameat2 교환이 commit 지점이다. 실패 시 기존 이름은 유지된다.
|
||||
|
||||
@@ -233,7 +370,7 @@ def generate(plan, source_directory, output, *, converter=None):
|
||||
entry.update({"file": name, "sourceSha256": asset["expectedSourceSha256"],
|
||||
"outputLicense": asset["license"], "verificationDateUtc": "2026-10-14", **info})
|
||||
files.append(entry)
|
||||
manifest = {"version": "0.2.3", "generatedAtUtc": datetime.now(timezone.utc).isoformat(),
|
||||
manifest = {"version": "0.2.5", "normalization": NORMALIZATION, "generatedAtUtc": datetime.now(timezone.utc).isoformat(),
|
||||
"toolVersions": {"ffmpeg": ffmpeg}, "sampleRate": SAMPLE_RATE,
|
||||
"channels": 1, "encoding": "PCM16", "files": files}
|
||||
(ready / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
|
||||
@@ -248,6 +385,7 @@ def generate(plan, source_directory, output, *, converter=None):
|
||||
|
||||
|
||||
def main():
|
||||
"""불변 v0.2.3 출처 계획을 읽되 v0.2.5 변환 계약으로만 새 bundle을 발행한다."""
|
||||
plan = json.loads((ROOT / ".agent/AMBIENCE-SOURCES-v0.2.3.json").read_text())
|
||||
manifest = generate(plan, Path("/tmp/findword-ambience-sources"), ROOT / "public/ambience")
|
||||
print(json.dumps({"files": len(manifest["files"]), "bytes": sum(f["bytes"] for f in manifest["files"])}, indent=2))
|
||||
|
||||
+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 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""ASR 진단과 별개로 exact-byte 소유자 청취 승인을 검증하는 로컬 릴리스 gate."""
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import zipfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
APPROVAL = ".agent/RELEASE-APPROVAL-v0.2.5.json"
|
||||
ASR = ".agent/evidence-v0.2.5/review-fixes/final-asr.json"
|
||||
PRIOR_ASR = ".agent/evidence-v0.2.5/final-asr.json"
|
||||
BUNDLE = ".agent/evidence-v0.2.5/release-approval/review-bundle.zip"
|
||||
DELIVERED_BUNDLE = "/home/hermes/voice-memos/find-word-v0.2.5-normalized-ambience-review.zip"
|
||||
# 사용자에게 전달된 ZIP의 고정 digest가 승인 범위의 신뢰 기준이다.
|
||||
# manifest/WAV/승인 JSON을 함께 바꿔도 이 ZIP과 달라지면 승인으로 통과할 수 없다.
|
||||
BUNDLE_SHA256 = "bb2e974b652df43db4a1f2a59716836e2c521839c724b34d4b71c1b40c3322e5"
|
||||
IDS = ("street", "mall", "theater", "department", "market", "subway", "cafe", "rain")
|
||||
REQUIRED_EMPTY = ["mall", "market", "cafe"]
|
||||
MANIFEST = "public/ambience/manifest.json"
|
||||
NOTICE = "public/ambience/AMBIENCE_NOTICES.md"
|
||||
SCOPE = {
|
||||
"version": "0.2.5",
|
||||
"kind": "exact-byte-ambience-bundle",
|
||||
"directory": "public/ambience",
|
||||
"files": [*(f"{name}.wav" for name in IDS), "manifest.json", "AMBIENCE_NOTICES.md"],
|
||||
}
|
||||
|
||||
|
||||
def require(condition, message):
|
||||
"""검증은 최적화 실행에서도 생략되지 않으며 실패 원인을 값 노출 없이 반환한다."""
|
||||
if not condition:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def sha256(raw):
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def unique_object(pairs):
|
||||
"""중복 JSON key의 마지막 값이 앞선 승인/clearance 값을 숨기지 못하게 한다."""
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
require(key not in result, f"Duplicate JSON key: {key}")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def read_json(path):
|
||||
return json.loads(path.read_bytes(), object_pairs_hook=unique_object)
|
||||
|
||||
|
||||
def validate_screening(root, manifest, manifest_hash):
|
||||
"""empty 결과뿐 아니라 exact 입력·동일 모델/VAD·screening 한계를 함께 확인한다."""
|
||||
screening = read_json(root / ASR)
|
||||
prior = read_json(root / PRIOR_ASR)
|
||||
require(screening["tool"] == prior["tool"] and screening["model"] == prior["model"],
|
||||
"ASR tool/model mismatch")
|
||||
for key in ("device", "computeType", "cpuThreads", "numWorkers", "randomSeed",
|
||||
"parameters", "vadModelSha256"):
|
||||
require(screening["method"][key] == prior["method"][key], f"ASR setting mismatch: {key}")
|
||||
require(screening["method"]["manifestSha256"] == manifest_hash, "ASR manifest mismatch")
|
||||
require(screening["requiredEmptyEnvironmentIds"] == REQUIRED_EMPTY, "ASR required scope mismatch")
|
||||
for key, value in {"screeningOnly": True, "humanListeningAcceptance": False,
|
||||
"legalPrivacyClearance": False, "textIsVerifiedTranscript": False}.items():
|
||||
require(screening["limitations"][key] is value, f"ASR limitation overclaim: {key}")
|
||||
require([row["environmentId"] for row in screening["files"]] == list(IDS), "ASR file scope mismatch")
|
||||
empty = True
|
||||
for entry, row in zip(manifest["files"], screening["files"], strict=True):
|
||||
require(row["file"] == f"public/ambience/{entry['file']}", "ASR file path mismatch")
|
||||
for key in ("sha256", "bytes", "sourceSha256", "selectedSegmentStartSeconds"):
|
||||
require(row[key] == entry[key], f"ASR asset mismatch: {key}")
|
||||
require(isinstance(row["segments"], list), "ASR segments missing")
|
||||
if entry["environmentId"] in REQUIRED_EMPTY:
|
||||
empty = empty and row["segments"] == [] and row["text"] == "" and row["info"]["duration_after_vad"] == 0
|
||||
require(screening["requiredEmptyPassed"] is empty, "ASR aggregate contradicts diagnostic evidence")
|
||||
return empty
|
||||
|
||||
|
||||
def validate_owner_approval(root, manifest, manifest_hash):
|
||||
"""보고된 Oz 회신을 고정된 전달 ZIP 및 현재 파일 전체와 연결한다. 전자서명 인증은 아니다."""
|
||||
approval = read_json(root / APPROVAL)
|
||||
expected = {
|
||||
"schemaVersion": 1,
|
||||
"owner": "Oz",
|
||||
"statement": "승인합니다. 이대로 진행하세요",
|
||||
"statementSource": "User-reported Oz reply after receipt of the review bundle",
|
||||
"timestampUtc": "2026-10-14T11:29:51Z",
|
||||
"timestampKst": "2026-10-14T20:29:51+09:00",
|
||||
"scope": SCOPE,
|
||||
"productOwnerReleaseApproval": True,
|
||||
"humanListeningAcceptance": True,
|
||||
"legalPrivacyClearance": False,
|
||||
"reviewBundle": {"file": BUNDLE, "deliveredPath": DELIVERED_BUNDLE, "sha256": BUNDLE_SHA256},
|
||||
"manifest": {"file": MANIFEST, "sha256": manifest_hash},
|
||||
"notice": {"file": NOTICE, "sha256": sha256((root / NOTICE).read_bytes())},
|
||||
"wavs": [{"environmentId": row["environmentId"], "file": f"public/ambience/{row['file']}",
|
||||
"sha256": row["sha256"], "bytes": row["bytes"]} for row in manifest["files"]],
|
||||
}
|
||||
# 알 수 없는 scope/clearance 필드도 거부한다. bool은 문자열/숫자를 승인값으로 인정하지 않는다.
|
||||
require(approval == expected, "Exact owner approval statement/scope/timestamp/hash mismatch")
|
||||
for key in ("productOwnerReleaseApproval", "humanListeningAcceptance", "legalPrivacyClearance"):
|
||||
require(approval[key] is expected[key], f"Invalid approval flag: {key}")
|
||||
archive_bytes = (root / BUNDLE).read_bytes()
|
||||
require(sha256(archive_bytes) == BUNDLE_SHA256, "Reviewed bundle hash mismatch")
|
||||
with zipfile.ZipFile(root / BUNDLE) as archive:
|
||||
names = [*SCOPE["files"], "LISTENING-REVIEW.txt"]
|
||||
require(sorted(archive.namelist()) == sorted(names), "Reviewed bundle file scope mismatch")
|
||||
# 압축 해제 없이 전수 비교하므로 ZIP path traversal도 발생하지 않는다.
|
||||
for name in SCOPE["files"]:
|
||||
require(archive.read(name) == (root / "public/ambience" / name).read_bytes(),
|
||||
f"Current asset differs from reviewed bundle: {name}")
|
||||
|
||||
|
||||
def validate_release(root=ROOT):
|
||||
"""자동 empty-ASR OR 유효한 exact 청취 승인. 존재하는 잘못된 승인은 항상 실패한다."""
|
||||
root = Path(root)
|
||||
manifest = read_json(root / MANIFEST)
|
||||
manifest_hash = sha256((root / MANIFEST).read_bytes())
|
||||
require(manifest["version"] == "0.2.5", "Release version mismatch")
|
||||
require([row["environmentId"] for row in manifest["files"]] == list(IDS), "Manifest asset scope mismatch")
|
||||
require(sorted(path.name for path in (root / "public/ambience").iterdir()) == sorted(SCOPE["files"]),
|
||||
"Current asset directory scope mismatch")
|
||||
for name, row in zip(IDS, manifest["files"], strict=True):
|
||||
require(row["file"] == f"{name}.wav", "Manifest filename mismatch")
|
||||
raw = (root / "public/ambience" / row["file"]).read_bytes()
|
||||
require(sha256(raw) == row["sha256"] and len(raw) == row["bytes"], "Current WAV hash/bytes mismatch")
|
||||
automated = validate_screening(root, manifest, manifest_hash)
|
||||
owner = (root / APPROVAL).exists()
|
||||
if owner:
|
||||
validate_owner_approval(root, manifest, manifest_hash)
|
||||
require(automated or owner, "ASR is nonempty and exact owner approval record is absent")
|
||||
return {
|
||||
"passed": True,
|
||||
"acceptancePath": "automated-empty-asr" if automated else "exact-byte-owner-listening-approval",
|
||||
"automatedEmptyAsrPassed": automated,
|
||||
"productOwnerReleaseApproval": owner,
|
||||
"humanListeningAcceptance": owner,
|
||||
"legalPrivacyClearance": False,
|
||||
"manifestSha256": manifest_hash,
|
||||
"reviewBundleSha256": BUNDLE_SHA256 if owner else None,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
print(json.dumps(validate_release(), ensure_ascii=False, indent=2))
|
||||
except (ValueError, KeyError, TypeError, OSError, zipfile.BadZipFile) as error:
|
||||
print(json.dumps({"passed": False, "reason": str(error)}, ensure_ascii=False))
|
||||
raise SystemExit(1) from error
|
||||
Reference in New Issue
Block a user