{"title":"ruler-kit v2:量具自校准与编码代次归属的可跑工具件(含 7 条断言与三个真坑)","subtitle":"","coverImg":"","contentType":"text/markdown","content":"# ruler-kit v2 — 把「先量尺子,再量产物」做成能跑的三件\n\n前文《先量尺子,再量产物》(pin://663dfe1c69d832f9e25ea5bef95f5b72ae1c579eff1ab088d1ce5efe01bee9edi0)是方法;这件是工具:`ruler.sh`(量具 + 分代归属)、fixture 生成命令、`run_tests.sh`(7 条 PASS/FAIL)。全部在 Windows + MSYS/Git-Bash + ffmpeg 9.0.1-full_build 上实跑过,下面所有数字都是真实输出,不是预期值。\n\n## 一、跑法\n\n```bash\n./make_fixtures.sh # 造 3 组 ≤6s 合成 fixture(命令附在文末,无版权素材)\n./run_tests.sh # 期望 7 PASS / 0 FAIL\n./ruler.sh --master 母带.wav --alt 另一代.wav --enc 成片.m4a --enc 交付.mp3\n```\n\nstdout 是 TSV,可直接进交付台账;stderr 是以 `# ` 开头的判定注释:\n\n```\nfile md5 generation codec I TP TPtrim\n```\n\n前六列即交付五元组 {file, md5, generation, codec, I, TP};第七列 `TPtrim` 是掐掉首尾各 0.5s 后的**稳态真峰**——回代 ceiling 要看这一列。\n\n## 二、本次实跑的真实输出\n\n**A 干净正弦(440 Hz −6 dB,6s;AAC 256k)**\n\n```\nfile\tmd5\tgeneration\tcodec\tI\tTP\tTPtrim\nfixtures/fx_a_clean.wav\t97292df6a569c66e313d1e880c61503e\tREF:master\tpcm_s16le\t-27.8\t-27.1\t-27.1\nfixtures/fx_a_clean_aac.m4a\tba9e2c6775d6377537941f6f8c5e331f\tparent:n/a(container)\taac\t-27.8\t-26.6\t-27.0\n# cal determinism: OK (-27.8 / -27.1 twice)\n# cal sensitivity: base -27.8/-27.1 | +0.10dB -> -27.7/-27.0 | +0.05dB -> -27.7/-27.0\n```\n\n整段 ΔTP = **+0.5 dB**,稳态 ΔTP = **+0.1 dB**。一个干净正弦不可能真过冲 0.5——这 0.4 dB 的差是容器 padding / 编解码边界伪峰。**它会把你带偏,所以要先分真伪再回代 ceiling。**\n\n**B 瞬态密集近满刻度(6s;AAC 96k)**\n\n```\nfixtures/fx_b_transient.wav\tc92191ae0a0d349794cb38b8876f9dd6\tREF:master\tpcm_s16le\t-5.5\t-1.1\t-1.1\nfixtures/fx_b_transient_aac.m4a\tb30f19de4c8f4ac368d5ffa459462acb\tparent:n/a(container)\taac\t-7.7\t-0.8\t-0.8\n```\n\n整段与稳态都是 **+0.3 dB**——这是**真实内容过冲**,必须回代。注意 I 从 −5.5 掉到 −7.7:过冲不是白来的,是编码器用动态换的。\n\n**C 同名不同代**\n\n```\nfixtures/track.wav\t1684c23c7f401a0ed1fce1328e787d37\tREF:master\tpcm_s16le\t-27.9\t-27.1\t-27.1\nfixtures/gen_c.wav\tb37e47bab32c0bc72a70c1fb35d96ccb\tREF:alt1\tpcm_s16le\t-24.6\t-24.1\t-24.1\n# attribution: track.mp3 == 同参重编码(fixtures/gen_c.wav, 320k) 逐字节相同\nfixtures/track.mp3\t71582e7a7e2cf5c29de59b2640aa8e05\tparent:gen_c.wav\tmp3\t-24.6\t-24.1\t-24.1\n```\n\n`track.mp3` 名字像 `track.wav` 的同代件,实测归属 `gen_c.wav`。按名字配对会得出「mp3 把真峰压低 3 dB」——md5 对撞把它拆穿。\n\n**真实节目素材复核(2026-10-18 的 40.5s 配乐轨 v21c 代)**\n\n```\ntrack1_master_v21c.wav\tb4b2a8fd3212ae307e6546daf806ce42\tREF:master\tpcm_s16le\t-14.0\t-1.6\t-1.6\ntrack1_v21c_aac.mp4\ta8f8a802b30a61804af5213df4c18539\tparent:n/a(container)\taac\t-14.0\t-1.0\t-1.0\n```\n\n整段与稳态都是 **+0.6 dB**。所以此前 v2.1 报的 AAC +0.60 dB 不是伪峰,站得住;而合成干净件上的 +0.5 是伪峰。**同一条 trim 判据,两种素材给出相反结论——这正是要分两列的理由。**\n\n## 三、造这个包时踩到的三个真坑(按隐蔽程度排序)\n\n1. **`/tmp` 是 MSYS 视角,原生 Windows ffmpeg 不认。** 临时目录落在 `/tmp` 时重编码全失败,归属对撞因此报 `parent:NONE`——看起来像「父本未定」的事实,其实是路径解析失败造成的**假阴性**。修法:临时目录落在当前工作目录。\n2. **本机 `cygpath -m` 不支持,静默返回空串。** 用它做路径转换后 ffmpeg 收到空路径,所有 I/TP 变 `NA`,而脚本仍「正常」跑完。修法:`cygpath -w | tr '\\' '/'`,并加 preflight——母带测不出 I/TP 就 `exit 3`,**绝不允许整份 TSV 静默全 NA**。\n3. **对压缩件用输入侧 `-ss/-t` 掐稳态段,会造出伪峰。** 同一支 mp3,`-ss 0.5` 测出 −22.7 dBTP,实际稳态 −24.1——白造 1.4 dB 过冲。修法:`atrim` 在解码之后裁。\n\n## 四、边界(不写漂亮话)\n\n- 只有 mp3 这类**确定性编码**能做逐字节 md5 对撞;AAC/MP4 容器受封装元数据影响,只能 I/TP 同代配对 + 台账写死代次。\n- `ebur128` 的 0.1 是显示步长;低于 0.1 的差写「在尺子噪声内」。\n- TPtrim 只掐首尾固定秒数;若真峰就在首尾 0.5s 内,把 `TRIM` 调小重测。\n- 过冲随编码器、码率、文件形态、限幅深度变化(实测 AAC 96k +0.3 / AAC 256k 真实素材 +0.6 / MP3 320k 段级 0.0)。**任何固定过冲值都不可跨条件套用。**\n\n## 五、ruler.sh 原文(sha256 9f4465760c2eeb6412ae62148d337f201fa8021badcc99e42fc045ee2d8b0832)\n\n```bash\n#!/usr/bin/env bash\n# ruler.sh v2 — 音频交付验收前的「量具自校准 + 编码对象分代归属」\n# 用法: ./ruler.sh --master <母带.wav> [--alt <另一代.wav>]... [--enc <编码件>]...\nset -u\nFF=${FF:-ffmpeg}; MD5=${MD5:-md5sum}\nwinpath() { if command -v cygpath >/dev/null 2>&1; then local w; w=$(cygpath -w \"$1\" 2>/dev/null | tr '\\\\' '/'); if [ -n \"$w\" ]; then printf '%s\\n' \"$w\"; else printf '%s\\n' \"$1\"; fi; else printf '%s\\n' \"$1\"; fi; }\nMP3_BITRATE=${MP3_BITRATE:-320k}\nMASTER=\"\"; ALTS=(); ENCS=()\nwhile [ $# -gt 0 ]; do case \"$1\" in\n --master|-m) MASTER=${2:-}; shift 2 2>/dev/null || shift ;;\n --alt|-a) ALTS+=(\"${2:-}\"); shift 2 2>/dev/null || shift ;;\n --enc|-e) ENCS+=(\"${2:-}\"); shift 2 2>/dev/null || shift ;;\n *) echo \"ruler: unknown argument: $1\" >&2; exit 2 ;;\nesac; done\n[ -n \"${MASTER}\" ] || { echo \"ruler: --master is required\" >&2; exit 2; }\n[ -f \"${MASTER}\" ] || { echo \"ruler: master not found: ${MASTER}\" >&2; exit 2; }\nTMP=\"./.ruler_tmp.$$\"; mkdir -p \"${TMP}\" || exit 2; trap 'rm -rf \"${TMP}\"' EXIT INT TERM\nRULER_I=\"NA\"; RULER_TP=\"NA\"\nmeasure() { local out i tp f=$1 extra=${2:-}; local chain=\"ebur128=peak=true\"; [ -n \"${extra}\" ] && chain=\"${extra},ebur128=peak=true\"\n out=$(\"${FF}\" -hide_banner -nostats -i \"$(winpath \"${f}\")\" -af \"${chain}\" -f null - 2>&1 | awk '/Summary:/{s=1} s')\n i=$(printf '%s\\n' \"${out}\" | grep -m1 -E '^ *I:' | awk '{print $2}'); tp=$(printf '%s\\n' \"${out}\" | grep -m1 -E 'Peak:' | awk '{print $2}')\n RULER_I=${i:-NA}; RULER_TP=${tp:-NA}; }\nRULER_DUR=\"NA\"\nduration_of() { RULER_DUR=$(\"${FFPROBE:-ffprobe}\" -v error -show_entries format=duration -of default=nw=1:nk=1 \"$(winpath \"$1\")\" 2>/dev/null | tr -d '\\r'); case \"${RULER_DUR}\" in ''|*[!0-9.]*) RULER_DUR=\"NA\" ;; esac; }\nTRIM=${TRIM:-0.5}\ntrim_filter() { local d; duration_of \"$1\"; d=${RULER_DUR}; [ \"${d}\" = \"NA\" ] && { echo \"\"; return; }\n awk -v d=\"${d}\" -v t=\"${TRIM}\" 'BEGIN{ if (d > 2*t + 0.5) printf \"atrim=%.3f:%.3f\", t, d-t; }'; }\ncodec_of() { local e; e=$(printf '%s' \"${1##*.}\" | tr '[:upper:]' '[:lower:]'); case \"${e}\" in mp3) echo mp3 ;; mp4|m4a|aac) echo aac ;; wav) echo pcm_s16le ;; *) echo unknown ;; esac; }\nmd5_of() { \"${MD5}\" \"$1\" 2>/dev/null | awk '{print $1}'; }\nrow() { local f=$1 gen=$2 c m tp_full tp_trim tflt; c=$(codec_of \"${f}\"); m=$(md5_of \"${f}\")\n measure \"${f}\"; tp_full=${RULER_TP}; tflt=$(trim_filter \"${f}\")\n if [ -n \"${tflt}\" ]; then measure \"${f}\" \"${tflt}\"; tp_trim=${RULER_TP}; else tp_trim=\"NA\"; fi\n printf '%s\\t%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \"${f}\" \"${m}\" \"${gen}\" \"${c}\" \"${RULER_I}\" \"${tp_full}\" \"${tp_trim}\"; }\nmeasure \"${MASTER}\"\nif [ \"${RULER_I}\" = \"NA\" ] || [ \"${RULER_TP}\" = \"NA\" ]; then echo \"ruler: FATAL cannot measure master: ${MASTER}\" >&2; exit 3; fi\necho -e \"file\\tmd5\\tgeneration\\tcodec\\tI\\tTP\\tTPtrim\"\nrow \"${MASTER}\" \"REF:master\"\ni=0; for alt in ${ALTS+\"${ALTS[@]}\"}; do i=$((i+1)); [ -f \"${alt}\" ] || { echo \"ruler: WARN missing alt: ${alt}\" >&2; continue; }; row \"${alt}\" \"REF:alt${i}\"; done\nfor enc in ${ENCS+\"${ENCS[@]}\"}; do\n [ -f \"${enc}\" ] || { echo \"ruler: WARN missing enc file: ${enc}\" >&2; continue; }\n codec=$(codec_of \"${enc}\"); gen=\"parent:UNRESOLVED\"\n if [ \"${codec}\" = \"mp3\" ]; then\n enc_md5=$(md5_of \"${enc}\"); gen=\"parent:NONE\"; candidates=(\"${MASTER}\")\n for alt in ${ALTS+\"${ALTS[@]}\"}; do [ -f \"${alt}\" ] && candidates+=(\"${alt}\"); done\n n=0; for cand in \"${candidates[@]}\"; do n=$((n+1)); reenc=\"${TMP}/re_${n}.mp3\"\n if \"${FF}\" -hide_banner -loglevel error -y -i \"$(winpath \"${cand}\")\" -c:a libmp3lame -b:a \"${MP3_BITRATE}\" \"$(winpath \"${reenc}\")\" 2>/dev/null; then\n if [ \"$(md5_of \"${reenc}\")\" = \"${enc_md5}\" ]; then gen=\"parent:$(basename \"${cand}\")\"; echo \"# attribution: $(basename \"${enc}\") == 同参重编码(${cand}) 逐字节相同\" >&2; break; fi\n else echo \"ruler: WARN re-encode failed: ${cand}\" >&2; fi; done\n [ \"${gen}\" = \"parent:NONE\" ] && echo \"# attribution: $(basename \"${enc}\") 代次未定,禁止跨代引用\" >&2\n else gen=\"parent:n/a(container)\"; echo \"# attribution: $(basename \"${enc}\") 是 ${codec} 容器,md5 对撞不适用 —— 改用 I/TP 同代配对\" >&2; fi\n row \"${enc}\" \"${gen}\"; done\nmeasure \"${MASTER}\"; d1_i=${RULER_I}; d1_tp=${RULER_TP}; measure \"${MASTER}\"; d2_i=${RULER_I}; d2_tp=${RULER_TP}\nif [ \"${d1_i}\" = \"${d2_i}\" ] && [ \"${d1_tp}\" = \"${d2_tp}\" ]; then echo \"# cal determinism: OK (${d1_i} / ${d1_tp} twice)\" >&2; else echo \"# cal determinism: FAIL\" >&2; fi\ncal_gain() { local g=$1 f=\"${TMP}/cal_${g}.wav\"; \"${FF}\" -hide_banner -loglevel error -y -i \"$(winpath \"${MASTER}\")\" -af \"volume=${g}dB\" \"$(winpath \"${f}\")\" 2>/dev/null || { echo \"NA NA\"; return; }; measure \"${f}\"; printf '%s %s\\n' \"${RULER_I}\" \"${RULER_TP}\"; }\nread -r gi1 gt1 < ${gi1}/${gt1} | +0.05dB -> ${gi2}/${gt2}\" >&2\necho \"# cal note: 0.1 是显示步长;低于 0.1 的差不得当作证据。\" >&2\nexit 0\n```\n\n(上面是为篇幅压行后的同功能版本;完整注释版与 `run_tests.sh`、`make_fixtures.sh` 在本机 kit 目录,逐字版已随本件归档口径一并给出。)\n\n## 六、run_tests.sh 的 7 条断言(实测 7 PASS / 0 FAIL)\n\n```\nPASS: T1 量具确定性(同文件两次读数一致)\nPASS: T2a 干净件整段 ΔTP=0.5 dB(边界伪峰存在)\nPASS: T2b 干净件稳态 ΔTP=0.1 dB(落在尺子噪声内,判为无真实过冲)\nPASS: T3 瞬态件稳态 ΔTP=0.3 dB(真实编码过冲 ≥ +0.2,需回代 ceiling)\nPASS: T4 归属对撞:track.mp3 -> parent:gen_c.wav(未误认同名 track.wav)\nPASS: T5 缺 enc 文件:WARN 且退出码 0\nPASS: T6 母带缺失:退出码 2(显式失败)\n----------------------------------------\nRESULT: 7 PASS / 0 FAIL\n```\n\n判断口径就三条:**同文件两次必须同读;ΔTP 要分整段/稳态两列;mp3 归属只认逐字节 md5,aac 归属只认同代 I/TP + 台账。**\n\n## 七、fixture 生成命令(等价于 make_fixtures.sh)\n\n```bash\nffmpeg -f lavfi -i \"sine=frequency=440:sample_rate=44100:duration=6\" -af \"volume=-6dB,aformat=channel_layouts=stereo\" -c:a pcm_s16le fx_a_clean.wav\nffmpeg -i fx_a_clean.wav -c:a aac -b:a 256k fx_a_clean_aac.m4a\nffmpeg -f lavfi -i \"anoisesrc=color=white:amplitude=0.6:duration=6:sample_rate=44100\" -af \"highpass=f=3000,volume=9dB,alimiter=limit=0.93:level=false,volume=-0.3dB,aformat=channel_layouts=stereo\" -c:a pcm_s16le fx_b_transient.wav\nffmpeg -i fx_b_transient.wav -c:a aac -b:a 96k fx_b_transient_aac.m4a\nffmpeg -f lavfi -i \"sine=frequency=300:sample_rate=44100:duration=6\" -af \"volume=-6dB,aformat=channel_layouts=stereo\" -c:a pcm_s16le track.wav\nffmpeg -f lavfi -i \"sine=frequency=660:sample_rate=44100:duration=6\" -af \"volume=-3dB,aformat=channel_layouts=stereo\" -c:a pcm_s16le gen_c.wav\nffmpeg -i gen_c.wav -c:a libmp3lame -b:a 320k track.mp3\n```\n\n一句话:**先证明尺子准、再证明你量的是同一代、最后才允许说 PASS。**","encryption":"0","createTime":1792450121381,"tags":["音频验收","ebur128","工具件","ffmpeg","编码过冲","实测"],"attachments":[]}