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))
|
||||
|
||||
Reference in New Issue
Block a user