Compare commits
7 Commits
4625650fc8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
954767b3b2 | ||
|
|
d990207cec | ||
|
|
1e355e6138 | ||
|
|
7635e5e766 | ||
| afa2027a00 | |||
|
|
78b87bfb24 | ||
|
|
5f6a242114 |
@@ -9,3 +9,4 @@ logs/
|
|||||||
.git/
|
.git/
|
||||||
.gitignore
|
.gitignore
|
||||||
test/
|
test/
|
||||||
|
build/
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -19,3 +19,8 @@ logs/
|
|||||||
|
|
||||||
# 测试数据与脚本(本地测试用,不入库)
|
# 测试数据与脚本(本地测试用,不入库)
|
||||||
/test/
|
/test/
|
||||||
|
|
||||||
|
# 导出的 Docker 镜像 tar(太大,不入库)
|
||||||
|
*.tar
|
||||||
|
|
||||||
|
build/
|
||||||
@@ -100,7 +100,9 @@ VOLUME ["/data", "/models"]
|
|||||||
# "Unable to load libcudnn_ops.so.9"。放在 final/dev 而非 deps,避免 ENV 变化
|
# "Unable to load libcudnn_ops.so.9"。放在 final/dev 而非 deps,避免 ENV 变化
|
||||||
# 导致 deps 的 apt/pip 层缓存失效。CPU 镜像无此目录,路径被忽略不影响。
|
# 导致 deps 的 apt/pip 层缓存失效。CPU 镜像无此目录,路径被忽略不影响。
|
||||||
ENV CONFIG_PATH=/app/config.yaml \
|
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
|
EXPOSE 8000
|
||||||
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
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,镜像本身无状态、无敏感数据
|
||||||
VOLUME ["/data", "/models"]
|
VOLUME ["/data", "/models"]
|
||||||
ENV CONFIG_PATH=/app/config.yaml \
|
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
|
EXPOSE 8000
|
||||||
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
849
README.md
849
README.md
@@ -9,45 +9,34 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 目录
|
## 快速开始
|
||||||
|
|
||||||
- [功能特性](#功能特性)
|
### GPU 环境(需 NVIDIA GPU + nvidia runtime)
|
||||||
- [架构](#架构)
|
|
||||||
- [部署:CPU 开发环境](#部署cpu-开发环境)
|
```bash
|
||||||
- [部署:GPU 生产环境](#部署gpu-生产环境)
|
AUDIO2TEXT_VARIANT=gpu ./setup.sh
|
||||||
- [配置文件说明](#配置文件说明)
|
./start.sh # 端口 8001
|
||||||
- [缓存清理与定时任务](#缓存清理与定时任务)
|
```
|
||||||
- [HTTP 接口](#http-接口)
|
|
||||||
- [断句与时间戳重算原理](#断句与时间戳重算原理)
|
启动后打开 `http://127.0.0.1:8000/`,拖入视频即可。详细部署流程、模型选型、CPU↔GPU 切换
|
||||||
- [模型不共驻(显存策略)](#模型不共驻显存策略)
|
见 [部署指南](./docs/DEPLOYMENT.md)。
|
||||||
- [Docker 说明](#docker-说明)
|
|
||||||
- [新建 / 重建容器](#新建--重建容器)
|
|
||||||
- [缓存分层与删除边界](#缓存分层与删除边界)
|
|
||||||
- [⚠️ 不要用 `docker builder prune --filter until`](#-不要用-docker-builder-prune---filter-until)
|
|
||||||
- [GPU 利用率优化](#gpu-利用率优化)
|
|
||||||
- [依赖](#依赖)
|
|
||||||
- [常见问题](#常见问题)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
- **主页** `/`:上传入口(拖拽 / 选择文件,多文件、4 MiB 分片、断点续传)+ 最近 10 个任务的实时进度卡片,完成的可直接下载字幕。
|
- **网页上传**:拖拽 / 选择文件,多文件并发、4 MiB 分片、断点续传
|
||||||
- **历史任务页** `/history`:分页查看所有历史任务,可下载完成的字幕。
|
- **双语字幕**:英文在上、中文在下,亦可单独下载英文 / 中文字幕
|
||||||
- **实时日志页** `/logs`:按级别分层查看——debug=详细子步骤、info=仅阶段转换、error=完整 traceback。
|
- **faster-whisper 转写**:词级时间戳,断句精确(取首末词时间戳)
|
||||||
- **大视频处理**:接收完成后用 ffmpeg 提取 16 kHz 单声道 PCM 音频;是否删原始视频由配置决定。
|
- **NLLB-200 英译中**:ASR 与翻译模型不共驻,翻译时独占显存跑大 batch
|
||||||
- **faster-whisper 转写英语**,带词级时间戳。
|
- **设置页**:运行时调整 batch_size / beam_size,保存后对后续任务生效(DB 持久化)
|
||||||
- **断句 + 时间戳重算**:按句末标点(`. ! ? ;`)切句、超长句按逗号拆,时间戳取首末词精确值;
|
- **任务管理**:删除已完成/失败任务及其产物,进度条按批次细分
|
||||||
无词级时间戳时退化为段内匀速估算。
|
- **任务状态机**:`queued -> uploading -> extracting -> transcribing -> segmenting -> translating -> done`
|
||||||
- **NLLB-200 英译中**;ASR 与翻译模型**不共驻**,翻译时卸载 Whisper 独占显存跑大 batch。
|
- **实时日志页**:按级别分层(debug=详细子步骤 / info=阶段转换 / error=完整 traceback)
|
||||||
- **双语合并 SRT** 输出(英文在上、中文在下),亦可单独下载英文 / 中文字幕。
|
- **定时缓存清理**:任务产物默认保留 7 天,超期连同 DB 记录一并删除
|
||||||
- **任务状态机**:`queued → extracting → transcribing → segmenting → translating → done`,
|
- **SQLite 持久化**(自包含,无需外部 DB)
|
||||||
页面自动轮询进度。
|
- **离线运行**:模型缓存就位后完全离线,无需访问 HuggingFace
|
||||||
- **定时缓存清理**:任务产物(字幕 / 中间音频 / 保留的原始视频)默认保留 7 天,超期后
|
- `/docs`(Swagger UI)公开访问
|
||||||
连同 DB 记录一并删除;容器内后台线程定时执行(启动时跑一次,默认每 24 小时一次),
|
|
||||||
保留期与间隔均可配置。
|
|
||||||
- **SQLite 持久化**(自包含,无需外部 DB)。
|
|
||||||
- `/docs`(Swagger UI)受 Basic Auth 保护。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -64,7 +53,7 @@ audio2text/
|
|||||||
│ └── prefetch_models.{sh,py} # 预拉模型权重到 ./models volume(避免首次启动下载)
|
│ └── prefetch_models.{sh,py} # 预拉模型权重到 ./models volume(避免首次启动下载)
|
||||||
├── requirements.txt
|
├── requirements.txt
|
||||||
├── config.example.yaml # 配置模板(复制为 config.yaml 后填值)
|
├── config.example.yaml # 配置模板(复制为 config.yaml 后填值)
|
||||||
├── README.md
|
├── docs/ # 详细文档(见下方索引)
|
||||||
└── app/
|
└── app/
|
||||||
├── main.py # FastAPI 应用工厂
|
├── main.py # FastAPI 应用工厂
|
||||||
├── config.py # 从 config.yaml 加载的类型化 Settings(pydantic)
|
├── config.py # 从 config.yaml 加载的类型化 Settings(pydantic)
|
||||||
@@ -81,6 +70,7 @@ audio2text/
|
|||||||
│ ├── segmenter.py # 断句 + 时间戳重算(纯算法,零模型依赖)
|
│ ├── segmenter.py # 断句 + 时间戳重算(纯算法,零模型依赖)
|
||||||
│ ├── translate_service.py # NLLB 翻译
|
│ ├── translate_service.py # NLLB 翻译
|
||||||
│ ├── model_manager.py # 模型加载/卸载(不共驻核心)
|
│ ├── model_manager.py # 模型加载/卸载(不共驻核心)
|
||||||
|
│ ├── scheduler.py # ffmpeg 串行队列 + GPU 调度线程
|
||||||
│ ├── pipeline.py # 编排:提取→识别→断句→翻译→写SRT
|
│ ├── pipeline.py # 编排:提取→识别→断句→翻译→写SRT
|
||||||
│ ├── srt_writer.py # SRT 写入 + 双语合并
|
│ ├── srt_writer.py # SRT 写入 + 双语合并
|
||||||
│ ├── log_buffer.py # 内存日志缓冲(供 /logs 页面查询)
|
│ ├── log_buffer.py # 内存日志缓冲(供 /logs 页面查询)
|
||||||
@@ -107,777 +97,102 @@ audio2text/
|
|||||||
upload_router ──► upload_service ──► UploadSession(SQLite) + 分片落盘
|
upload_router ──► upload_service ──► UploadSession(SQLite) + 分片落盘
|
||||||
│ complete
|
│ 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(带词级时间戳)
|
├─ GPU 调度线程(单线程,模型复用):
|
||||||
├─ 3. segmenter.resegment → 规范字幕条目(精确/估算两路)
|
│ get_asr → asr_service.transcribe → segments(带词级时间戳)
|
||||||
├─ 4. model_manager.unload_asr → get_translator
|
│ unload_asr → get_translator
|
||||||
│ translate_service.translate → 中文译文(独占显存大 batch)
|
│ 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
|
更新 Task(done) + 写 output_dir
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 部署:CPU 开发环境
|
## 快速开始
|
||||||
|
|
||||||
CPU 模式用于本地开发与流程验证,模型选同系列最小尺寸,2GB 内存开发机即可跑通完整流程。
|
### CPU 开发环境(2GB 内存即可)
|
||||||
|
|
||||||
### 前置要求
|
|
||||||
|
|
||||||
- Docker(用于构建镜像 + 运行容器)
|
|
||||||
- 约 500 MB 磁盘(模型缓存)+ 上传视频空间
|
|
||||||
|
|
||||||
CPU 模式**不需要** NVIDIA 驱动,普通 Linux / macOS / WSL 均可。
|
|
||||||
|
|
||||||
### 步骤
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /root/zikai/audio2text
|
./setup.sh # 构建镜像 + 生成 config.yaml
|
||||||
|
./start.sh # 启动容器(端口 8000)
|
||||||
# 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`
|
### GPU 生产环境(需 NVIDIA GPU + nvidia runtime)
|
||||||
复制为 `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
|
```bash
|
||||||
./scripts/prefetch_models.sh # 读 config.yaml(当前激活配置)
|
AUDIO2TEXT_VARIANT=gpu ./setup.sh
|
||||||
./scripts/prefetch_models.sh config.gpu.yaml # 读指定配置(如切换到 GPU 前预拉大模型)
|
./start.sh # 自动检测 GPU 镜像 + nvidia-smi,端口 8001
|
||||||
```
|
```
|
||||||
|
|
||||||
脚本用已构建的镜像跑一次性容器,读配置里的 `asr.model` / `translation.model`,下载到
|
启动后打开 `http://127.0.0.1:8000/`,拖入视频即可。详细部署流程、模型选型、CPU↔GPU 切换
|
||||||
`./models/huggingface`(HF 标准缓存)。**幂等**:已下过的模型自动跳过。换 config 的模型
|
见 [部署指南](./docs/DEPLOYMENT.md)。
|
||||||
名后重跑即可补下新模型,无需重建镜像。
|
|
||||||
|
|
||||||
### CPU 模型选型
|
|
||||||
|
|
||||||
| 组件 | 模型 | 大小 | 说明 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| ASR | `tiny.en` | ~39M | Whisper 同系列最小,英文专用版(比通用 `tiny` 在英语上更准) |
|
|
||||||
| 翻译 | `Helsinki-NLP/opus-mt-en-zh` | ~300MB | 最轻量英译中。NLLB 同系列最小 `distilled-600M` 需 ~2.4GB,2GB 机 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 视频几分钟出字幕。
|
已有 `audio2text-gpu.tar` 镜像文件时,新机器无需构建,直接导入即可启动(仍需 NVIDIA 驱动 +
|
||||||
|
nvidia container runtime + 模型缓存 `./models`):
|
||||||
### 前置要求
|
|
||||||
|
|
||||||
- Docker
|
|
||||||
- **NVIDIA GPU 驱动**(宿主机)
|
|
||||||
- **nvidia container runtime**(让容器能用 GPU;安装 `nvidia-container-toolkit`)
|
|
||||||
- 约 6 GB 磁盘(模型缓存:large-v3-turbo ~3GB + NLLB-1.3B ~2.5GB)
|
|
||||||
|
|
||||||
验证 GPU 可用:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nvidia-smi # 宿主能看到 GPU
|
# 1. 导入镜像
|
||||||
docker run --rm --gpus all nvidia/cuda:12.1.0-runtime-ubuntu22.04 nvidia-smi
|
docker load -i audio2text-gpu.tar
|
||||||
# 上面容器内也能列出 GPU 即说明 nvidia runtime 已就绪
|
|
||||||
|
# 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
|
详细步骤见 [Docker 说明 - 导入预构建镜像](./docs/DOCKER.md#导入预构建镜像离线部署)。
|
||||||
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` | ~3GB(FP16) | 8x 速度,质量接近 large-v3 |
|
|
||||||
| 翻译 | `facebook/nllb-200-distilled-1.3B` | ~2.5GB(FP16) | 质量最好的蒸馏版 |
|
|
||||||
|
|
||||||
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/` |
|
||||||
| 历史任务 | `http://127.0.0.1:8000/history`(分页查看所有任务,可按文件名搜索、下载字幕) |
|
| 历史任务 | `http://127.0.0.1:8000/history` |
|
||||||
| 日志页 | `http://127.0.0.1:8000/logs`(按级别分层、自动刷新) |
|
| 实时日志 | `http://127.0.0.1:8000/logs` |
|
||||||
| API 文档 | `http://127.0.0.1:8000/docs`(Basic Auth,凭据见 config.yaml `docs` 段) |
|
| API 文档 | `http://127.0.0.1:8000/docs` |
|
||||||
| 健康检查 | `http://127.0.0.1:8000/health` |
|
| 健康检查 | `http://127.0.0.1:8000/health` |
|
||||||
| 任务列表 | `http://127.0.0.1:8000/api/tasks` |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 配置文件说明
|
## 文档索引
|
||||||
|
|
||||||
项目预置两份配置文件,`setup.sh` 按 `AUDIO2TEXT_VARIANT` 自动复制对应文件为
|
详细文档按主题拆分,主页只保留核心速览:
|
||||||
`config.yaml`(运行时实际读取的文件,不入库):
|
|
||||||
|
| 文档 | 内容 |
|
||||||
| 文件 | 激活方式 | 说明 |
|
|---|---|
|
||||||
|---|---|---|
|
| [部署指南](./docs/DEPLOYMENT.md) | CPU / GPU 完整部署流程、前置要求、模型选型、CPU↔GPU 切换、自定义端口 |
|
||||||
| `config.cpu.yaml` | `./setup.sh`(默认) | CPU 开发,最小模型 |
|
| [配置文件说明](./docs/CONFIG.md) | CPU/GPU 配置差异表、全部字段说明(server/storage/asr/translation/...)、配置示例 |
|
||||||
| `config.gpu.yaml` | `AUDIO2TEXT_VARIANT=gpu ./setup.sh` | GPU 生产,质量优先 |
|
| [Docker 说明](./docs/DOCKER.md) | 镜像构建、新建/重建/改配置/改依赖四种场景、**缓存分层与删除边界**、⚠️ until filter 失效根因、Volume 挂载 |
|
||||||
| `config.example.yaml` | — | 带完整注释的字段参考模板 |
|
| [HTTP 接口](./docs/API.md) | 接口一览表、分片上传协议、请求/响应示例 |
|
||||||
|
| [架构与原理](./docs/ARCHITECTURE.md) | 断句算法、模型不共驻显存策略、GPU 利用率优化、缓存清理机制 |
|
||||||
也可手动切换:`cp config.gpu.yaml config.yaml` 后重启容器即可,无需重建镜像(镜像不含配置)。
|
| [常见问题](./docs/FAQ.md) | CPU 跑 NLLB、模型下载、断点续传、保留原始视频、自动清理等 |
|
||||||
运行时通过环境变量 `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`(~300MB,2GB 机可跑)。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: 2(turbo 鲁棒可降,候选数↓解码步数↓)
|
|
||||||
|
|
||||||
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 规范化
|
|
||||||
|
|
||||||
最后统一处理:单条 1–7 秒(过短合并)、≤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.6GB,3090 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.yaml(CPU 默认)
|
|
||||||
./setup.sh
|
|
||||||
# GPU:AUDIO2TEXT_VARIANT=gpu ./setup.sh
|
|
||||||
|
|
||||||
# 2. 启动容器
|
|
||||||
./start.sh
|
|
||||||
# GPU:start.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"`
|
|
||||||
- **换 VARIANT(cpu↔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"` 验证。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 依赖
|
## 依赖
|
||||||
|
|
||||||
### Python(`requirements.txt`)
|
- **Python**:FastAPI + uvicorn + SQLAlchemy + faster-whisper + transformers(torch 按 VARIANT 分叉,CPU/GPU 装不同 wheel)。完整列表见 `requirements.txt`
|
||||||
|
- **系统**:ffmpeg(镜像内 apt 装)、patchelf(修复 ctranslate2 可执行栈)。GPU 需宿主 NVIDIA 驱动 + nvidia container runtime
|
||||||
|
|
||||||
| 包 | 用途 |
|
镜像构建与依赖安装细节见 [Docker 说明](./docs/DOCKER.md)。
|
||||||
|---|---|
|
|
||||||
| `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(~300MB),2GB 内存开发机即可跑通完整流程。
|
|
||||||
若想在 CPU 上验证 NLLB 翻译质量,可手动改 `translation.model`:
|
|
||||||
- `facebook/nllb-200-distilled-600M`(~1.2GB,同系列最小)——需 ≥4GB 内存,2GB 机会 OOM。
|
|
||||||
- `facebook/nllb-200-distilled-1.3B`(~2.5GB,GPU 生产同款)——需 ~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` 显式启动。
|
|
||||||
|
|||||||
102
app/config.py
102
app/config.py
@@ -128,13 +128,111 @@ def _load_yaml(path: Path) -> dict:
|
|||||||
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
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)
|
@lru_cache(maxsize=1)
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
"""读取 config.yaml + 应用 DB 覆盖,返回完整 Settings。
|
||||||
|
|
||||||
|
结果被 lru_cache 缓存。修改设置后调 reload_settings() 清缓存,
|
||||||
|
下次调用返回含新值的 Settings。
|
||||||
|
"""
|
||||||
path = Path(os.getenv("CONFIG_PATH", str(DEFAULT_CONFIG_PATH)))
|
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:
|
def reload_settings() -> Settings:
|
||||||
"""清缓存并重新读取,供脚本与测试使用。"""
|
"""清缓存并重新读取(含 DB 覆盖),供设置页保存后调用。"""
|
||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
return get_settings()
|
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()
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"""路由聚合:导出各 controller 的 router,供 main.py include。"""
|
"""路由聚合:导出各 controller 的 router,供 main.py include。"""
|
||||||
|
|
||||||
from .log_router import router as log_router
|
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 .task_router import router as task_router
|
||||||
from .upload_router import router as upload_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"]
|
||||||
|
|||||||
92
app/controllers/settings_router.py
Normal file
92
app/controllers/settings_router.py
Normal 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}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
"""任务路由:列表 / 状态 / 下载字幕。"""
|
"""任务路由:列表 / 状态 / 下载字幕 / 删除。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -11,10 +13,11 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
from ..database import get_db
|
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 ..models.upload_session import UploadSession
|
||||||
from ..schemas.task import TaskListResponse, TaskResponse
|
from ..schemas.task import TaskListResponse, TaskResponse
|
||||||
|
|
||||||
|
logger = logging.getLogger("audio2text.tasks")
|
||||||
router = APIRouter(prefix="/api/tasks", tags=["task"])
|
router = APIRouter(prefix="/api/tasks", tags=["task"])
|
||||||
|
|
||||||
|
|
||||||
@@ -129,3 +132,47 @@ def download_subtitle(
|
|||||||
media_type="application/x-subrip",
|
media_type="application/x-subrip",
|
||||||
filename=download_name,
|
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}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ def init_db_schema() -> None:
|
|||||||
"""
|
"""
|
||||||
from .models.task import Task # noqa: F401
|
from .models.task import Task # noqa: F401
|
||||||
from .models.upload_session import UploadSession # noqa: F401
|
from .models.upload_session import UploadSession # noqa: F401
|
||||||
|
from .models.setting import Setting # noqa: F401
|
||||||
|
|
||||||
engine = get_engine()
|
engine = get_engine()
|
||||||
Base.metadata.create_all(engine)
|
Base.metadata.create_all(engine)
|
||||||
|
|||||||
19
app/main.py
19
app/main.py
@@ -19,21 +19,21 @@ import logging
|
|||||||
import threading
|
import threading
|
||||||
from contextlib import asynccontextmanager
|
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.openapi.docs import get_redoc_html, get_swagger_ui_html
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .config import get_settings
|
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 .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.cache_cleaner import purge_expired_cache, run_forever as run_cache_cleaner
|
||||||
from .services.log_buffer import init_log_buffer
|
from .services.log_buffer import init_log_buffer
|
||||||
from .services.reaper import reap_stale_sessions
|
from .services.reaper import reap_stale_sessions
|
||||||
from .views.history_html import render as render_history_html
|
from .views.history_html import render as render_history_html
|
||||||
from .views.home_html import render as render_home_html
|
from .views.home_html import render as render_home_html
|
||||||
from .views.logs_html import render as render_logs_html
|
from .views.logs_html import render as render_logs_html
|
||||||
|
from .views.settings_html import render as render_settings_html
|
||||||
|
|
||||||
# 日志分层:
|
# 日志分层:
|
||||||
# - audio2text logger 始终设 DEBUG,确保所有记录(含子步骤)都能产生。
|
# - audio2text logger 始终设 DEBUG,确保所有记录(含子步骤)都能产生。
|
||||||
@@ -126,20 +126,21 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(upload_router)
|
app.include_router(upload_router)
|
||||||
app.include_router(task_router)
|
app.include_router(task_router)
|
||||||
app.include_router(log_router)
|
app.include_router(log_router)
|
||||||
|
app.include_router(settings_router)
|
||||||
|
|
||||||
# 受 Basic Auth 保护的文档接口
|
# 文档接口(无认证,直接公开)
|
||||||
@app.get("/openapi.json")
|
@app.get("/openapi.json")
|
||||||
def protected_openapi(_: str = Depends(require_docs_auth)) -> JSONResponse:
|
def openapi_endpoint() -> JSONResponse:
|
||||||
return JSONResponse(app.openapi())
|
return JSONResponse(app.openapi())
|
||||||
|
|
||||||
@app.get("/docs")
|
@app.get("/docs")
|
||||||
def protected_docs(_: str = Depends(require_docs_auth)):
|
def docs_endpoint():
|
||||||
return get_swagger_ui_html(
|
return get_swagger_ui_html(
|
||||||
openapi_url="/openapi.json", title="audio2text docs", swagger_favicon_url=""
|
openapi_url="/openapi.json", title="audio2text docs", swagger_favicon_url=""
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/redoc")
|
@app.get("/redoc")
|
||||||
def protected_redoc(_: str = Depends(require_docs_auth)):
|
def redoc_endpoint():
|
||||||
return get_redoc_html(
|
return get_redoc_html(
|
||||||
openapi_url="/openapi.json", title="audio2text docs", redoc_favicon_url=""
|
openapi_url="/openapi.json", title="audio2text docs", redoc_favicon_url=""
|
||||||
)
|
)
|
||||||
@@ -187,6 +188,10 @@ def create_app() -> FastAPI:
|
|||||||
def logs_page() -> HTMLResponse:
|
def logs_page() -> HTMLResponse:
|
||||||
return HTMLResponse(render_logs_html())
|
return HTMLResponse(render_logs_html())
|
||||||
|
|
||||||
|
@app.get("/settings", response_class=HTMLResponse)
|
||||||
|
def settings_page() -> HTMLResponse:
|
||||||
|
return HTMLResponse(render_settings_html())
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
36
app/models/setting.py
Normal file
36
app/models/setting.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""运行时设置覆盖(键值存储)。
|
||||||
|
|
||||||
|
config.yaml 是只读挂载(镜像内不含配置),改完需重启容器才生效。
|
||||||
|
本表持久化用户在「设置页」修改的参数,进程重启后自动加载,
|
||||||
|
无需改 config.yaml 或重建镜像。
|
||||||
|
|
||||||
|
当前支持的键见 _ALLOWED_KEYS(settings_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})"
|
||||||
@@ -6,7 +6,9 @@ CPU dev: tiny.en + int8;GPU prod: large-v3-turbo + float16。同一份代码
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
from .model_manager import get_model_manager
|
from .model_manager import get_model_manager
|
||||||
@@ -14,12 +16,20 @@ from .types import Segment, Word
|
|||||||
|
|
||||||
logger = logging.getLogger("audio2text.asr")
|
logger = logging.getLogger("audio2text.asr")
|
||||||
|
|
||||||
|
# Whisper 默认 chunk_length(秒):BatchedInferencePipeline 按 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(含词级时间戳)。
|
"""转写 wav,返回 segments(含词级时间戳)。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
wav_path: 16kHz mono PCM wav
|
wav_path: 16kHz mono PCM wav
|
||||||
|
on_progress: 可选进度回调 (current_chunk, total_chunks)。
|
||||||
|
每 transcribe 完一个 30s chunk 调一次,用于细分进度条。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list[Segment],每个 Segment 带词级 words(若 word_timestamps 启用)。
|
list[Segment],每个 Segment 带词级 words(若 word_timestamps 启用)。
|
||||||
@@ -29,8 +39,8 @@ def transcribe(wav_path: Path) -> list[Segment]:
|
|||||||
raise FileNotFoundError(f"音频不存在:{wav_path}")
|
raise FileNotFoundError(f"音频不存在:{wav_path}")
|
||||||
|
|
||||||
model = get_model_manager().get_asr()
|
model = get_model_manager().get_asr()
|
||||||
logger.debug("开始转写 %s(model=%s language=%s batch_size=%d)",
|
logger.debug("开始转写 %s(model=%s language=%s batch_size=%d beam_size=%d)",
|
||||||
wav_path.name, s.model, s.language, s.batch_size)
|
wav_path.name, s.model, s.language, s.batch_size, s.beam_size)
|
||||||
|
|
||||||
segments_gen, info = model.transcribe(
|
segments_gen, info = model.transcribe(
|
||||||
str(wav_path),
|
str(wav_path),
|
||||||
@@ -41,12 +51,19 @@ def transcribe(wav_path: Path) -> list[Segment]:
|
|||||||
batch_size=s.batch_size, # 批量解码:多音频块一次性送 GPU
|
batch_size=s.batch_size, # 批量解码:多音频块一次性送 GPU
|
||||||
without_timestamps=False, # BatchedInferencePipeline 默认 True,需显式关闭以生成段级时间戳
|
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(
|
logger.debug(
|
||||||
"音频时长 %.1fs,检测语言=%s(置信度 %.2f)",
|
"音频时长 %.1fs(VAD 后 %.1fs),检测语言=%s(置信度 %.2f),约 %d 个 chunk",
|
||||||
info.duration, info.language, info.language_probability,
|
info.duration, duration, info.language, info.language_probability, total_chunks,
|
||||||
)
|
)
|
||||||
|
if on_progress is not None:
|
||||||
|
on_progress(0, total_chunks)
|
||||||
|
|
||||||
segments: list[Segment] = []
|
segments: list[Segment] = []
|
||||||
|
last_chunk = 0 # 已报进度的 chunk 序号(避免同 chunk 内多个 segment 重复回调)
|
||||||
for seg in segments_gen:
|
for seg in segments_gen:
|
||||||
words: list[Word] = []
|
words: list[Word] = []
|
||||||
if s.word_timestamps and getattr(seg, "words", None):
|
if s.word_timestamps and getattr(seg, "words", None):
|
||||||
@@ -63,6 +80,15 @@ def transcribe(wav_path: Path) -> list[Segment]:
|
|||||||
end=float(seg.end),
|
end=float(seg.end),
|
||||||
words=words,
|
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 词。",
|
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
|
return segments
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,15 @@ def asr_phase(db, task) -> None:
|
|||||||
|
|
||||||
logger.info("任务 %d [ASR 开始] %s", task.id, wav_path.name)
|
logger.info("任务 %d [ASR 开始] %s", task.id, wav_path.name)
|
||||||
_set_status(db, task, STATUS_TRANSCRIBING, P_TRANSCRIBE_START)
|
_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,
|
_set_status(db, task, STATUS_TRANSCRIBING, P_TRANSCRIBE_END,
|
||||||
note=f"识别出 {len(segments)} 段")
|
note=f"识别出 {len(segments)} 段")
|
||||||
|
|
||||||
@@ -114,7 +122,14 @@ def translate_phase(db, task) -> None:
|
|||||||
_set_status(db, task, STATUS_TRANSLATING, P_TRANSLATE_START)
|
_set_status(db, task, STATUS_TRANSLATING, P_TRANSLATE_START)
|
||||||
subs = [_dict_to_subtitle(d) for d in json.loads(task.segments_json)]
|
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,
|
_set_status(db, task, STATUS_TRANSLATING, P_TRANSLATE_END,
|
||||||
note=f"翻译 {len(zh_texts)} 条")
|
note=f"翻译 {len(zh_texts)} 条")
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
from .model_manager import get_model_manager
|
from .model_manager import get_model_manager
|
||||||
@@ -25,11 +26,15 @@ logger = logging.getLogger("audio2text.translate")
|
|||||||
_TOKENS_PER_WORD = 1.2
|
_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:
|
Args:
|
||||||
subtitles: 断句后的英文字幕条目(按时间顺序)
|
subtitles: 断句后的英文字幕条目(按时间顺序)
|
||||||
|
on_progress: 可选进度回调 (done_count, total_count),每批完成时调一次。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list[str],与 subtitles 等长、顺序对应的中文译文。
|
list[str],与 subtitles 等长、顺序对应的中文译文。
|
||||||
@@ -52,9 +57,9 @@ def translate(subtitles: list[Subtitle]) -> list[str]:
|
|||||||
sort_by_length = False
|
sort_by_length = False
|
||||||
|
|
||||||
if sort_by_length:
|
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:
|
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))
|
logger.debug("翻译完成:%d 条。", len(results))
|
||||||
return results
|
return results
|
||||||
@@ -64,6 +69,7 @@ def translate(subtitles: list[Subtitle]) -> list[str]:
|
|||||||
|
|
||||||
def _translate_sorted(
|
def _translate_sorted(
|
||||||
pipe, texts: list[str], batch_size: int, max_len: int,
|
pipe, texts: list[str], batch_size: int, max_len: int,
|
||||||
|
on_progress: Callable[[int, int], None] | None = None,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""按长度排序后分批翻译,翻译完按原序散回。
|
"""按长度排序后分批翻译,翻译完按原序散回。
|
||||||
|
|
||||||
@@ -120,7 +126,9 @@ def _translate_sorted(
|
|||||||
for idx, zh in zip(orig_indices, translated):
|
for idx, zh in zip(orig_indices, translated):
|
||||||
results[idx] = zh
|
results[idx] = zh
|
||||||
done += len(batch)
|
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)
|
logger.debug("已翻译 %d/%d 条。", done, n)
|
||||||
|
|
||||||
# None(理论不会发生,_translate_batch 保证返回等长)→ 回退原文
|
# None(理论不会发生,_translate_batch 保证返回等长)→ 回退原文
|
||||||
@@ -142,15 +150,20 @@ def _estimate_sequential_padding(texts: list[str], batch_size: int) -> int:
|
|||||||
|
|
||||||
def _translate_sequential(
|
def _translate_sequential(
|
||||||
pipe, texts: list[str], batch_size: int, max_len: int,
|
pipe, texts: list[str], batch_size: int, max_len: int,
|
||||||
|
on_progress: Callable[[int, int], None] | None = None,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""按原序分批翻译(旧行为,便于 A/B 对比)。"""
|
"""按原序分批翻译(旧行为,便于 A/B 对比)。"""
|
||||||
results: list[str] = []
|
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]
|
chunk = texts[i:i + batch_size]
|
||||||
translated = _translate_batch(pipe, chunk, max_len)
|
translated = _translate_batch(pipe, chunk, max_len)
|
||||||
results.extend(translated)
|
results.extend(translated)
|
||||||
if (i // batch_size + 1) % 5 == 0:
|
done = min(i + len(chunk), n)
|
||||||
logger.debug("已翻译 %d/%d 条。", min(i + len(chunk), len(texts)), len(texts))
|
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
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ _NAV_ITEMS = [
|
|||||||
("/", "主页", "home"),
|
("/", "主页", "home"),
|
||||||
("/history", "历史", "history"),
|
("/history", "历史", "history"),
|
||||||
("/logs", "日志", "logs"),
|
("/logs", "日志", "logs"),
|
||||||
|
("/settings", "设置", "settings"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,12 @@ function renderTable(tasks) {{
|
|||||||
}} else if (task.status === "failed") {{
|
}} else if (task.status === "failed") {{
|
||||||
action = `<span class="err-tip" title="${{escapeHtml(task.error || "")}}">查看错误</span>`;
|
action = `<span class="err-tip" title="${{escapeHtml(task.error || "")}}">查看错误</span>`;
|
||||||
}} else {{
|
}} 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;
|
let progress;
|
||||||
@@ -117,6 +122,17 @@ function renderPagination() {{
|
|||||||
paginationEl.innerHTML = html;
|
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);
|
load(0);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,12 @@ function renderTaskInner(task) {{
|
|||||||
try {{ created = fmtDateTime24(new Date(task.created_at + "Z")); }}
|
try {{ created = fmtDateTime24(new Date(task.created_at + "Z")); }}
|
||||||
catch (e) {{ created = task.created_at; }}
|
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;
|
let body;
|
||||||
if (task.status === "done") {{
|
if (task.status === "done") {{
|
||||||
body = `<div class="task-meta"><b>完成</b> · ${{downloadLinks(task.id)}}</div>`;
|
body = `<div class="task-meta"><b>完成</b> · ${{downloadLinks(task.id)}}</div>`;
|
||||||
@@ -91,11 +97,30 @@ function renderTaskInner(task) {{
|
|||||||
<div class="task-head">
|
<div class="task-head">
|
||||||
<span class="fname">${{task.is_upload ? "" : "#" + task.id + " "}}${{escapeHtml(task.filename)}}</span>
|
<span class="fname">${{task.is_upload ? "" : "#" + task.id + " "}}${{escapeHtml(task.filename)}}</span>
|
||||||
<span class="fstate ${{stateClass}}">${{label}}</span>
|
<span class="fstate ${{stateClass}}">${{label}}</span>
|
||||||
|
${{delBtn}}
|
||||||
</div>
|
</div>
|
||||||
${{body}}
|
${{body}}
|
||||||
<div class="task-time">${{created}}</div>`;
|
<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) {{
|
async function pollTask(task) {{
|
||||||
// 上传会话:轮询 upload status 接口;Task:轮询 task 接口
|
// 上传会话:轮询 upload status 接口;Task:轮询 task 接口
|
||||||
const isUpload = task.is_upload === true && task.upload_id;
|
const isUpload = task.is_upload === true && task.upload_id;
|
||||||
|
|||||||
182
app/views/settings_html.py
Normal file
182
app/views/settings_html.py
Normal 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); }
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 页面专属 JS(f-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
70
docs/API.md
Normal 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
140
docs/ARCHITECTURE.md
Normal 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 规范化
|
||||||
|
|
||||||
|
最后统一处理:单条 1–7 秒(过短合并)、≤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.6GB,3090 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
187
docs/CONFIG.md
Normal 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`(~300MB,2GB 机可跑)。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: 2(turbo 鲁棒可降,候选数↓解码步数↓)
|
||||||
|
|
||||||
|
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
170
docs/DEPLOYMENT.md
Normal 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.4GB,2GB 机 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` | ~3GB(FP16) | 8x 速度,质量接近 large-v3 |
|
||||||
|
| 翻译 | `facebook/nllb-200-distilled-1.3B` | ~2.5GB(FP16) | 质量最好的蒸馏版 |
|
||||||
|
|
||||||
|
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
273
docs/DOCKER.md
Normal 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.yaml(CPU 默认)
|
||||||
|
./setup.sh
|
||||||
|
# GPU:AUDIO2TEXT_VARIANT=gpu ./setup.sh
|
||||||
|
|
||||||
|
# 2. 启动容器
|
||||||
|
./start.sh
|
||||||
|
# GPU:start.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"`
|
||||||
|
- **换 VARIANT(cpu↔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"` 验证。
|
||||||
42
docs/FAQ.md
Normal file
42
docs/FAQ.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
← [返回主页](../README.md)
|
||||||
|
|
||||||
|
# 常见问题
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Q: CPU 开发机能跑 NLLB 吗?
|
||||||
|
|
||||||
|
`config.cpu.yaml` 默认用 opus-mt-en-zh(~300MB),2GB 内存开发机即可跑通完整流程。
|
||||||
|
若想在 CPU 上验证 NLLB 翻译质量,可手动改 `translation.model`:
|
||||||
|
- `facebook/nllb-200-distilled-600M`(~1.2GB,同系列最小)——需 ≥4GB 内存,2GB 机会 OOM。
|
||||||
|
- `facebook/nllb-200-distilled-1.3B`(~2.5GB,GPU 生产同款)——需 ~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` 显式启动。
|
||||||
Reference in New Issue
Block a user