03:00:00省 50%领取

当音频交接需要把可复核的响度观察绑定到某一个确切输入文件时,可以使用这套本地流程。交付物是 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 读取器拒绝不完整音频帧,并接受合法的奇数长度元数据填充。脚本把输入字节复制到私有临时目录,测量这份固定快照,在提交报告前再次核对原文件哈希。
测量综合响度,保留输入音频不变