
做播客素材整理的时候我经常要从一堆视频里把音轨单独抠出来。不管是把课程录像存成 flac 保留无损音质还是把临时录制的 mp4 压成 m4a 塞进手机手动一个个拖进软件里转文件少还能忍一旦某个目录下面嵌套了十几层子文件夹几十上百个文件堆在一起人就开始麻了。所以我花了点时间写了个 bash 脚本专门做“批量递归转换 mp4 为 flac/m4a”这件事一直用到现在。这篇文章就把脚本本体、为什么这样设计、以及实际跑起来会遇到哪些坑一次说清楚给有同样需求的人做个参考。我假设你已经装了 ffmpeg、知道它是个什么东西——不知道也没关系后面会顺带解释。脚本本身不复杂核心就三件事递归找到所有 mp4、调用 ffmpeg 提取音频、把结果按原目录结构写到新的目录里。难点主要集中在文件名兼容、目录结构保留、以及不同平台下 bash 环境的行为差异上。1. 需求拆解与脚本设计思路1.1 这个脚本到底解决了什么问题先说需求。很多人会有这么几个场景从 B 站、YouTube 或内部培训系统下载的视频想转成音频丢进播放器听自己录的 mp4 访谈需要提取音轨交给剪辑或转写视频素材归档时单独保留一份音频作为备份从网上下载的无损资源是 m4a/mp4 格式想统一转成 flac 放进 NAS 或音乐库这些场景共同的特征是文件数量多、目录层级深、源文件不一定只有 mp4 这一种后缀。如果手动用格式工厂这类工具转一次能选一批文件但没法保留子目录结构而且也不方便重复执行。如果只用命令行一条条敲文件名里有空格、中文就能让你折腾半天。所以我要写的这个脚本必须满足这几个条件能递归进入所有子目录、能自动处理特殊字符文件名、能把输出目录结构映射成和输入一致、能重复运行不重复劳动。递归这个词在 bash 场景里很简单就是用 find 命令一层层往里翻不需要程序自己维护栈或者调用递归函数。设计成 bash 而不是 Python是因为它不依赖额外的运行时Linux、macOS、Windows 下的 Git Bash 都能直接用而 ffmpeg 本身就是跨平台的组合起来非常顺手。1.2 为什么选 bash ffmpeg 而不是 GUI 工具GUI 转码工具不是不能用但批量场景下痛点很明显。第一很多工具不支持递归扫描子目录你得手动把每个子文件夹拖进去第二一旦源文件命名不规则输出后的结构就乱了第三GUI 操作没法写成脚本下次遇到同样需求你还得再来一遍同样的鼠标点击。ffmpeg 是音视频处理的事实标准几乎所有开源 GUI 工具底层都是它。既然都绕不开它不如直接面对省掉那一层壳。bash 的优势在于find、mkdir、basename 这些工具在系统里天然存在脚本可以写得短小精悍。而且 bash 脚本是可以放进 cron 定时任务、或者配合 inotify 做自动监听的可扩展性强很多。我的设计目标很明确输入输出目录通过命令行参数指定保留输入目录的完整子目录层级遇到同名同格式的已生成文件自动跳过默认只转 mp4但可以顺便覆盖 m4v、mov 这类视频容器支持两种目标格式flac无损、m4a兼容性优先1.3 拿到需求后先列出的坑想清楚了目标再把可能踩到的坑提前列一遍脚本结构就会稳很多。我在动手前梳理了四个最容易翻车的地方。第一是文件名。很多视频文件名长这样[1080P] 第03集 访谈实录完整版.mp4。里面有空格有中文还有括号如果在 for 循环里直接for f in $(find . -name *.mp4)空格就会把文件名切成好几段脚本当场崩掉。正确做法是用 find 的-print0参数配合while IFS read -r -d 来读取这样才能保证文件名原封不动传进去。第二是后缀大小写。有的文件叫.MP4有的叫.Mp4如果 find 只用-name *.mp4会漏掉一部分。要用-iname做大小写不敏感匹配。第三是输出目录不能和输入目录重合。如果脚本输出的路径是固定的比如直接输出到当前目录那反复执行会把源文件目录搅乱。所以我强制要求输出目录独立并且每次执行前检查同名输出文件是否存在。第四是编码器支持。flac 和 m4a 对编码器的要求不一样。flac 用原生 FLAC 编码器即可m4a 可以封装 AAC 或 ALAC。AAC 是有损ALAC 是无损。不同需求对应不同参数不能一刀切。这一点我会在后面的脚本解析里详细展开。2. 核心脚本代码与逐段解析2.1 完整脚本一览直接给可以复制的成品。这个脚本我在 Linux、macOS、Windows Git Bash 上都跑过稳定性没问题。把下面的内容保存成mp4_to_audio.sh然后chmod x mp4_to_audio.sh就能用了。#!/bin/bash # # 批量递归提取 mp4/m4v/mov 音频为 flac 或 m4a # 依赖: ffmpeg必须存在于 PATH 中 # 用法: # ./mp4_to_audio.sh [输入目录] [输出目录] [flac|m4a] [transcode|copy] [并发数] # 示例: # ./mp4_to_audio.sh ./videos ./music flac transcode 4 # ./mp4_to_audio.sh ./videos ./music m4a copy 2 # set -uo pipefail INPUT_DIR${1:-$(pwd)} OUTPUT_DIR${2:-$(pwd)/audio} TARGET_FMT${3:-flac} MODE${4:-transcode} JOBS${5:-1} # 去掉输入目录末尾的斜杠避免后续相对路径计算出错 INPUT_DIR${INPUT_DIR%/} mkdir -p $OUTPUT_DIR # 检查 ffmpeg 是否可用 command -v ffmpeg /dev/null 21 || { echo [错误] 未检测到 ffmpeg请先安装并把它加入 PATH。 exit 1 } LOG_FILE/tmp/mp4_to_audio_$$.log : $LOG_FILE convert_one() { local file$1 local rel_path${file#$INPUT_DIR/} local dir_name dir_name$(dirname $rel_path) local base_name base_name$(basename $file) local stem${base_name%.*} local out_dir$OUTPUT_DIR/$dir_name local out_file$out_dir/$stem.$TARGET_FMT mkdir -p $out_dir # 同名输出已存在则跳过 if [ -f $out_file ]; then echo [跳过] 已存在: $out_file $LOG_FILE return 0 fi local codec_args case $TARGET_FMT-$MODE in flac-*) codec_args-c:a flac ;; m4a-transcode) codec_args-c:a aac -b:a 192k ;; m4a-copy) codec_args-c:a copy ;; *) echo [错误] 不支持的格式/模式组合: $TARGET_FMT-$MODE return 1 ;; esac echo [处理] $file - $out_file $LOG_FILE if ffmpeg -hide_banner -loglevel error -y \ -i $file -vn -map 0:a:0 $codec_args -map_metadata 0 $out_file \ 2 /tmp/ffmpeg_err_$$.log; then echo [OK] $out_file $LOG_FILE else echo [ERR] $file $LOG_FILE cat /tmp/ffmpeg_err_$$.log rm -f /tmp/ffmpeg_err_$$.log fi } export -f convert_one export INPUT_DIR OUTPUT_DIR TARGET_FMT MODE LOG_FILE # 串行模式 if [ $JOBS -le 1 ]; then while IFS read -r -d file; do convert_one $file done (find $INPUT_DIR -type f \( -iname *.mp4 -o -iname *.m4v -o -iname *.mov \) -print0) else # 并发模式 find $INPUT_DIR -type f \( -iname *.mp4 -o -iname *.m4v -o -iname *.mov \) -print0 | \ xargs -0 -P $JOBS -n 1 bash -c convert_one $ _ fi echo 转换结束 echo 成功: $(grep -c ^\[OK\] $LOG_FILE) echo 失败: $(grep -c ^\[ERR\] $LOG_FILE) echo 跳过: $(grep -c ^\[跳过\] $LOG_FILE) echo 输出目录: $OUTPUT_DIR rm -f $LOG_FILE /tmp/ffmpeg_err_$$.log2.2 关键代码段拆解find 与安全读取脚本最核心的一行是find $INPUT_DIR -type f \( -iname *.mp4 -o -iname *.m4v -o -iname *.mov \) -print0这里面有三个关键点。第一是-type f它保证只处理普通文件不会把目录也捞进来。第二是括号里的-o逻辑它表示“扩展名是 mp4 或 m4v 或 mov”注意括号两侧需要转义写成\( ... \)否则 find 的求值顺序会出问题。第三是-print0它让 find 以 null 字符而不是换行符来分隔结果。为什么不用常见的for file in *.mp4因为for循环基于空格分割遇到文件 01.mp4这种名字时会把文件名拆成两半。而-print0配合while IFS read -r -d 是最稳妥的组合IFS禁止修剪首尾空白-r防止反斜杠被解释-d 表示以 null 字符作为分隔符。这个组合是 bash 里处理任意文件名的安全姿势值得记下来。再看相对路径计算。INPUT_DIR在开头做了${INPUT_DIR%/}处理这样当输入是/data/videos/时它会变成/data/videos后续用${file#$INPUT_DIR/}就能把前缀去掉得到从输入目录算起的相对路径。如果保留末尾的斜杠${file#$INPUT_DIR/}会匹配不上导致相对路径变成/data/videos/xxx.mp4输出目录结构就会多嵌一层。这个细节也算是一个小坑。stem${base_name%.*}用来去掉文件名最后一个扩展名保留主名。这里不用basename $file .mp4是因为后者只能处理小写后缀遇到.MP4就罢工而${base_name%.*}是纯粹按最后一个点切分不关心后缀大小写更稳妥。2.3 为什么 m4a 要分 transcode 和 copy 两种模式这里专门说一下 m4a 容器。m4a 是 MPEG-4 Audio 的标准扩展名但容器内部的音频编码可以是 AAC也可以是 ALACApple Lossless。两种编码差别很大AAC 是有损压缩体积小兼容性最好几乎所有播放器和手机都能播ALAC 是无损压缩体积和 flac 接近Apple 生态兼容好但部分安卓播放器支持不佳AAC 和 m4a 哪个好这个问题其实问的就是编码层面和封装层面谁优谁劣。m4a 只是壳AAC 是里面的内容。同样是 m4a 文件可能是 128kbps 的低质量 AAC也可能是 24bit/96kHz 的 ALAC 无损信息量差着数量级。所以脚本里给了两个模式。m4a-transcode用-c:a aac -b:a 192k适合把体积压小、丢进手机听m4a-copy用-c:a copy直接把源文件里的音频流原样拷贝进 m4a 容器。很多 mp4 内部本来就是 AAC 音频流copy 模式不重新编码速度快且没有二次质量损失等于把音轨“解包”出来只是换了个扩展名。flac 模式则必须是转码。因为 mp4 容器里放 FLAC 音频流的情况非常少见绝大多数 mp4 内封的是 AAC 或 AC3想要 flac 只能重新编码。-c:a flac会做一次完全无损的 PCM 转换虽然计算量大但音质没有损耗。-map 0:a:0这一行值得展开。它表示只选用输入文件中的第一个音频流。有些视频会内封多条音轨一条国语一条粤语如果不指定 mapffmpeg 的默认选流策略有时会选出不是你想要的音轨或者因为多音轨而额外输出多个文件。加上这个参数后行为就可预测了。-vn用来丢弃所有视频流-map_metadata 0则把源文件的全局元数据标题、专辑、日期等复制到输出文件对后续管理音乐库很有帮助。3. 实操测试从生成样例到不同平台运行3.1 先造一批测试文件再动手拿到脚本别急着往整个音乐库上跑稳妥的做法是先用几个小文件验证。下面这条命令可以快速生成一个带音视频流的 mp4 测试文件ffmpeg -f lavfi -i testsrcsize320x240:rate30 \ -f lavfi -i sinefrequency1000:duration5 \ -c:v libx264 -c:a aac -shortest test.mp4这条命令用 testsrc 生成一段动态测试画面用 sine 生成 5 秒 1kHz 正弦波封装成带 H.264 视频和 AAC 音频的 mp4。多复制几个到不同子目录mkdir -p test_input/sub1 test_input/sub2 cp test.mp4 test_input/第 1 个文件.mp4 cp test.mp4 test_input/sub1/another.MP4 cp test.mp4 test_input/sub2/中文名 with space.mp4然后跑脚本./mp4_to_audio.sh ./test_input ./test_output flac transcode 1正常情况下你会看到日志里依次出现[处理]和[OK]然后 test_output 下会出现 flac 文件目录层级和 test_input 保持一致。3.2 Linux 和 macOS 下的运行注意点Linux 上主要确认 ffmpeg 已经安装。Debian/Ubuntu 系用sudo apt install ffmpegFedora 用sudo dnf install ffmpeg。macOS 上用 Homebrew 安装brew install ffmpeg即可默认编译版本已经带 flac、aac、alac 编码器不需要额外装第三方库。macOS 下还有一个容易出问题的点如果脚本源文件是在 Windows 或某些编辑器里创建的换行符可能是 CRLF。Mac 的终端在解释这个脚本时会把\r当成参数的一部分报出command not found或$\r: command not found这类错误。解决办法是用 VS Code 底部状态栏把换行符切换成 LF或者执行sed -i s/\r$// mp4_to_audio.sh直接清理。Linux 同样会踩这个坑但更多发生在 Windows 编辑过的脚本身上。macOS 的 find 命令和 GNU find 有一些细节差异不过-iname、-print0、-0这几个特性在 macOS 自带的 BSD find 里都是支持的所以脚本不需要修改就能直接用。3.3 Windows 下用 Git Bash 运行的全流程Windows 用户大概率没有原生的 bash最省事的方式是装 Git Bash。git bash 本身自带 find、basename、xargs、grep 这些基础工具所以脚本里除了 ffmpeg 之外没有需要额外安装的依赖。ffmpeg 在 Windows 下有两个选择。一是从 BtbN 或 gyan.dev 下载编译好的 release 包解压后把bin目录加进系统 PATH。二是在 Git Bash 里临时把路径指过去export PATH/c/ffmpeg/bin:$PATH假设 ffmpeg 放在C:\ffmpeg\bin\ffmpeg.exe那在 Git Bash 里它的路径就是/c/ffmpeg/bin/ffmpeg.exe。加进 PATH 后command -v ffmpeg就能找到它。Git Bash 下运行需要注意三点。第一脚本必须保存为 LF 换行否则 bash 会把\r当成命令的一部分直接导致脚本无法正常解析。第二路径写法用/c/Users/xxx而不是C:\Users\xxx脚本开头给的参数要写成 Git Bash 风格。第三如果输出路径里有中文且脚本所在分区和输出分区不一致偶发会出现权限问题建议把测试目录建在用户主目录下比如/c/Users/你的用户名/audio避免系统盘写入受限。我在 Git Bash 里实测过 200 多个文件、目录深度 5 层的转换单线程跑完大约用时和 Linux 接近没有遇到明显的性能瓶颈或文件丢失问题。Git Bash 的 xargs 是完整 GNU 实现所以xargs -0 -P并发同样是可用的。3.4 并发加速从 1 个任务到 4 个任务转码是 CPU 密集型操作单线程时 ffmpeg 本身就会用多核加速单个文件的编码所以对多文件场景脚本进程层面的并发带来的提升不是线性的。我实测下来4 个并发任务在 8 核机器上比单任务大约快 2 到 3 倍再往上加比如 8 个并发速度提升不明显反而会因为 CPU 争抢和磁盘 IO 竞争导致个别文件耗时变长。所以脚本的第五个参数建议设成 1 到 4。如果你只是偶尔转几个文件用默认的 1 就够如果是一次转几百个设成 3 或 4 比较合适。并发模式用 xargs 的-P参数实现每个任务通过bash -c convert_one $ _把文件名位置参数化传入函数这样即使用-n 1也不会因为文件名里有空格而出错。需要注意并发模式下的日志会集中写到同一个临时文件里。因为每次 echo 都是短字符串POSIX 系统对 O_APPEND 方式的追加写入基本是原子的所以基本不会出现交错串行。如果你发现日志出现串行可以在 echo 语句里加上文件名强制刷新不过实际上我跑到现在还没遇到过一次。4. 常见问题与排查技巧实录4.1 ffmpeg 未找到与编码器不支持如果脚本报[错误] 未检测到 ffmpeg说明 PATH 里没有 ffmpeg。先运行ffmpeg -version确认是否安装。如果在 Windows 下装了 ffmpeg 但 Git Bash 里还是找不到大概率是 PATH 没刷新新开一个 Git Bash 窗口或者手动执行export PATH/c/ffmpeg/bin:$PATH。如果提示Unknown encoder flac说明当前 ffmpeg 编译版本里没有 FLAC 编码器。这样的发行版很少见但确实存在。解决办法是重装完整版 ffmpeg或者改用 ALAC 作为无损方案。把脚本里的-c:a flac替换成-c:a alac输出格式保持 m4a也能达到无损目的。4.2 文件名包含特殊字符时如何处理我见过最极端的文件名是带换行符和引号的。这类文件在普通脚本里必挂但在当前脚本中是安全的因为所有关键路径都加了双引号包裹读取时用的是-print0加read -d 。唯一要提醒的是不要在脚本内部像这样写“$file.mp4”而是用stem变量去掉扩展名后再拼新扩展名否则会得到双重扩展名。中文文件名乱码的情况在 Linux 下通常是系统 locale 问题而不是脚本问题。确保系统 locale 支持 UTF-8运行locale查看一般现代桌面发行版默认就是 UTF-8。Windows 的 Git Bash 默认也支持 UTF-8中文文件名的处理比 CMD 好不少。4.3 输出文件已存在时的跳过逻辑脚本里的跳过逻辑很简单目标 flac 或 m4a 文件只要存在就跳过这个源文件。这个机制对断点续跑非常有用。比如转了一半停电重新跑一次脚本已经转好的文件就不会再动只处理剩下的。但这里有个隐蔽坑如果你调整了参数比如之前用copy模式生成了 m4a现在想改用transcode模式重新转那么旧文件还在脚本会全部跳过。解决办法是删掉旧输出目录或者改输出目录名称。更精细的做法是把跳过条件改成“输出存在且大小大于某个阈值”但实际意义不大因为 ffmpeg 很少会生成零字节文件。真遇到了零字节输出大概率是源文件本身有问题可以用下面的方式检查源文件是否包含音频流ffprobe -v error -select_streams a -show_entries streamcodec_name -of defaultnoprint_wrappers1 input.mp4如果这条命令没有任何输出说明该 mp4 没有音频流转码必然失败。4.4 flac 96kHz/24bit 转 alac 失败怎么处理GitHub 上有个典型问题flac 96kHz/24bit 转换 alac 失败。这通常不是 flac 文件损坏而是 ffmpeg 的 alac 编码器在无损重采样链路上有兼容限制。具体情况是某些 96kHz 的 flac 文件采样点布局是 s32psigned 32-bit planar而 alac encoder 内部对部分高位深输入支持不完整直接转会在编码阶段报错。解决办法是显式指定采样格式让编码器拿到一个确定能处理的输入。比如ffmpeg -i input.flac -c:a alac -sample_fmt s16p output.m4a强制转成 16bit 会丢失一部分动态范围严格意义上算不上无损了。另一个方案是保留位深但重采样到 48kHzffmpeg -i input.flac -c:a alac -ar 48000 output.m4a实际操作中真正追求无损又不折腾的人通常会选择保持 flac 原始格式因为在 NAS、PC 播放器这类场景里flac 兼容性反而比 alac 更好。alac 只在 Apple 生态的硬解场景下才有明显优势。4.5 目录结构丢失或输出路径多嵌套一层如果输出目录里出现了完整的绝对路径比如/data/videos/音频而不是相对输入目录的结构大概率是相对路径计算出了问题。常见原因有两个一是输入目录末尾带斜杠二是输入目录用的是相对路径但 find 给出的路径不是以输入目录前缀开头。例如你运行./mp4_to_audio.sh videos out此时INPUT_DIRvideos而 find 返回的路径是./videos/sub/file.mp4${file#$INPUT_DIR/}并不能正确去掉./videos/前缀因为字符串前缀是videos/而不是./videos/。解决办法我提供两个。最简单的是在脚本开头做一次 realpath 归一化INPUT_DIR$(realpath $INPUT_DIR)这样不管用户传的是相对路径还是带./的路径都会被转为绝对路径find 的路径和前缀计算就保持一致了。我当前版本为了保持脚本最简没有加这一步读者可以自己加上。加了之后建议对输出目录也做同样的 realpath 处理避免混用相对绝对路径造成意外。4.6 常见问题速查表问题现象主要原因解决办法command not found: ffmpeg未安装或不在 PATH安装 ffmpeg确认ffmpeg -version可用$\r: command not found脚本是 CRLF 换行sed -i s/\r$// mp4_to_audio.sh输出多嵌套一层目录输入目录末尾斜杠或相对路径问题用realpath归一化输入目录中文文件名乱码locale 非 UTF-8检查locale切换到 UTF-8已存在文件全部跳过输出目录里有旧文件删除输出目录或更换输出目录名没有输出 flac 文件源 mp4 无音频流先用 ffprobe 查流信息alac 转码失败高位深格式不兼容加-sample_fmt s16p或-ar 48000并发日志串行多进程同时写日志文件正常情况不会发生可忽略或改用分行加锁我自己最常用的是 flac transcode 模式因为手上的音乐库对无损有硬性要求NAS 里存的音频全部是 flac 容器。给手机临时做缓存时会用 m4a copy速度快且不占空间。日常维护这套脚本的体感是写一次后面每换一台电脑只需装好 ffmpeg 和 git bash脚本拷过去就能跑省掉了所有 GUI 操作的重复劳动。最后分享两个实测心得。第一运行前先用ffprobe -v error -show_streams看一下视频文件里的音频编码格式如果本来就是 AACm4a copy 模式几乎瞬间完成如果是 AC3 或 DTS想转成 m4a 就绕不开重编码。第二如果你有一些体积特别大、动辄 4K 的长视频转码前可以考虑先用-ss和-t做无损切片把真正需要保留的片段切出来再转能节省大量时间这其实是 ffmpeg 另一个非常实用的能力和本文的批量转码脚本组合使用效果很好。