用纯 Python 标准库写一个本地音频处理工具
想做什么
手头有一批不同格式的音频文件,想统一转成 mp3。不想装一堆依赖,也不想把文件传到任何在线服务——最好是双击一下、浏览器打开、拖进去就能转的本地小工具。
于是就有了这个项目:一个零第三方依赖的本地 Web 工具。后端只用 Python 标准库,前端纯原生 HTML/JS,转码交给 ffmpeg。整个流程数据不出本机。
整体架构
1 | 浏览器(拖拽上传 / 展示 / 下载) |
前端和后端通过几个简单的 HTTP 接口通信,后端跑在本机 127.0.0.1,只有自己能访问。
后端:只用标准库搭 HTTP 服务
没装 Flask、没装 FastAPI,直接基于标准库的 http.server。
1. 一个 Handler 处理所有请求
1 | import http.server, socketserver |
启动时用多线程版本,并允许端口复用,方便重启:
1 | socketserver.ThreadingTCPServer.allow_reuse_address = True |
ThreadingTCPServer 让每个请求跑在独立线程里,多文件转换不会互相阻塞;allow_reuse_address 让你重启服务时不会因为端口还占着而报错。
2. 静态文件服务 + 目录穿越防护
前端页面就是几个静态文件,直接读出来返回。但不能直接把用户传来的路径拼上去,否则 /../../etc/passwd 这种目录穿越就能读到任意文件:
1 | target = (WEB_DIR / path.lstrip("/")).resolve() |
resolve() 把路径规范化成绝对路径,再校验它确实落在 WEB_DIR 目录内,越界就 404。
3. 按 Content-Length 精确读上传体
接收上传的文件时,先读请求头里的 Content-Length,再精确读那么多字节,而不是一直读到超时:
1 | length = int(self.headers.get("Content-Length", "0")) |
4. 中文文件名的正确姿势
文件名通过自定义请求头 X-Filename 传过来(前端用 encodeURIComponent 编码过)。服务端解码后,再用 pathlib.Path(name).name 把可能的路径部分剥掉,避免文件名里夹带路径:
1 | raw_name = urllib.parse.unquote(self.headers.get("X-Filename", "input.mflac")) |
返回 mp3 时,用 RFC 5987 的方式编码中文文件名,否则浏览器会拿到一串乱码:
1 | disp = f"attachment; filename*=UTF-8''{urllib.parse.quote(out_file.name)}" |
前端:拖拽上传 + 批量 + 下载
前端没用任何框架,就原生 DOM + fetch。
拖拽上传
监听 drop 事件拿到文件列表,过滤出想要的扩展名,再逐个处理:
1 | dropzone.addEventListener("drop", (e) => { |
逐个上传 + 进度
批量时按顺序一个个转,避免同时打爆后端:
1 | for (let i = 0; i < files.length; i++) { |
单个文件用 fetch POST,请求体直接就是文件本身,文件名放 X-Filename 头:
1 | const resp = await fetch("/api/convert", { |
把响应存成文件下载
后端返回的是 mp3 二进制,用 Blob 包一层,再借一个临时 <a> 标签触发下载:
1 | function downloadBlob(name, blob) { |
URL.revokeObjectURL 那一步很重要——createObjectURL 会占住内存,不释放的话批量下多了会越占越多。
选整个文件夹
给 <input type="file"> 加上 webkitdirectory,就能让用户直接选一整个文件夹:
1 | <input type="file" id="folderInput" webkitdirectory multiple hidden> |
音频处理管道
核心库负责两件事:探测容器格式 和 调 ffmpeg 转码。
探测格式:读文件头的魔数
不同音频容器的开头有几个固定的「魔数」字节,读一下就知道是什么格式,不用靠扩展名猜:
1 | with path.open("rb") as f: |
交给 ffmpeg 转码
转码用子进程调 ffmpeg,输出 320k 的 mp3:
1 | cmd = [ |
几个值得说的参数:
-map 0:a:0 -vn -sn -dn:只取第一条音频流,丢掉视频、字幕、附件,转纯音频。-codec:a libmp3lame:指定用 LAME 编码器出 mp3。-b:a 320k:目标码率。
ffmpeg 是个独立的二进制,单独放一个 bin/ 目录,程序启动时按「显式路径 → 项目自带 → PATH」的优先级自动找,找不到就给出友好提示。
还有个 Windows 小细节:用 subprocess 调 ffmpeg 时,如果不处理会闪一下黑色控制台窗口。加两行 STARTUPINFO 就没了:
1 | startupinfo = subprocess.STARTUPINFO() |
踩过的坑
- Windows 控制台打不了
✓——默认 GBK 编码,一 print emoji 就UnicodeEncodeError。解决:sys.stdout.reconfigure(errors="replace"),打不出来的字符自动替换成?,不崩了。 - 中文文件名下载变乱码——要用 RFC 5987 的
filename*=UTF-8''...,而不是老的filename=。 - 忘了做目录穿越防护——静态文件路径必须
resolve()后校验范围,否则能被读到任意文件。 createObjectURL忘了释放——批量下载会内存泄漏,记得revokeObjectURL。- 文件名带路径——上传文件名要
pathlib.Path(name).name剥干净,否则写临时文件时可能写到别的目录去。
下一步
- 用 PyInstaller 打包成单个
.exe,双击就用,不依赖 Python 环境。 - 前端加个并发上限(比如同时最多 2 个),大批量时更快又不压垮本机。
- 加个任务队列,转换中关页面也不丢进度。
这次最大的收获是:不装任何框架,光靠标准库也能搭出一个体面的本地 Web 工具。http.server 加 socketserver 完全够用,剩下的就是几个边界细节(路径、编码、文件名)磨平。工具全程跑在本机,处理的是自己本地的文件,数据不出一台电脑。
