Compare commits

...

7 Commits

Author SHA1 Message Date
audio2text dev
954767b3b2 merge: 合并远程 README 精简 + 保留本地快速开始/离线部署章节 2026-07-11 12:12:01 +08:00
audio2text dev
d990207cec fix 2026-07-11 12:09:20 +08:00
audio2text dev
1e355e6138 docs: 新增离线部署导入镜像说明 + 更新功能特性
README:
- 新增「离线部署(导入预构建镜像)」章节:docker load + 启动命令
- 功能特性更新:设置页、任务删除、进度细分、离线运行、/docs 公开

docs/DOCKER.md:
- 新增「导入预构建镜像(离线部署)」完整章节:
  前置要求、导出步骤、需拷贝文件清单、导入启动命令、离线说明、后续更新代码

.gitignore:
- 排除 *.tar(导出的镜像文件不入库)
2026-07-11 11:37:52 +08:00
audio2text dev
7635e5e766 feat: 设置页调 batch_size + 删除任务 + /docs 去认证 + 离线模式
设置页(/settings):
- 新增 Setting DB 模型(键值存储,持久化运行时覆盖)
- config.py 加 _apply_overrides:get_settings() 合并 DB 覆盖值
  (_applying_overrides 标志防递归:DB 初始化回调 get_settings 时不重入)
- save_setting() 写 DB + 清 lru_cache,后续任务读到新值
- settings_router.py: GET/PUT /api/settings
- settings_html.py: 设置页表单(batch_size/beam_size/sort_by_length)
  + 设备只读信息 + 保存后 reload 确认
- 验证:改 asr_batch_size=16 beam_size=1 -> 任务 ASR 日志确认生效
- 验证:重启容器后设置从 DB 恢复(持久化)

删除任务:
- task_router.py: DELETE /api/tasks/{id},仅 done/failed 可删
  删字幕/音频/视频产物 + UploadSession + Task DB 记录
- home_html.py + history_html.py: done/failed 任务显示删除按钮
  + confirm 确认 + 调用 DELETE API + 刷新列表
- 验证:删除 task 15 成功,删除进行中 task 返回 409

/docs 去认证:
- 移除 require_docs_auth 依赖,/docs /redoc /openapi.json 直接公开

离线模式:
- Dockerfile dev/final 加 HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1
  (模型已缓存在 ./models volume,无需联网验证)
- 验证:离线模式完整跑通 ASR+翻译(task 15 done)
2026-07-11 11:33:34 +08:00
afa2027a00 更新 README.md 2026-07-07 01:48:12 +00:00
audio2text dev
78b87bfb24 feat: 进度条颗粒度优化 — ASR/翻译按批次细分进度
原来进度只在阶段结束时跳一次(ASR 5%→55%、翻译 60%→98%),长视频时
进度条卡住不动。现在每完成一个批次就更新进度。

ASR(asr_service.py):
- 用 info.duration_after_vad 算总 chunk 数(ceil(时长/30s))
- 消费生成器时按 seg.end 跨 30s chunk 边界回调 on_progress
- 30s 粒度自然节流,长视频约几十次更新

翻译(translate_service.py):
- _translate_sorted / _translate_sequential 每批完成后回调 on_progress(done, total)
- 批数循环前已知(len(batches)),每批都回调

pipeline.py:
- asr_phase / translate_phase 定义闭包回调,把 (current,total) 占比映射到
  对应进度区间(ASR 5%→55%、翻译 60%→98%),调 _set_status 写 DB(DEBUG 级)

验证(test/55.mp4, 640 条字幕):
- ASR: 5%→19.9%→23.8%→33.7%→48.6%→55% 平滑增长 
- 翻译: 20 批,65.3%→68.9%→74.2%→79.6%→84.9%→90.2%→93.8%→98% 
- 翻译卡在 60% 的 ~50s 是模型加载时间(卸载ASR+加载NLLB),属调度器层面
2026-07-06 23:18:30 +08:00
audio2text dev
5f6a242114 docs: README 分层重构 — 主页精简到 150 行 + 6 个子文档
原 883 行单体 README 信息密度过高且重复(配置差异表出现 2 次、缓存说明
散落多处)。按主题拆分:

主页 README.md (150行):
- 一句话简介 + 功能特性(精简) + 架构(目录树+数据流) + 快速开始
- 文档索引表(链接到 6 个子文档,每行一句话说明)
- 入口地址表 + 依赖(精简)

docs/ 子文档(原样搬运,不重写):
- DEPLOYMENT.md (170行) CPU/GPU 部署、模型选型、CPU↔GPU 切换
- CONFIG.md     (187行) 配置差异表、完整字段表、配置示例
- DOCKER.md     (185行) 构建/重建/缓存分层/until根因/Volume
- API.md        (70行)  HTTP接口表、分片上传协议、示例
- ARCHITECTURE.md(140行) 断句算法、显存策略、GPU优化、缓存清理
- FAQ.md        (42行)  6 条常见问题

每个子文档顶部加「← 返回主页」链接,相关处加交叉引用
(如 DEPLOYMENT 提到缓存时链接 DOCKER.md)。无内容丢失。
2026-07-06 22:59:05 +08:00
24 changed files with 1562 additions and 797 deletions

View File

@@ -9,3 +9,4 @@ logs/
.git/
.gitignore
test/
build/

5
.gitignore vendored
View File

@@ -19,3 +19,8 @@ logs/
# 测试数据与脚本(本地测试用,不入库)
/test/
# 导出的 Docker 镜像 tar太大不入库
*.tar
build/

View File

@@ -100,7 +100,9 @@ VOLUME ["/data", "/models"]
# "Unable to load libcudnn_ops.so.9"。放在 final/dev 而非 deps避免 ENV 变化
# 导致 deps 的 apt/pip 层缓存失效。CPU 镜像无此目录,路径被忽略不影响。
ENV CONFIG_PATH=/app/config.yaml \
LD_LIBRARY_PATH=/usr/local/lib/python3.12/site-packages/nvidia/cudnn/lib:/usr/local/lib/python3.12/dist-packages/nvidia/cudnn/lib:/usr/local/lib/python3.12/site-packages/nvidia/cublas/lib:/usr/local/lib/python3.12/dist-packages/nvidia/cublas/lib
LD_LIBRARY_PATH=/usr/local/lib/python3.12/site-packages/nvidia/cudnn/lib:/usr/local/lib/python3.12/dist-packages/nvidia/cudnn/lib:/usr/local/lib/python3.12/site-packages/nvidia/cublas/lib:/usr/local/lib/python3.12/dist-packages/nvidia/cublas/lib \
HF_HUB_OFFLINE=1 \
TRANSFORMERS_OFFLINE=1
EXPOSE 8000
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
@@ -118,7 +120,9 @@ COPY config.example.yaml /app/config.example.yaml
# 全部走 volume镜像本身无状态、无敏感数据
VOLUME ["/data", "/models"]
ENV CONFIG_PATH=/app/config.yaml \
LD_LIBRARY_PATH=/usr/local/lib/python3.12/site-packages/nvidia/cudnn/lib:/usr/local/lib/python3.12/dist-packages/nvidia/cudnn/lib:/usr/local/lib/python3.12/site-packages/nvidia/cublas/lib:/usr/local/lib/python3.12/dist-packages/nvidia/cublas/lib
LD_LIBRARY_PATH=/usr/local/lib/python3.12/site-packages/nvidia/cudnn/lib:/usr/local/lib/python3.12/dist-packages/nvidia/cudnn/lib:/usr/local/lib/python3.12/site-packages/nvidia/cublas/lib:/usr/local/lib/python3.12/dist-packages/nvidia/cublas/lib \
HF_HUB_OFFLINE=1 \
TRANSFORMERS_OFFLINE=1
EXPOSE 8000
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

849
README.md
View File

@@ -9,45 +9,34 @@
---
## 目录
## 快速开始
- [功能特性](#功能特性)
- [架构](#架构)
- [部署CPU 开发环境](#部署cpu-开发环境)
- [部署GPU 生产环境](#部署gpu-生产环境)
- [配置文件说明](#配置文件说明)
- [缓存清理与定时任务](#缓存清理与定时任务)
- [HTTP 接口](#http-接口)
- [断句与时间戳重算原理](#断句与时间戳重算原理)
- [模型不共驻(显存策略)](#模型不共驻显存策略)
- [Docker 说明](#docker-说明)
- [新建 / 重建容器](#新建--重建容器)
- [缓存分层与删除边界](#缓存分层与删除边界)
- [⚠️ 不要用 `docker builder prune --filter until`](#-不要用-docker-builder-prune---filter-until)
- [GPU 利用率优化](#gpu-利用率优化)
- [依赖](#依赖)
- [常见问题](#常见问题)
### GPU 环境(需 NVIDIA GPU + nvidia runtime
```bash
AUDIO2TEXT_VARIANT=gpu ./setup.sh
./start.sh # 端口 8001
```
启动后打开 `http://127.0.0.1:8000/`拖入视频即可。详细部署流程、模型选型、CPU↔GPU 切换
见 [部署指南](./docs/DEPLOYMENT.md)
---
## 功能特性
- **主页** `/`:上传入口(拖拽 / 选择文件多文件、4 MiB 分片、断点续传+ 最近 10 个任务的实时进度卡片,完成的可直接下载字幕。
- **历史任务页** `/history`:分页查看所有历史任务,可下载完成的字幕
- **实时日志页** `/logs`按级别分层查看——debug=详细子步骤、info=仅阶段转换、error=完整 traceback。
- **大视频处理**:接收完成后用 ffmpeg 提取 16 kHz 单声道 PCM 音频;是否删原始视频由配置决定。
- **faster-whisper 转写英语**,带词级时间戳。
- **断句 + 时间戳重算**:按句末标点(`. ! ? ;`)切句、超长句按逗号拆,时间戳取首末词精确值;
无词级时间戳时退化为段内匀速估算。
- **NLLB-200 英译中**ASR 与翻译模型**不共驻**,翻译时卸载 Whisper 独占显存跑大 batch。
- **双语合并 SRT** 输出(英文在上、中文在下),亦可单独下载英文 / 中文字幕。
- **任务状态机**`queued → extracting → transcribing → segmenting → translating → done`
页面自动轮询进度。
- **定时缓存清理**:任务产物(字幕 / 中间音频 / 保留的原始视频)默认保留 7 天,超期后
连同 DB 记录一并删除;容器内后台线程定时执行(启动时跑一次,默认每 24 小时一次),
保留期与间隔均可配置。
- **SQLite 持久化**(自包含,无需外部 DB
- `/docs`Swagger UI受 Basic Auth 保护。
- **网页上传**拖拽 / 选择文件,多文件并发、4 MiB 分片、断点续传
- **双语字幕**:英文在上、中文在下,亦可单独下载英文 / 中文字幕
- **faster-whisper 转写**:词级时间戳,断句精确(取首末词时间戳)
- **NLLB-200 英译中**ASR 与翻译模型不共驻,翻译时独占显存跑大 batch
- **设置页**:运行时调整 batch_size / beam_size保存后对后续任务生效DB 持久化)
- **任务管理**:删除已完成/失败任务及其产物,进度条按批次细分
- **任务状态机**`queued -> uploading -> extracting -> transcribing -> segmenting -> translating -> done`
- **实时日志页**按级别分层debug=详细子步骤 / info=阶段转换 / error=完整 traceback
- **定时缓存清理**:任务产物默认保留 7 天,超期连同 DB 记录一并删除
- **SQLite 持久化**(自包含,无需外部 DB
- **离线运行**:模型缓存就位后完全离线,无需访问 HuggingFace
- `/docs`Swagger UI公开访问
---
@@ -64,7 +53,7 @@ audio2text/
│ └── prefetch_models.{sh,py} # 预拉模型权重到 ./models volume避免首次启动下载
├── requirements.txt
├── config.example.yaml # 配置模板(复制为 config.yaml 后填值)
├── README.md
├── docs/ # 详细文档(见下方索引)
└── app/
├── main.py # FastAPI 应用工厂
├── config.py # 从 config.yaml 加载的类型化 Settingspydantic
@@ -81,6 +70,7 @@ audio2text/
│ ├── segmenter.py # 断句 + 时间戳重算(纯算法,零模型依赖)
│ ├── translate_service.py # NLLB 翻译
│ ├── model_manager.py # 模型加载/卸载(不共驻核心)
│ ├── scheduler.py # ffmpeg 串行队列 + GPU 调度线程
│ ├── pipeline.py # 编排提取→识别→断句→翻译→写SRT
│ ├── srt_writer.py # SRT 写入 + 双语合并
│ ├── log_buffer.py # 内存日志缓冲(供 /logs 页面查询)
@@ -107,777 +97,102 @@ audio2text/
upload_router ──► upload_service ──► UploadSession(SQLite) + 分片落盘
│ complete
创建 Task(queued) ──► pipeline 后台线程
创建 Task(queued) ──► scheduler
├─ 1. ffmpeg_service.extract_audio → 16k mono wav
├─ ffmpeg 串行队列(最多 1 个并发,其余排队)→ 16k mono wav
│ (按配置删原始视频)
├─ 2. model_manager.get_asr → asr_service.transcribe → segments(带词级时间戳)
├─ 3. segmenter.resegment → 规范字幕条目(精确/估算两路)
├─ 4. model_manager.unload_asr → get_translator
├─ GPU 调度线程(单线程,模型复用):
│ get_asr → asr_service.transcribe → segments(带词级时间戳)
unload_asr → get_translator
│ translate_service.translate → 中文译文(独占显存大 batch
└─ 5. srt_writer → en.srt / zh.srt / bilingual.srt
└─ srt_writer → en.srt / zh.srt / bilingual.srt
更新 Task(done) + 写 output_dir
```
---
## 部署CPU 开发环境
## 快速开始
CPU 模式用于本地开发与流程验证模型选同系列最小尺寸2GB 内存开发机即可跑通完整流程。
### 前置要求
- Docker用于构建镜像 + 运行容器)
- 约 500 MB 磁盘(模型缓存)+ 上传视频空间
CPU 模式**不需要** NVIDIA 驱动,普通 Linux / macOS / WSL 均可。
### 步骤
### CPU 开发环境2GB 内存即可)
```bash
cd /root/zikai/audio2text
# 1. 构建 CPU 镜像 + 复制 config.cpu.yaml → config.yaml
./setup.sh # 默认 AUDIO2TEXT_VARIANT=cpu
# 2. 启动容器(默认端口 8000
./start.sh
# 3. 停止 / 重启
./stop.sh
./start.sh
./setup.sh # 构建镜像 + 生成 config.yaml
./start.sh # 启动容器(端口 8000
```
`setup.sh` 做三件事:检查 docker → 构建 `audio2text:cpu` 镜像 → 把 `config.cpu.yaml`
复制为 `config.yaml`(运行时实际读取的文件)。可重复执行;改完配置后重新 `cp` 并重启即可,
无需重建镜像。
首次启动会下载模型Whisper `tiny.en` ~39M + opus-mt ~300MB`./models` volume
之后秒起。启动后浏览器打开 `http://127.0.0.1:8000/`,拖入视频或音频文件即可。
### 预拉模型(避免首次启动卡在下载)
容器首次处理任务时会从 HuggingFace 下载模型大模型GPU 的 large-v3-turbo ~3GB +
NLLB-1.3B ~2.5GB)下载耗时较长。可用预拉脚本提前下好到 `./models` volume之后容器启动
即用、无需联网:
### GPU 生产环境(需 NVIDIA GPU + nvidia runtime
```bash
./scripts/prefetch_models.sh # 读 config.yaml当前激活配置
./scripts/prefetch_models.sh config.gpu.yaml # 读指定配置(如切换到 GPU 前预拉大模型)
AUDIO2TEXT_VARIANT=gpu ./setup.sh
./start.sh # 自动检测 GPU 镜像 + nvidia-smi端口 8001
```
脚本用已构建的镜像跑一次性容器,读配置里的 `asr.model` / `translation.model`,下载到
`./models/huggingface`HF 标准缓存)。**幂等**:已下过的模型自动跳过。换 config 的模型
名后重跑即可补下新模型,无需重建镜像。
### CPU 模型选型
| 组件 | 模型 | 大小 | 说明 |
|---|---|---|---|
| ASR | `tiny.en` | ~39M | Whisper 同系列最小,英文专用版(比通用 `tiny` 在英语上更准) |
| 翻译 | `Helsinki-NLP/opus-mt-en-zh` | ~300MB | 最轻量英译中。NLLB 同系列最小 `distilled-600M` 需 ~2.4GB2GB 机 OOM故回退 |
> 翻译质量与 GPU 的 NLLB-1.3B 有差异,但**完整流程一致**(提取→识别→断句→翻译→双语 SRT
> 足以验证端到端逻辑。如需在 CPU 上验证 NLLB 翻译质量,可把 `translation.model` 改为
> `nllb-200-distilled-600M`(需 ≥4GB 内存)或 `nllb-200-distilled-1.3B`(需 ~5GB 内存)。
### 自定义端口
```bash
AUDIO2TEXT_PORT=9000 ./start.sh
```
启动后打开 `http://127.0.0.1:8000/`拖入视频即可。详细部署流程、模型选型、CPU↔GPU 切换
见 [部署指南](./docs/DEPLOYMENT.md)。
---
## 部署GPU 生产环境
## 离线部署(导入预构建镜像)
GPU 模式用于生产模型质量优先NVIDIA 3090 24G 上几 GB 视频几分钟出字幕。
### 前置要求
- Docker
- **NVIDIA GPU 驱动**(宿主机)
- **nvidia container runtime**(让容器能用 GPU安装 `nvidia-container-toolkit`
- 约 6 GB 磁盘模型缓存large-v3-turbo ~3GB + NLLB-1.3B ~2.5GB
验证 GPU 可用:
已有 `audio2text-gpu.tar` 镜像文件时,新机器无需构建,直接导入即可启动(仍需 NVIDIA 驱动 +
nvidia container runtime + 模型缓存 `./models`
```bash
nvidia-smi # 宿主能看到 GPU
docker run --rm --gpus all nvidia/cuda:12.1.0-runtime-ubuntu22.04 nvidia-smi
# 上面容器内也能列出 GPU 即说明 nvidia runtime 已就绪
# 1. 导入镜像
docker load -i audio2text-gpu.tar
# 2. 准备配置 + 数据目录
mkdir -p data-gpu/uploads data-gpu/.work data-gpu/outputs models
# 3. 启动容器config.gpu.yaml 需自行准备,或从项目仓库取)
docker run -d --name audio2text-gpu \
--gpus all \
-p 8001:8000 \
-v "$(pwd)/data-gpu:/data" \
-v "$(pwd)/models:/models" \
-v "$(pwd)/config.gpu.yaml:/app/config.yaml:ro" \
--restart unless-stopped \
audio2text:gpu
```
### 步骤
> **模型缓存**`./models` 目录需包含 Whisper `large-v3-turbo` + NLLB `distilled-1.3B` 权重
> (约 5.5GB)。首次部署时从源机器拷贝 `models/` 目录,或联网用 `prefetch_models.sh` 预拉。
> 镜像内置 `HF_HUB_OFFLINE=1`,模型就位后完全离线运行,无需访问 HuggingFace。
```bash
cd /root/zikai/audio2text
详细步骤见 [Docker 说明 - 导入预构建镜像](./docs/DOCKER.md#导入预构建镜像离线部署)。
# 1. 构建 GPU 镜像 + 复制 config.gpu.yaml → config.yaml
AUDIO2TEXT_VARIANT=gpu ./setup.sh
---
# 2. 启动容器start.sh 检测到 gpu 镜像 + nvidia-smi 自动加 --gpus all
./start.sh
# 3. 停止 / 重启
./stop.sh
./start.sh
```
`start.sh` 的镜像选择逻辑:若本机存在 `audio2text:gpu` 镜像**且**有 `nvidia-smi`,自动用
GPU 模式(`--gpus all`);否则回退 CPU 镜像。也可用 docker compose 显式启动:
```bash
docker compose --profile gpu up -d --build # GPU
docker compose --profile cpu up -d --build # CPU
```
### GPU 模型选型
| 组件 | 模型 | 显存 | 说明 |
|---|---|---|---|
| ASR | `large-v3-turbo` | ~3GBFP16 | 8x 速度,质量接近 large-v3 |
| 翻译 | `facebook/nllb-200-distilled-1.3B` | ~2.5GBFP16 | 质量最好的蒸馏版 |
ASR 与翻译**不共驻**:翻译阶段先卸载 Whisper 释放显存,独占跑大 batch`batch_size=32`
两者峰值显存互不叠加,远低于 24G 上限。模型缓存(`./models` volume跨容器复用
CPU→GPU 切换时 NLLB/Whisper 大模型首次下载、之后秒起。
### CPU ↔ GPU 切换
同一份代码,仅靠 `AUDIO2TEXT_VARIANT` 切换镜像 + 配置:
```bash
AUDIO2TEXT_VARIANT=gpu ./setup.sh # 切到 GPU构建 gpu 镜像 + config.gpu.yaml
AUDIO2TEXT_VARIANT=cpu ./setup.sh # 切回 CPU构建 cpu 镜像 + config.cpu.yaml
./start.sh # 重新启动
```
两套配置的差异仅在 8 项(其余字段完全一致):
| 字段 | CPU`config.cpu.yaml` | GPU`config.gpu.yaml` |
|---|---|---|
| `asr.model` | `tiny.en` | `large-v3-turbo` |
| `asr.device` | `cpu` | `cuda` |
| `asr.compute_type` | `int8` | `float16` |
| `asr.batch_size` | `8` | `32` |
| `asr.beam_size` | `5` | `2` |
| `translation.model` | `Helsinki-NLP/opus-mt-en-zh` | `facebook/nllb-200-distilled-1.3B` |
| `translation.device` | `cpu` | `cuda` |
| `translation.batch_size` | `8` | `32` |
### 启动后的入口
两种模式通用:
## 入口
| 入口 | 地址 |
|---|---|
| 主页 | `http://127.0.0.1:8000/`(上传入口 + 最近 10 任务进度卡片) |
| 历史任务 | `http://127.0.0.1:8000/history`(分页查看所有任务,可按文件名搜索、下载字幕) |
| 日志 | `http://127.0.0.1:8000/logs`(按级别分层、自动刷新) |
| API 文档 | `http://127.0.0.1:8000/docs`Basic Auth凭据见 config.yaml `docs` 段) |
| 主页(上传 + 最近任务) | `http://127.0.0.1:8000/` |
| 历史任务 | `http://127.0.0.1:8000/history` |
| 实时日志 | `http://127.0.0.1:8000/logs` |
| API 文档 | `http://127.0.0.1:8000/docs` |
| 健康检查 | `http://127.0.0.1:8000/health` |
| 任务列表 | `http://127.0.0.1:8000/api/tasks` |
---
## 配置文件说明
项目预置两份配置文件,`setup.sh``AUDIO2TEXT_VARIANT` 自动复制对应文件为
`config.yaml`(运行时实际读取的文件,不入库):
| 文件 | 激活方式 | 说明 |
|---|---|---|
| `config.cpu.yaml` | `./setup.sh`(默认) | CPU 开发,最小模型 |
| `config.gpu.yaml` | `AUDIO2TEXT_VARIANT=gpu ./setup.sh` | GPU 生产,质量优先 |
| `config.example.yaml` | — | 带完整注释的字段参考模板 |
也可手动切换:`cp config.gpu.yaml config.yaml` 后重启容器即可,无需重建镜像(镜像不含配置)。
运行时通过环境变量 `CONFIG_PATH` 指定路径(容器内默认 `/app/config.yaml`)。所有路径相对
容器内文件系统。`config.py` 用 pydantic 做类型校验,缺字段时回退默认值。
### CPU / GPU 两份配置的差异
其余字段存储、断句、日志、docs两份配置完全一致仅以下 8 项不同:
| 字段 | `config.cpu.yaml` | `config.gpu.yaml` |
|---|---|---|
| `asr.model` | `tiny.en` | `large-v3-turbo` |
| `asr.device` | `cpu` | `cuda` |
| `asr.compute_type` | `int8` | `float16` |
| `asr.batch_size` | `8` | `32` |
| `asr.beam_size` | `5` | `2` |
| `translation.model` | `Helsinki-NLP/opus-mt-en-zh` | `facebook/nllb-200-distilled-1.3B` |
| `translation.device` | `cpu` | `cuda` |
| `translation.batch_size` | `8` | `32` |
### 完整字段
#### `server` — 服务监听
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `host` | str | `0.0.0.0` | 容器内监听地址(由 `docker -p` 映射到宿主) |
| `port` | int | `8000` | 容器内监听端口 |
| `workers` | int | `1` | uvicorn worker 数。ML 推理为重,固定单 worker 避免显存重复占用 |
#### `storage` — 文件存储
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `upload_dir` | str | `/data/uploads` | 上传视频落盘根目录(按 `yyyy/mm` 分子目录) |
| `work_dir` | str | `/data/.work` | 分片会话暂存 + 中间音频 + SQLite 数据库 |
| `output_dir` | str | `/data/outputs` | 生成的 SRT 字幕输出目录 |
| `chunk_bytes` | int | `1048576` | 流式分片大小1 MiB。注意前端上传页固定 4 MiB此项影响服务端缓冲 |
| `chunk_session_ttl_seconds` | int | `300` | 被放弃的分片会话存活秒数,超时后后台 reaper 清理(短 TTL与下方缓存清理不同 |
| `cache_retention_days` | int | `7` | 任务产物(字幕 / 中间音频 / 保留的原始视频)保留天数;超期任务连同 DB 记录一并删除。`0` = 禁用清理 |
| `cache_cleanup_interval_hours` | int | `24` | 定时清理间隔(小时)。容器启动时跑一次,之后按此间隔循环 |
#### `processing` — 处理流程
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `delete_original_after_extract` | bool | `true` | 提取音频成功后删除原始视频,省空间。`false` 则保留视频 |
| `keep_audio` | bool | `false` | 任务完成后是否保留中间 wav。`false` 则只留字幕、删 wav |
#### `asr` — 语音识别faster-whisper
| 字段 | 类型 | 默认CPU | 说明 |
|---|---|---|---|
| `model` | str | `tiny.en` | Whisper 模型名。CPU dev 用 `tiny.en`39M英文专用同系列最小GPU prod 用 `large-v3-turbo`8x 速度,质量接近 large-v3 |
| `device` | str | `cpu` | `cpu``cuda` |
| `compute_type` | str | `int8` | CPU 用 `int8`GPU 用 `float16` |
| `language` | str | `en` | 识别语言,仅英语 |
| `word_timestamps` | bool | `true` | 词级时间戳:让断句精确(取首末词时间戳)而非纯匀速估算。建议开 |
| `vad_filter` | bool | `true` | 过滤静音段,提升识别质量与速度 |
| `batch_size` | int | `8`CPU/ `32`GPU | `BatchedInferencePipeline` 批量解码的音频块数。GPU 拉大 batch 拉长单次 GPU 解码时间,掩盖 CPU 提取 Mel 特征的间隙,提升平均利用率 |
| `beam_size` | int | `5`CPU/ `2`GPU | beam search 宽度。GPU turbo 模型鲁棒,降到 2 减少解码候选数与步数加速明显、质量损失小CPU 无加速诉求保持默认 5 |
#### `translation` — 翻译NLLB-200
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `model` | str | `facebook/nllb-200-distilled-1.3B` | HuggingFace 模型名。GPU 生产用 1.3B质量最好CPU dev 用 `Helsinki-NLP/opus-mt-en-zh`~300MB2GB 机可跑。NLLB 同系列最小为 `distilled-600M`~1.2GB,需 ≥4GB 内存) |
| `device` | str | `cpu` | `cpu``cuda` |
| `src_lang` | str | `eng_Latn` | NLLB 语言码:英语 |
| `tgt_lang` | str | `zho_Hans` | NLLB 语言码:简体中文 |
| `batch_size` | int | `8`CPU/ `32`GPU | 翻译批量大小。不与 ASR 共驻时显存独占,可用大 batch |
| `max_length` | int | `256` | 单条翻译最大 token 数 |
| `sort_by_length` | bool | `true` | 按句子长度排序后分批,减少批内 padding 浪费GPU 收益大) |
#### `segmentation` — 断句与字幕规范化
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `max_words_per_line` | int | `14` | 单行最多词数,超出按逗号拆分 |
| `max_duration_seconds` | float | `7.0` | 单条字幕最长 7 秒 |
| `min_duration_seconds` | float | `1.0` | 单条字幕最短 1 秒(太短则与下条合并) |
| `max_chars_per_line` | int | `42` | SRT 规范:每行 ≤42 字符超出按词折行≤2 行) |
#### `logging` — 日志
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `level` | str | `info` | 控制台输出的最低级别:`debug` / `info` / `warning` / `error`。不影响 `/logs` 页面(页面可自由切换级别查看) |
| `buffer_size` | int | `2000` | `/logs` 页面内存缓冲条数(有界 deque旧记录自动淘汰 |
日志分层语义:
| 级别 | 内容 | 示例 |
|---|---|---|
| **debug**(详细) | 子步骤ffmpeg 命令、模型加载/卸载、转写逐段、翻译逐批进度 | `加载 ASR 模型 model=tiny.en device=cpu` / `ffmpeg 命令ffmpeg -y ...` |
| **info**(简略) | 仅任务阶段转换,看当前进行到哪一步 | `任务 1 [transcribing 55%] 识别出 3 段` |
| **error**(详细) | 完整 traceback文件名+行号+调用链),可点击展开 | `任务 1 失败ffmpeg 失败 (code=183)...` + traceback |
> **注意**`logging.level` 只控制控制台输出级别。`/logs` 页面始终全量缓冲DEBUG 起),
> 页面上的级别按钮是查询过滤,不受此配置限制——所以控制台设 `info` 保持简略,而 `/logs`
> 页面切到 DEBUG 仍能看到所有详细子步骤。
#### `docs` — API 文档保护
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `enabled` | bool | `true` | 是否开启 `/docs` `/redoc` `/openapi.json` |
| `username` | str | `admin` | Basic Auth 用户名 |
| `password` | str | `CHANGE_ME` | Basic Auth 明文密码(常量时间比较)。**部署前务必修改** |
| `realm` | str | `audio2text docs` | WWW-Authenticate realm |
### 配置示例
```yaml
server:
host: 0.0.0.0
port: 8000
workers: 1
storage:
upload_dir: /data/uploads
work_dir: /data/.work
output_dir: /data/outputs
chunk_bytes: 1048576
chunk_session_ttl_seconds: 300
cache_retention_days: 7 # 任务产物保留天数超期清理0=禁用)
cache_cleanup_interval_hours: 24 # 定时清理间隔(启动时跑一次,之后循环)
processing:
delete_original_after_extract: true
keep_audio: false
asr:
model: tiny.en # GPU: large-v3-turbo
device: cpu # GPU: cuda
compute_type: int8 # GPU: float16
language: en
word_timestamps: true
vad_filter: true
batch_size: 8 # GPU: 32(拉长单次 GPU 解码,掩盖 CPU 特征提取间隙)
beam_size: 5 # GPU: 2turbo 鲁棒可降,候选数↓解码步数↓)
translation:
model: facebook/nllb-200-distilled-1.3B
device: cpu # GPU: cuda
src_lang: eng_Latn
tgt_lang: zho_Hans
batch_size: 8 # GPU: 32(显存独占可用大 batch
max_length: 256
sort_by_length: true # 按长度排序分批,减少 padding 浪费
segmentation:
max_words_per_line: 14
max_duration_seconds: 7.0
min_duration_seconds: 1.0
max_chars_per_line: 42
logging:
level: info # debug | info | warning | error控制台输出最低级别
buffer_size: 2000
docs:
enabled: true
username: admin
password: "CHANGE_ME"
realm: "audio2text docs"
```
---
## 缓存清理与定时任务
每个任务落盘的产物(字幕、中间音频、保留的原始视频)会持续占用磁盘。容器内置定时
清理(`app/services/cache_cleaner.py`),无需外部 cron
### 清理什么
| 产物 | 路径 | 何时产生 |
|---|---|---|
| 字幕输出 | `<output_dir>/task_<id>/` | 任务完成 |
| 中间音频 | `<work_dir>/task_<id>.wav` | `keep_audio=true` 且管线未删时残留 |
| 保留的原始视频 | `<upload_dir>/yyyy/mm/<uuid>.<ext>` | `delete_original_after_extract=false` 时 |
| 孤儿目录 | 上述目录中无对应 Task 的残留 | 进程崩溃 / 异常退出留下 |
### 清理策略
1. **超期任务**`Task.created_at` 早于 `now - cache_retention_days`(默认 7 天)的任务,
删除其全部产物,并删除对应的 `Task``UploadSession` 行——避免历史页出现指向已删
文件的死链接。
2. **孤儿扫描**`output_dir` / `work_dir` 下名为 `task_<id>` 但 DB 中已无该 Task 的目录
(崩溃残留),按目录 `mtime` 判超期后删除。
3. **DB 一致性**:删任务时先删关联的 `UploadSession`FK再删 `Task`,保持引用完整。
### 触发时机
- **启动时跑一次**:容器启动 lifespan 中立即执行(`purge_expired_cache`),清掉停机期间
超期的产物。
- **后台定时循环**:守护线程 `cache-cleaner``cache_cleanup_interval_hours`(默认 24h
循环执行,随进程退出而终止。
- **手动触发**(调试用):进容器跑 `python -m app.services.cache_cleaner`,打印清理统计 JSON。
### 相关配置(`storage` 段)
| 字段 | 默认 | 说明 |
|---|---|---|
| `cache_retention_days` | `7` | 保留天数。`0` = 禁用清理(产物永久保留) |
| `cache_cleanup_interval_hours` | `24` | 定时循环间隔(小时) |
### 与上传会话 reaper 的区别
| 机制 | 清理对象 | 判定 | 触发 |
|---|---|---|---|
| **reaper**`reaper.py` | 被放弃的**分片上传会话**(未 complete 的) | `status=pending``updated_at``chunk_session_ttl_seconds`300s | 仅启动时一次 |
| **cache_cleaner**(本节) | 已完成/失败**任务的产物** + 崩溃孤儿 | `created_at``cache_retention_days`7d/ 孤儿 mtime 超期 | 启动一次 + 定时循环 |
> 后台清理线程与请求线程并发写同一 SQLite 库,`database.py` 已设 `busy_timeout=30s`
> 拿锁时阻塞等待而非立即报 `database is locked`。单 worker 部署下无并发写入压力。
---
## HTTP 接口
| 方法 | 路径 | 认证 | 说明 |
|---|---|---|---|
| GET | `/` | 无 | 主页(上传入口 + 最近 10 任务进度卡片) |
| GET | `/health` | 无 | 存活探针 |
| GET | `/history` | 无 | 历史任务页(分页表格,可按文件名搜索、下载字幕) |
| GET | `/logs` | 无 | 实时日志页(按级别过滤、自动刷新、可展开 traceback |
| GET | `/docs` `/redoc` | Basic Auth | API 文档 |
| POST | `/api/tasks/chunk-uploads` | 无 | 创建分片上传会话 |
| GET | `/api/tasks/chunk-uploads/{id}/status` | 无 | 查已传分片(断点续传) |
| POST | `/api/tasks/chunk-uploads/{id}/chunks/{index}` | 无 | 上传单个分片(原始二进制 body |
| POST | `/api/tasks/chunk-uploads/{id}/complete` | 无 | 拼接 + 创建转写任务 |
| GET | `/api/tasks` | 无 | 任务列表(`limit` / `offset` 分页,`q` 按文件名模糊搜索) |
| GET | `/api/tasks/{id}` | 无 | 任务状态status / progress / error |
| GET | `/api/tasks/{id}/subtitle?type=bilingual\|en\|zh` | 无 | 下载字幕 |
| GET | `/api/logs?level=debug\|info\|warning\|error&tail=N` | 无 | 查询日志(按级别过滤,最近 N 条) |
| DELETE | `/api/logs` | 无 | 清空日志缓冲 |
### 分片上传协议(与 server 一致)
1. **建会话** `POST /api/tasks/chunk-uploads`body 含 `filename` / `size_bytes` /
`chunk_size` / `total_chunks`,返回 `upload_id`
2. **查状态** `GET .../status`,返回 `uploaded_chunks`(已传分片下标列表)。
断点续传时先查此接口,只补传缺失分片。
3. **传分片** `POST .../chunks/{index}`body 为原始二进制。分片可乱序、可重传覆盖。
4. **完成** `POST .../complete`,服务端按 index 顺序拼接为正式视频文件,创建转写 Task
并入队。complete 幂等:重复调用返回同一 `task_id`
### 请求/响应示例
创建会话:
```bash
curl -X POST http://127.0.0.1:8000/api/tasks/chunk-uploads \
-H 'Content-Type: application/json' \
-d '{"filename":"demo.mp4","size_bytes":10485760,"chunk_size":4194304,"total_chunks":3}'
# → {"upload_id":"a1b2...","filename":"demo.mp4","size_bytes":10485760,"chunk_size":4194304,"total_chunks":3}
```
查任务状态:
```bash
curl http://127.0.0.1:8000/api/tasks/1
# → {"id":1,"filename":"demo.mp4","status":"done","progress":100.0,"error":null,"has_subtitle":true,...}
```
下载字幕:
```bash
curl -OJ http://127.0.0.1:8000/api/tasks/1/subtitle?type=bilingual
```
---
## 断句与时间戳重算原理
Whisper 原始 segment 的断句通常很混乱:每段不是完整句子,时间戳也不对齐句界。
`segmenter.py` 基于词级时间戳重组,两路策略:
### 精确路(`word_timestamps=true`,默认)
1. 汇集所有词的 `(text, start, end)`
2. 按**句末标点**`. ! ? ;`)切句。
3. 超长句(> `max_words_per_line` 或 > `max_duration_seconds`)按**逗号**`, : —`)再拆;
无逗号则按词数等分。
4. 每条字幕的时间戳:`start = 首词.start``end = 末词.end`**精确无误**。
### 匀速估算路(无词级时间戳时 fallback
段内按字符数比例分配时间 —— 即「短时匀速」假设,零模型开销:
```
句start = 段start + (前缀字符数 / 段总字符数) × 段时长
```
### SRT 规范化
最后统一处理:单条 17 秒过短合并、≤2 行、每行 ≤42 字符(按词折行)。
---
## 模型不共驻(显存策略)
ASR 与翻译模型**不会同时驻留 GPU**。`model_manager.py` 单例跟踪当前加载的模型类型:
- `get_translator()`:若 ASR 在内存 → 先 `del WhisperModel` + `gc.collect()` +
`torch.cuda.empty_cache()` 释放显存 → 再加载 NLLB。
- `get_asr()`:若翻译器在内存 → 先卸载 → 再加载 Whisper。
翻译阶段独占显存,因此可用大 `batch_size`。24G 3090 上Whisper large-v3-turbo FP16
~3GB / NLLB-1.3B FP16 ~2.5GB,互不叠加,远低于显存上限。
---
## GPU 利用率优化
faster-whisper 的 GPU 利用率曲线常呈**尖刺波**(峰=批量解码满载,谷=CPU 提取 Mel 特征
+ 处理结果时 GPU 空闲),平均利用率偏低。瓶颈不在 GPU 算力,而在 CPU 特征提取与 GPU
解码未重叠:
```
CPU: [VAD+切片+Mel特征 N个chunk] ──► [处理结果] ──► [VAD+切片+Mel特征] ──► ...
GPU: (空闲) [批量解码] (空闲) [批量解码]
```
`BatchedInferencePipeline` 内部把音频按 30s chunk 切分,凑够 `batch_size` 个 chunk 一次性
送 GPU 解码。每批解码完后回到 CPU 处理结果 + 提取下一批 Mel 特征,这期间 GPU 空闲。
### 已做的优化GPU 配置)
| 参数 | 旧值 | 新值 | 作用 |
|---|---|---|---|
| `asr.batch_size` | 16 | **32** | 单次 GPU 解码时长翻倍CPU 特征提取间隙占比减半 → 尖刺变宽、谷底变浅平均利用率上升。turbo FP16 仅 ~1.6GB3090 24G 充裕 |
| `asr.beam_size` | 5 | **2** | 解码候选数 5→2每步计算量与解码步数下降 → 峰更密、间隙更短。turbo 鲁棒,保留 1 个候选做歧义发音保险,质量损失小 |
### 为什么不关 `word_timestamps`
`segmenter.py` 强依赖词级时间戳做精确断句——只要任一 segment 没词级时间戳,就整体退化
到匀速估算路(时间戳按字符数比例估算),字幕精度下降明显。所以 `word_timestamps=true`
必须保留,即使它是 CPU↔GPU 同步开销的来源之一。
### 验证方法
```bash
# 1. 确认配置生效
curl -s http://127.0.0.1:8001/health | python -m json.tool
# 应见 asr_batch_size=32, asr_beam_size=2
# 2. 跑长视频(如 test/1-5.mp4观察 GPU 利用率曲线
nvidia-smi dmon -s u # 实时 GPU 利用率d=dec u=util
# 3. 对比字幕质量(可选):同一视频改前改后 SRT diff
```
优化后尖刺应比之前密且谷底变浅,平均利用率上升。`beam_size=2` 对 turbo 模型质量损失
极小,但仍建议用同一视频 A/B 对比字幕确认无歧义发音处的降级。
---
## Docker 说明
### 一份 Dockerfile两个镜像
`ARG VARIANT=cpu|gpu` 控制基础镜像与 torch 轮子:
| VARIANT | 基础镜像 | torch |
|---|---|---|
| `cpu`(默认) | `python:3.12-slim` | CPU 版(`--index-url .../whl/cpu` |
| `gpu` | `nvidia/cuda:12.1.0-runtime-ubuntu22.04` | CUDA 版 |
两个镜像的 Python 依赖列表(`requirements.txt`)完全一致,仅 torch 不同。镜像内 apt 装
`ffmpeg` + `patchelf`
### 新建 / 重建容器
项目提供 `setup.sh` / `start.sh` / `stop.sh` 包装脚本,也可直接用 `docker` / `docker compose`
#### 首次新建(新机器 / 全新拉取代码后)
```bash
# 1. 构建镜像 + 生成 config.yamlCPU 默认)
./setup.sh
# GPUAUDIO2TEXT_VARIANT=gpu ./setup.sh
# 2. 启动容器
./start.sh
# GPUstart.sh 检测到 audio2text:gpu 镜像 + nvidia-smi 自动加 --gpus all
```
`setup.sh` 做三件事:检查 docker → 构建 `audio2text:{variant}` 镜像 → 把
`config.{variant}.yaml` 复制为 `config.yaml`(运行时实际读取的文件)。
#### 重建镜像(改了 app 代码或 requirements 后)
依赖层apt + pip + torch由 BuildKit 缓存挂载复用,只有 `COPY app` 层重建,通常
30 秒内完成。**重建不会动运行时数据**`./data` / `./models` 是挂载的 volume
```bash
# CPU直接重跑 setup.sh幂等会复用缓存层
./setup.sh
# 或显式构建:
docker build --build-arg VARIANT=cpu -t audio2text:cpu .
# GPU
AUDIO2TEXT_VARIANT=gpu ./setup.sh
# 或:
docker build --build-arg VARIANT=gpu -t audio2text:gpu .
# 重建后重启容器(替换运行中的旧镜像):
./stop.sh && ./start.sh
```
#### 改配置(不重建镜像)
`config.yaml` 是只读挂载,改完重启容器即生效,**无需重建镜像**
```bash
cp config.gpu.yaml config.yaml # 切换配置(或直接编辑 config.yaml
./stop.sh && ./start.sh
```
#### 改依赖requirements.txt / torch 版本)
会触发 deps 层重建,耗时较长(重装 torch + 全部依赖CPU ~3 分钟GPU ~5 分钟)。
BuildKit 的 pip 缓存挂载(`/root/.cache/pip`)跨构建复用已下载的 wheel二次构建会快
很多。
```bash
# 编辑 requirements.txt 后
./setup.sh # 或 docker build --build-arg VARIANT=gpu -t audio2text:gpu .
./stop.sh && ./start.sh
```
#### docker compose替代脚本
```bash
docker compose --profile dev up -d --build # 开发:源码挂载 + uvicorn reload改代码零重建
docker compose --profile cpu up -d --build # CPU 生产
docker compose --profile gpu up -d --build # GPU 生产(需 nvidia runtime
```
### 缓存分层与删除边界
这套构建涉及三类缓存,**删除策略截然不同**,乱删会导致全量重建:
| 缓存类型 | 位置 | 存什么 | 能删吗 | 删了会怎样 |
|---|---|---|---|---|
| **BuildKit 构建缓存** | Docker 内部(`docker builder` 管理) | Dockerfile 各层base / deps / final的构建产物 | ⚠️ 谨慎,见下方 | 命中失效 → 该层及下游全量重建 |
| **pip wheel 缓存** | BuildKit cache mount `/root/.cache/pip` | 下载过的 `.whl` 文件 | ✅ 可删 | 下次构建重新下载 wheel不重编译 |
| **模型缓存** | `./models` volume容器内 `/models` | Whisper / NLLB 权重HF + ctranslate2 | ✅ 可删 | 下次启动重新下载模型(~5.5GB GPU |
| **运行时数据** | `./data` volume容器内 `/data` | 上传视频 / 中间音频 / 输出字幕 / SQLite | ⚠️ 视情况 | 删了任务历史和产物全没 |
### ⚠️ 不要用 `docker builder prune --filter until`
**这是踩过的坑**。BuildKit 的 `--filter "until=30m"`(或任意时长)会清除"最近 N 分钟未
访问"的缓存层。问题在于:**稳定的基础层**(如 `base-gpu` 的 apt 装 python3.12)只在
首次构建时执行一次,之后每次构建都直接 CACHED 跳过——它的"最后访问时间"一直停在首次
构建那一刻,永远不会更新。于是 `--filter "until=..."` 会把这些**仍然在用的稳定层**当成
"很久没访问"清掉,导致下一次构建从 base 层开始全量重来GPU 镜像 ~10 分钟 + 重新下载
torch ~2.5GB)。
正确做法:
```bash
# ✅ 想清理磁盘、释放 BuildKit 缓存:用不带 filter 的 prune清全部未引用缓存
docker builder prune -f
# 或只清 dangling悬挂的、无引用的中间层
docker builder prune -f --filter "type=regular"
# ✅ 清旧镜像(不影响构建缓存)
docker image prune -a # 删所有未被容器使用的镜像
docker image prune # 只删 dangling 镜像
# ✅ 清 pip wheel 缓存BuildKit cache mount安全
docker builder prune -f --filter "type=exec.cachemount"
# ❌ 永远不要这样用——会清掉仍在用的稳定 base 层
docker builder prune -f --filter "until=30m"
docker builder prune -f --filter "until=24h"
```
> 根因BuildKit 的 `until` filter 按"最后访问时间"判定,而非"是否仍在被引用"。CACHED
> 跳过的层不会刷新访问时间,于是被误判为可回收。这是 BuildKit 的已知行为,不是 bug
> 但对"稳定 base + 频繁改代码"的构建模式特别致命。详见
> [moby/buildkit#2414](https://github.com/moby/buildkit/issues/2414)。
#### 什么时候需要主动清缓存
- **磁盘紧张**`docker builder prune -f` + `docker image prune` 释放空间
- **依赖换了 torch / CUDA 大版本**BuildKit 可能复用了不兼容的旧 wheel清 pip 缓存
mount 强制重下:`docker builder prune -f --filter "type=exec.cachemount"`
- **换 VARIANTcpu↔gpu**:不需要清——两条构建链独立,缓存互不干扰
- **想从零验证构建**`docker builder prune -af` 清全部,模拟新机器首次构建
#### 模型缓存(`./models`
模型权重在 `./models` volume容器内 `HF_HOME=/models/huggingface`
`CT2_CACHE=/models/ctranslate2`),跨容器复用。首次启动下载,之后秒起。
```bash
# 查看模型缓存大小
du -sh ./models
# 删了强制重下GPU 大模型 ~5.5GB,建议用 prefetch 脚本提前下好)
rm -rf ./models
./scripts/prefetch_models.sh config.gpu.yaml
```
### Volume 挂载
| 容器路径 | 宿主路径 | 用途 | 删除影响 |
|---|---|---|---|
| `/data` | `./data`CPU/ `./data-gpu`GPU | 上传视频、中间音频、输出字幕、SQLite | 任务历史和产物全没 |
| `/models` | `./models` | 模型缓存HF + ctranslate2跨容器复用 | 下次启动重下模型 |
| `/app/config.yaml` | `./config.yaml`(只读) | 配置文件 | 改配置需重启容器 |
镜像本身无状态、无敏感数据。
### ctranslate2 可执行栈修复
ctranslate2 的 `.so`(在 `ctranslate2.libs/` 隐藏目录)带 PT_GNU_STACK 可执行栈标志,
在某些内核 + Docker 组合下会报 `cannot enable executable stack as shared object requires`
Dockerfile 在构建时用 `patchelf --clear-execstack` 清掉该标志,无需放宽容器安全策略。
构建末尾有 `python -c "import ctranslate2"` 验证。
## 文档索引
详细文档按主题拆分,主页只保留核心速览:
| 文档 | 内容 |
|---|---|
| [部署指南](./docs/DEPLOYMENT.md) | CPU / GPU 完整部署流程、前置要求、模型选型、CPU↔GPU 切换、自定义端口 |
| [配置文件说明](./docs/CONFIG.md) | CPU/GPU 配置差异表、全部字段说明server/storage/asr/translation/...)、配置示例 |
| [Docker 说明](./docs/DOCKER.md) | 镜像构建、新建/重建/改配置/改依赖四种场景、**缓存分层与删除边界**、⚠️ until filter 失效根因、Volume 挂载 |
| [HTTP 接口](./docs/API.md) | 接口一览表、分片上传协议、请求/响应示例 |
| [架构与原理](./docs/ARCHITECTURE.md) | 断句算法、模型不共驻显存策略、GPU 利用率优化、缓存清理机制 |
| [常见问题](./docs/FAQ.md) | CPU 跑 NLLB、模型下载、断点续传、保留原始视频、自动清理等 |
---
## 依赖
### Python`requirements.txt`
- **Python**FastAPI + uvicorn + SQLAlchemy + faster-whisper + transformerstorch 按 VARIANT 分叉CPU/GPU 装不同 wheel。完整列表见 `requirements.txt`
- **系统**ffmpeg镜像内 apt 装、patchelf修复 ctranslate2 可执行栈。GPU 需宿主 NVIDIA 驱动 + nvidia container runtime
| 包 | 用途 |
|---|---|
| `fastapi` + `uvicorn[standard]` + `python-multipart` | Web 服务 |
| `pydantic` + `pydantic-settings` | 配置类型校验 |
| `PyYAML` | 读 config.yaml |
| `SQLAlchemy` | SQLite ORM |
| `faster-whisper` + `ctranslate2` | 语音识别 |
| `transformers` + `sentencepiece` + `accelerate` | NLLB 翻译 |
| `psutil` | 进程信息 |
torch 单独安装CPU / CUDA 轮子不同),不在 requirements.txt 中。
### 系统
- `ffmpeg`(镜像内 apt 装)—— 提取音频
- `patchelf`(镜像内 apt 装)—— 修复 ctranslate2 可执行栈
- GPU 镜像额外需要宿主 NVIDIA 驱动 + nvidia container runtime
---
## 常见问题
### Q: CPU 开发机能跑 NLLB 吗?
`config.cpu.yaml` 默认用 opus-mt-en-zh~300MB2GB 内存开发机即可跑通完整流程。
若想在 CPU 上验证 NLLB 翻译质量,可手动改 `translation.model`
- `facebook/nllb-200-distilled-600M`~1.2GB,同系列最小)——需 ≥4GB 内存2GB 机会 OOM。
- `facebook/nllb-200-distilled-1.3B`~2.5GBGPU 生产同款)——需 ~5GB 内存。
生产环境3090 24G用 NLLB-1.3B 质量最好。
### Q: 模型下载到哪里?每次启动都重下吗?
模型缓存到 `/models` volume`HF_HOME=/models/huggingface``CT2_CACHE=/models/ctranslate2`)。
首次启动下载,之后跨容器复用秒起。删除 `./models` 目录会强制重下。
### Q: 上传大视频中断了怎么办?
分片上传支持断点续传。重新上传同一文件时,前端先调 `status` 接口查已传分片,只补传缺失的。
分片可乱序、可重传覆盖。
### Q: 怎么保留原始视频不删?
`config.yaml``processing.delete_original_after_extract` 改为 `false`
注意:保留的视频仍受缓存清理策略约束——任务超期(默认 7 天)后会被 `cache_cleaner`
连同字幕一起删除。想永久保留请把 `storage.cache_retention_days` 设为 `0`(禁用清理)。
### Q: 字幕 / 任务记录多久会被自动清理?能禁用吗?
默认保留 7 天(`storage.cache_retention_days`)。超期任务的字幕、中间音频、保留的原始
视频连同 DB 记录一并删除,启动时跑一次 + 每 `cache_cleanup_interval_hours`(默认 24h
循环一次。设 `cache_retention_days: 0` 可禁用自动清理(产物永久保留,需自行管理磁盘)。
手动触发:`docker exec audio2text python -m app.services.cache_cleaner`
### Q: GPU 镜像构建好了但 start.sh 还是用 CPU
`start.sh` 检测到 `audio2text:gpu` 镜像**且**本机有 `nvidia-smi` 才用 GPU。确认宿主装了
NVIDIA 驱动 + nvidia container runtime。也可用 `docker compose --profile gpu up -d` 显式启动。
镜像构建与依赖安装细节见 [Docker 说明](./docs/DOCKER.md)。

View File

@@ -128,13 +128,111 @@ def _load_yaml(path: Path) -> dict:
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
# ---------------- 运行时覆盖 ----------------
# 允许通过设置页修改的配置项(点分路径 -> 类型。config.yaml 是只读挂载,
# 改它需重启容器;运行时覆盖存 DB进程重启后自动加载无需重建镜像。
# 设置页保存时调 save_setting() 写 DB + 清 lru_cache下次 get_settings() 生效。
_applying_overrides = False # 防递归标志_apply_overrides 内部 DB 初始化会回调 get_settings()
_OVERIDEABLE_FIELDS: dict[str, type] = {
"asr.batch_size": int,
"asr.beam_size": int,
"translation.batch_size": int,
"translation.sort_by_length": bool,
}
def _apply_overrides(settings: Settings) -> Settings:
"""从 DB 读取覆盖值并应用到 Settings 对象。
在 lru_cache 的 get_settings() 内部调用,保证缓存的对象已含覆盖。
DB 还没初始化时(首次 import静默跳过用 YAML 原值。
注意get_session_local() -> get_engine() -> _db_path() -> get_settings()
会形成递归。用 _applying_overrides 标志阻断:递归调用直接返回当前 settings
(此时 DB 路径只需 work_dir无覆盖也无妨
"""
global _applying_overrides
if _applying_overrides:
return settings # 递归调用_db_path 触发),直接返回 YAML 原值
_applying_overrides = True
try:
from .database import get_session_local
from .models.setting import Setting
import json
db = get_session_local()()
try:
rows = db.query(Setting).all()
overrides = {r.key: r.value for r in rows}
finally:
db.close()
for key, type_ in _OVERIDEABLE_FIELDS.items():
if key not in overrides:
continue
try:
val = json.loads(overrides[key])
val = type_(val)
except (json.JSONDecodeError, ValueError, TypeError):
continue
_set_nested(settings, key, val)
except Exception:
# DB 未就绪(首次 import 时 database.py 可能还在初始化)-> 跳过,用 YAML 原值
pass
finally:
_applying_overrides = False
return settings
def _set_nested(settings: Settings, key: str, val) -> None:
"""按点分路径设置嵌套属性,如 'asr.batch_size' -> settings.asr.batch_size"""
parts = key.split(".")
obj = settings
for p in parts[:-1]:
obj = getattr(obj, p)
setattr(obj, parts[-1], val)
@lru_cache(maxsize=1)
def get_settings() -> Settings:
"""读取 config.yaml + 应用 DB 覆盖,返回完整 Settings。
结果被 lru_cache 缓存。修改设置后调 reload_settings() 清缓存,
下次调用返回含新值的 Settings。
"""
path = Path(os.getenv("CONFIG_PATH", str(DEFAULT_CONFIG_PATH)))
return Settings.model_validate(_load_yaml(path))
settings = Settings.model_validate(_load_yaml(path))
return _apply_overrides(settings)
def reload_settings() -> Settings:
"""清缓存并重新读取,供脚本与测试使用。"""
"""清缓存并重新读取(含 DB 覆盖),供设置页保存后调用。"""
get_settings.cache_clear()
return get_settings()
def save_setting(key: str, value) -> None:
"""保存单个配置项覆盖到 DB + 清 lru_cache。
Args:
key: 点分路径,必须在 _OVERIDEABLE_FIELDS 中
value: 要保存的值(自动 JSON 编码)
"""
import json
if key not in _OVERIDEABLE_FIELDS:
raise ValueError(f"不允许修改的配置项:{key}")
from .database import get_session_local
from .models.setting import Setting
type_ = _OVERIDEABLE_FIELDS[key]
encoded = json.dumps(type_(value))
db = get_session_local()()
try:
row = db.get(Setting, key)
if row is None:
row = Setting(key=key, value=encoded)
db.add(row)
else:
row.value = encoded
db.commit()
finally:
db.close()
# 清缓存,让后续 get_settings() 读到新值
get_settings.cache_clear()

View File

@@ -1,7 +1,8 @@
"""路由聚合:导出各 controller 的 router供 main.py include。"""
from .log_router import router as log_router
from .settings_router import router as settings_router
from .task_router import router as task_router
from .upload_router import router as upload_router
__all__ = ["log_router", "task_router", "upload_router"]
__all__ = ["log_router", "settings_router", "task_router", "upload_router"]

View File

@@ -0,0 +1,92 @@
"""设置路由:查询/修改运行时可调参数。
config.yaml 是只读挂载改它需重启容器。本路由把部分参数batch_size 等)
存到 DB 的 setting 表,通过 config.save_setting() + reload_settings() 实现
运行时热更新:保存后清 lru_cache后续任务读到新值。
当前可调项与 config._OVERIDEABLE_FIELDS 对齐。
"""
from __future__ import annotations
import logging
from fastapi import APIRouter
from pydantic import BaseModel, Field
from ..config import _OVERIDEABLE_FIELDS, get_settings, save_setting
logger = logging.getLogger("audio2text.settings")
router = APIRouter(prefix="/api/settings", tags=["settings"])
# ---------------- 响应 / 请求 DTO ----------------
class SettingsResponse(BaseModel):
"""当前生效的设置值YAML 基础 + DB 覆盖后的合并值)。"""
asr_batch_size: int
asr_beam_size: int
translation_batch_size: int
translation_sort_by_length: bool
# 不可改但展示的只读信息
asr_model: str
asr_device: str
asr_compute_type: str
translation_model: str
translation_device: str
class SettingsUpdate(BaseModel):
"""设置更新请求:只传要改的字段,未传的保持不变。"""
asr_batch_size: int | None = Field(default=None, ge=1, le=128)
asr_beam_size: int | None = Field(default=None, ge=1, le=10)
translation_batch_size: int | None = Field(default=None, ge=1, le=256)
translation_sort_by_length: bool | None = None
# ---------------- 字段映射DTO 字段名 -> config 点分路径 ----------------
_FIELD_MAP: dict[str, str] = {
"asr_batch_size": "asr.batch_size",
"asr_beam_size": "asr.beam_size",
"translation_batch_size": "translation.batch_size",
"translation_sort_by_length": "translation.sort_by_length",
}
# ---------------- 接口 ----------------
@router.get("", summary="查询当前生效的设置")
def get_current_settings() -> SettingsResponse:
"""返回当前生效的设置YAML 基础 + DB 覆盖合并后的值)。"""
s = get_settings()
return SettingsResponse(
asr_batch_size=s.asr.batch_size,
asr_beam_size=s.asr.beam_size,
translation_batch_size=s.translation.batch_size,
translation_sort_by_length=s.translation.sort_by_length,
asr_model=s.asr.model,
asr_device=s.asr.device,
asr_compute_type=s.asr.compute_type,
translation_model=s.translation.model,
translation_device=s.translation.device,
)
@router.put("", summary="更新设置(保存后对后续任务生效)")
def update_settings(req: SettingsUpdate) -> dict:
"""保存修改的设置项到 DB清配置缓存。
只处理请求中非 None 的字段。保存后立即生效(后续任务读到新值),
已在跑的任务不受影响(任务在各阶段开始时读配置)。
"""
changed: dict = {}
for field, path in _FIELD_MAP.items():
val = getattr(req, field)
if val is not None:
save_setting(path, val)
changed[field] = val
logger.info("设置已更新:%s = %s(对后续任务生效)", path, val)
if not changed:
return {"status": "no_change", "changed": {}}
return {"status": "saved", "changed": changed}

View File

@@ -1,7 +1,9 @@
"""任务路由:列表 / 状态 / 下载字幕。"""
"""任务路由:列表 / 状态 / 下载字幕 / 删除"""
from __future__ import annotations
import logging
import shutil
from datetime import datetime, timezone
from pathlib import Path
@@ -11,10 +13,11 @@ from sqlalchemy.orm import Session
from ..config import get_settings
from ..database import get_db
from ..models.task import Task, STATUS_UPLOADING
from ..models.task import Task, STATUS_UPLOADING, STATUS_DONE, STATUS_FAILED
from ..models.upload_session import UploadSession
from ..schemas.task import TaskListResponse, TaskResponse
logger = logging.getLogger("audio2text.tasks")
router = APIRouter(prefix="/api/tasks", tags=["task"])
@@ -129,3 +132,47 @@ def download_subtitle(
media_type="application/x-subrip",
filename=download_name,
)
@router.delete("/{task_id}", summary="删除任务(仅允许已完成/失败)")
def delete_task(task_id: int, db: Session = Depends(get_db)) -> dict:
"""删除任务及其产物(字幕 / 中间音频 / 保留的原始视频)+ DB 记录。
仅允许删除已完成done或失败failed的任务进行中的任务不可删。
"""
task = db.get(Task, task_id)
if task is None:
raise HTTPException(404, f"任务不存在:{task_id}")
if task.status not in (STATUS_DONE, STATUS_FAILED):
raise HTTPException(409, f"任务进行中,无法删除(当前状态:{task.status}")
s = get_settings()
deleted: list[str] = []
# 删字幕输出目录
out_dir = s.output_dir() / f"task_{task.id}"
if out_dir.is_dir():
shutil.rmtree(out_dir, ignore_errors=True)
deleted.append("outputs")
# 删中间音频
if task.wav_path:
wav = Path(task.wav_path)
if wav.is_file():
wav.unlink(missing_ok=True)
deleted.append("audio")
# 删保留的原始视频
if task.source_path:
src = s.upload_dir() / task.source_path
if src.is_file():
src.unlink(missing_ok=True)
deleted.append("video")
# 删 DB 记录(先删关联的 UploadSession再删 Task
db.query(UploadSession).filter(UploadSession.task_id == task.id).delete()
db.delete(task)
db.commit()
logger.info("删除任务 %d%s%s", task_id, task.filename, ", ".join(deleted) or "无产物")
return {"status": "deleted", "task_id": task_id, "cleaned": deleted}

View File

@@ -58,6 +58,7 @@ def init_db_schema() -> None:
"""
from .models.task import Task # noqa: F401
from .models.upload_session import UploadSession # noqa: F401
from .models.setting import Setting # noqa: F401
engine = get_engine()
Base.metadata.create_all(engine)

View File

@@ -19,21 +19,21 @@ import logging
import threading
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI
from fastapi import FastAPI
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.responses import HTMLResponse, JSONResponse
from sqlalchemy.orm import Session
from .config import get_settings
from .controllers import log_router, task_router, upload_router
from .controllers import log_router, settings_router, task_router, upload_router
from .database import get_db, init_db_schema
from .security import require_docs_auth
from .services.cache_cleaner import purge_expired_cache, run_forever as run_cache_cleaner
from .services.log_buffer import init_log_buffer
from .services.reaper import reap_stale_sessions
from .views.history_html import render as render_history_html
from .views.home_html import render as render_home_html
from .views.logs_html import render as render_logs_html
from .views.settings_html import render as render_settings_html
# 日志分层:
# - audio2text logger 始终设 DEBUG确保所有记录含子步骤都能产生。
@@ -126,20 +126,21 @@ def create_app() -> FastAPI:
app.include_router(upload_router)
app.include_router(task_router)
app.include_router(log_router)
app.include_router(settings_router)
# 受 Basic Auth 保护的文档接口
# 文档接口(无认证,直接公开)
@app.get("/openapi.json")
def protected_openapi(_: str = Depends(require_docs_auth)) -> JSONResponse:
def openapi_endpoint() -> JSONResponse:
return JSONResponse(app.openapi())
@app.get("/docs")
def protected_docs(_: str = Depends(require_docs_auth)):
def docs_endpoint():
return get_swagger_ui_html(
openapi_url="/openapi.json", title="audio2text docs", swagger_favicon_url=""
)
@app.get("/redoc")
def protected_redoc(_: str = Depends(require_docs_auth)):
def redoc_endpoint():
return get_redoc_html(
openapi_url="/openapi.json", title="audio2text docs", redoc_favicon_url=""
)
@@ -187,6 +188,10 @@ def create_app() -> FastAPI:
def logs_page() -> HTMLResponse:
return HTMLResponse(render_logs_html())
@app.get("/settings", response_class=HTMLResponse)
def settings_page() -> HTMLResponse:
return HTMLResponse(render_settings_html())
return app

36
app/models/setting.py Normal file
View File

@@ -0,0 +1,36 @@
"""运行时设置覆盖(键值存储)。
config.yaml 是只读挂载(镜像内不含配置),改完需重启容器才生效。
本表持久化用户在「设置页」修改的参数,进程重启后自动加载,
无需改 config.yaml 或重建镜像。
当前支持的键见 _ALLOWED_KEYSsettings_router 维护),值为 JSON 字符串。
"""
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import String, DateTime, Text
from sqlalchemy.orm import Mapped, mapped_column
from ..database import Base
def _now() -> datetime:
return datetime.now(timezone.utc)
class Setting(Base):
"""单个配置项的覆盖值key = 'asr.batch_size' 之类的点分路径)。"""
__tablename__ = "setting"
key: Mapped[str] = mapped_column(String(128), primary_key=True)
value: Mapped[str] = mapped_column(Text) # JSON 编码的值
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=_now, onupdate=_now,
)
def __repr__(self) -> str:
return f"Setting(key={self.key!r}, value={self.value!r})"

View File

@@ -6,7 +6,9 @@ CPU dev: tiny.en + int8GPU prod: large-v3-turbo + float16。同一份代码
from __future__ import annotations
import logging
import math
from pathlib import Path
from typing import Callable
from ..config import get_settings
from .model_manager import get_model_manager
@@ -14,12 +16,20 @@ from .types import Segment, Word
logger = logging.getLogger("audio2text.asr")
# Whisper 默认 chunk_lengthBatchedInferencePipeline 按 30s 窗口切音频
_CHUNK_SECONDS = 30.0
def transcribe(wav_path: Path) -> list[Segment]:
def transcribe(
wav_path: Path,
on_progress: Callable[[int, int], None] | None = None,
) -> list[Segment]:
"""转写 wav返回 segments含词级时间戳
Args:
wav_path: 16kHz mono PCM wav
on_progress: 可选进度回调 (current_chunk, total_chunks)。
每 transcribe 完一个 30s chunk 调一次,用于细分进度条。
Returns:
list[Segment],每个 Segment 带词级 words若 word_timestamps 启用)。
@@ -29,8 +39,8 @@ def transcribe(wav_path: Path) -> list[Segment]:
raise FileNotFoundError(f"音频不存在:{wav_path}")
model = get_model_manager().get_asr()
logger.debug("开始转写 %smodel=%s language=%s batch_size=%d",
wav_path.name, s.model, s.language, s.batch_size)
logger.debug("开始转写 %smodel=%s language=%s batch_size=%d beam_size=%d",
wav_path.name, s.model, s.language, s.batch_size, s.beam_size)
segments_gen, info = model.transcribe(
str(wav_path),
@@ -41,12 +51,19 @@ def transcribe(wav_path: Path) -> list[Segment]:
batch_size=s.batch_size, # 批量解码:多音频块一次性送 GPU
without_timestamps=False, # BatchedInferencePipeline 默认 True需显式关闭以生成段级时间戳
)
# VAD 过滤后的实际语音时长 → 算总 chunk 数(进度颗粒度细分用)
duration = info.duration_after_vad or info.duration
total_chunks = max(1, math.ceil(duration / _CHUNK_SECONDS))
logger.debug(
"音频时长 %.1fs检测语言=%s(置信度 %.2f",
info.duration, info.language, info.language_probability,
"音频时长 %.1fsVAD 后 %.1fs,检测语言=%s(置信度 %.2f,约 %d 个 chunk",
info.duration, duration, info.language, info.language_probability, total_chunks,
)
if on_progress is not None:
on_progress(0, total_chunks)
segments: list[Segment] = []
last_chunk = 0 # 已报进度的 chunk 序号(避免同 chunk 内多个 segment 重复回调)
for seg in segments_gen:
words: list[Word] = []
if s.word_timestamps and getattr(seg, "words", None):
@@ -63,6 +80,15 @@ def transcribe(wav_path: Path) -> list[Segment]:
end=float(seg.end),
words=words,
))
# 按 30s chunk 边界报进度seg.end 跨过 chunk 边界时回调
if on_progress is not None:
cur_chunk = min(total_chunks, int(seg.end / _CHUNK_SECONDS) + 1)
if cur_chunk > last_chunk:
last_chunk = cur_chunk
on_progress(cur_chunk, total_chunks)
logger.debug("转写完成:%d 段,%d 词。",
len(segments), sum(len(s.words) for s in segments))
len(segments), sum(len(seg.words) for seg in segments))
return segments

View File

@@ -85,7 +85,15 @@ def asr_phase(db, task) -> None:
logger.info("任务 %d [ASR 开始] %s", task.id, wav_path.name)
_set_status(db, task, STATUS_TRANSCRIBING, P_TRANSCRIBE_START)
segments = asr_service.transcribe(wav_path)
# 进度回调:按 30s chunk 细分 ASR 进度5%→55% 区间)
# current=已处理 chunk 数, total=总 chunk 数
def on_asr_progress(current: int, total: int) -> None:
frac = current / total if total else 0.0
progress = P_TRANSCRIBE_START + (P_TRANSCRIBE_END - P_TRANSCRIBE_START) * frac
_set_status(db, task, STATUS_TRANSCRIBING, progress)
segments = asr_service.transcribe(wav_path, on_progress=on_asr_progress)
_set_status(db, task, STATUS_TRANSCRIBING, P_TRANSCRIBE_END,
note=f"识别出 {len(segments)}")
@@ -114,7 +122,14 @@ def translate_phase(db, task) -> None:
_set_status(db, task, STATUS_TRANSLATING, P_TRANSLATE_START)
subs = [_dict_to_subtitle(d) for d in json.loads(task.segments_json)]
zh_texts = translate_service.translate(subs)
# 进度回调按批次细分翻译进度60%→98% 区间)
# done=已翻译条数, total=总条数
def on_translate_progress(done: int, total: int) -> None:
frac = done / total if total else 0.0
progress = P_TRANSLATE_START + (P_TRANSLATE_END - P_TRANSLATE_START) * frac
_set_status(db, task, STATUS_TRANSLATING, progress)
zh_texts = translate_service.translate(subs, on_progress=on_translate_progress)
_set_status(db, task, STATUS_TRANSLATING, P_TRANSLATE_END,
note=f"翻译 {len(zh_texts)}")

View File

@@ -14,6 +14,7 @@ from __future__ import annotations
import logging
import os
from typing import Callable
from ..config import get_settings
from .model_manager import get_model_manager
@@ -25,11 +26,15 @@ logger = logging.getLogger("audio2text.translate")
_TOKENS_PER_WORD = 1.2
def translate(subtitles: list[Subtitle]) -> list[str]:
def translate(
subtitles: list[Subtitle],
on_progress: Callable[[int, int], None] | None = None,
) -> list[str]:
"""批量翻译英文字幕为中文。
Args:
subtitles: 断句后的英文字幕条目(按时间顺序)
on_progress: 可选进度回调 (done_count, total_count),每批完成时调一次。
Returns:
list[str],与 subtitles 等长、顺序对应的中文译文。
@@ -52,9 +57,9 @@ def translate(subtitles: list[Subtitle]) -> list[str]:
sort_by_length = False
if sort_by_length:
results = _translate_sorted(pipe, texts, batch_size, max_len)
results = _translate_sorted(pipe, texts, batch_size, max_len, on_progress)
else:
results = _translate_sequential(pipe, texts, batch_size, max_len)
results = _translate_sequential(pipe, texts, batch_size, max_len, on_progress)
logger.debug("翻译完成:%d 条。", len(results))
return results
@@ -64,6 +69,7 @@ def translate(subtitles: list[Subtitle]) -> list[str]:
def _translate_sorted(
pipe, texts: list[str], batch_size: int, max_len: int,
on_progress: Callable[[int, int], None] | None = None,
) -> list[str]:
"""按长度排序后分批翻译,翻译完按原序散回。
@@ -120,7 +126,9 @@ def _translate_sorted(
for idx, zh in zip(orig_indices, translated):
results[idx] = zh
done += len(batch)
if (done // batch_size + 1) % 5 == 0:
if on_progress is not None:
on_progress(done, n)
elif (done // batch_size + 1) % 5 == 0:
logger.debug("已翻译 %d/%d 条。", done, n)
# None理论不会发生_translate_batch 保证返回等长)→ 回退原文
@@ -142,15 +150,20 @@ def _estimate_sequential_padding(texts: list[str], batch_size: int) -> int:
def _translate_sequential(
pipe, texts: list[str], batch_size: int, max_len: int,
on_progress: Callable[[int, int], None] | None = None,
) -> list[str]:
"""按原序分批翻译(旧行为,便于 A/B 对比)。"""
results: list[str] = []
for i in range(0, len(texts), batch_size):
n = len(texts)
for i in range(0, n, batch_size):
chunk = texts[i:i + batch_size]
translated = _translate_batch(pipe, chunk, max_len)
results.extend(translated)
if (i // batch_size + 1) % 5 == 0:
logger.debug("已翻译 %d/%d 条。", min(i + len(chunk), len(texts)), len(texts))
done = min(i + len(chunk), n)
if on_progress is not None:
on_progress(done, n)
elif (i // batch_size + 1) % 5 == 0:
logger.debug("已翻译 %d/%d 条。", done, n)
return results

View File

@@ -19,6 +19,7 @@ _NAV_ITEMS = [
("/", "主页", "home"),
("/history", "历史", "history"),
("/logs", "日志", "logs"),
("/settings", "设置", "settings"),
]

View File

@@ -82,7 +82,12 @@ function renderTable(tasks) {{
}} else if (task.status === "failed") {{
action = `<span class="err-tip" title="${{escapeHtml(task.error || "")}}">查看错误</span>`;
}} else {{
action = `<span class="muted"></span>`;
action = `<span class="muted">-</span>`;
}}
// done/failed 且非上传中:加删除按钮
const canDelete = (task.status === "done" || task.status === "failed") && !task.is_upload;
if (canDelete) {{
action += ` <button class="btn-sm" onclick="deleteTask(${{task.id}})">删除</button>`;
}}
let progress;
@@ -117,6 +122,17 @@ function renderPagination() {{
paginationEl.innerHTML = html;
}}
async function deleteTask(taskId) {{
if (!confirm("确认删除任务 #" + taskId + "?字幕和中间文件将被清除。")) return;
try {{
const r = await fetch("/api/tasks/" + taskId, {{ method: "DELETE" }});
if (!r.ok) {{ alert("删除失败:" + await r.text()); return; }}
load(currentOffset);
}} catch (e) {{
alert("删除失败:" + e);
}}
}}
load(0);
"""

View File

@@ -77,6 +77,12 @@ function renderTaskInner(task) {{
try {{ created = fmtDateTime24(new Date(task.created_at + "Z")); }}
catch (e) {{ created = task.created_at; }}
// 删除按钮:仅 done/failed 且非上传中任务显示
const canDelete = (task.status === "done" || task.status === "failed") && !task.is_upload;
const delBtn = canDelete
? `<button class="btn-sm del-btn" onclick="deleteTask(${{task.id}}, this)">删除</button>`
: "";
let body;
if (task.status === "done") {{
body = `<div class="task-meta"><b>完成</b> · ${{downloadLinks(task.id)}}</div>`;
@@ -91,11 +97,30 @@ function renderTaskInner(task) {{
<div class="task-head">
<span class="fname">${{task.is_upload ? "" : "#" + task.id + " "}}${{escapeHtml(task.filename)}}</span>
<span class="fstate ${{stateClass}}">${{label}}</span>
${{delBtn}}
</div>
${{body}}
<div class="task-time">${{created}}</div>`;
}}
async function deleteTask(taskId, btn) {{
if (!confirm("确认删除任务 #" + taskId + "?字幕和中间文件将被清除。")) return;
btn.disabled = true;
try {{
const r = await fetch("/api/tasks/" + taskId, {{ method: "DELETE" }});
if (!r.ok) {{
const err = await r.text();
alert("删除失败:" + err);
btn.disabled = false;
return;
}}
refreshList();
}} catch (e) {{
alert("删除失败:" + e);
btn.disabled = false;
}}
}}
async function pollTask(task) {{
// 上传会话:轮询 upload status 接口Task轮询 task 接口
const isUpload = task.is_upload === true && task.upload_id;

182
app/views/settings_html.py Normal file
View File

@@ -0,0 +1,182 @@
"""设置页:调整批处理大小等运行时参数,保存后对后续任务生效。
页面结构表单展示当前生效值GET /api/settings用户修改后点保存PUT /api/settings
保存到 DB 并清配置缓存,后续任务读到新值。已在跑的任务不受影响。
"""
from __future__ import annotations
from ._shared import render_page
# 页面专属 CSS
_PAGE_CSS = """
.field-group { margin: 1em 0; }
.field-group h2 { margin-bottom: 0.3em; }
.field-row {
display: flex; align-items: center; gap: 0.8em;
padding: 0.6em 0; border-bottom: 1px solid var(--border);
}
.field-row:last-child { border-bottom: none; }
.field-label { font-weight: 600; min-width: 200px; }
.field-desc { color: var(--muted); font-size: 0.82em; flex: 1; }
.field-input { width: 80px; }
.field-input[type="number"] {
padding: 0.3em 0.5em; border: 1px solid var(--border); border-radius: 4px;
background: var(--card-bg); color: var(--fg); font-size: 0.92em; text-align: center;
}
.field-input[type="checkbox"] { width: auto; transform: scale(1.3); }
.readonly-info {
display: grid; grid-template-columns: 1fr 1fr; gap: 0.5em 1.5em;
margin: 1em 0; padding: 0.8em 1em; background: var(--card-bg);
border: 1px solid var(--border); border-radius: 8px; font-size: 0.88em;
}
.readonly-info .kv { display: flex; gap: 0.5em; }
.readonly-info .k { color: var(--muted); min-width: 90px; }
.save-bar { display: flex; align-items: center; gap: 1em; margin-top: 1.2em; }
.save-msg { font-size: 0.88em; }
.save-msg.ok { color: var(--success); }
.save-msg.err { color: var(--error); }
"""
# 页面专属 JSf-string花括号需 {{ }}
_PAGE_JS = """
let originalValues = {};
async function loadSettings() {
try {
const r = await fetch("/api/settings");
if (!r.ok) throw new Error("HTTP " + r.status);
const s = await r.json();
document.getElementById("asr_batch_size").value = s.asr_batch_size;
document.getElementById("asr_beam_size").value = s.asr_beam_size;
document.getElementById("translation_batch_size").value = s.translation_batch_size;
document.getElementById("translation_sort_by_length").checked = s.translation_sort_by_length;
// 只读信息
document.getElementById("ro_asr_model").textContent = s.asr_model;
document.getElementById("ro_asr_device").textContent = s.asr_device;
document.getElementById("ro_asr_compute_type").textContent = s.asr_compute_type;
document.getElementById("ro_translation_model").textContent = s.translation_model;
document.getElementById("ro_translation_device").textContent = s.translation_device;
// 记录原始值用于检测是否有变更
originalValues = {
asr_batch_size: s.asr_batch_size,
asr_beam_size: s.asr_beam_size,
translation_batch_size: s.translation_batch_size,
translation_sort_by_length: s.translation_sort_by_length,
};
setMsg("", "");
} catch (e) {
setMsg("加载失败:" + escapeHtml(String(e.message || e)), "err");
}
}
function collectChanges() {
const body = {};
const cur = {
asr_batch_size: parseInt(document.getElementById("asr_batch_size").value, 10),
asr_beam_size: parseInt(document.getElementById("asr_beam_size").value, 10),
translation_batch_size: parseInt(document.getElementById("translation_batch_size").value, 10),
translation_sort_by_length: document.getElementById("translation_sort_by_length").checked,
};
for (const [k, v] of Object.entries(cur)) {
if (v !== originalValues[k]) body[k] = v;
}
return body;
}
async function saveSettings() {
const changes = collectChanges();
if (Object.keys(changes).length === 0) {
setMsg("没有变更", "");
return;
}
const btn = document.getElementById("save-btn");
btn.disabled = true;
btn.textContent = "保存中...";
setMsg("正在保存...", "");
try {
const r = await fetch("/api/settings", {
method: "PUT",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(changes),
});
if (!r.ok) throw new Error("HTTP " + r.status + " " + await r.text());
const resp = await r.json();
setMsg("已保存:" + Object.keys(resp.changed).join(", ") + "(对后续任务生效)", "ok");
await loadSettings(); // 重新加载确认
} catch (e) {
setMsg("保存失败:" + escapeHtml(String(e.message || e)), "err");
} finally {
btn.disabled = false;
btn.textContent = "保存设置";
}
}
function setMsg(text, cls) {
const el = document.getElementById("save-msg");
el.textContent = text;
el.className = "save-msg" + (cls ? " " + cls : "");
}
loadSettings();
"""
_BODY = """
<h1>设置</h1>
<p class="sub">调整批处理大小等运行时参数。保存后对<strong>后续任务</strong>生效,已在运行的任务不受影响。</p>
<div class="field-group">
<h2>语音识别ASR</h2>
<div class="field-row">
<span class="field-label">batch_size</span>
<input type="number" id="asr_batch_size" class="field-input" min="1" max="128" value="16">
<span class="field-desc">批量解码的音频块数。增大可拉长单次 GPU 解码、提升利用率,但显存占用增加</span>
</div>
<div class="field-row">
<span class="field-label">beam_size</span>
<input type="number" id="asr_beam_size" class="field-input" min="1" max="10" value="5">
<span class="field-desc">beam search 宽度。GPU turbo 建议 2加速、质量损失小CPU 建议 5</span>
</div>
</div>
<div class="field-group">
<h2>翻译NLLB</h2>
<div class="field-row">
<span class="field-label">batch_size</span>
<input type="number" id="translation_batch_size" class="field-input" min="1" max="256" value="32">
<span class="field-desc">翻译批量大小。显存独占时可用大 batch 填充 GPU</span>
</div>
<div class="field-row">
<span class="field-label">sort_by_length</span>
<input type="checkbox" id="translation_sort_by_length" class="field-input">
<span class="field-desc">按句子长度排序后分批,减少批内 padding 浪费GPU 收益大)</span>
</div>
</div>
<div class="save-bar">
<button class="btn" id="save-btn" onclick="saveSettings()">保存设置</button>
<span id="save-msg" class="save-msg"></span>
</div>
<div class="field-group">
<h2>设备信息(只读)</h2>
<div class="readonly-info">
<div class="kv"><span class="k">ASR 模型</span><span id="ro_asr_model"></span></div>
<div class="kv"><span class="k">ASR 设备</span><span id="ro_asr_device"></span></div>
<div class="kv"><span class="k">计算精度</span><span id="ro_asr_compute_type"></span></div>
<div class="kv"><span class="k">翻译模型</span><span id="ro_translation_model"></span></div>
<div class="kv"><span class="k">翻译设备</span><span id="ro_translation_device"></span></div>
</div>
</div>
"""
def render() -> str:
return render_page(
title="audio2text - 设置",
nav_active="settings",
body=_BODY,
page_js=_PAGE_JS,
page_css=_PAGE_CSS,
)

70
docs/API.md Normal file
View File

@@ -0,0 +1,70 @@
← [返回主页](../README.md)
# HTTP 接口
---
## 接口一览
| 方法 | 路径 | 认证 | 说明 |
|---|---|---|---|
| GET | `/` | 无 | 主页(上传入口 + 最近 10 任务进度卡片) |
| GET | `/health` | 无 | 存活探针 + 设备/模型配置信息 |
| GET | `/history` | 无 | 历史任务页(分页表格,可按文件名搜索、下载字幕) |
| GET | `/logs` | 无 | 实时日志页(按级别过滤、自动刷新、可展开 traceback |
| GET | `/docs` `/redoc` | Basic Auth | API 文档 |
| POST | `/api/tasks/chunk-uploads` | 无 | 创建分片上传会话 |
| GET | `/api/tasks/chunk-uploads/{id}/status` | 无 | 查已传分片(断点续传) |
| POST | `/api/tasks/chunk-uploads/{id}/chunks/{index}` | 无 | 上传单个分片(原始二进制 body |
| POST | `/api/tasks/chunk-uploads/{id}/complete` | 无 | 拼接 + 创建转写任务 |
| GET | `/api/tasks` | 无 | 任务列表(`limit` / `offset` 分页,`q` 按文件名模糊搜索) |
| GET | `/api/tasks/{id}` | 无 | 任务状态status / progress / error |
| GET | `/api/tasks/{id}/subtitle?type=bilingual\|en\|zh` | 无 | 下载字幕 |
| GET | `/api/logs?level=debug\|info\|warning\|error&tail=N` | 无 | 查询日志(按级别过滤,最近 N 条) |
| DELETE | `/api/logs` | 无 | 清空日志缓冲 |
---
## 分片上传协议
1. **建会话** `POST /api/tasks/chunk-uploads`body 含 `filename` / `size_bytes` /
`chunk_size` / `total_chunks`,返回 `upload_id`
2. **查状态** `GET .../status`,返回 `uploaded_chunks`(已传分片下标列表)。
断点续传时先查此接口,只补传缺失分片。
3. **传分片** `POST .../chunks/{index}`body 为原始二进制。分片可乱序、可重传覆盖。
4. **完成** `POST .../complete`,服务端按 index 顺序拼接为正式视频文件,创建转写 Task
并入队。complete 幂等:重复调用返回同一 `task_id`
---
## 请求/响应示例
创建会话:
```bash
curl -X POST http://127.0.0.1:8000/api/tasks/chunk-uploads \
-H 'Content-Type: application/json' \
-d '{"filename":"demo.mp4","size_bytes":10485760,"chunk_size":4194304,"total_chunks":3}'
# → {"upload_id":"a1b2...","filename":"demo.mp4","size_bytes":10485760,"chunk_size":4194304,"total_chunks":3}
```
查任务状态:
```bash
curl http://127.0.0.1:8000/api/tasks/1
# → {"id":1,"filename":"demo.mp4","status":"done","progress":100.0,"error":null,"has_subtitle":true,...}
```
下载字幕:
```bash
curl -OJ http://127.0.0.1:8000/api/tasks/1/subtitle?type=bilingual
```
健康检查(含设备与模型配置):
```bash
curl -s http://127.0.0.1:8001/health | python -m json.tool
# → {"status":"ok","cuda_available":true,"gpu":"NVIDIA GeForce RTX 3090",
# "asr_model":"large-v3-turbo","asr_batch_size":32,"asr_beam_size":2,...}
```

140
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,140 @@
← [返回主页](../README.md)
# 架构与原理
本文档覆盖核心设计原理断句算法、模型不共驻显存策略、GPU 利用率优化、缓存清理机制。
---
## 断句与时间戳重算原理
Whisper 原始 segment 的断句通常很混乱:每段不是完整句子,时间戳也不对齐句界。
`segmenter.py` 基于词级时间戳重组,两路策略:
### 精确路(`word_timestamps=true`,默认)
1. 汇集所有词的 `(text, start, end)`
2. 按**句末标点**`. ! ? ;`)切句。
3. 超长句(> `max_words_per_line` 或 > `max_duration_seconds`)按**逗号**`, : —`)再拆;
无逗号则按词数等分。
4. 每条字幕的时间戳:`start = 首词.start``end = 末词.end`**精确无误**。
### 匀速估算路(无词级时间戳时 fallback
段内按字符数比例分配时间 —— 即「短时匀速」假设,零模型开销:
```
句start = 段start + (前缀字符数 / 段总字符数) × 段时长
```
### SRT 规范化
最后统一处理:单条 17 秒过短合并、≤2 行、每行 ≤42 字符(按词折行)。
---
## 模型不共驻(显存策略)
ASR 与翻译模型**不会同时驻留 GPU**。`model_manager.py` 单例跟踪当前加载的模型类型:
- `get_translator()`:若 ASR 在内存 → 先 `del WhisperModel` + `gc.collect()` +
`torch.cuda.empty_cache()` 释放显存 → 再加载 NLLB。
- `get_asr()`:若翻译器在内存 → 先卸载 → 再加载 Whisper。
翻译阶段独占显存,因此可用大 `batch_size`。24G 3090 上Whisper large-v3-turbo FP16
~3GB / NLLB-1.3B FP16 ~2.5GB,互不叠加,远低于显存上限。
---
## GPU 利用率优化
faster-whisper 的 GPU 利用率曲线常呈**尖刺波**(峰=批量解码满载,谷=CPU 提取 Mel 特征
+ 处理结果时 GPU 空闲),平均利用率偏低。瓶颈不在 GPU 算力,而在 CPU 特征提取与 GPU
解码未重叠:
```
CPU: [VAD+切片+Mel特征 N个chunk] ──► [处理结果] ──► [VAD+切片+Mel特征] ──► ...
GPU: (空闲) [批量解码] (空闲) [批量解码]
```
`BatchedInferencePipeline` 内部把音频按 30s chunk 切分,凑够 `batch_size` 个 chunk 一次性
送 GPU 解码。每批解码完后回到 CPU 处理结果 + 提取下一批 Mel 特征,这期间 GPU 空闲。
### 已做的优化GPU 配置)
| 参数 | 旧值 | 新值 | 作用 |
|---|---|---|---|
| `asr.batch_size` | 16 | **32** | 单次 GPU 解码时长翻倍CPU 特征提取间隙占比减半 → 尖刺变宽、谷底变浅平均利用率上升。turbo FP16 仅 ~1.6GB3090 24G 充裕 |
| `asr.beam_size` | 5 | **2** | 解码候选数 5→2每步计算量与解码步数下降 → 峰更密、间隙更短。turbo 鲁棒,保留 1 个候选做歧义发音保险,质量损失小 |
### 为什么不关 `word_timestamps`
`segmenter.py` 强依赖词级时间戳做精确断句——只要任一 segment 没词级时间戳,就整体退化
到匀速估算路(时间戳按字符数比例估算),字幕精度下降明显。所以 `word_timestamps=true`
必须保留,即使它是 CPU↔GPU 同步开销的来源之一。
### 验证方法
```bash
# 1. 确认配置生效
curl -s http://127.0.0.1:8001/health | python -m json.tool
# 应见 asr_batch_size=32, asr_beam_size=2
# 2. 跑长视频(如 test/1-5.mp4观察 GPU 利用率曲线
nvidia-smi dmon -s u # 实时 GPU 利用率d=dec u=util
# 3. 对比字幕质量(可选):同一视频改前改后 SRT diff
```
优化后尖刺应比之前密且谷底变浅,平均利用率上升。`beam_size=2` 对 turbo 模型质量损失
极小,但仍建议用同一视频 A/B 对比字幕确认无歧义发音处的降级。
---
## 缓存清理与定时任务
每个任务落盘的产物(字幕、中间音频、保留的原始视频)会持续占用磁盘。容器内置定时
清理(`app/services/cache_cleaner.py`),无需外部 cron
### 清理什么
| 产物 | 路径 | 何时产生 |
|---|---|---|
| 字幕输出 | `<output_dir>/task_<id>/` | 任务完成 |
| 中间音频 | `<work_dir>/task_<id>.wav` | `keep_audio=true` 且管线未删时残留 |
| 保留的原始视频 | `<upload_dir>/yyyy/mm/<uuid>.<ext>` | `delete_original_after_extract=false` 时 |
| 孤儿目录 | 上述目录中无对应 Task 的残留 | 进程崩溃 / 异常退出留下 |
### 清理策略
1. **超期任务**`Task.created_at` 早于 `now - cache_retention_days`(默认 7 天)的任务,
删除其全部产物,并删除对应的 `Task``UploadSession` 行——避免历史页出现指向已删
文件的死链接。
2. **孤儿扫描**`output_dir` / `work_dir` 下名为 `task_<id>` 但 DB 中已无该 Task 的目录
(崩溃残留),按目录 `mtime` 判超期后删除。
3. **DB 一致性**:删任务时先删关联的 `UploadSession`FK再删 `Task`,保持引用完整。
### 触发时机
- **启动时跑一次**:容器启动 lifespan 中立即执行(`purge_expired_cache`),清掉停机期间
超期的产物。
- **后台定时循环**:守护线程 `cache-cleaner``cache_cleanup_interval_hours`(默认 24h
循环执行,随进程退出而终止。
- **手动触发**(调试用):进容器跑 `python -m app.services.cache_cleaner`,打印清理统计 JSON。
### 相关配置(`storage` 段)
| 字段 | 默认 | 说明 |
|---|---|---|
| `cache_retention_days` | `7` | 保留天数。`0` = 禁用清理(产物永久保留) |
| `cache_cleanup_interval_hours` | `24` | 定时循环间隔(小时) |
### 与上传会话 reaper 的区别
| 机制 | 清理对象 | 判定 | 触发 |
|---|---|---|---|
| **reaper**`reaper.py` | 被放弃的**分片上传会话**(未 complete 的) | `status=pending``updated_at``chunk_session_ttl_seconds`300s | 仅启动时一次 |
| **cache_cleaner**(本节) | 已完成/失败**任务的产物** + 崩溃孤儿 | `created_at``cache_retention_days`7d/ 孤儿 mtime 超期 | 启动一次 + 定时循环 |
> 后台清理线程与请求线程并发写同一 SQLite 库,`database.py` 已设 `busy_timeout=30s`
> 拿锁时阻塞等待而非立即报 `database is locked`。单 worker 部署下无并发写入压力。

187
docs/CONFIG.md Normal file
View File

@@ -0,0 +1,187 @@
← [返回主页](../README.md)
# 配置文件说明
项目预置两份配置文件,`setup.sh``AUDIO2TEXT_VARIANT` 自动复制对应文件为
`config.yaml`(运行时实际读取的文件,不入库):
| 文件 | 激活方式 | 说明 |
|---|---|---|
| `config.cpu.yaml` | `./setup.sh`(默认) | CPU 开发,最小模型 |
| `config.gpu.yaml` | `AUDIO2TEXT_VARIANT=gpu ./setup.sh` | GPU 生产,质量优先 |
| `config.example.yaml` | — | 带完整注释的字段参考模板 |
也可手动切换:`cp config.gpu.yaml config.yaml` 后重启容器即可,无需重建镜像(镜像不含配置)。
运行时通过环境变量 `CONFIG_PATH` 指定路径(容器内默认 `/app/config.yaml`)。所有路径相对
容器内文件系统。`config.py` 用 pydantic 做类型校验,缺字段时回退默认值。
部署流程见 [部署指南](./DEPLOYMENT.md)。
---
## CPU / GPU 两份配置的差异
其余字段存储、断句、日志、docs两份配置完全一致仅以下 8 项不同:
| 字段 | `config.cpu.yaml` | `config.gpu.yaml` |
|---|---|---|
| `asr.model` | `tiny.en` | `large-v3-turbo` |
| `asr.device` | `cpu` | `cuda` |
| `asr.compute_type` | `int8` | `float16` |
| `asr.batch_size` | `8` | `32` |
| `asr.beam_size` | `5` | `2` |
| `translation.model` | `Helsinki-NLP/opus-mt-en-zh` | `facebook/nllb-200-distilled-1.3B` |
| `translation.device` | `cpu` | `cuda` |
| `translation.batch_size` | `8` | `32` |
---
## 完整字段
### `server` — 服务监听
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `host` | str | `0.0.0.0` | 容器内监听地址(由 `docker -p` 映射到宿主) |
| `port` | int | `8000` | 容器内监听端口 |
| `workers` | int | `1` | uvicorn worker 数。ML 推理为重,固定单 worker 避免显存重复占用 |
### `storage` — 文件存储
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `upload_dir` | str | `/data/uploads` | 上传视频落盘根目录(按 `yyyy/mm` 分子目录) |
| `work_dir` | str | `/data/.work` | 分片会话暂存 + 中间音频 + SQLite 数据库 |
| `output_dir` | str | `/data/outputs` | 生成的 SRT 字幕输出目录 |
| `chunk_bytes` | int | `1048576` | 流式分片大小1 MiB。注意前端上传页固定 4 MiB此项影响服务端缓冲 |
| `chunk_session_ttl_seconds` | int | `300` | 被放弃的分片会话存活秒数,超时后后台 reaper 清理(短 TTL与下方缓存清理不同 |
| `cache_retention_days` | int | `7` | 任务产物(字幕 / 中间音频 / 保留的原始视频)保留天数;超期任务连同 DB 记录一并删除。`0` = 禁用清理 |
| `cache_cleanup_interval_hours` | int | `24` | 定时清理间隔(小时)。容器启动时跑一次,之后按此间隔循环 |
### `processing` — 处理流程
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `delete_original_after_extract` | bool | `true` | 提取音频成功后删除原始视频,省空间。`false` 则保留视频 |
| `keep_audio` | bool | `false` | 任务完成后是否保留中间 wav。`false` 则只留字幕、删 wav |
### `asr` — 语音识别faster-whisper
| 字段 | 类型 | 默认CPU | 说明 |
|---|---|---|---|
| `model` | str | `tiny.en` | Whisper 模型名。CPU dev 用 `tiny.en`39M英文专用同系列最小GPU prod 用 `large-v3-turbo`8x 速度,质量接近 large-v3 |
| `device` | str | `cpu` | `cpu``cuda` |
| `compute_type` | str | `int8` | CPU 用 `int8`GPU 用 `float16` |
| `language` | str | `en` | 识别语言,仅英语 |
| `word_timestamps` | bool | `true` | 词级时间戳:让断句精确(取首末词时间戳)而非纯匀速估算。建议开 |
| `vad_filter` | bool | `true` | 过滤静音段,提升识别质量与速度 |
| `batch_size` | int | `8`CPU/ `32`GPU | `BatchedInferencePipeline` 批量解码的音频块数。GPU 拉大 batch 拉长单次 GPU 解码时间,掩盖 CPU 提取 Mel 特征的间隙,提升平均利用率 |
| `beam_size` | int | `5`CPU/ `2`GPU | beam search 宽度。GPU turbo 模型鲁棒,降到 2 减少解码候选数与步数加速明显、质量损失小CPU 无加速诉求保持默认 5 |
### `translation` — 翻译NLLB-200
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `model` | str | `facebook/nllb-200-distilled-1.3B` | HuggingFace 模型名。GPU 生产用 1.3B质量最好CPU dev 用 `Helsinki-NLP/opus-mt-en-zh`~300MB2GB 机可跑。NLLB 同系列最小为 `distilled-600M`~1.2GB,需 ≥4GB 内存) |
| `device` | str | `cpu` | `cpu``cuda` |
| `src_lang` | str | `eng_Latn` | NLLB 语言码:英语 |
| `tgt_lang` | str | `zho_Hans` | NLLB 语言码:简体中文 |
| `batch_size` | int | `8`CPU/ `32`GPU | 翻译批量大小。不与 ASR 共驻时显存独占,可用大 batch |
| `max_length` | int | `256` | 单条翻译最大 token 数 |
| `sort_by_length` | bool | `true` | 按句子长度排序后分批,减少批内 padding 浪费GPU 收益大) |
### `segmentation` — 断句与字幕规范化
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `max_words_per_line` | int | `14` | 单行最多词数,超出按逗号拆分 |
| `max_duration_seconds` | float | `7.0` | 单条字幕最长 7 秒 |
| `min_duration_seconds` | float | `1.0` | 单条字幕最短 1 秒(太短则与下条合并) |
| `max_chars_per_line` | int | `42` | SRT 规范:每行 ≤42 字符超出按词折行≤2 行) |
### `logging` — 日志
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `level` | str | `info` | 控制台输出的最低级别:`debug` / `info` / `warning` / `error`。不影响 `/logs` 页面(页面可自由切换级别查看) |
| `buffer_size` | int | `2000` | `/logs` 页面内存缓冲条数(有界 deque旧记录自动淘汰 |
日志分层语义:
| 级别 | 内容 | 示例 |
|---|---|---|
| **debug**(详细) | 子步骤ffmpeg 命令、模型加载/卸载、转写逐段、翻译逐批进度 | `加载 ASR 模型 model=tiny.en device=cpu` / `ffmpeg 命令ffmpeg -y ...` |
| **info**(简略) | 仅任务阶段转换,看当前进行到哪一步 | `任务 1 [transcribing 55%] 识别出 3 段` |
| **error**(详细) | 完整 traceback文件名+行号+调用链),可点击展开 | `任务 1 失败ffmpeg 失败 (code=183)...` + traceback |
> **注意**`logging.level` 只控制控制台输出级别。`/logs` 页面始终全量缓冲DEBUG 起),
> 页面上的级别按钮是查询过滤,不受此配置限制——所以控制台设 `info` 保持简略,而 `/logs`
> 页面切到 DEBUG 仍能看到所有详细子步骤。
### `docs` — API 文档保护
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `enabled` | bool | `true` | 是否开启 `/docs` `/redoc` `/openapi.json` |
| `username` | str | `admin` | Basic Auth 用户名 |
| `password` | str | `CHANGE_ME` | Basic Auth 明文密码(常量时间比较)。**部署前务必修改** |
| `realm` | str | `audio2text docs` | WWW-Authenticate realm |
---
## 配置示例
```yaml
server:
host: 0.0.0.0
port: 8000
workers: 1
storage:
upload_dir: /data/uploads
work_dir: /data/.work
output_dir: /data/outputs
chunk_bytes: 1048576
chunk_session_ttl_seconds: 300
cache_retention_days: 7 # 任务产物保留天数超期清理0=禁用)
cache_cleanup_interval_hours: 24 # 定时清理间隔(启动时跑一次,之后循环)
processing:
delete_original_after_extract: true
keep_audio: false
asr:
model: tiny.en # GPU: large-v3-turbo
device: cpu # GPU: cuda
compute_type: int8 # GPU: float16
language: en
word_timestamps: true
vad_filter: true
batch_size: 8 # GPU: 32(拉长单次 GPU 解码,掩盖 CPU 特征提取间隙)
beam_size: 5 # GPU: 2turbo 鲁棒可降,候选数↓解码步数↓)
translation:
model: facebook/nllb-200-distilled-1.3B
device: cpu # GPU: cuda
src_lang: eng_Latn
tgt_lang: zho_Hans
batch_size: 8 # GPU: 32(显存独占可用大 batch
max_length: 256
sort_by_length: true # 按长度排序分批,减少 padding 浪费
segmentation:
max_words_per_line: 14
max_duration_seconds: 7.0
min_duration_seconds: 1.0
max_chars_per_line: 42
logging:
level: info # debug | info | warning | error控制台输出最低级别
buffer_size: 2000
docs:
enabled: true
username: admin
password: "CHANGE_ME"
realm: "audio2text docs"
```

170
docs/DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,170 @@
← [返回主页](../README.md)
# 部署指南
CPU 开发 / GPU 生产同一份代码,仅靠 `AUDIO2TEXT_VARIANT` 切换镜像 + 配置。
本文档覆盖两种环境的完整部署流程、模型选型与切换方法。
构建 / 重建镜像的 Docker 操作细节见 [Docker 说明](./DOCKER.md)
配置字段含义见 [配置文件说明](./CONFIG.md)。
---
## 部署CPU 开发环境
CPU 模式用于本地开发与流程验证模型选同系列最小尺寸2GB 内存开发机即可跑通完整流程。
### 前置要求
- Docker用于构建镜像 + 运行容器)
- 约 500 MB 磁盘(模型缓存)+ 上传视频空间
CPU 模式**不需要** NVIDIA 驱动,普通 Linux / macOS / WSL 均可。
### 步骤
```bash
cd /root/zikai/audio2text
# 1. 构建 CPU 镜像 + 复制 config.cpu.yaml → config.yaml
./setup.sh # 默认 AUDIO2TEXT_VARIANT=cpu
# 2. 启动容器(默认端口 8000
./start.sh
# 3. 停止 / 重启
./stop.sh
./start.sh
```
`setup.sh` 做三件事:检查 docker → 构建 `audio2text:cpu` 镜像 → 把 `config.cpu.yaml`
复制为 `config.yaml`(运行时实际读取的文件)。可重复执行;改完配置后重新 `cp` 并重启即可,
无需重建镜像。
首次启动会下载模型Whisper `tiny.en` ~39M + opus-mt ~300MB`./models` volume
之后秒起。启动后浏览器打开 `http://127.0.0.1:8000/`,拖入视频或音频文件即可。
### 预拉模型(避免首次启动卡在下载)
容器首次处理任务时会从 HuggingFace 下载模型大模型GPU 的 large-v3-turbo ~3GB +
NLLB-1.3B ~2.5GB)下载耗时较长。可用预拉脚本提前下好到 `./models` volume之后容器启动
即用、无需联网:
```bash
./scripts/prefetch_models.sh # 读 config.yaml当前激活配置
./scripts/prefetch_models.sh config.gpu.yaml # 读指定配置(如切换到 GPU 前预拉大模型)
```
脚本用已构建的镜像跑一次性容器,读配置里的 `asr.model` / `translation.model`,下载到
`./models/huggingface`HF 标准缓存)。**幂等**:已下过的模型自动跳过。换 config 的模型
名后重跑即可补下新模型,无需重建镜像。
### CPU 模型选型
| 组件 | 模型 | 大小 | 说明 |
|---|---|---|---|
| ASR | `tiny.en` | ~39M | Whisper 同系列最小,英文专用版(比通用 `tiny` 在英语上更准) |
| 翻译 | `Helsinki-NLP/opus-mt-en-zh` | ~300MB | 最轻量英译中。NLLB 同系列最小 `distilled-600M` 需 ~2.4GB2GB 机 OOM故回退 |
> 翻译质量与 GPU 的 NLLB-1.3B 有差异,但**完整流程一致**(提取→识别→断句→翻译→双语 SRT
> 足以验证端到端逻辑。如需在 CPU 上验证 NLLB 翻译质量,可把 `translation.model` 改为
> `nllb-200-distilled-600M`(需 ≥4GB 内存)或 `nllb-200-distilled-1.3B`(需 ~5GB 内存)。
### 自定义端口
```bash
AUDIO2TEXT_PORT=9000 ./start.sh
```
---
## 部署GPU 生产环境
GPU 模式用于生产模型质量优先NVIDIA 3090 24G 上几 GB 视频几分钟出字幕。
### 前置要求
- Docker
- **NVIDIA GPU 驱动**(宿主机)
- **nvidia container runtime**(让容器能用 GPU安装 `nvidia-container-toolkit`
- 约 6 GB 磁盘模型缓存large-v3-turbo ~3GB + NLLB-1.3B ~2.5GB
验证 GPU 可用:
```bash
nvidia-smi # 宿主能看到 GPU
docker run --rm --gpus all nvidia/cuda:12.1.0-runtime-ubuntu22.04 nvidia-smi
# 上面容器内也能列出 GPU 即说明 nvidia runtime 已就绪
```
### 步骤
```bash
cd /root/zikai/audio2text
# 1. 构建 GPU 镜像 + 复制 config.gpu.yaml → config.yaml
AUDIO2TEXT_VARIANT=gpu ./setup.sh
# 2. 启动容器start.sh 检测到 gpu 镜像 + nvidia-smi 自动加 --gpus all
./start.sh
# 3. 停止 / 重启
./stop.sh
./start.sh
```
`start.sh` 的镜像选择逻辑:若本机存在 `audio2text:gpu` 镜像**且**有 `nvidia-smi`,自动用
GPU 模式(`--gpus all`);否则回退 CPU 镜像。也可用 docker compose 显式启动:
```bash
docker compose --profile gpu up -d --build # GPU
docker compose --profile cpu up -d --build # CPU
```
### GPU 模型选型
| 组件 | 模型 | 显存 | 说明 |
|---|---|---|---|
| ASR | `large-v3-turbo` | ~3GBFP16 | 8x 速度,质量接近 large-v3 |
| 翻译 | `facebook/nllb-200-distilled-1.3B` | ~2.5GBFP16 | 质量最好的蒸馏版 |
ASR 与翻译**不共驻**:翻译阶段先卸载 Whisper 释放显存,独占跑大 batch`batch_size=32`
两者峰值显存互不叠加,远低于 24G 上限。模型缓存(`./models` volume跨容器复用
CPU→GPU 切换时 NLLB/Whisper 大模型首次下载、之后秒起。
GPU 利用率调优batch_size / beam_size 选择依据)见
[架构与原理 - GPU 利用率优化](./ARCHITECTURE.md#gpu-利用率优化)。
### CPU ↔ GPU 切换
同一份代码,仅靠 `AUDIO2TEXT_VARIANT` 切换镜像 + 配置:
```bash
AUDIO2TEXT_VARIANT=gpu ./setup.sh # 切到 GPU构建 gpu 镜像 + config.gpu.yaml
AUDIO2TEXT_VARIANT=cpu ./setup.sh # 切回 CPU构建 cpu 镜像 + config.cpu.yaml
./start.sh # 重新启动
```
两套配置的差异仅在 8 项(其余字段完全一致),完整对照见
[配置文件说明 - CPU/GPU 差异](./CONFIG.md#cpu--gpu-两份配置的差异)。
### 启动后的入口
两种模式通用:
| 入口 | 地址 |
|---|---|
| 主页 | `http://127.0.0.1:8000/`(上传入口 + 最近 10 任务进度卡片) |
| 历史任务 | `http://127.0.0.1:8000/history`(分页查看所有任务,可按文件名搜索、下载字幕) |
| 日志页 | `http://127.0.0.1:8000/logs`(按级别分层、自动刷新) |
| API 文档 | `http://127.0.0.1:8000/docs`Basic Auth凭据见 config.yaml `docs` 段) |
| 健康检查 | `http://127.0.0.1:8000/health` |
| 任务列表 | `http://127.0.0.1:8000/api/tasks` |
### 验证 GPU 配置生效
```bash
curl -s http://127.0.0.1:8001/health | python -m json.tool
# 应见 cuda_available=true, gpu="NVIDIA GeForce RTX 3090",
# asr_batch_size=32, asr_beam_size=2, asr_model=large-v3-turbo
```

273
docs/DOCKER.md Normal file
View File

@@ -0,0 +1,273 @@
← [返回主页](../README.md)
# Docker 说明
一份 Dockerfile 出 CPU / GPU 两个镜像,依赖层缓存复用,改代码秒级重建。本文档覆盖
构建、重建、缓存管理与 Volume 挂载。部署流程见 [部署指南](./DEPLOYMENT.md)。
---
## 一份 Dockerfile两个镜像
`ARG VARIANT=cpu|gpu` 控制基础镜像与 torch 轮子:
| VARIANT | 基础镜像 | torch |
|---|---|---|
| `cpu`(默认) | `python:3.12-slim` | CPU 版(`--index-url .../whl/cpu` |
| `gpu` | `nvidia/cuda:12.1.0-runtime-ubuntu22.04` | CUDA 版 |
两个镜像的 Python 依赖列表(`requirements.txt`)完全一致,仅 torch 不同。镜像内 apt 装
`ffmpeg` + `patchelf`
安全约束PyTorch CPU wheel 与 GPU(CUDA) wheel 是两个不兼容二进制包CPU 版
`torch.cuda.is_available()=False`GPU 版 `=True`。torch 必须按 VARIANT 分叉装不同 wheel
绝不能跨 variant 共享依赖层。deps 阶段用 `FROM base-${VARIANT}`CPU/GPU 是两条独立
构建链,各自装对应 torch。
---
## 新建 / 重建容器
项目提供 `setup.sh` / `start.sh` / `stop.sh` 包装脚本,也可直接用 `docker` / `docker compose`
### 首次新建(新机器 / 全新拉取代码后)
```bash
# 1. 构建镜像 + 生成 config.yamlCPU 默认)
./setup.sh
# GPUAUDIO2TEXT_VARIANT=gpu ./setup.sh
# 2. 启动容器
./start.sh
# GPUstart.sh 检测到 audio2text:gpu 镜像 + nvidia-smi 自动加 --gpus all
```
`setup.sh` 做三件事:检查 docker → 构建 `audio2text:{variant}` 镜像 → 把
`config.{variant}.yaml` 复制为 `config.yaml`(运行时实际读取的文件)。
### 重建镜像(改了 app 代码或 requirements 后)
依赖层apt + pip + torch由 BuildKit 缓存挂载复用,只有 `COPY app` 层重建,通常
30 秒内完成。**重建不会动运行时数据**`./data` / `./models` 是挂载的 volume
```bash
# CPU直接重跑 setup.sh幂等会复用缓存层
./setup.sh
# 或显式构建:
docker build --build-arg VARIANT=cpu -t audio2text:cpu .
# GPU
AUDIO2TEXT_VARIANT=gpu ./setup.sh
# 或:
docker build --build-arg VARIANT=gpu -t audio2text:gpu .
# 重建后重启容器(替换运行中的旧镜像):
./stop.sh && ./start.sh
```
### 改配置(不重建镜像)
`config.yaml` 是只读挂载,改完重启容器即生效,**无需重建镜像**
```bash
cp config.gpu.yaml config.yaml # 切换配置(或直接编辑 config.yaml
./stop.sh && ./start.sh
```
### 改依赖requirements.txt / torch 版本)
会触发 deps 层重建,耗时较长(重装 torch + 全部依赖CPU ~3 分钟GPU ~5 分钟)。
BuildKit 的 pip 缓存挂载(`/root/.cache/pip`)跨构建复用已下载的 wheel二次构建会快
很多。
```bash
# 编辑 requirements.txt 后
./setup.sh # 或 docker build --build-arg VARIANT=gpu -t audio2text:gpu .
./stop.sh && ./start.sh
```
### docker compose替代脚本
```bash
docker compose --profile dev up -d --build # 开发:源码挂载 + uvicorn reload改代码零重建
docker compose --profile cpu up -d --build # CPU 生产
docker compose --profile gpu up -d --build # GPU 生产(需 nvidia runtime
```
### 导入预构建镜像(离线部署)
当目标机器无法访问 Docker Hub或构建太慢可在已构建好镜像的机器上导出 tar
拷到新机器导入,跳过整个构建过程。
#### 前置要求(新机器)
- **NVIDIA GPU 驱动**(宿主机)
- **nvidia container runtime**`nvidia-container-toolkit`
- Docker
- `config.gpu.yaml` 配置文件(从项目仓库取,或自行编写)
- 模型缓存 `./models` 目录(约 5.5GB,从源机器拷贝或联网预拉)
验证 GPU 可用:
```bash
nvidia-smi # 宿主能看到 GPU
docker run --rm --gpus all nvidia/cuda:12.1.0-runtime-ubuntu22.04 nvidia-smi
```
#### 步骤 1源机器导出镜像
```bash
# 在已构建好 audio2text:gpu 镜像的机器上
docker save -o audio2text-gpu.tar audio2text:gpu
ls -lh audio2text-gpu.tar # ~4.3GB
```
#### 步骤 2拷贝到新机器
需要拷贝的文件:
| 文件/目录 | 大小 | 说明 |
|---|---|---|
| `audio2text-gpu.tar` | ~4.3GB | Docker 镜像(含 ffmpeg + torch + faster-whisper + transformers + app 代码) |
| `config.gpu.yaml` | <1KB | GPU 配置文件 |
| `models/` | ~5.5GB | 模型缓存Whisper large-v3-turbo + NLLB distilled-1.3B 权重 |
> `models/` 可不拷贝,新机器联网时用 `prefetch_models.sh` 预拉。但离线部署必须拷贝。
```bash
# 用 scp / rsync / U盘 等方式拷贝
scp audio2text-gpu.tar config.gpu.yaml user@newhost:~/audio2text/
rsync -avP models/ user@newhost:~/audio2text/models/
```
#### 步骤 3新机器导入并启动
```bash
cd ~/audio2text
# 1. 导入镜像
docker load -i audio2text-gpu.tar
# 输出Loaded image: audio2text:gpu
# 2. 准备数据目录
mkdir -p data-gpu/uploads data-gpu/.work data-gpu/outputs
# 3. 启动容器
docker run -d --name audio2text-gpu \
--gpus all \
-p 8001:8000 \
-v "$(pwd)/data-gpu:/data" \
-v "$(pwd)/models:/models" \
-v "$(pwd)/config.gpu.yaml:/app/config.yaml:ro" \
--restart unless-stopped \
audio2text:gpu
# 4. 验证
curl -s http://127.0.0.1:8001/health | python -m json.tool
# 应见 cuda_available=true, gpu="NVIDIA GeForce RTX 3090"
```
打开 `http://127.0.0.1:8001/` 即可使用
#### 离线运行说明
镜像内置 `HF_HUB_OFFLINE=1` + `TRANSFORMERS_OFFLINE=1` 环境变量模型缓存就位后
**完全离线运行**不会尝试访问 HuggingFace这避免了离线环境下 transformers
pipeline 因网络请求超时导致的翻译失败
#### 后续更新代码
导入的镜像包含导出时的 app 代码如需更新代码有两个选择
1. **重新构建**把项目代码拷到新机器`docker build --build-arg VARIANT=gpu -t audio2text:gpu .`
2. **挂载源码**临时调试启动时加 `-v "$(pwd)/app:/app/app"` 覆盖镜像内代码
---
## 缓存分层与删除边界
这套构建涉及三类缓存**删除策略截然不同**乱删会导致全量重建
| 缓存类型 | 位置 | 存什么 | 能删吗 | 删了会怎样 |
|---|---|---|---|---|
| **BuildKit 构建缓存** | Docker 内部`docker builder` 管理 | Dockerfile 各层base / deps / final的构建产物 | 谨慎见下方 | 命中失效 该层及下游全量重建 |
| **pip wheel 缓存** | BuildKit cache mount `/root/.cache/pip` | 下载过的 `.whl` 文件 | 可删 | 下次构建重新下载 wheel不重编译 |
| **模型缓存** | `./models` volume容器内 `/models` | Whisper / NLLB 权重HF + ctranslate2 | 可删 | 下次启动重新下载模型~5.5GB GPU |
| **运行时数据** | `./data` volume容器内 `/data` | 上传视频 / 中间音频 / 输出字幕 / SQLite | 视情况 | 删了任务历史和产物全没 |
### ⚠️ 不要用 `docker builder prune --filter until`
**这是踩过的坑**BuildKit `--filter "until=30m"`或任意时长会清除"最近 N 分钟未
访问"的缓存层问题在于**稳定的基础层** `base-gpu` apt python3.12只在
首次构建时执行一次之后每次构建都直接 CACHED 跳过——它的"最后访问时间"一直停在首次
构建那一刻永远不会更新于是 `--filter "until=..."` 会把这些**仍然在用的稳定层**当成
"很久没访问"清掉导致下一次构建从 base 层开始全量重来GPU 镜像 ~10 分钟 + 重新下载
torch ~2.5GB)。
正确做法
```bash
# ✅ 想清理磁盘、释放 BuildKit 缓存:用不带 filter 的 prune清全部未引用缓存
docker builder prune -f
# 或只清 dangling悬挂的、无引用的中间层
docker builder prune -f --filter "type=regular"
# ✅ 清旧镜像(不影响构建缓存)
docker image prune -a # 删所有未被容器使用的镜像
docker image prune # 只删 dangling 镜像
# ✅ 清 pip wheel 缓存BuildKit cache mount安全
docker builder prune -f --filter "type=exec.cachemount"
# ❌ 永远不要这样用——会清掉仍在用的稳定 base 层
docker builder prune -f --filter "until=30m"
docker builder prune -f --filter "until=24h"
```
> 根因BuildKit 的 `until` filter 按"最后访问时间"判定,而非"是否仍在被引用"。CACHED
> 跳过的层不会刷新访问时间,于是被误判为可回收。这是 BuildKit 的已知行为,不是 bug
> 但对"稳定 base + 频繁改代码"的构建模式特别致命。详见
> [moby/buildkit#2414](https://github.com/moby/buildkit/issues/2414)。
### 什么时候需要主动清缓存
- **磁盘紧张**`docker builder prune -f` + `docker image prune` 释放空间
- **依赖换了 torch / CUDA 大版本**BuildKit 可能复用了不兼容的旧 wheel pip 缓存
mount 强制重下`docker builder prune -f --filter "type=exec.cachemount"`
- ** VARIANTcpugpu**不需要清——两条构建链独立缓存互不干扰
- **想从零验证构建**`docker builder prune -af` 清全部模拟新机器首次构建
### 模型缓存(`./models`
模型权重在 `./models` volume容器内 `HF_HOME=/models/huggingface`
`CT2_CACHE=/models/ctranslate2`跨容器复用首次启动下载之后秒起
```bash
# 查看模型缓存大小
du -sh ./models
# 删了强制重下GPU 大模型 ~5.5GB,建议用 prefetch 脚本提前下好)
rm -rf ./models
./scripts/prefetch_models.sh config.gpu.yaml
```
---
## Volume 挂载
| 容器路径 | 宿主路径 | 用途 | 删除影响 |
|---|---|---|---|
| `/data` | `./data`CPU/ `./data-gpu`GPU | 上传视频中间音频输出字幕SQLite | 任务历史和产物全没 |
| `/models` | `./models` | 模型缓存HF + ctranslate2跨容器复用 | 下次启动重下模型 |
| `/app/config.yaml` | `./config.yaml`只读 | 配置文件 | 改配置需重启容器 |
镜像本身无状态无敏感数据
---
## ctranslate2 可执行栈修复
ctranslate2 `.so` `ctranslate2.libs/` 隐藏目录 PT_GNU_STACK 可执行栈标志
在某些内核 + Docker 组合下会报 `cannot enable executable stack as shared object requires`
Dockerfile 在构建时用 `patchelf --clear-execstack` 清掉该标志无需放宽容器安全策略
构建末尾有 `python -c "import ctranslate2"` 验证

42
docs/FAQ.md Normal file
View File

@@ -0,0 +1,42 @@
← [返回主页](../README.md)
# 常见问题
---
### Q: CPU 开发机能跑 NLLB 吗?
`config.cpu.yaml` 默认用 opus-mt-en-zh~300MB2GB 内存开发机即可跑通完整流程。
若想在 CPU 上验证 NLLB 翻译质量,可手动改 `translation.model`
- `facebook/nllb-200-distilled-600M`~1.2GB,同系列最小)——需 ≥4GB 内存2GB 机会 OOM。
- `facebook/nllb-200-distilled-1.3B`~2.5GBGPU 生产同款)——需 ~5GB 内存。
生产环境3090 24G用 NLLB-1.3B 质量最好。
### Q: 模型下载到哪里?每次启动都重下吗?
模型缓存到 `/models` volume`HF_HOME=/models/huggingface``CT2_CACHE=/models/ctranslate2`)。
首次启动下载,之后跨容器复用秒起。删除 `./models` 目录会强制重下。
### Q: 上传大视频中断了怎么办?
分片上传支持断点续传。重新上传同一文件时,前端先调 `status` 接口查已传分片,只补传缺失的。
分片可乱序、可重传覆盖。
### Q: 怎么保留原始视频不删?
`config.yaml``processing.delete_original_after_extract` 改为 `false`
注意:保留的视频仍受缓存清理策略约束——任务超期(默认 7 天)后会被 `cache_cleaner`
连同字幕一起删除。想永久保留请把 `storage.cache_retention_days` 设为 `0`(禁用清理)。
### Q: 字幕 / 任务记录多久会被自动清理?能禁用吗?
默认保留 7 天(`storage.cache_retention_days`)。超期任务的字幕、中间音频、保留的原始
视频连同 DB 记录一并删除,启动时跑一次 + 每 `cache_cleanup_interval_hours`(默认 24h
循环一次。设 `cache_retention_days: 0` 可禁用自动清理(产物永久保留,需自行管理磁盘)。
手动触发:`docker exec audio2text python -m app.services.cache_cleaner`
### Q: GPU 镜像构建好了但 start.sh 还是用 CPU
`start.sh` 检测到 `audio2text:gpu` 镜像**且**本机有 `nvidia-smi` 才用 GPU。确认宿主装了
NVIDIA 驱动 + nvidia container runtime。也可用 `docker compose --profile gpu up -d` 显式启动。