fix: normalize ambience loudness for v0.2.5

This commit is contained in:
oz
2026-10-14 12:12:13 +00:00
parent 812540c743
commit 91aff66445
390 changed files with 97709 additions and 257 deletions
+159 -21
View File
@@ -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
View File
@@ -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 });
}
+148
View File
@@ -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