当音频交接需要把可复核的响度观察绑定到某一个确切输入文件时,可以使用这套本地流程。交付物是 JSON 报告和测量器日志;脚本不输出处理后的音频,也不应用响度归一化目标。报告里的样本峰值可用于比较信号,但单凭峰值不能判断节目综合响度。
测量综合响度,保留输入音频不变
调整音量之前,先交付一份测量报告
当音频交接需要把可复核的响度观察绑定到某一个确切输入文件时,可以使用这套本地流程。交付物是 JSON 报告和测量器日志;脚本不输出处理后的音频,也不应用响度归一化目标。报告里的样本峰值可用于比较信号,但单凭峰值不能判断节目综合响度。
这份候选是独立的 Python/FFmpeg 流程,不是网站当前已经提供的按钮。请使用自己拥有或获准处理的声音。下方数值例子均由展示的代码在本机合成,没有付费 API 调用。
测量覆盖的范围
使用 macOS 上已有的 Python 3.12 和 FFmpeg 6.0 可执行文件。实际测试环境为 macOS 14.5、Python 3.12.3、FFmpeg 6.0;这些文件不会安装依赖。输入须为非空、未压缩的有符号 PCM16 WAV,单声道或双声道、采样率严格为 48,000 Hz,长度 0.4 至 120 秒,文件小于 16,000,000 字节。RIFF 读取器拒绝不完整音频帧,并接受合法的奇数长度元数据填充。脚本把输入字节复制到私有临时目录,测量这份固定快照,在提交报告前再次核对原文件哈希。
单声道按一个声道测量,明确关闭 dual-mono 补偿;双声道按声明的两个声道测量。环绕声、LFE 路由、其他采样率、压缩上传文件和浏览器解码不在本候选范围内,脚本不会自动转换这些输入。
命令使用 ebur128 的 peak=none,并输出到 null。报告保留日志末尾的综合响度与相对门限,精度为测量器显示的一位小数;不重建哪些分块通过门限。全零输入,或日志综合值不高于本流程保守设置的 -69.9 LUFS 显示底限,会得到 integratedLufs=null 和 NO_REPORTABLE_MEASUREMENT。该状态不等于 FFmpeg 启动失败,退出码为零也不表示达到节目交付标准。
完整本地源码
新建一个文件夹,按下列确切文件名保存全部四个文件,将三个 Python 文件与 version.json 放在一起。版本锁绑定三个 Python 源码的哈希。每个 --out 目标都必须不存在,且父目录已经存在。报告提交复用限定 PCM 工作中的 macOS 独占重命名助手;此处不支持其他操作系统。可捕获错误会清理临时目录,强制终止进程或断电仍可能留下临时同级目录。
media_core.py
from pathlib import Path
import ctypes, errno, hashlib, io, json, math, os, struct, sys, wave
class ValidationError(Exception):
pass
def require(ok, code):
if not ok:
raise ValidationError(code)
def sha(path):
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
def dump(path, value):
Path(path).write_text(json.dumps(value, ensure_ascii=False, indent=2,
sort_keys=True, allow_nan=False)+'\n')
def read_contract(path):
p=Path(path)
require(p.is_file() and not p.is_symlink(), 'CONTRACT_FILE')
require(p.stat().st_size <= 4096, 'CONTRACT_SIZE')
def invalid_constant(value):
raise ValidationError('CONTRACT_NONFINITE')
try:
data=json.loads(p.read_text(), parse_constant=invalid_constant)
except (ValueError, UnicodeError):
raise ValidationError('CONTRACT_JSON')
require(type(data) is dict, 'CONTRACT_OBJECT')
return data
def contract_keys(c, required):
require(type(c) is dict, 'CONTRACT_OBJECT')
require(set(required) <= set(c), 'CONTRACT_MISSING_KEY')
require(set(c) <= set(required) | {'purpose'}, 'CONTRACT_UNKNOWN_KEY')
if 'purpose' in c:
require(type(c['purpose']) is str, 'CONTRACT_PURPOSE')
def riff_pcm_layout(data):
require(len(data)>=12 and data[:4]==b'RIFF' and data[8:12]==b'WAVE', 'WAV_RIFF_HEADER')
riff_end=8+struct.unpack_from('<I',data,4)[0]
require(riff_end<=len(data), 'TRUNCATED_PCM')
require(riff_end==len(data), 'WAV_RIFF_SIZE')
offset=12;fmt=None;audio=None
while offset<riff_end:
require(offset+8<=riff_end, 'WAV_CHUNK_HEADER')
kind=data[offset:offset+4]
size=struct.unpack_from('<I',data,offset+4)[0]
start=offset+8;end=start+size
require(end<=riff_end, 'TRUNCATED_PCM' if kind==b'data' else 'WAV_CHUNK_BOUNDS')
# RIFF chunkSize excludes the one-byte pad for odd payload sizes.
next_offset=end+(size&1)
require(next_offset<=riff_end, 'WAV_CHUNK_PAD')
if kind==b'fmt ':
require(fmt is None and audio is None, 'WAV_FMT_ORDER')
require(size>=16, 'WAV_FMT_SIZE')
fmt=struct.unpack_from('<HHIIHH',data,start)
elif kind==b'data':
require(fmt is not None, 'WAV_FMT_ORDER')
require(audio is None, 'WAV_MULTIPLE_DATA')
audio=(start,size)
offset=next_offset
require(fmt is not None and audio is not None, 'WAV_REQUIRED_CHUNK')
tag,channels,rate,byte_rate,block_align,bits=fmt
require(tag in (1,0xfffe) and channels in (1,2) and bits==16, 'PCM16_ONLY')
require(block_align==channels*2, 'PCM_BLOCK_ALIGN')
require(audio[1]%block_align==0, 'PCM_PARTIAL_FRAME')
return rate,channels,block_align,audio[0],audio[1]
def pcm_read(path):
p=Path(path)
require(p.is_file() and not p.is_symlink(), 'INPUT_FILE')
require(p.stat().st_size < 16000000, 'INPUT_TOO_LARGE')
data=p.read_bytes()
require(len(data)<16000000, 'INPUT_TOO_LARGE')
expected_rate,expected_ch,frame_bytes,audio_start,audio_bytes=riff_pcm_layout(data)
try:
with wave.open(io.BytesIO(data), 'rb') as w:
require(w.getsampwidth()==2 and w.getnchannels() in (1,2)
and w.getcomptype()=='NONE', 'PCM16_ONLY')
rate,ch,n=w.getframerate(),w.getnchannels(),w.getnframes()
require((rate,ch,n)==(expected_rate,expected_ch,audio_bytes//frame_bytes), 'PCM_GEOMETRY')
require(n>0, 'EMPTY_PCM')
require(0<rate<=384000, 'SAMPLE_RATE')
require(n*ch*2<16000000, 'INPUT_TOO_LARGE')
raw=w.readframes(n)
except (wave.Error, EOFError, struct.error):
raise ValidationError('WAV_INVALID')
require(len(raw)==audio_bytes and raw==data[audio_start:audio_start+audio_bytes], 'TRUNCATED_PCM')
return rate,ch,list(struct.unpack('<'+'h'*(len(raw)//2),raw))
def pcm_write(path, rate, channels, samples):
require(type(rate) is int and 0<rate<=384000, 'SAMPLE_RATE')
require(type(channels) is int and channels in (1,2), 'PCM_CHANNELS')
require(samples and len(samples)%channels==0, 'PCM_CHANNEL_ALIGNMENT')
require(all(type(v) is int and -32768<=v<=32767 for v in samples), 'PCM_RANGE')
with wave.open(str(path), 'wb') as w:
w.setparams((channels,2,rate,0,'NONE','not compressed'))
w.writeframes(struct.pack('<'+'h'*len(samples),*samples))
def rms(samples):
return math.sqrt(sum(x*x for x in samples)/len(samples))
def commit_directory(stage, target):
require(sys.platform=='darwin', 'ATOMIC_PLATFORM_UNSUPPORTED')
libc=ctypes.CDLL(None, use_errno=True)
require(hasattr(libc,'renamex_np'), 'ATOMIC_API_UNAVAILABLE')
rename=libc.renamex_np
rename.argtypes=[ctypes.c_char_p,ctypes.c_char_p,ctypes.c_uint]
rename.restype=ctypes.c_int
if rename(os.fsencode(stage),os.fsencode(target),4)!=0:
code=ctypes.get_errno()
if code in (errno.EEXIST,errno.ENOTEMPTY):
raise ValidationError('OUTPUT_EXISTS')
raise ValidationError('ATOMIC_COMMIT_FAILED:'+str(code))
measure.py
from pathlib import Path
import argparse, hashlib, json, math, os, re, shutil, subprocess, sys, tempfile
from media_core import ValidationError, require, pcm_read, commit_directory, dump, sha
VERSION = '0.1.0'
def main():
ap=argparse.ArgumentParser()
ap.add_argument('--ffmpeg',required=True)
ap.add_argument('--input',required=True)
ap.add_argument('--out',required=True)
args=ap.parse_args()
code=Path(__file__).resolve().parent
lock=json.loads((code/'version.json').read_text())
require(lock.get('version')==VERSION,'VERSION_LOCK')
require(set(lock.get('files',{}))=={'media_core.py','measure.py','make-inputs.py'},'LOCK_FILES')
for name,digest in lock['files'].items():
require(sha(code/name)==digest,'CODE_HASH:'+name)
source=Path(args.input).absolute()
require(source.is_file() and not source.is_symlink(),'INPUT_FILE')
require(source.stat().st_size<16000000,'INPUT_TOO_LARGE')
data=source.read_bytes()
require(len(data)<16000000,'INPUT_TOO_LARGE')
original_hash=hashlib.sha256(data).hexdigest()
target=Path(args.out).absolute()
require(target.parent.is_dir() and not target.parent.is_symlink(),'OUTPUT_PARENT')
require(not os.path.lexists(target),'OUTPUT_EXISTS')
binary=Path(args.ffmpeg).resolve()
require(binary.is_file() and os.access(binary,os.X_OK),'FFMPEG_EXECUTABLE')
require(sys.platform=='darwin','ATOMIC_PLATFORM_UNSUPPORTED')
stage=Path(tempfile.mkdtemp(prefix='.'+target.name+'-',dir=target.parent))
try:
# The decoder reads this fixed snapshot; the user's source is never an output.
staged_input=stage/'input.wav';staged_input.write_bytes(data)
rate,ch,samples=pcm_read(staged_input)
frames=len(samples)//ch
require(rate==48000,'RATE_48000_REQUIRED')
require(19200<=frames<=5760000,'DURATION_0_4_TO_120_SECONDS')
version_args=[str(binary),'-version']
version=subprocess.run(version_args,capture_output=True,text=True,timeout=10)
require(version.returncode==0,'FFMPEG_VERSION_FAILED')
first_line=version.stdout.splitlines()[0] if version.stdout else ''
require(re.match(r'^ffmpeg version 6\.0(?:\s|[-.])',first_line) is not None,'FFMPEG_6_0_REQUIRED')
argv=[str(binary),'-hide_banner','-nostdin','-nostats','-loglevel','info',
'-i',str(staged_input),'-map','0:a:0','-af',
'ebur128=peak=none:dualmono=false','-f','null','-']
result=subprocess.run(argv,capture_output=True,text=True,timeout=60)
require(result.returncode==0,'FFMPEG_SCAN_FAILED')
found=re.findall(r'Integrated loudness:\s*I:\s*([-+0-9.inf]+) LUFS\s*Threshold:\s*([-+0-9.inf]+) LUFS',result.stderr)
require(bool(found),'SUMMARY_MISSING')
integrated,threshold=map(float,found[-1])
require(math.isfinite(integrated) and math.isfinite(threshold),'SUMMARY_NONFINITE')
require(sha(staged_input)==original_hash,'SNAPSHOT_CHANGED')
require(sha(source)==original_hash,'INPUT_CHANGED')
sample_peak=max(abs(v) for v in samples)
# FFmpeg can emit a finite display floor with exit 0 for empty gated energy.
# This is a conservative report policy, not a reconstruction of gate membership.
eligible=sample_peak>0 and integrated>-69.9
reason=None if eligible else ('ZERO_SAMPLES' if sample_peak==0 else 'DISPLAY_FLOOR_OR_NO_REPORTABLE_ENERGY')
report={
'schema':'original-loudness-observation-v1','version':VERSION,
'input':{'name':source.name,'bytes':len(data),'sha256':original_hash,
'sampleRate':rate,'channels':ch,'frames':frames,'durationSeconds':frames/rate,
'signedPcm16SamplePeak':sample_peak},
'inputUnchangedAtFinalHashCheck':True,
'measurementStatus':'MEASURED' if eligible else 'NO_REPORTABLE_MEASUREMENT',
'reason':reason,
'integratedLufs':integrated if eligible else None,
'rawFfmpegIntegratedLufs':integrated,
'rawFfmpegRelativeThresholdLufs':threshold,
'reportedPrecisionLufs':0.1,
'dualMonoCompensation':False,
'meter':{'versionLine':first_line,'binarySha256':sha(binary),'argv':argv,
'exitCode':result.returncode,'logSha256':hashlib.sha256(result.stderr.encode()).hexdigest()},
'codeLock':lock,'python':sys.version,
'audioOutputWritten':False,'targetNormalizationApplied':False,
'truePeakMeasured':False,'gateMembershipReconstructed':False,
'standardsCertification':False,'deliveryTargetPass':None,
'scope':'Bounded FFmpeg 6.0 observation; mono/stereo PCM16 at 48000 Hz, 0.4–120 seconds.'}
staged_input.unlink()
(stage/'ffmpeg.log').write_text(result.stderr)
dump(stage/'execution.json',{'versionArgv':version_args,'versionExitCode':version.returncode,
'versionStdout':version.stdout,'versionStderr':version.stderr,
'scanArgv':argv,'scanExitCode':result.returncode,'scanStdout':result.stdout})
dump(stage/'report.json',report)
dump(stage/'version.json',lock)
commit_directory(stage,target)
print(json.dumps({'out':str(target),'measurementStatus':report['measurementStatus'],
'integratedLufs':report['integratedLufs']},allow_nan=False))
finally:
if stage.exists():shutil.rmtree(stage)
if __name__=='__main__':
try:main()
except (ValidationError, OSError, ValueError, subprocess.TimeoutExpired) as error:
print(str(error),file=sys.stderr);sys.exit(2)
make-inputs.py
from pathlib import Path
import argparse, json, math, shutil, tempfile
from media_core import require, pcm_write, commit_directory, dump
ap=argparse.ArgumentParser()
ap.add_argument('--out',required=True)
a=ap.parse_args()
out=Path(a.out).absolute()
require(out.parent.is_dir() and not out.exists() and not out.is_symlink(),'NEW_OUTPUT_REQUIRED')
stage=Path(tempfile.mkdtemp(prefix='.'+out.name+'-',dir=out.parent))
try:
rate=48000
cycle=[round(10000*math.sin(2*math.pi*i/48)) for i in range(48)]
steady=cycle*10000
quieter=cycle*1000+[round(v/2) for v in cycle]*9000
signals={'steady':steady,'same-peak-lower-body':quieter,
'same-peak-lower-body-plus-silence':quieter+[0]*(rate*10),
'all-zero':[0]*(rate*2)}
for name,samples in signals.items():pcm_write(stage/(name+'.wav'),rate,1,samples)
dump(stage/'construction.json',{'sampleRate':rate,'channels':1,'bits':16,
'steady':'10 seconds of a 1000 Hz numeric sine, integer amplitude 10000',
'same-peak-lower-body':'first second identical; remaining 9 seconds half integer amplitude',
'same-peak-lower-body-plus-silence':'previous signal followed by 10 seconds of zero samples',
'all-zero':'2 seconds of zero samples','externalAudioUsed':False})
commit_directory(stage,out)
print(json.dumps({'out':str(out),'files':len(signals)}))
finally:
if stage.exists():shutil.rmtree(stage)
version.json
{
"version": "0.1.0",
"files": {
"media_core.py": "02252e46781c5fea4cdb03b13d3d7eaacb09a7b86d2dbbfaebc8b623ba42fe86",
"measure.py": "81e0b391d35f81611ff726d64b4d6616d507d5de3369b7c8df1c7d3a60200724",
"make-inputs.py": "dc3744bf19251042b4417c4140e799fec6c29dfafb8a8dd05878c23e02e0ec6b"
},
"status": "author-candidate-pending-independent-review"
}
使用自己的输入运行
把可执行文件与输入路径换成实际存在的本地文件,从四个源码文件所在目录执行。
python3 measure.py --ffmpeg "/absolute/path/to/ffmpeg" --input "/absolute/path/to/owned.wav" --out "my-report"
新目录包含 report.json、ffmpeg.log、execution.json 和 version.json,不包含输出 WAV。报告记录输入 SHA-256、帧结构、二进制 SHA-256、版本行、确切测量参数、源码锁和最终哈希核对结果。参数中的临时输入路径表示该次运行使用的快照;提交结果目录之前会删除它。
先看 measurementStatus。MEASURED 表示这个限定解析器获得了符合本报告条件的有限数值,不表示满足发行方规范。NO_REPORTABLE_MEASUREMENT 保留原始测量值供排查,并将 integratedLufs 留空。采样率不符、时长超限、PCM 损坏、源码锁变化或输出目录已经存在都会报错;修正原因后选择新的输出路径。
构造并比较数值例子
python3 make-inputs.py --out "my-inputs"
python3 measure.py --ffmpeg "/absolute/path/to/ffmpeg" --input "my-inputs/steady.wav" --out "steady-report"
python3 measure.py --ffmpeg "/absolute/path/to/ffmpeg" --input "my-inputs/same-peak-lower-body.wav" --out "quieter-report"
python3 measure.py --ffmpeg "/absolute/path/to/ffmpeg" --input "my-inputs/same-peak-lower-body-plus-silence.wav" --out "silence-report"
python3 measure.py --ffmpeg "/absolute/path/to/ffmpeg" --input "my-inputs/all-zero.wav" --out "zero-report"
第一个信号是十秒数值合成的 1 kHz 正弦,样本峰值 10000。第二个保留相同的首秒和峰值,后九秒振幅减半。第三个在第二个之后附加十秒零样本;最后一个是两秒零样本。固定 FFmpeg 6.0 二进制的先前限定观察依次为 -13.3、-18.3、-18.4 LUFS,对应日志门限 -23.3、-28.3、-28.4 LUFS。全零信号日志显示 -70.0 LUFS、门限 0.0,但可报告值为空。这些是确切构造输入的参考观察,不是人声或音乐的普遍预测。
前两个十秒非零输入峰值相同,此次观察却相差 5.0 LU。附加零尾段后,报告数值变化 -0.1 LU。这展示了该测量器在这个例子里的门控结果,不证明静音永远没有影响。重新构造例子时,应同时查看报告与原始日志,不能仅因结果目录存在就接受报告。
把结果用于编辑交接
交接时附上报告与确切源文件哈希,并说明单声道是按单声道使用,还是之后将在两个声道播放。若决定调整音量,使用编辑器或归一化流程,再单独测量新文件,保留前后两份报告。本流程交付的是绑定输入文件的观察报告,不修改音频,也不选择归一化目标。
不要将本报告标注为 EBU 认证、BS.1770 认证、真峰值安全或适用于所有交付平台。综合响度有可报告数值,不能单独证明真峰值上限、声道布局选择或接收方验收要求。当前作者检查覆盖数值构造信号和部分输入/输出防护;独立的门控数值重建、标准测量器向量、多样真实录音、双声道数值校准及发布准入仍是后续独立工作。
第一方参考资料
峰值与节目响度的区分参考 EBU R 128 第 5.0 版;算法资料为 ITU-R BS.1770-5。命令和选项参考 FFmpeg 6.0 滤镜文档。这些资料说明各自的方法,不为本脚本提供认证,也不自动授予例子的发布权。本页为原创解释,不打包标准文档或第三方音频。
准备你的输入
把可执行文件与输入路径换成实际存在的本地文件,从四个源码文件所在目录执行。
使用 macOS 上已有的 Python 3.12 和 FFmpeg 6.0 可执行文件。实际测试环境为 macOS 14.5、Python 3.12.3、FFmpeg 6.0;这些文件不会安装依赖。输入须为非空、未压缩的有符号 PCM16 WAV,单声道或双声道、采样率严格为 48,000 Hz,长度 0.4 至 120 秒,文件小于 16,000,000 字节。RIFF 读取器拒绝不完整音频帧,并接受合法的奇数长度元数据填充。脚本把输入字节复制到私有临时目录,测量这份固定快照,在提交报告前再次核对原文件哈希。
单声道按一个声道测量,明确关闭 dual-mono 补偿;双声道按声明的两个声道测量。环绕声、LFE 路由、其他采样率、压缩上传文件和浏览器解码不在本候选范围内,脚本不会自动转换这些输入。
第一个信号是十秒数值合成的 1 kHz 正弦,样本峰值 10000。第二个保留相同的首秒和峰值,后九秒振幅减半。第三个在第二个之后附加十秒零样本;最后一个是两秒零样本。固定 FFmpeg 6.0 二进制的先前限定观察依次为 -13.3、-18.3、-18.4 LUFS,对应日志门限 -23.3、-28.3、-28.4 LUFS。全零信号日志显示 -70.0 LUFS、门限 0.0,但可报告值为空。这些是确切构造输入的参考观察,不是人声或音乐的普遍预测。
- 可选:使用 macOS 上已有的 Python 3.12 和 FFmpeg 6.0 可执行文件。实际测试环境为 macOS 14.5、Python 3.12.3、FFmpeg 6.0;这些文件不会安装依赖。输入须为非空、未压缩的有符号 PCM16 WAV,单声道或双声道、采样率严格为 48,000 Hz,长度 0.4 至 120 秒,文件小于 16,000,000 字节。RIFF 读取器拒绝不完整音频帧,并接受合法的奇数长度元数据填充。脚本把输入字节复制到私有临时目录,测量这份固定快照,在提交报告前再次核对原文件哈希。