我自己做的音频收取工具43 项测试全绿,程序里却躺着 3 个真实缺陷
我以为一下午能写完的转码小工具,最后栽在三个「测试全绿」的缺陷上 我想做个东西,把一堆 mp4 的音频批量抽出来存成 MP3,音质能自己选。
按理说这是个下午茶的活。Python 生态里现成轮子一抓一大把,套个 tkinter 界面就完事,代码量撑死几百行。
然后这件事从头到尾都在打我的脸。不是因为它难,而是因为我几乎每一个「想当然」,最后都被证明是错的。
先摸底,第一脚就踩空 动手之前照例先看环境。这一看就发现问题了,这台机器上根本没有 ffmpeg。
更迷惑的是,pip list 里赫然躺着一个叫 ffmpeg 的包,版本 1.4。名字对得上,版本号也挺齐全,看着就像那么回事。
真相是,这个包是个空壳,它只负责转发调用系统的 ffmpeg,自己一点二进制都不带。
而「从 mp4 里抽音频」这件事,本质上必须有 demuxer 和 decoder。我顺手查了下那几个常用库,moviepy、pydub、librosa,底层全都依赖 ffmpeg,一个都绕不开。
所以「怎么搞到 ffmpeg」不是可选项,它才是这个项目的第一个架构决策。
摆在面前三条路。让用户自己装,程序启动时探测 PATH。或者首次运行时联网下载。或者找那种 pip 装完就能直接用的。
我选了第三条,imageio-ffmpeg。
理由很简单,前两条都会把麻烦转嫁给使用者。这个工具最后是要打包发给同事的,如果同事拿到 exe 之后还得自己去官网下 ffmpeg 配环境变量,那打包这件事本身就失去意义了。
定下来之后我没有直接开写,而是先花时间验证了一件事,这个包里到底有没有 MP3 编码器。
因为 ffmpeg 本身并不自带 MP3 编码能力,它靠的是 libmp3lame 这个外部库。如果随包附带的构建恰好是精简版没带这个库,那整个项目的前提就不成立,前面所有选型都白做。
跑完确认,libmp3lame 在。地基稳了。
差点被一个字段名骗过去 真正开始写代码之后,我挖出一个很阴险的坑。
ffmpeg 有个 -progress 参数,能把进度以机器可读的 key=value 形式吐出来。我本来打算读其中的 out_time_ms 来算百分比,名字看着特别直白,毫秒嘛。
实测输出长这样
out_time_us=3018594 out_time_ms=3018594 一个 3 秒的文件,这两个字段的数值一模一样。
也就是说 out_time_ms 这个名字本身就是错的,它的值根本不是毫秒,而是和 out_time_us 相同的微秒。这是 ffmpeg 一个长期存在的历史遗留问题,字段名写错了但一直没改,因为改了会破坏兼容性。
如果我照着字段名当毫秒用,算出来的进度会放大一千倍,3 秒会被当成 50 分钟。表现出来就是进度条纹丝不动,然后在最后一瞬间突然跳满。
这种 bug 最烦人的地方在于,它不报错、不崩溃,只是安静地给你一个错误的结果。而且后面任何一个人读这段代码,都会觉得「读 out_time_ms 有什么问题」。
我在代码里留了注释说明原因,还专门写了个测试把这个坑钉死,断言的写法就是「如果按毫秒算,结果会差 1000 倍」。
我以为它 25 MB,实际是 87.6 MB 选型的时候我脑子里有个大概印象,ffmpeg 的静态构建差不多二十几兆。
打包出来一看,傻了。那个二进制文件是 87.6 MB,单独一个文件就占了整个包的 85%。
我把包解开看了体积构成,除了 ffmpeg 之外,Python 运行时加上各种依赖库总共才十几个兆。整个 111 MB 的解压体积里,ffmpeg 是绝对主角。
这个数字直接改变了后面的一个决策。
单文件 exe 的卖点,其实是假的 同事要的是「双击就能用」的东西。我第一反应是打包成单个 exe,干净利落,发一个文件过去多省事。
但我不太喜欢凭印象下结论,就实际构建了两种形态来测。
结果挺打脸的。
单个 exe,38.5 MB,每次启动 9.30 秒。
文件夹形态压缩成的 zip,39.7 MB,每次启动 0.66 秒。
体积几乎一样,启动速度差 14 倍。
原因在于「单文件」模式每次运行都要把整个包解压到临时目录。而它唯一的卖点,那个「只发一个文件」的便利,在体积上根本没兑现,因为 ffmpeg 那 87 MB 压缩之后也就三十来兆,两种形态的压缩率没有区别。
所以单文件的优势是想象出来的,代价却是每次都要等 9 秒。双击之后对着空屏干等 9 秒,用户第一反应肯定是程序坏了。
最后选了文件夹方案,压缩成一个 zip 发出去。
测试全绿,但程序是坏的 这部分是整件事里最值得说的。
代码写完之后我跑了 43 项测试,全绿。如果就此收工,交付出去的会是一个有缺陷的东西。
但我决定真的把程序跑起来看一眼。这一看,问题全出来了。
第一个缺陷,一个纯视频(没有音轨)被标记成了「失败」。可我设计的时候明确写过,这种情况应该标记为「跳过」,而且不计入失败总数。
往深了想还有一层。探测数据其实能区分两种情况,如果一个文件能读出正常时长但找不到音轨,那是真的没有音轨;如果连时长都读不出来,那是文件损坏。这两种情况在界面上应该给出完全不同的提示,我却把它们混成了一类,都报「未检测到音轨」。用户看到一个正常的无声视频和看到一个损坏文件,得到的信息是一模一样的。
第二个缺陷更吓人。
我在入口脚本里加了个自检功能,跑起来却始终返回退出码 1,但报告内容明明白白写着一切正常。
查下来是两个 bug 叠在一起。一是入口文件里漏了 import os,而自检代码里用到了 os.path.isfile。二是我的崩溃兜底写成了 except BaseException,而 sys.exit(0) 抛出的 SystemExit 正好是它的子类,于是所有正常退出都被自己的兜底捕获,反手改成了退出码 1。
但这里有个细节值得单独拎出来说。
那个 import os 的问题,在打包后的版本里根本没有暴露。 因为 PyInstaller 的冻结运行时恰好把 os 注入到了全局命名空间,程序侥幸能跑。
也就是说,如果我只测打包版本,这个 bug 永远不会被发现。而它一旦在别的环境里触发,就是启动即崩,用户看到的是「双击没反应」。
靠环境巧合掩盖的 bug 是最危险的,因为你所有的验证都会告诉你「没事」。
还有一个假阳性,是我自己造的 我还写了个冒烟测试,驱动界面跑完整流程。测到「中途取消」这一步时,它显示通过。
但输出里有一行让我起了疑心,它说取消时正在运行的任务数是 0。
也就是说,那次取消打断的只是排队等待的任务,压根没碰到正在运行的进程。而我真正想验证的那条路径,进程强杀之后半成品文件能不能清理干净,其实根本没被覆盖到。
原因是我在测试脚本里用了 time.sleep 等待。这期间 tkinter 的事件循环是停摆的,界面状态当然不会更新,我看到的「没有正在运行的任务」纯粹是个假象。
改成边等待边泵事件循环之后,测试才真正打断了两个正在运行的进程。结果符合预期,输出目录里一个残缺文件都没留下。
但如果我没有多看那一眼输出,我会一直以为这条路径已经验证过了。
写在最后 这个工具本身没什么了不起的,就是把 mp4 的音频抽出来存成 MP3,支持五档音质,并行转换,窗口里能看到每个文件的进度和状态。
但它让我重新确认了几件事。
别信自己的印象,去测。 我以为 ffmpeg 是 25 MB,实际 87.6 MB。我以为单文件 exe 只是稍微慢一点,实际慢 14 倍。这两个数字,都直接改变了我的技术决策。如果我按印象走,交付出去的就是一个双击要等 9 秒、同事以为坏了的程序。
测试通过和程序能用,是两件完全不同的事。 43 项测试全绿的时候,程序里躺着三个真实缺陷。它们都不在测试覆盖的范围内,只有在真的把程序跑起来、真的去逐字看输出的时候才会现形。
靠巧合成立的代码最危险。 那个漏掉的 import os 在所有打包测试里都毫无症状,因为构建工具恰好替它兜了底。而侥幸这件事,不会一直侥幸下去。
那个自检功能我最后保留了下来。因为打包后的程序是没有控制台的,出了问题用户只会看到「双击没反应」,完全无从排查。现在让同事跑一句带 --selftest 的命令,就能拿到一份报告,明确告诉他到底是哪里不对。
顺便说,这个项目还有个绕不过去的现实问题。包内那份 ffmpeg 是 GPL v3 授权的,随程序一起分发给同事,严格来讲构成 GPL 意义上的再分发。内部使用没人会追究,但这属于应该知情的事。如果哪天要对外发布,换成 LGPL 构建就行,代价是少部分编码器不可用,不过这个项目只需要 libmp3lame,LGPL 版本同样有。
工具已经打包好了,43 MB,解压双击就能用。
你在自己的项目里,有没有遇到过那种「测试全绿但其实是坏的」的情况?

