# 施工任务：本地课堂录音批量转文字（混合引擎）

> **本文件是一份自包含的施工说明书**，写给在全新对话中执行它的编码 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、不提交
