nageyqma·git
HTTPS https://git.nageyqma.icu/nageyqma/audio_to_text.git SSH git@git.nageyqma.icu:nageyqma/audio_to_text.git

HTTPS 链接可直接 git clone;推送代码请使用 SSH。

仅展示默认分支 main 最近 100 条提交。

feat: 本地课堂录音批量转文字(FunASR + faster-whisper 混合引擎)
- 中文主导录音走 FunASR SeACo-Paraformer(原生热词偏置),英/法等走 faster-whisper - 按文件自动探测语言并路由引擎,支持 --language/--engine/--model/--dry-run/--download-only - 断点续传:输出 md 比音频新则跳过;单文件失败不中断批次 - run.sh 依赖未就绪时自动执行 setup.sh(装 uv/venv/CPU torch + 预下载约 3GB 模型) - .gitignore 排除模型缓存、虚拟环境、录音与转写数据(隐私与大体积)
nageyqma · 2026-09-18 · 008434c
查看变更
.gitignore           |  26 +++++++
 README.md            | 100 ++++++++++++++++++++++++
 hotwords.txt         | 500 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 input_audio/.gitkeep |   0
 output_text/.gitkeep |   0
 prompt.md            | 405 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 requirements.txt     |   7 ++
 run.sh               |  12 +++
 setup.sh             |  57 ++++++++++++++
 transcribe.py        | 542 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 10 files changed, 1649 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c65e308
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,26 @@
+# ---- 大体积 / 自动生成,不入版本库 ----
+# Python 虚拟环境(约 1.8GB,setup.sh 自动重建)
+.venv/
+# 模型缓存(约 3GB,首次运行自动下载)
+models/
+# Python 字节码
+__pycache__/
+*.py[cod]
+
+# ---- 个人数据:录音与转写结果 ----
+# 分享仓库时不应包含真实课堂录音及其转写文本(隐私 + 体积)
+# 目录本身通过 .gitkeep 保留,需要单独共享时自行拷贝
+input_audio/*
+!input_audio/.gitkeep
+output_text/*
+!output_text/.gitkeep
+
+# ---- 内部工作目录 ----
+.zcode/
+.qoder/
+work/
+.venv.bak/
+
+# ---- 编辑器 / 系统 ----
+.DS_Store
+*.swp
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..23432ba
--- /dev/null
+++ b/README.md
@@ -0,0 +1,100 @@
+# 本地课堂录音批量转文字(混合引擎)
+
+把课堂录音(iOS 语音备忘录的 m4a 等)在**本地**批量转成 Markdown 纯文本,事后自己整理成笔记和考点。
+不联网调用任何云端 API;中文主导的录音走 **FunASR SeACo-Paraformer**(中文最准 + 原生土木术语热词),
+英语/法语等走 **faster-whisper**。按整个文件自动路由引擎,全程 CPU 推理,无需 GPU、无需 sudo。
+
+## 用法(三步)
+
+```bash
+./setup.sh        # ① 首次配置:装依赖 + 预下载约 3GB 模型(联网一次,之后离线可用)
+# ② 把录音文件丢进 input_audio/
+./run.sh          # ③ 转写:output_text/ 里出现同名 .md 文件
+```
+
+> 也可以跳过 ① 直接 `./run.sh`:检测到依赖未就绪(如刚克隆本仓库)会**自动执行
+> `setup.sh`,装完接着转写**;`requirements.txt`/`setup.sh` 有更新时同样会自动补装。
+> `models/` 被删除也没关系,转写时按需自动重新下载。
+
+## CLI 参数
+
+| 命令 | 作用 |
+|---|---|
+| `./run.sh` | 全自动:探测语言 → 路由引擎 → 转写 |
+| `./run.sh --language zh` | 强制语言:`zh`→Paraformer;`en`/`fr`/…→Whisper |
+| `./run.sh --engine paraformer` | 强制引擎:`paraformer` 或 `whisper`(优先级高于 `--language`) |
+| `./run.sh --model large-v3` | 换更准的 Whisper 模型(默认 `large-v3-turbo`,更准但更慢) |
+| `./run.sh --dry-run` | 只解码+探测+打印每个文件路由到哪个引擎(依据),不转写 |
+| `./run.sh --download-only` | 只预下载/校验模型到 `models/`,可重复运行 |
+
+## 特性
+
+- **断点续传**:`output_text/<同名>.md` 已存在且比音频新 → 自动跳过。重新录制/替换音频后自动重转。
+- **失败隔离**:单个文件损坏/转写失败不中断批次,结束打印「成功 N / 跳过 N / 失败 N」及原因。
+- **热词纠错**:Paraformer 原生词级偏置,土木术语识别更准(见下节)。
+- **长录音**:VAD 自动切分,几小时的录音无需手动分段。
+- **输出格式**:`# 文件名` + 正文(无时间戳),UTF-8 纯文本 Markdown,可直接整理。
+
+## 添加课程专属热词
+
+编辑 `hotwords.txt`,**一行一个词**,保存即生效(下次运行自动加载)。
+`#` 开头的行为注释/分组标题,会被自动忽略,可按课程分区管理。
+Paraformer 用全部热词做词级偏置;Whisper 的 initial_prompt 取前 50 个。
+课程里新出现的术语(老师名字、教材名词、规范编号等)都值得加进去。
+现有分组:基础土木词 + 桥梁工程 / 结构鉴定和加固 / 基础工程 / 土木防灾减灾 + 通用易错词。
+若发现某个热词「抢词」(如把别的词错误识别成它),直接删掉该行再重转即可。
+
+## 想更准 / 想强制引擎
+
+- 更准(慢):`./run.sh --model large-v3`
+- 整段中文但夹杂英文术语的录音,若被误路由到 Whisper:`./run.sh --language zh`
+- 反之,英语课里想走 Paraformer 是不支持的(Paraformer 只适合中文主导);
+  英语/法语主导请用默认路由或 `--engine whisper`。
+
+## 网络问题备选
+
+HuggingFace 下载慢/失败时,先设置国内镜像再重跑:
+
+```bash
+export HF_ENDPOINT=https://hf-mirror.com
+./setup.sh
+```
+
+FunASR(Paraformer/VAD/标点)走 ModelScope(国内直连,一般无需镜像)。
+
+## 将来有 NVIDIA GPU
+
+```bash
+uv pip install torch torchaudio          # 换回默认源(CUDA 版)
+A2T_DEVICE=cuda ./run.sh                 # 代码保留了 device 参数位,环境变量即可切换
+```
+
+(同时把 `setup.sh` 里的 `--index-url https://download.pytorch.org/whl/cpu` 去掉重装 torch。)
+
+## 分享 / 克隆
+
+仓库**不含**模型(约 3GB)、虚拟环境(约 1.8GB)以及录音/转写数据(隐私,见 `.gitignore`),
+克隆后直接 `./run.sh` 即可:自动安装依赖、下载模型,然后转写。
+
+## 目录结构
+
+```
+audio_to_text/
+├── setup.sh          # 首次一键配置(幂等;--check 供 run.sh 校验就绪状态)
+├── run.sh            # 日常入口(依赖未就绪时自动先执行 setup.sh)
+├── transcribe.py     # 主程序
+├── hotwords.txt      # 土木工程热词表(一行一个,即改即生效)
+├── requirements.txt
+├── input_audio/      # ← 录音放这里(内容不入库)
+├── output_text/      # ← 转写结果在这里(同名 .md,内容不入库)
+├── models/           # 模型缓存(不入库;可整体删除,运行时自动重新下载)
+└── .venv/            # 项目内 Python 3.12 环境(不入库;不动系统 Python)
+```
+
+## 常见问题
+
+- **内存**:两套引擎严格分时加载(先 Whisper 组 → 释放 → Paraformer 组),15GB 内存机器可放心用。
+- **m4a 解码失败**:优先 PyAV 直接解码;异常时自动回退 `ffmpeg` 转码,仍失败则该文件记为失败并继续其余文件。
+- **重复转写**:只想强制重转某个文件,删掉 `output_text/` 里对应的 `.md` 再运行即可(或 `touch` 音频文件)。
+- **转写质量评估**:本次验证用的是 piper TTS 合成音;真实课堂录音的准确率请以实际录音为准,
+  遇到专业词识别不准时优先往 `hotwords.txt` 加词。
diff --git a/hotwords.txt b/hotwords.txt
new file mode 100644
index 0000000..e163ffd
--- /dev/null
+++ b/hotwords.txt
@@ -0,0 +1,500 @@
+混凝土
+钢筋混凝土
+预应力混凝土
+水泥
+砂浆
+骨料
+外加剂
+水灰比
+坍落度
+和易性
+强度等级
+立方体抗压强度
+养护
+收缩
+徐变
+碳化
+钢筋锈蚀
+保护层厚度
+箍筋
+纵筋
+弯起钢筋
+架立筋
+分布筋
+锚固长度
+搭接长度
+受弯承载力
+斜截面
+最小配筋率
+张拉
+锚具
+孔道灌浆
+先张法
+后张法
+无粘结预应力
+弯矩
+剪力
+轴力
+扭矩
+挠度
+应力
+应变
+弹性模量
+泊松比
+惯性矩
+剪切模量
+屈曲
+疲劳
+蠕变
+超静定
+桁架
+支座反力
+力法
+位移法
+型钢
+焊缝
+角焊缝
+高强螺栓
+铆钉
+节点板
+加劲肋
+防火涂料
+土力学
+孔隙比
+密实度
+含水率
+液限
+塑限
+塑性指数
+压缩模量
+固结
+渗透系数
+抗剪强度
+黏聚力
+内摩擦角
+土压力
+主动土压力
+被动土压力
+朗肯
+库仑
+地基承载力
+地基处理
+换填
+夯实
+桩基
+预制桩
+灌注桩
+摩擦桩
+端承桩
+承台
+沉降
+不均匀沉降
+基坑
+支护
+地下连续墙
+降水
+边坡稳定
+挡土墙
+模板工程
+钢筋工程
+混凝土浇筑
+振捣
+施工缝
+后浇带
+脚手架
+塔吊
+起重机
+隐蔽工程
+监理
+网络计划
+关键线路
+工程量清单
+概算
+预算
+招投标
+桥梁
+桥墩
+桥台
+主梁
+支座
+伸缩缝
+桥面铺装
+涵洞
+隧道
+衬砌
+围岩
+新奥法
+锚杆
+喷射混凝土
+超前支护
+路基
+路面
+基层
+面层
+沥青混凝土
+水泥混凝土路面
+级配碎石
+压实度
+弯沉
+边沟
+工程测量
+水准测量
+水准仪
+全站仪
+经纬仪
+导线测量
+高程
+放样
+控制点
+沉降观测
+
+# ====================================================================
+# 四门在修专业课针对性热词(2026-09-18 添加,# 开头的行为注释,不参与热词)
+# 依据:已转写课堂录音中实际出现的识别错误 + 各课程标准术语。
+# ====================================================================
+
+# ---------- 通用易错词(四门课高频且实际转写中反复出错) ----------
+地基
+基础
+上部结构
+下部结构
+剪切
+失稳
+验算
+构造要求
+承载力
+裂缝
+构件
+黏性土
+粉土
+砂土
+粒径
+均布荷载
+局部荷载
+集中荷载
+计算简图
+参数化设计
+比萨斜塔
+
+# ---------- 桥梁工程 ----------
+桥梁工程
+简支梁
+简支梁桥
+悬臂梁
+连续梁桥
+梁桥
+桥跨结构
+桥面板
+行车道板
+桥面系
+桥面连续
+现浇
+装配式
+单向板
+双向板
+悬臂板
+铰接
+铰接悬臂板
+铰缝
+企口
+钢板连接
+湿接缝
+空心板
+箱梁
+横隔梁
+盖梁
+墩台
+搭板
+锥坡
+护坡
+跨径
+计算跨径
+跨中
+有效分布宽度
+有效工作宽度
+分布宽度
+着地宽度
+车轮荷载
+车辆荷载
+车道荷载
+人群荷载
+轴重
+荷载组合
+恒载
+活载
+冲击系数
+影响线
+横向分布系数
+抗扭刚度
+扭转
+弹性支承
+最不利
+配筋
+配筋率
+裂缝宽度
+承载能力极限状态
+正常使用极限状态
+预拱度
+铺装层
+公路桥梁设计通用规范
+
+# ---------- 结构鉴定和加固 ----------
+结构鉴定
+加固
+加固改造
+可靠性鉴定
+抗震鉴定
+危房
+回弹法
+回弹仪
+钻芯法
+超声回弹综合法
+碳纤维
+碳纤维布
+粘钢
+植筋
+加大截面
+灌浆
+注浆
+纠偏
+纠倾
+掏土
+迫降
+顶升
+托换
+基础托换
+锚杆静压桩
+树根桩
+残余变形
+倾斜观测
+露筋
+蜂窝麻面
+空鼓
+剥落
+锈胀
+抗倾覆
+倾覆
+整体剪切破坏
+局部剪切破坏
+冲剪破坏
+冲切破坏
+塑性区
+滑动面
+隆起
+松砂
+摩尔库仑
+三七灰土
+灰土
+生石灰
+熟石灰
+麻刀
+湿陷性
+湿陷性黄土
+膨胀土
+自由膨胀率
+季节性冻土
+冻胀
+冻结深度
+切向冻胀力
+淤泥质土
+
+# ---------- 基础工程 ----------
+基础工程
+基础埋深
+埋置深度
+持力层
+下卧层
+软弱下卧层
+地基模型
+文克勒
+弹性半空间
+基底反力
+基底压力
+附加应力
+自重应力
+应力扩散角
+分层总和法
+角点法
+压缩系数
+固结度
+有效应力
+孔隙水压力
+静止土压力
+超固结
+先期固结压力
+太沙基
+沉降差
+差异沉降
+沉降速率
+倾斜率
+浅基础
+深基础
+筏板基础
+筏形基础
+箱形基础
+独立基础
+条形基础
+扩展基础
+刚性基础
+柔性基础
+沉井
+倒梁法
+地基承载力特征值
+特征值
+临塑荷载
+极限荷载
+比例界限
+载荷试验
+静力触探
+标准贯入试验
+勘察
+验槽
+桩基础
+单桩承载力
+桩侧摩阻力
+桩端阻力
+负摩阻力
+群桩
+预应力管桩
+管桩
+钢管桩
+钻孔灌注桩
+人工挖孔桩
+沉管灌注桩
+静压桩
+沉桩
+试桩
+静载荷试验
+桩身完整性
+断桩
+缩颈
+钢筋笼
+泥浆护壁
+水下混凝土
+嵌岩桩
+基岩
+复合地基
+褥垫层
+桩土应力比
+置换率
+水泥粉煤灰碎石桩
+水泥土搅拌桩
+深层搅拌法
+旋喷桩
+砂石桩
+振冲法
+强夯法
+垫层
+堆载预压
+真空预压
+砂井
+塑料排水板
+土工格栅
+土工布
+加筋土
+基坑支护
+支护结构
+围护结构
+地下水位
+潜水
+承压水
+井点降水
+止水帷幕
+土钉墙
+排桩
+冠梁
+内支撑
+钢支撑
+钢板桩
+双排桩
+逆作法
+放坡
+抗滑桩
+抗隆起
+泄水孔
+排水沟
+截水沟
+盲沟
+反滤层
+流砂
+管涌
+坝基
+
+# ---------- 土木防灾减灾 ----------
+防灾减灾
+抗震设防
+设防烈度
+烈度
+震级
+震中
+地震作用
+地震动
+场地类别
+特征周期
+反应谱
+阻尼
+阻尼比
+自振周期
+振型
+时程分析
+底部剪力法
+多遇地震
+罕遇地震
+延性
+塑性铰
+强柱弱梁
+强剪弱弯
+强节点弱构件
+轴压比
+层间位移角
+薄弱层
+刚度
+侧向刚度
+平面不规则
+竖向不规则
+隔震
+消能减震
+阻尼器
+黏滞阻尼器
+屈曲约束支撑
+铅芯橡胶支座
+防震缝
+非结构构件
+生命线工程
+震害
+抗震加固
+砂土液化
+液化判别
+震陷
+地面塌陷
+采空区
+岩溶
+土洞
+泥石流
+崩塌
+滑坡
+风灾
+台风
+基本风压
+风振
+脉动风
+顺风向
+横风向
+涡激振动
+抖振
+颤振
+驰振
+风洞试验
+大跨空间结构
+高耸结构
+耐火极限
+防火分区
+结构抗火
+连续倒塌
+韧性
+易损性
+风险评估
+唐山地震
+汶川地震
+建筑抗震设计规范
+建筑地基基础设计规范
diff --git a/input_audio/.gitkeep b/input_audio/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/output_text/.gitkeep b/output_text/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/prompt.md b/prompt.md
new file mode 100644
index 0000000..b4be629
--- /dev/null
+++ b/prompt.md
@@ -0,0 +1,405 @@
+# 施工任务:本地课堂录音批量转文字(混合引擎)
+
+> **本文件是一份自包含的施工说明书**,写给在全新对话中执行它的编码 agent。请完整读完再动手。
+> 文中「环境事实」一节已在 2026-09-09 实际探测验证过,**无需重复探测**。
+> 架构方向已由用户拍板,**不要更改整体方向**;某一步失败时按第 7 节的兜底方案处理。
+> 本任务涉及:安装 uv(用户级)、在项目内建 venv、下载约 3GB 模型、长时间本地转写。全程不需要 sudo,不做任何系统级安装,不使用云端 API。
+
+---
+
+## 0. 项目目标
+
+用户是土木工程专业的学生/从业者,需要把**每天大量的课堂录音**(主要来自 iOS 内置「语音备忘录」的 m4a)在本地转成文字,事后人工整理成笔记和考点。
+
+- **质量要求**:整体语义正确即可,用户容忍个别词的小瑕疵;但希望**土木工程专业术语**尽量识别准确。
+- **语言构成**:中英文混合,偶尔出现法语。中文占主体的录音用 Paraformer(中文最准 + 原生热词机制),英语/法语占主体的用 Whisper(多语言强项)。
+- **最终使用形态(务必保证)**:把音频文件丢进 `input_audio/` → 运行 `./run.sh` → `output_text/` 里出现**同名**的 `.md` 文件(纯文本 Markdown,无时间戳)。
+- **零系统安装**:所有依赖装在项目文件夹内(`.venv/`),模型缓存在项目内(`models/`),不动系统 Python、不装全局包。
+
+## 1. 环境事实(已探测,直接采信)
+
+| 项目 | 值 |
+|---|---|
+| 项目目录 | `/media/data/usrdir/agent/zcode/audio_to_text`(已有本文件,其余为空) |
+| 系统 | Arch Linux,zsh,内核 7.x |
+| Python | 仅 `/usr/bin/python3` = **3.14.7**,**无 pip**、无 uv/conda/pyenv |
+| GPU | **无**(无 NVIDIA 驱动/设备),CPU:i7-13700H,20 线程 |
+| 内存 | 15GB(可用约 7.3GB)——两套引擎不要同时常驻,按组分时加载 |
+| 磁盘 | `/media/data` 剩余 535GB,充足 |
+| ffmpeg | 有,`/usr/sbin/ffmpeg`,版本 9.0.1(兜底转码可用,但主路径不需要) |
+| 网络 | modelscope.cn / huggingface.co / pypi.org 均可达 |
+
+**推论**:系统 Python 3.14 太新且无 pip,直接用会有 wheel 兼容风险 → 用 **uv** 在项目内建 **Python 3.12** 的 venv(uv 会自动下载托管版 CPython 3.12,无需系统权限)。
+
+## 2. 架构设计(定案)
+
+混合引擎,**按文件路由**(不做句级混切,保持语义连贯和实现简单):
+
+```
+扫描 input_audio/ 全部音频
+  └─ 对每个文件:
+       1. 解码 → 16kHz float32 单声道 numpy(faster_whisper.audio.decode_audio,PyAV 自带解码,m4a/AAC 直接支持)
+       2. 语言探测:取开头 45s + 时长中点 45s 两个切片(文件短于 90s 则只用全片),
+          用 faster-whisper transcribe(language=None) 读 info.language / info.language_probability,两切片概率取平均
+       3. 路由:
+          - 用户强制 --engine → 用指定引擎
+          - 用户强制 --language zh → Paraformer;--language en/fr/… → Whisper(language 传入)
+          - 否则自动:zh 概率 > 0.6 且为最高 → **Paraformer**;否则 → **Whisper**(language 用探测结果)
+  └─ 按引擎分组:
+       - 先处理 **Whisper 组**(探测时 turbo 模型已加载,直接复用)
+       - 处理完 `del model; gc.collect()` 释放,再加载 **Paraformer 组**
+  └─ 每个文件写 output_text/<原文件名(去扩展名)>.md,UTF-8
+```
+
+### 引擎一:FunASR SeACo-Paraformer(中文主导)
+
+```python
+from funasr import AutoModel
+model = AutoModel(
+    model="iic/speech_seaco_paraformer_large_asr_nat-zh-cn-16k-common-vocab8404-pytorch",
+    vad_model="iic/speech_fsmn_vad_zh-cn-16k-common-pytorch",
+    vad_kwargs={"max_single_segment_time": 30000},
+    punc_model="iic/punc_ct-transformer_zh-cn-common-vocab272727-pytorch",
+    device="cpu",
+    cache_dir="models/funasr",          # 模型缓存在项目内
+    disable_update=True,                 # 关掉启动时联网检查
+)
+res = model.generate(
+    input=audio_np,                      # 支持 numpy 输入;若该版本不接受 ndarray,见第 7 节兜底
+    fs=16000,
+    batch_size_s=300,                    # 长音频按 300s 一批
+    hotword=" ".join(hotwords),          # 热词:读 hotwords.txt,空格拼接传入(SeACo 原生热词机制)
+)
+text = res[0]["text"]
+```
+
+- VAD 会自动切分几小时的录音;标点模型自动加标点。
+- **热词是 Paraformer 相对 Whisper 的核心优势**(真正的词级偏置,土木术语纠正效果比 Whisper 的 initial_prompt 强)。
+
+### 引擎二:faster-whisper(英/法/其他)
+
+```python
+from faster_whisper import WhisperModel, BatchedInferencePipeline
+model = WhisperModel(
+    "large-v3-turbo",                    # 默认;--model 可覆盖为 large-v3(更准更慢)等
+    device="cpu", compute_type="int8",
+    cpu_threads=16,                      # 20 线程机器留一点给系统
+    download_root="models/faster-whisper",
+)
+pipe = BatchedInferencePipeline(model=model)   # 批处理提速
+segments, info = pipe.transcribe(
+    audio_np,
+    language=None,                       # 或探测/强制的语言码 (zh/en/fr)
+    vad_filter=True,
+    vad_parameters={"min_silence_duration_ms": 500},
+    beam_size=5,
+    initial_prompt=whisper_prompt,       # 见下;若 Batched 版不支持该参数,见第 7 节兜底
+    condition_on_previous_text=True,
+)
+text = "".join(s.text for s in segments)
+```
+
+- `whisper_prompt`:`"这是一堂土木工程专业课,可能涉及以下术语:"` + 热词表前 50 个词顿号连接(控制在约 200 token 内,超长会被截断)。仅中文相关时价值大,对 en/fr 无害。
+- 语言探测也用这个模型实例(transcribe 前 45s 切片即可拿到 info),**探测和转写复用同一实例**。
+
+### 输出格式
+
+`output_text/<stem>.md`,UTF-8:
+
+```markdown
+# <原文件名(不含扩展名)>
+
+<转写正文>
+```
+
+- Whisper:把连续约 6 个 segment 合并为一个自然段,段间空行。
+- Paraformer:`res[0]["text"]` 本身就是带标点的整段文本,直接写入(可按句号每 5~8 句插一个空行分段)。
+
+### CLI 规格
+
+```bash
+./run.sh                      # 全自动:探测语言 → 路由引擎
+./run.sh --language zh        # 强制语言(zh→Paraformer;en/fr→Whisper)
+./run.sh --engine whisper     # 强制引擎:paraformer | whisper
+./run.sh --model large-v3     # 覆盖 Whisper 模型(默认 large-v3-turbo)
+./run.sh --dry-run            # 只打印每个文件将路由到哪个引擎,不转写(调路由用)
+```
+
+### 断点续传(重要,日常增量使用依赖它)
+
+若 `output_text/<stem>.md` 已存在且其 mtime **晚于**音频文件 mtime → 跳过。重新录制/替换音频后会自动重转。单文件转写失败不中断批次,结束打印汇总(成功 N / 跳过 N / 失败 N 及失败原因)。
+
+## 3. 需要创建的文件(全部在项目根目录)
+
+```
+audio_to_text/
+├── setup.sh          # 首次一键配置(幂等,可重复运行)
+├── run.sh            # 日常入口
+├── transcribe.py     # 主程序(--download-only / --dry-run / 转写)
+├── hotwords.txt      # 土木工程热词表(内容见第 4 节,直接照抄)
+├── requirements.txt
+├── README.md
+├── .gitignore        # .venv/ models/ work/ __pycache__/ (可选但建议)
+├── input_audio/      # setup.sh 创建
+├── output_text/      # setup.sh 创建
+└── models/           # 模型缓存(funasr/ 与 faster-whisper/ 两个子目录)
+```
+
+### setup.sh 要点
+
+```bash
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "$0")"
+# 1) uv:没有则装(用户级,装到 ~/.local/bin,无需 sudo)
+command -v uv >/dev/null || curl -LsSf https://astral.sh/uv/install.sh | sh
+export PATH="$HOME/.local/bin:$PATH"
+# 2) 项目内 venv(Python 3.12;uv 自动下载托管版 CPython)
+[ -d .venv ] || uv venv --python 3.12 .venv
+source .venv/bin/activate
+# 3) 先装 CPU 版 torch(必须用 CPU 源,否则会拉 2GB+ 的 CUDA 包;本机无 GPU)
+uv pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu
+# 4) 其余依赖
+uv pip install -r requirements.txt
+# 5) 建目录 + 预下载全部模型(联网一次,之后可离线)
+mkdir -p input_audio output_text models
+python transcribe.py --download-only
+echo "配置完成。以后:把音频放进 input_audio/ 然后运行 ./run.sh"
+```
+
+### requirements.txt
+
+```
+numpy==1.26.4
+faster-whisper>=1.0.0
+funasr>=1.1.0
+modelscope>=1.15
+```
+
+> numpy 锁 1.26.4:funasr 及其依赖对 numpy 2.x 兼容性不稳。torch/torchaudio 由 setup.sh 单独装(CPU 源),不写进 requirements。
+
+### run.sh
+
+```bash
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "$0")"
+source .venv/bin/activate
+exec python transcribe.py "$@"
+```
+
+### transcribe.py 实现要点
+
+- 结构:`parse_args` → `collect_audio_files("input_audio")` → `resume 过滤` → `decode + probe + route` → `按组转写` → `写输出` → `汇总打印`。
+- 支持的输入扩展名:`.m4a .mp3 .wav .flac .aac .ogg .mp4 .wma .amr`(m4a 是重点,iOS 语音备忘录 = AAC in m4a 容器,务必用真实 m4a 测试)。
+- `--download-only`:依次实例化 Whisper 模型(触发 HF 下载到 `models/faster-whisper`)和 FunASR AutoModel(触发 ModelScope 下载到 `models/funasr`),加载成功即释放退出,打印各模型缓存路径。
+- `--dry-run`:只做解码+探测+路由,打印每条 `文件名 → 引擎(依据)`,不加载 FunASR、不转写。
+- 解码兜底:`faster_whisper.audio.decode_audio(path, sampling_rate=16000, mono=True)` 为主;异常时退到 `ffmpeg -y -i <in> -ac 1 -ar 16000 <tmp>.wav` 再读 wav(`wave`/`soundfile` 均可)。
+- 日志:每个文件开始/结束/耗时/路由结果打印一行;全程中文输出。
+- 文件名可能含空格/中文:subprocess 一律用参数列表形式(`shell=False`),路径用 pathlib。
+
+### README.md 要点
+
+- 三行用法(setup → 丢音频 → run.sh)、各 CLI 参数说明
+- 如何往 `hotwords.txt` 加课程专属词(一行一个,改完即生效)
+- 想更准:`./run.sh --model large-v3`;想强制引擎/语言:`--engine` / `--language`
+- HuggingFace 下载慢/失败的备选:`export HF_ENDPOINT=https://hf-mirror.com` 后重跑 setup
+- 以后若加了 NVIDIA GPU:`setup.sh` 中 torch 换回默认源并把 `device` 改 `cuda` 即可(代码里保留 device 参数位)
+
+## 4. hotwords.txt 内容(直接照抄,一行一个)
+
+```text
+混凝土
+钢筋混凝土
+预应力混凝土
+水泥
+砂浆
+骨料
+外加剂
+水灰比
+坍落度
+和易性
+强度等级
+立方体抗压强度
+养护
+收缩
+徐变
+碳化
+钢筋锈蚀
+保护层厚度
+箍筋
+纵筋
+弯起钢筋
+架立筋
+分布筋
+锚固长度
+搭接长度
+受弯承载力
+斜截面
+最小配筋率
+张拉
+锚具
+孔道灌浆
+先张法
+后张法
+无粘结预应力
+弯矩
+剪力
+轴力
+扭矩
+挠度
+应力
+应变
+弹性模量
+泊松比
+惯性矩
+剪切模量
+屈曲
+疲劳
+蠕变
+超静定
+桁架
+支座反力
+力法
+位移法
+型钢
+焊缝
+角焊缝
+高强螺栓
+铆钉
+节点板
+加劲肋
+防火涂料
+土力学
+孔隙比
+密实度
+含水率
+液限
+塑限
+塑性指数
+压缩模量
+固结
+渗透系数
+抗剪强度
+黏聚力
+内摩擦角
+土压力
+主动土压力
+被动土压力
+朗肯
+库仑
+地基承载力
+地基处理
+换填
+夯实
+桩基
+预制桩
+灌注桩
+摩擦桩
+端承桩
+承台
+沉降
+不均匀沉降
+基坑
+支护
+地下连续墙
+降水
+边坡稳定
+挡土墙
+模板工程
+钢筋工程
+混凝土浇筑
+振捣
+施工缝
+后浇带
+脚手架
+塔吊
+起重机
+隐蔽工程
+监理
+网络计划
+关键线路
+工程量清单
+概算
+预算
+招投标
+桥梁
+桥墩
+桥台
+主梁
+支座
+伸缩缝
+桥面铺装
+涵洞
+隧道
+衬砌
+围岩
+新奥法
+锚杆
+喷射混凝土
+超前支护
+路基
+路面
+基层
+面层
+沥青混凝土
+水泥混凝土路面
+级配碎石
+压实度
+弯沉
+边沟
+工程测量
+水准测量
+水准仪
+全站仪
+经纬仪
+导线测量
+高程
+放样
+控制点
+沉降观测
+```
+
+## 5. 实施步骤
+
+1. 写 `requirements.txt` / `hotwords.txt` / `setup.sh` / `run.sh` / `.gitignore` → `chmod +x` 两个 sh
+2. 跑 `./setup.sh`(下载约 3GB:whisper-turbo ≈1.6GB 来自 HF;paraformer+vad+punc ≈1.2GB 来自 ModelScope,国内很快)
+3. 写 `transcribe.py`(含 `--download-only`、`--dry-run`)
+4. 验证(见第 6 节)
+5. 写 `README.md`
+6. 向用户报告使用方法
+
+## 6. 验证与验收标准
+
+**路由与流程测试**(不依赖真实课堂录音):
+1. `./run.sh --dry-run` 对空目录输出 0 个文件不报错。
+2. 造测试音频:若有 `espeak-ng`/`espeak`,合成三句话(中文土木句如"预应力混凝土梁的箍筋间距应满足计算要求"、一句英语、一句法语)→ 合并为一个 wav → `ffmpeg -c:a aac` 转成 **m4a** 放进 `input_audio/`。若没有 espeak,就合成一段 30s 音调 m4a(此时只验证机械流程,不验证识别质量),并明确告知用户需要真实录音来评估质量。
+3. `./run.sh --dry-run`:确认探测/路由输出合理(多语言混合音频路由到哪个引擎都要有依据打印)。
+4. `./run.sh --engine paraformer --language zh` 等强制组合各跑一次,确认参数生效。
+5. 完整跑一次:确认 `output_text/<同名>.md` 生成、UTF-8、内容非空;espeak 合成音需能识别出接近原文的句子。
+6. **断点续传**:立即重跑,确认全部跳过;`touch input_audio/<文件>` 改 mtime 后重跑,确认重转。
+7. 单文件故意损坏(截断的 m4a):确认报错被捕获、其余文件继续、汇总正确。
+
+**验收清单**:以上全过 + `--download-only` 幂等 + 全程未用 sudo/未装系统包 + 项目外无残留(uv 本体在 `~/.local/bin` 属用户级,允许)。
+
+## 7. 已知坑与兜底
+
+| 坑 | 兜底 |
+|---|---|
+| FunASR 某版本不接受 ndarray 输入 | 改为传临时 wav 路径给 `model.generate(input=wav_path)` |
+| `BatchedInferencePipeline.transcribe` 不认 `initial_prompt` | 对 Whisper 组退回普通 `model.transcribe(..., vad_filter=True)`(慢一点但功能全) |
+| HF 下载慢/断 | `export HF_ENDPOINT=https://hf-mirror.com`;ModelScope 一般无需镜像 |
+| 内存吃紧 | 两引擎严格分时加载(Whisper 组 → 释放 → Paraformer 组);`batch_size` 降到 4 |
+| torch 装成 CUDA 巨包 | 必须用 `--index-url https://download.pytorch.org/whl/cpu` |
+| funasr 启动联网检查卡住 | `disable_update=True` |
+| numpy 2.x 与 funasr 不兼容 | 锁 `numpy==1.26.4` |
+| uv 安装脚本被网络策略拦截 | 从 `https://github.com/astral-sh/uv/releases` 手动下载 x86_64-unknown-linux-gnu tar 包解压到 `~/.local/bin` |
+| Whisper 对中文输出繁体 | `initial_prompt` 开头加"以下是普通话的句子。"(已知有效做法);或结果做 `opencc` 简化(可选,不强制) |
+| 长录音(2h+) | VAD 自动切分,无需额外逻辑;确保 batch_size_s=300 生效 |
+
+## 8. 边界(本次不做)
+
+- 不做说话人分离(课堂以老师单声道为主)
+- 不做时间戳输出(用户明确不要)
+- 不做云端 API、不做 GPU 支持(代码留 device 参数位即可)
+- 不初始化 git、不提交
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..f4a1b85
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,7 @@
+# numpy 锁 1.26.4:funasr 及其依赖对 numpy 2.x 兼容性不稳
+numpy==1.26.4
+faster-whisper>=1.0.0
+funasr>=1.1.0
+modelscope>=1.15
+# uv 创建的 venv 默认不带 setuptools;funasr 生态部分依赖运行时仍 import pkg_resources
+setuptools
diff --git a/run.sh b/run.sh
new file mode 100755
index 0000000..1f66f47
--- /dev/null
+++ b/run.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+# 日常入口:把音频放进 input_audio/ 后运行 ./run.sh [参数]
+# 依赖/模型未就绪时(如刚克隆本仓库),自动先执行 ./setup.sh 完成安装与下载,再继续转写。
+set -euo pipefail
+cd "$(dirname "$0")"
+
+if ! bash setup.sh --check; then
+  echo "==> 依赖未就绪(或配置已更新),自动执行 ./setup.sh(首次需联网,约 3GB)…"
+  bash setup.sh
+fi
+source .venv/bin/activate
+exec python transcribe.py "$@"
diff --git a/setup.sh b/setup.sh
new file mode 100755
index 0000000..ec08a1b
--- /dev/null
+++ b/setup.sh
@@ -0,0 +1,57 @@
+#!/usr/bin/env bash
+# 首次一键配置(幂等,可重复运行):
+#   装 uv(用户级)→ 项目内 venv(Python 3.12)→ CPU 版 torch → 其余依赖 → 预下载模型
+# 全程无需 sudo,不动系统 Python。
+#
+# 用法:
+#   ./setup.sh            完整配置(依赖 + 约 3GB 模型)
+#   ./setup.sh --check    只校验是否已配置(供 run.sh 自动引导),就绪退出码 0,否则 1
+set -euo pipefail
+cd "$(dirname "$0")"
+
+MARKER=".venv/.a2t-ready"
+
+# 配置指纹:依赖清单或本安装脚本内容变化 → 指纹不匹配 → 视为未配置,自动重装
+stamp() { sha256sum requirements.txt setup.sh 2>/dev/null | sha256sum | cut -d' ' -f1; }
+
+if [ "${1:-}" = "--check" ]; then
+  if [ -x .venv/bin/python ] && [ -f "$MARKER" ] && [ "$(cat "$MARKER")" = "$(stamp)" ]; then
+    exit 0
+  fi
+  exit 1
+fi
+
+# 1) uv:没有则装(用户级,装到 ~/.local/bin,无需 sudo)
+if ! command -v uv >/dev/null 2>&1; then
+  echo "==> 安装 uv(用户级,~/.local/bin)…"
+  curl -LsSf https://astral.sh/uv/install.sh | sh
+fi
+export PATH="$HOME/.local/bin:$PATH"
+if ! command -v uv >/dev/null 2>&1; then
+  echo "uv 安装失败。可从 https://github.com/astral-sh/uv/releases 手动下载" >&2
+  echo "x86_64-unknown-linux-gnu tar 包,解压出 uv 放入 ~/.local/bin 后重跑本脚本。" >&2
+  exit 1
+fi
+
+# 2) 项目内 venv(Python 3.12;uv 自动下载托管版 CPython,不动系统 Python)
+if [ ! -x .venv/bin/python ]; then
+  echo "==> 创建项目内 venv(Python 3.12)…"
+  uv venv --python 3.12 .venv
+fi
+source .venv/bin/activate
+
+# 3) 先装 CPU 版 torch(必须用 CPU 源,否则会拉 2GB+ 的 CUDA 包;本机无 GPU)
+echo "==> 安装 CPU 版 torch / torchaudio(download.pytorch.org 的 cpu 源)…"
+uv pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu
+
+# 4) 其余依赖
+echo "==> 安装其余依赖(faster-whisper / funasr / modelscope …)…"
+uv pip install -r requirements.txt
+
+# 5) 建目录 + 预下载全部模型(联网一次,之后可离线转写)
+mkdir -p input_audio output_text models
+python transcribe.py --download-only
+
+printf '%s\n' "$(stamp)" > "$MARKER"
+echo
+echo "配置完成。以后:把音频放进 input_audio/ 然后运行 ./run.sh"
diff --git a/transcribe.py b/transcribe.py
new file mode 100644
index 0000000..55632cc
--- /dev/null
+++ b/transcribe.py
@@ -0,0 +1,542 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""本地课堂录音批量转文字(混合引擎:FunASR SeACo-Paraformer + faster-whisper)。
+
+中文主导的录音路由到 FunASR SeACo-Paraformer(原生热词机制),
+英语/法语等其他语言路由到 faster-whisper。按文件整体路由,不做句级混切。
+所有推理均在本地完成,不使用云端 API。
+
+用法:
+    ./run.sh                      # 全自动:探测语言 → 路由引擎 → 转写
+    ./run.sh --language zh        # 强制语言(zh→Paraformer;en/fr/…→Whisper)
+    ./run.sh --engine whisper     # 强制引擎:paraformer | whisper
+    ./run.sh --model large-v3     # 覆盖 Whisper 模型(默认 large-v3-turbo)
+    ./run.sh --dry-run            # 只解码+探测+打印路由结果,不转写
+    ./run.sh --download-only      # 只预下载全部模型到 models/ 后退出
+"""
+
+from __future__ import annotations
+
+import argparse
+import gc
+import inspect
+import os
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+import time
+import wave
+from pathlib import Path
+
+import numpy as np
+
+# ---------------------------------------------------------------- 路径与常量
+
+PROJECT_DIR = Path(__file__).resolve().parent
+INPUT_DIR = PROJECT_DIR / "input_audio"
+OUTPUT_DIR = PROJECT_DIR / "output_text"
+MODELS_DIR = PROJECT_DIR / "models"
+FUNASR_CACHE_DIR = MODELS_DIR / "funasr"
+WHISPER_CACHE_DIR = MODELS_DIR / "faster-whisper"
+
+# funasr(≥1.2) 不再读取 AutoModel 的 cache_dir 参数,统一走 modelscope 缓存,
+# 只认 MODELSCOPE_CACHE 环境变量 —— 必须在 import funasr 之前设置,保证模型缓存在项目内
+os.environ["MODELSCOPE_CACHE"] = str(FUNASR_CACHE_DIR)
+
+SUPPORTED_EXTS = {".m4a", ".mp3", ".wav", ".flac", ".aac", ".ogg", ".mp4", ".wma", ".amr"}
+
+PARAFORMER_MODEL = "iic/speech_seaco_paraformer_large_asr_nat-zh-cn-16k-common-vocab8404-pytorch"
+VAD_MODEL = "iic/speech_fsmn_vad_zh-cn-16k-common-pytorch"
+PUNC_MODEL = "iic/punc_ct-transformer_zh-cn-common-vocab272727-pytorch"
+
+WHISPER_MODEL_DEFAULT = "large-v3-turbo"
+WHISPER_CPU_THREADS = 16          # 20 线程机器留一点给系统
+WHISPER_BATCH_SIZE = 8            # 15GB 内存的保守批大小
+WHISPER_PROMPT_HOTWORDS = 50      # initial_prompt 携带的热词个数(超长会被截断)
+
+SAMPLE_RATE = 16000
+PROBE_SLICE_SECONDS = 45.0        # 语言探测切片长度
+ZH_PROB_THRESHOLD = 0.6           # zh 平均概率超过该值且为最高才走 Paraformer
+SEGMENTS_PER_PARAGRAPH = 6        # Whisper 每 ~6 个 segment 合并成一个自然段
+SENTENCES_PER_PARAGRAPH = 6       # Paraformer 每 ~6 句插一个空行
+
+# 将来有 NVIDIA GPU:export A2T_DEVICE=cuda(代码保留 device 参数位)
+DEVICE = os.environ.get("A2T_DEVICE", "cpu")
+COMPUTE_TYPE = "int8" if DEVICE == "cpu" else "float16"
+
+WS_RE = re.compile(r"[ \t\r\n\f\v]+")
+SENT_RE = re.compile(r"[^。!?;]*[。!?;]+|[^。!?;]+$")
+
+
+def log(msg: str) -> None:
+    print(msg, flush=True)
+
+
+def short_err(exc: BaseException, limit: int = 150) -> str:
+    return re.sub(r"\s+", " ", str(exc))[:limit] or type(exc).__name__
+
+
+# ---------------------------------------------------------------- CLI
+
+def parse_args() -> argparse.Namespace:
+    p = argparse.ArgumentParser(
+        prog="run.sh",
+        description="本地课堂录音批量转文字(中文主导→Paraformer,英/法等→Whisper)",
+    )
+    p.add_argument("--language", metavar="CODE", default=None,
+                   help="强制语言:zh→Paraformer;en/fr/…→Whisper(语言码传给 Whisper)")
+    p.add_argument("--engine", choices=("paraformer", "whisper"), default=None,
+                   help="强制指定转写引擎")
+    p.add_argument("--model", default=WHISPER_MODEL_DEFAULT,
+                   help=f"Whisper 模型(默认 {WHISPER_MODEL_DEFAULT})")
+    p.add_argument("--dry-run", action="store_true",
+                   help="只解码+探测+打印路由结果,不加载 FunASR、不转写")
+    p.add_argument("--download-only", action="store_true",
+                   help="只预下载/校验全部模型到 models/ 后退出")
+    args = p.parse_args()
+    if args.language:
+        args.language = args.language.strip().lower()
+        args.lang_primary = args.language.split("-")[0]
+    else:
+        args.lang_primary = None
+    return args
+
+
+# ---------------------------------------------------------------- 基础工具
+
+def collect_audio_files() -> list[Path]:
+    if not INPUT_DIR.is_dir():
+        return []
+    return [p for p in sorted(INPUT_DIR.iterdir())
+            if p.is_file() and p.suffix.lower() in SUPPORTED_EXTS]
+
+
+def output_is_fresh(audio: Path) -> bool:
+    """断点续传:输出 md 存在且 mtime 晚于音频 mtime → 视为最新,跳过。"""
+    out = OUTPUT_DIR / (audio.stem + ".md")
+    return out.exists() and out.stat().st_mtime > audio.stat().st_mtime
+
+
+def load_hotwords() -> list[str]:
+    p = PROJECT_DIR / "hotwords.txt"
+    if not p.exists():
+        return []
+    # 「#」开头的行为注释/分组标题,不参与热词
+    return [ln.strip() for ln in p.read_text(encoding="utf-8").splitlines()
+            if ln.strip() and not ln.strip().startswith("#")]
+
+
+def decode_audio(path: Path, offset: float = 0.0, duration: float | None = None) -> np.ndarray:
+    """解码为 16kHz float32 单声道。主路径 PyAV(faster-whisper 自带),异常回退 ffmpeg。"""
+    from faster_whisper.audio import decode_audio as fw_decode
+    try:
+        # 该实现签名只有 (input_file, sampling_rate, split_stereo),默认即输出单声道 float32
+        audio = fw_decode(str(path), sampling_rate=SAMPLE_RATE)
+    except Exception as exc:
+        log(f"    [解码] PyAV 失败({type(exc).__name__}: {short_err(exc)}),回退 ffmpeg 转码 …")
+        audio = decode_audio_ffmpeg(path)
+    if offset > 0 or duration is not None:
+        i0 = max(0, int(offset * SAMPLE_RATE))
+        i1 = len(audio) if duration is None else max(i0, min(len(audio), int((offset + duration) * SAMPLE_RATE)))
+        audio = audio[i0:i1]
+    return audio
+
+
+def decode_audio_ffmpeg(path: Path) -> np.ndarray:
+    ffmpeg = shutil.which("ffmpeg")
+    if not ffmpeg:
+        raise RuntimeError("PyAV 解码失败,且系统中没有 ffmpeg 可作兜底")
+    fd, name = tempfile.mkstemp(suffix=".wav", prefix="a2t_")
+    os.close(fd)
+    tmp = Path(name)
+    try:
+        # 文件名可能含空格/中文:一律参数列表形式,shell=False
+        proc = subprocess.run(
+            [ffmpeg, "-y", "-i", str(path), "-ac", "1", "-ar", str(SAMPLE_RATE), str(tmp)],
+            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+        )
+        if proc.returncode != 0:
+            raise RuntimeError(f"ffmpeg 兜底转码也失败(返回码 {proc.returncode}),文件可能损坏或为空")
+        with wave.open(str(tmp), "rb") as w:
+            if w.getframerate() != SAMPLE_RATE or w.getnchannels() != 1:
+                raise RuntimeError("ffmpeg 输出 wav 参数异常")
+            data = w.readframes(w.getnframes())
+        return np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0
+    finally:
+        tmp.unlink(missing_ok=True)
+
+
+def write_temp_wav(audio: np.ndarray) -> Path:
+    """float32 [-1,1] 单声道 16k → 临时 16bit PCM wav(FunASR ndarray 兜底用)。"""
+    fd, name = tempfile.mkstemp(suffix=".wav", prefix="a2t_")
+    os.close(fd)
+    pcm = np.clip(audio, -1.0, 1.0)
+    pcm = (pcm * 32767.0).astype(np.int16)
+    with wave.open(name, "wb") as w:
+        w.setnchannels(1)
+        w.setsampwidth(2)
+        w.setframerate(SAMPLE_RATE)
+        w.writeframes(pcm.tobytes())
+    return Path(name)
+
+
+# ---------------------------------------------------------------- 模型加载
+
+def dir_has_files(d: Path) -> bool:
+    return d.is_dir() and any(d.iterdir())
+
+
+def load_whisper(model_name: str):
+    from faster_whisper import WhisperModel
+    if not dir_has_files(WHISPER_CACHE_DIR):
+        log(f"    首次使用:本地无缓存,正在从 HuggingFace 下载 {model_name}"
+            f"(体积较大,仅此一次,之后离线可用)…")
+        log("    下载慢/失败可设置国内镜像后重试:export HF_ENDPOINT=https://hf-mirror.com")
+    return WhisperModel(
+        model_name,
+        device=DEVICE,
+        compute_type=COMPUTE_TYPE,
+        cpu_threads=WHISPER_CPU_THREADS,
+        download_root=str(WHISPER_CACHE_DIR),
+    )
+
+
+def make_batched_pipeline(model):
+    from faster_whisper import BatchedInferencePipeline
+    return BatchedInferencePipeline(model=model)
+
+
+def load_paraformer():
+    import torch
+    torch.set_num_threads(WHISPER_CPU_THREADS)
+    if not dir_has_files(FUNASR_CACHE_DIR):
+        log("    首次使用:本地无缓存,正在从 ModelScope 下载 SeACo-Paraformer/VAD/标点"
+            "(约 1.3GB,仅此一次,之后离线可用)…")
+    from funasr import AutoModel
+    return AutoModel(
+        model=PARAFORMER_MODEL,
+        vad_model=VAD_MODEL,
+        vad_kwargs={"max_single_segment_time": 30000},
+        punc_model=PUNC_MODEL,
+        device=DEVICE,
+        cache_dir=str(FUNASR_CACHE_DIR),
+        disable_update=True,
+    )
+
+
+def release(*objs) -> None:
+    for o in objs:
+        del o
+    gc.collect()
+
+
+# ---------------------------------------------------------------- 语言探测与路由
+
+def probe_slices(path: Path) -> list[np.ndarray]:
+    """探测切片:开头 45s + 时长中点 45s;文件短于 90s 则只用全片(单次完整解码)。"""
+    audio = decode_audio(path)
+    total_s = len(audio) / SAMPLE_RATE
+    if total_s <= PROBE_SLICE_SECONDS * 2:
+        return [audio]
+    first_len = int(PROBE_SLICE_SECONDS * SAMPLE_RATE)
+    mid_start = max(0, len(audio) // 2 - first_len // 2)
+    return [audio[:first_len], audio[mid_start:mid_start + first_len]]
+
+
+def probe_language(whisper_model, audio_slices: list[np.ndarray]) -> tuple[str, float]:
+    """对每个切片 transcribe(language=None) 读 info.language/概率,各语言概率取平均。"""
+    per_lang: dict[str, list[float]] = {}
+    for idx, sl in enumerate(audio_slices, 1):
+        _segments, info = whisper_model.transcribe(sl, language=None, beam_size=5)
+        lang = info.language or "unknown"
+        prob = float(info.language_probability or 0.0)
+        per_lang.setdefault(lang, []).append(prob)
+        log(f"    [探测] 切片 {idx}/{len(audio_slices)}:{lang}(p={prob:.2f})")
+    avg = {lang: sum(v) / len(v) for lang, v in per_lang.items()}
+    best = max(avg, key=lambda k: avg[k])
+    return best, avg[best]
+
+
+def forced_route(args: argparse.Namespace) -> tuple[str, str | None, str]:
+    """强制模式下的路由:--engine 优先于 --language。返回 (引擎, whisper语言码, 依据)。"""
+    if args.engine and args.language:
+        eng = args.engine
+        basis = f"强制 --engine={eng} + --language={args.language}"
+    elif args.engine:
+        eng = args.engine
+        basis = f"强制 --engine={eng}"
+    else:
+        eng = "paraformer" if args.lang_primary == "zh" else "whisper"
+        basis = f"强制 --language={args.language}"
+    lang = args.lang_primary if (eng == "whisper" and args.language) else None
+    return eng, lang, basis
+
+
+def auto_route(detected: str, prob: float) -> tuple[str, str]:
+    if detected.startswith("zh") and prob > ZH_PROB_THRESHOLD:
+        return "paraformer", f"自动:探测到 {detected}(平均p={prob:.2f} > {ZH_PROB_THRESHOLD})"
+    return "whisper", f"自动:探测到 {detected}(平均p={prob:.2f})"
+
+
+# ---------------------------------------------------------------- 转写
+
+def build_whisper_prompt(hotwords: list[str], language: str | None) -> str | None:
+    if not hotwords:
+        return None
+    terms = "、".join(hotwords[:WHISPER_PROMPT_HOTWORDS])
+    prompt = f"这是一堂土木工程专业课,可能涉及以下术语:{terms}"
+    if language and language.startswith("zh"):
+        prompt = "以下是普通话的句子。" + prompt  # 防止中文输出成繁体
+    return prompt
+
+
+def _accepted_kwargs(func) -> set[str]:
+    try:
+        params = inspect.signature(func).parameters
+    except (TypeError, ValueError):
+        return set()
+    return {n for n, v in params.items()
+            if v.kind in (v.POSITIONAL_OR_KEYWORD, v.KEYWORD_ONLY)}
+
+
+def whisper_transcribe(model, pipe, audio: np.ndarray, language: str | None, initial_prompt: str | None):
+    """优先 BatchedInferencePipeline(快);不支持 initial_prompt 时按兜底方案退回普通 transcribe。"""
+    want = {
+        "language": language,
+        "beam_size": 5,
+        "vad_filter": True,
+        "vad_parameters": {"min_silence_duration_ms": 500},
+        "condition_on_previous_text": True,
+        "initial_prompt": initial_prompt,
+        "batch_size": WHISPER_BATCH_SIZE,
+    }
+    candidates = []
+    if pipe is not None and (not initial_prompt or "initial_prompt" in _accepted_kwargs(pipe.transcribe)):
+        candidates.append(pipe.transcribe)
+    candidates.append(model.transcribe)
+    last_err: TypeError | None = None
+    for fn in candidates:
+        accepted = _accepted_kwargs(fn)
+        kwargs = {k: v for k, v in want.items() if v is not None and k in accepted}
+        try:
+            return fn(audio, **kwargs)
+        except TypeError as exc:  # 该实现不认某个参数 → 换下一个候选
+            last_err = exc
+    raise RuntimeError(f"transcribe 调用失败:{last_err}")
+
+
+def merge_whisper_segments(segments) -> str:
+    """连续约 6 个 segment 合并为一个自然段,段间空行。"""
+    paragraphs: list[str] = []
+    buf: list[str] = []
+    for i, seg in enumerate(segments, 1):
+        buf.append(seg.text)
+        if i % 50 == 0:
+            log(f"    … 已转写 {i} 段")
+        if i % SEGMENTS_PER_PARAGRAPH == 0:
+            paragraphs.append(WS_RE.sub(" ", "".join(buf)).strip())
+            buf = []
+    if buf:
+        paragraphs.append(WS_RE.sub(" ", "".join(buf)).strip())
+    return "\n\n".join(p for p in paragraphs if p)
+
+
+def paraformer_transcribe(am, audio: np.ndarray, hotword_str: str) -> str:
+    gen_kwargs = {"fs": SAMPLE_RATE, "batch_size_s": 300}
+    if hotword_str:
+        gen_kwargs["hotword"] = hotword_str
+    try:
+        res = am.generate(input=audio, **gen_kwargs)
+    except Exception as exc:
+        log(f"    [Paraformer] ndarray 输入不被接受({type(exc).__name__}: {short_err(exc)}),"
+            f"按兜底方案改传临时 wav 路径重试")
+        wav = write_temp_wav(audio)
+        try:
+            res = am.generate(input=str(wav), **gen_kwargs)
+        finally:
+            wav.unlink(missing_ok=True)
+    if not res or not isinstance(res[0], dict) or "text" not in res[0]:
+        raise RuntimeError(f"FunASR 返回结果异常:{short_err(Exception(repr(res)))}")
+    return str(res[0]["text"])
+
+
+def paraformer_paragraphs(text: str) -> str:
+    """整段带标点文本按句号等断句,每 ~6 句插一个空行。"""
+    sents = [s for s in SENT_RE.findall(text) if s.strip()]
+    if len(sents) <= SENTENCES_PER_PARAGRAPH:
+        return text.strip()
+    return "\n\n".join(
+        "".join(sents[i:i + SENTENCES_PER_PARAGRAPH]).strip()
+        for i in range(0, len(sents), SENTENCES_PER_PARAGRAPH)
+    )
+
+
+def write_output(audio: Path, body: str) -> Path:
+    out = OUTPUT_DIR / (audio.stem + ".md")
+    out.write_text(f"# {audio.stem}\n\n{body}\n", encoding="utf-8")
+    return out
+
+
+# ---------------------------------------------------------------- 子命令
+
+def cmd_download_only(args: argparse.Namespace) -> int:
+    log(f"==> 预下载/校验 Whisper 模型:{args.model}(来自 HuggingFace)…")
+    m = load_whisper(args.model)
+    log(f"    缓存目录:{WHISPER_CACHE_DIR}")
+    del m
+    gc.collect()
+    log("==> 预下载/校验 FunASR 模型:SeACo-Paraformer + VAD + 标点(来自 ModelScope)…")
+    am = load_paraformer()
+    log(f"    缓存目录:{FUNASR_CACHE_DIR}")
+    del am
+    gc.collect()
+    log("全部模型就绪,之后可离线转写。")
+    return 0
+
+
+# ---------------------------------------------------------------- 主流程
+
+def main() -> int:
+    args = parse_args()
+    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+    INPUT_DIR.mkdir(parents=True, exist_ok=True)
+
+    if args.download_only:
+        return cmd_download_only(args)
+
+    files = collect_audio_files()
+    if not files:
+        log("input_audio/ 中没有音频文件"
+            f"(支持:{' '.join(sorted(SUPPORTED_EXTS))})。把录音放进去后再运行。")
+        return 0
+    log(f"在 input_audio/ 找到 {len(files)} 个音频文件。")
+
+    todo: list[Path] = []
+    skipped = 0
+    for f in files:
+        if output_is_fresh(f):
+            skipped += 1
+            log(f"  跳过(输出已存在且比音频新):{f.name}")
+        else:
+            todo.append(f)
+    if not todo:
+        log(f"==== 汇总 ====")
+        log(f"成功 0 / 跳过 {skipped} / 失败 0(全部文件均为最新)")
+        return 0
+
+    hotwords = load_hotwords()
+    log(f"热词:{len(hotwords)} 个(hotwords.txt)")
+
+    forced = bool(args.engine or args.language)
+    whisper_model = None
+    whisper_items: list[tuple[Path, str | None]] = []   # (音频, whisper 语言码或 None=自动)
+    para_items: list[Path] = []
+    ok_n = fail_n = 0
+    failures: list[tuple[str, str]] = []
+
+    # ---------- 阶段一:解码 + 语言探测 + 路由 ----------
+    if forced:
+        for f in todo:
+            eng, lang, basis = forced_route(args)
+            log(f"  路由:{f.name} → {eng}({basis})")
+            if eng == "whisper":
+                whisper_items.append((f, lang))
+            else:
+                para_items.append(f)
+    else:
+        log(f"==> 加载 Whisper({args.model}):先做语言探测,之后复用同一实例转写 …")
+        whisper_model = load_whisper(args.model)
+        for f in todo:
+            log(f"  探测:{f.name}")
+            try:
+                slices = probe_slices(f)
+                detected, prob = probe_language(whisper_model, slices)
+                del slices
+            except Exception as exc:
+                fail_n += 1
+                failures.append((f.name, f"解码/探测失败:{short_err(exc, 200)}"))
+                log(f"  路由:{f.name} → 失败(解码/探测失败:{short_err(exc)})")
+                continue
+            eng, basis = auto_route(detected, prob)
+            log(f"  路由:{f.name} → {eng}({basis})")
+            if eng == "whisper":
+                whisper_items.append((f, detected))
+            else:
+                para_items.append(f)
+
+    if args.dry_run:
+        log("==== dry-run 结束(未转写)====")
+        log(f"Whisper 组 {len(whisper_items)} 个 / Paraformer 组 {len(para_items)} 个 / 跳过 {skipped} 个")
+        return 0
+
+    # ---------- 阶段二:Whisper 组(探测时模型已加载,直接复用) ----------
+    pipe = None
+    if whisper_items:
+        if whisper_model is None:
+            log(f"==> 加载 Whisper({args.model})…")
+            whisper_model = load_whisper(args.model)
+        try:
+            pipe = make_batched_pipeline(whisper_model)
+        except Exception:
+            pipe = None  # 旧版无 BatchedInferencePipeline → 直接用普通 transcribe
+        for f, lang in whisper_items:
+            t0 = time.monotonic()
+            log(f"  [Whisper] 开始:{f.name}(language={lang or '自动'})")
+            try:
+                audio = decode_audio(f)
+                prompt = build_whisper_prompt(hotwords, lang)
+                segments, _info = whisper_transcribe(whisper_model, pipe, audio, lang, prompt)
+                body = merge_whisper_segments(segments)
+                if not body.strip():
+                    raise RuntimeError("转写结果为空(可能是纯静音或音频异常)")
+                out = write_output(f, body)
+                log(f"  [Whisper] 完成:{f.name} → {out.name}(耗时 {time.monotonic() - t0:.1f}s)")
+                ok_n += 1
+            except Exception as exc:
+                fail_n += 1
+                failures.append((f.name, f"{type(exc).__name__}: {short_err(exc, 300)}"))
+                log(f"  [Whisper] 失败:{f.name}({short_err(exc, 200)})")
+
+    # 两引擎不同时常驻:先彻底释放 Whisper,再加载 Paraformer
+    if whisper_model is not None:
+        del whisper_model, pipe
+        whisper_model = None
+        gc.collect()
+        log("==> 已释放 Whisper 模型")
+
+    # ---------- 阶段三:Paraformer 组 ----------
+    if para_items:
+        log("==> 加载 FunASR(SeACo-Paraformer + VAD + 标点)…")
+        am = load_paraformer()
+        hotword_str = " ".join(hotwords)
+        for f in para_items:
+            t0 = time.monotonic()
+            log(f"  [Paraformer] 开始:{f.name}")
+            try:
+                audio = decode_audio(f)
+                text = paraformer_transcribe(am, audio, hotword_str)
+                body = paraformer_paragraphs(text)
+                if not body.strip():
+                    raise RuntimeError("转写结果为空(可能是纯静音或音频异常)")
+                out = write_output(f, body)
+                log(f"  [Paraformer] 完成:{f.name} → {out.name}(耗时 {time.monotonic() - t0:.1f}s)")
+                ok_n += 1
+            except Exception as exc:
+                fail_n += 1
+                failures.append((f.name, f"{type(exc).__name__}: {short_err(exc, 300)}"))
+                log(f"  [Paraformer] 失败:{f.name}({short_err(exc, 200)})")
+        del am
+        gc.collect()
+
+    # ---------- 汇总 ----------
+    log("==== 汇总 ====")
+    log(f"成功 {ok_n} / 跳过 {skipped} / 失败 {fail_n}")
+    for name, reason in failures:
+        log(f"  ✗ {name}:{reason}")
+    return 1 if fail_n else 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())