v1tz@blog~$cat ./posts/2026-08-06-whisper-mlx-bilingual-meeting-transcription.md

Whisper本地部署会议转写MLX

用 Mac mini 本地 Whisper 转写中日双语会议录音(含小音量修复)

category=语言模型/kind=笔记published=2026-08-06

Apple M4 上用 mlx-whisper 把 81 分钟中日混说的会议录音在 10 分钟内转成带时间戳的双语文字稿:小音量修复、按静音切窗、双语种对照择优、温度回退为何帮倒忙。

目标

把开会录的视频转成带时间戳的中日双语文字稿,全程在内网 Mac mini(Apple M4)上跑,音频不出内网,不花 API 费用。

实测:81 分钟录音 ≈ 10 分钟跑完(107 个窗口 × 2 个语种)。


为什么不用云 API,也不用主力 Mac

方案 判断
火山方舟 / 云 ASR 会议内容要上传第三方;且单次音频有 25MB 上限,81 分钟必须切片
主力机(Intel i9-9980HK) ❌ 无 Metal 可用,large 模型约 4–8 小时;且 Python 3.14 缺 ctranslate2 / torch 轮子
Mac mini(M4) ✅ MLX 直接吃 Metal GPU,11.7 倍速,81 分钟约 10 分钟

关键差别在芯片,不在内存

M4 只有 16GB,反而比 64GB 的 Intel i9 快一个数量级——因为 MLX 走统一内存 + Metal, Intel Mac 上这条路完全不通。有 Apple Silicon 就用它,别在 Intel 上硬跑。


环境(Mac mini 上已装好,只需确认)

ssh mac-mini '~/whisper-env/bin/python -c "import mlx_whisper, mlx.core as mx; print(mx.metal.is_available())"'

输出 True 即可用。已缓存 whisper-large-v3-turbo 与 whisper-large-v3 两个模型。

如果要在新机器重建环境

python3 -m venv ~/whisper-env
~/whisper-env/bin/pip install mlx-whisper

不需要 Homebrew,不需要 sudo,不需要 ffmpeg(见下方“坑 1”)。 Xcode CLT 自带的 Python 3.9 就够。


第一步:提取音频并修复小音量

会议录音普遍偏小。先测原始电平:

ffmpeg -i 录音.mov -vn -af volumedetect -f null - 2>&1 | grep -E "mean_volume|max_volume"

正常语音 mean_volume 应在 −20 dB 左右。这次实测 −33.0 dB,但 max_volume 已经是 0.0 dB。

不能直接加增益

均值很低但峰值已到顶,说明有个别爆音。简单 volume=+14dB 会把那些峰值削平失真。 必须用语音动态归一化——把弱音抬起来,同时压住峰值。

ffmpeg -i 录音.mov -vn \
  -af "highpass=f=70,speechnorm=p=0.95:e=12.5:r=0.0005:l=1,loudnorm=I=-16:TP=-1.5:LRA=11" \
  -ar 16000 -ac 1 -c:a pcm_s16le meeting_16k_mono.wav

三级链路各管一段:

滤镜 作用
highpass=f=70 滤掉空调、桌面震动这类低频隆隆声
speechnorm 语音动态归一化,逐帧抬升弱音、跟随峰值
loudnorm 广播级响度标准化(EBU R128)

实测结果:−33.0 dB → −18.6 dB(+14.4 dB),峰值 −0.8 dB 无削波。

顺手再出一份给人听的增强版:

ffmpeg -i 录音.mov -vn -af "<同上滤镜>" -ar 48000 -ac 2 -c:a aac -b:a 128k 音频_增强版.m4a

第二步:按静音点切成语句窗口

不要整段丢给模型——Whisper 只在开头做一次语种判定,中日混说会全程按一种语言硬解。

先找静音点:

ffmpeg -i meeting_16k_mono.wav -af "silencedetect=noise=-20dB:d=0.3" -f null - 2>&1 \
  | grep -E "silence_start|silence_end" > sil.txt

阈值要试

归一化之后噪声底被抬高了,-32dB 只能找到 3 个点(81 分钟!),-20dB:d=0.3 才有 806 个(约 6 秒一个)。 先数一下点的个数再决定,太少就把阈值调宽松。

然后按目标 45 秒、贴着静音中点切(脚本 plan_chunks.py)。实测切出 107 个窗口,平均 45.4 秒,全部落在静音处,不切断句子。


第三步:双语种对照转写(核心)

每个窗口分别按中文和日语各解码一次,比较置信度择优。

for lang in ("zh", "ja"):
    r = mlx_whisper.transcribe(
        seg, path_or_hf_repo="mlx-community/whisper-large-v3-turbo",
        language=lang, initial_prompt=PROMPT[lang],
        condition_on_previous_text=False, temperature=0.0, verbose=None)

打分不能只看 avg_logprob,要对重复度加惩罚:

score = mean(avg_logprob) - 0.5 * max(0, mean(compression_ratio) - 2.4)

实测有效性:某段中文,zh 得 −0.743、ja 得 −0.880,正确判为中文;ja 版本是一堆硬凑的幻觉日语。

再加一道邻域平滑——会议里语种是局部连贯的,孤立的低置信翻转基本是误判:

if 前后窗口语种相同 and 本窗口不同 and margin < 0.6:
    改判为前后一致的语种

这次修正了 2 处(35:43、41:00 被误判成日语)。


第四步:后处理

四件事,缺一不可:

  1. 繁简转换 — Whisper 中文输出会混繁体

    pip install opencc-python-reimplemented   # 装在 venv 里
    opencc.OpenCC('t2s').convert(text)
  2. 折叠重复短语 — ABABAB… → AB(n-gram 扫描,连续 ≥4 次即折叠)

  3. 丢弃套话幻觉 — Whisper 训练数据里的字幕组残留,出现即为幻觉:

    请不吝点赞|订阅|转发|打赏|明镜|字幕组|Amara.org
    ご視聴ありがとう|次の動画|チャンネル登録|お会いしましょう
  4. 输出 SRT + 可读全文 — 按语种块和时间间隔合并成段落


踩过的坑

坑 1:mlx-whisper 内部调 ffmpeg,但机器上没有

报 FileNotFoundError: 'ffmpeg'。不用装 ffmpeg——本机已经转好 16kHz 单声道 WAV 了,直接用标准库读成数组喂进去:

import wave, numpy as np
def load_wav(p):
    with wave.open(p, 'rb') as w:
        assert w.getframerate() ** 16000 and w.getnchannels() ** 1
        d = w.readframes(w.getnframes())
    return np.frombuffer(d, np.int16).astype(np.float32) / 32768.0

mlx_whisper.transcribe(load_wav(path), ...)   # 传数组,不传路径

坑 2:温度回退在嘈杂音频上帮倒忙 ⚠️

temperature=0.0 会关掉 Whisper 的防重复机制,于是我改成 temperature=(0.0, 0.2, ..., 1.0) 重跑——

结果重复幻觉从 5 处涨到 13 处,最严重的重复了 223 次。

原因:这段音频信噪比差,logprob_threshold 频繁触发回退,而高温采样比贪心更容易陷入循环。

结论

嘈杂录音就用贪心解码(temperature=0.0)。 想两头兼顾就两版都跑,逐窗口比较重复度择优——这次 107 个窗口里 103 个取贪心版、只有 4 个取回退版。

坑 3:initial_prompt 自己会诱发循环

有一个窗口两种语种都输出垃圾(日语套话循环 + 中文「约片片片…」),但音量正常(−19.2 dB),不是静音段。

去掉 initial_prompt 重跑就干净了(cr 从 4.2 降到 1.3)。领域词 prompt 能提升专有名词准确率,但偶尔会把模型带进循环——遇到死活解不出的窗口,先试试把 prompt 去掉。

坑 4:模型之间会犯同一个错

日方说的一个以长音「イー」结尾的公司名,large-v3 和 turbo 六次输出全部吞掉了尾音,一致地识别成短一截的写法。

模型一致 ≠ 正确

两个模型共享训练数据偏置,会犯同样的错。专有名词一定要人工核对或外部查证, 不要因为多个模型输出一致就当成确认。

坑 5:环境琐碎问题

  • Python 3.9 不支持 f-string 里嵌套同种引号(3.12+ 才行)→ 别写内联 python -c,写成脚本文件传过去
  • macOS 系统 Python 受 PEP 668 保护,pip install 报 externally-managed → 建 venv,别用 --break-system-packages
  • macOS 自带 rsync 是 2.6.9,不支持 --info=progress2 这类新选项
  • timeout 命令 macOS 没有,用 gtimeout(coreutils)或改用后台任务

完整脚本

全套脚本五个文件,职责如下:

脚本/
├── plan_chunks.py    切分窗口
├── run_asr.py        双语种对照转写(主脚本,支持断点续跑)
├── postprocess.py    繁简转换 / 折叠重复 / 过滤套话 / 出 SRT 和全文
├── appendix.py       追加校对清单与处理说明
└── rescue.py         抢救解不出的窗口(多模型多配置对比)

run_asr.py 会把每个窗口的结果逐条追加写入 jsonl,中断后重跑会自动跳过已完成的——81 分钟的任务不怕断。


效率参考(Apple M4,10 核 / 16GB)

环节 耗时
音频提取 + 归一化(在 Intel 主力机上做) 约 3 分钟
传输 155MB WAV 到 mini(局域网) 35 秒
语种普查(26 个 30 秒窗口) 130 秒
双语种转写(107 窗口 × 2) 10 分钟
后处理 数秒

转写倍速 11.7×。若开温度回退会掉到约 3.4×(34.5 分钟)——又慢又差,别开。


已知局限

  1. 没有说话人分离——本流程不做声纹聚类,多人快速交替会连成一段
  2. 同句内中日混说会被整体归入某一语种,另一语种的零星词汇可能失真
  3. 数字与型号(容量、日期、金额)ASR 易错,涉及决策前务必对照原音频核实
  4. 专有名词必须人工核对,见坑 4

相关

 
[0] v1tz@blog · ~try: 'help'···--:--:--