Compare commits
12 Commits
00e2a95fb7
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
954767b3b2 | ||
|
|
d990207cec | ||
|
|
1e355e6138 | ||
|
|
7635e5e766 | ||
| afa2027a00 | |||
|
|
78b87bfb24 | ||
|
|
5f6a242114 | ||
|
|
4625650fc8 | ||
|
|
a6b5c7231c | ||
|
|
e0dd987dba | ||
|
|
2f68c7e1f8 | ||
|
|
73110848f4 |
@@ -8,3 +8,5 @@ logs/
|
||||
*.pid
|
||||
.git/
|
||||
.gitignore
|
||||
test/
|
||||
build/
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -7,6 +7,7 @@ __pycache__/
|
||||
|
||||
# 运行时数据(走 docker volume,不入库)
|
||||
data/
|
||||
data-gpu/
|
||||
/models/
|
||||
config.yaml
|
||||
*.pid
|
||||
@@ -15,3 +16,11 @@ logs/
|
||||
# 编辑器
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# 测试数据与脚本(本地测试用,不入库)
|
||||
/test/
|
||||
|
||||
# 导出的 Docker 镜像 tar(太大,不入库)
|
||||
*.tar
|
||||
|
||||
build/
|
||||
95
Dockerfile
95
Dockerfile
@@ -1,44 +1,81 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# audio2text — 一份 Dockerfile,CPU(dev) / GPU(prod) 双形态。
|
||||
# docker build --build-arg VARIANT=cpu -t audio2text:cpu .
|
||||
# docker build --build-arg VARIANT=gpu -t audio2text:gpu .
|
||||
# 区别仅在基础镜像与 torch 轮子;Python 依赖列表完全一致。
|
||||
# docker compose --profile dev up -d --build # 开发:dev target + 源码挂载 + reload
|
||||
#
|
||||
# 分层目标:依赖层(deps)稳定固化,代码层(final/dev)在最末,改代码不重装依赖。
|
||||
# BuildKit 缓存挂载(--mount=type=cache):pip wheel 跨构建复用,二次构建秒级。
|
||||
#
|
||||
# 安全约束:PyTorch CPU wheel 与 GPU(CUDA) wheel 是两个不兼容二进制包。
|
||||
# - CPU 版 torch.cuda.is_available()=False(CPU 预期)
|
||||
# - GPU 版 torch.cuda.is_available()=True(GPU 可调度)
|
||||
# 因此 torch 必须按 VARIANT 分叉装不同 wheel,绝不能跨 variant 共享依赖层。
|
||||
# deps 阶段用 FROM base-${VARIANT},CPU/GPU 是两条独立构建链,各自装对应 torch。
|
||||
|
||||
ARG VARIANT=cpu
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. 基础镜像分叉:CPU 用 slim Python,GPU 用 CUDA runtime + 手动装 python3.12
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM python:3.12-slim AS base-cpu
|
||||
# GPU 基础镜像带 CUDA 运行时,torch 可装 CUDA 轮子
|
||||
|
||||
FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04 AS base-gpu
|
||||
# Ubuntu 22.04 默认源只有 python3.10/3.11,需加 deadsnakes PPA 才能装 python3.12。
|
||||
# 注意:不能用 python3-pip(那是 3.10 的系统 pip,会把包装到 3.10 site-packages)。
|
||||
# 用 python3.12 -m ensurepip 给 3.12 装 pip,确保所有包进 3.12 目录。
|
||||
# DEBIAN_FRONTEND=noninteractive 避免 tzdata 等包进入交互式配置卡住构建。
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update -y && apt-get install -y --no-install-recommends \
|
||||
python3.12 python3.12-venv python3.12-dev python3-pip \
|
||||
software-properties-common gnupg ca-certificates \
|
||||
&& add-apt-repository -y ppa:deadsnakes/ppa \
|
||||
&& apt-get update -y && apt-get install -y --no-install-recommends \
|
||||
python3.12 python3.12-venv python3.12-dev \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& ln -sf /usr/bin/python3.12 /usr/local/bin/python3 \
|
||||
&& ln -sf /usr/bin/python3.12 /usr/local/bin/python
|
||||
&& ln -sf /usr/bin/python3.12 /usr/local/bin/python \
|
||||
&& python3 -m ensurepip \
|
||||
&& python3 -m pip install --upgrade pip
|
||||
|
||||
FROM base-${VARIANT} AS final
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. deps:系统依赖 + Python 依赖(含 torch)。CPU/GPU 各自一条独立链。
|
||||
# final / dev 都 FROM deps,继承对应 variant 的 torch,不串。
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM base-${VARIANT} AS deps
|
||||
ARG VARIANT
|
||||
ENV VARIANT=${VARIANT} \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
HF_HOME=/models/huggingface \
|
||||
CT2_CACHE=/models/ctranslate2
|
||||
|
||||
# ffmpeg 是核心系统依赖,必须装
|
||||
RUN apt-get update -y && apt-get install -y --no-install-recommends \
|
||||
# ffmpeg 是核心系统依赖;patchelf 用于修复 ctranslate2 可执行栈
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update -y && apt-get install -y --no-install-recommends \
|
||||
ffmpeg ca-certificates patchelf \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt /app/requirements.txt
|
||||
|
||||
|
||||
# CPU 装 CPU 版 torch;GPU 走默认 index(带 CUDA 的轮子)
|
||||
RUN if [ "$VARIANT" = "cpu" ]; then \
|
||||
# 【顺序关键】必须先装 torch(按 VARIANT 分叉),再装 requirements。
|
||||
# 原因:requirements 里的 transformers / accelerate 依赖 torch,若先装 requirements,
|
||||
# pip 会从默认 PyPI 拉来 GPU 版 torch + nvidia-* 全家桶(~2GB),即使后续覆盖装 CPU torch,
|
||||
# 那些无用的 nvidia 包仍残留在镜像里。先装 torch 让 pip 解析 requirements 时 torch 已满足。
|
||||
#
|
||||
# CPU 走 cpu index(~200MB,无 nvidia 依赖);GPU 走默认 PyPI(带 CUDA,~2.5GB)。
|
||||
# 两套 wheel 二进制不兼容:CPU 版 cuda.is_available()=False,GPU 版 =True。不可共享。
|
||||
RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \
|
||||
pip install --upgrade pip && \
|
||||
if [ "$VARIANT" = "cpu" ]; then \
|
||||
pip install torch --index-url https://download.pytorch.org/whl/cpu ; \
|
||||
else \
|
||||
pip install --upgrade pip && \
|
||||
pip install torch ; \
|
||||
fi
|
||||
RUN pip install -r /app/requirements.txt
|
||||
|
||||
# torch 已就位,requirements 里的 transformers/accelerate 解析时复用已装 torch,不重复拉
|
||||
RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \
|
||||
pip install -r /app/requirements.txt
|
||||
|
||||
# ctranslate2 的 .so 带可执行栈标志(PT_GNU_STACK X),在某些内核 + Docker 组合下
|
||||
# 会触发 "cannot enable executable stack as shared object requires"。用 patchelf
|
||||
@@ -51,13 +88,41 @@ RUN for d in /usr/local/lib/python3.12/site-packages/ctranslate2.libs \
|
||||
done; \
|
||||
python -c "import ctranslate2; print('ctranslate2 stack fix verified', ctranslate2.__version__)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. dev:从 deps 继承依赖,不 COPY 代码(compose 用 volume 挂载源码)。
|
||||
# 开启 uvicorn --reload,改代码零重建、保存即生效。
|
||||
# 注意:dev 必须在 final 之前,保证 `docker build` 默认 target 是 final。
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM deps AS dev
|
||||
VOLUME ["/data", "/models"]
|
||||
# ctranslate2/faster-whisper 用系统动态链接器找 cuDNN(不走 torch 的库加载),
|
||||
# 需把 torch wheel 自带的 nvidia 库目录加入 LD_LIBRARY_PATH,否则 GPU 推理报
|
||||
# "Unable to load libcudnn_ops.so.9"。放在 final/dev 而非 deps,避免 ENV 变化
|
||||
# 导致 deps 的 apt/pip 层缓存失效。CPU 镜像无此目录,路径被忽略不影响。
|
||||
ENV CONFIG_PATH=/app/config.yaml \
|
||||
LD_LIBRARY_PATH=/usr/local/lib/python3.12/site-packages/nvidia/cudnn/lib:/usr/local/lib/python3.12/dist-packages/nvidia/cudnn/lib:/usr/local/lib/python3.12/site-packages/nvidia/cublas/lib:/usr/local/lib/python3.12/dist-packages/nvidia/cublas/lib \
|
||||
HF_HUB_OFFLINE=1 \
|
||||
TRANSFORMERS_OFFLINE=1
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. final(prod):从 deps 继承全部依赖,只加 app 代码。
|
||||
# 放在最后 = `docker build` 默认 target。setup.sh / start.sh 依赖此默认行为。
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM deps AS final
|
||||
COPY app /app/app
|
||||
COPY scripts /app/scripts
|
||||
COPY config.example.yaml /app/config.example.yaml
|
||||
|
||||
# 运行时数据:上传 / 中间产物 / 输出字幕 / 模型缓存
|
||||
# 全部走 volume,镜像本身无状态、无敏感数据
|
||||
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 \
|
||||
HF_HUB_OFFLINE=1 \
|
||||
TRANSFORMERS_OFFLINE=1
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
655
README.md
655
README.md
@@ -9,41 +9,34 @@
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
## 快速开始
|
||||
|
||||
- [功能特性](#功能特性)
|
||||
- [架构](#架构)
|
||||
- [部署:CPU 开发环境](#部署cpu-开发环境)
|
||||
- [部署:GPU 生产环境](#部署gpu-生产环境)
|
||||
- [配置文件说明](#配置文件说明)
|
||||
- [缓存清理与定时任务](#缓存清理与定时任务)
|
||||
- [HTTP 接口](#http-接口)
|
||||
- [断句与时间戳重算原理](#断句与时间戳重算原理)
|
||||
- [模型不共驻(显存策略)](#模型不共驻显存策略)
|
||||
- [Docker 说明](#docker-说明)
|
||||
- [依赖](#依赖)
|
||||
- [常见问题](#常见问题)
|
||||
### GPU 环境(需 NVIDIA GPU + nvidia runtime)
|
||||
|
||||
```bash
|
||||
AUDIO2TEXT_VARIANT=gpu ./setup.sh
|
||||
./start.sh # 端口 8001
|
||||
```
|
||||
|
||||
启动后打开 `http://127.0.0.1:8000/`,拖入视频即可。详细部署流程、模型选型、CPU↔GPU 切换
|
||||
见 [部署指南](./docs/DEPLOYMENT.md)。
|
||||
|
||||
---
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **主页** `/`:上传入口(拖拽 / 选择文件,多文件、4 MiB 分片、断点续传)+ 最近 10 个任务的实时进度卡片,完成的可直接下载字幕。
|
||||
- **历史任务页** `/history`:分页查看所有历史任务,可下载完成的字幕。
|
||||
- **实时日志页** `/logs`:按级别分层查看——debug=详细子步骤、info=仅阶段转换、error=完整 traceback。
|
||||
- **大视频处理**:接收完成后用 ffmpeg 提取 16 kHz 单声道 PCM 音频;是否删原始视频由配置决定。
|
||||
- **faster-whisper 转写英语**,带词级时间戳。
|
||||
- **断句 + 时间戳重算**:按句末标点(`. ! ? ;`)切句、超长句按逗号拆,时间戳取首末词精确值;
|
||||
无词级时间戳时退化为段内匀速估算。
|
||||
- **NLLB-200 英译中**;ASR 与翻译模型**不共驻**,翻译时卸载 Whisper 独占显存跑大 batch。
|
||||
- **双语合并 SRT** 输出(英文在上、中文在下),亦可单独下载英文 / 中文字幕。
|
||||
- **任务状态机**:`queued → extracting → transcribing → segmenting → translating → done`,
|
||||
页面自动轮询进度。
|
||||
- **定时缓存清理**:任务产物(字幕 / 中间音频 / 保留的原始视频)默认保留 7 天,超期后
|
||||
连同 DB 记录一并删除;容器内后台线程定时执行(启动时跑一次,默认每 24 小时一次),
|
||||
保留期与间隔均可配置。
|
||||
- **SQLite 持久化**(自包含,无需外部 DB)。
|
||||
- `/docs`(Swagger UI)受 Basic Auth 保护。
|
||||
- **网页上传**:拖拽 / 选择文件,多文件并发、4 MiB 分片、断点续传
|
||||
- **双语字幕**:英文在上、中文在下,亦可单独下载英文 / 中文字幕
|
||||
- **faster-whisper 转写**:词级时间戳,断句精确(取首末词时间戳)
|
||||
- **NLLB-200 英译中**:ASR 与翻译模型不共驻,翻译时独占显存跑大 batch
|
||||
- **设置页**:运行时调整 batch_size / beam_size,保存后对后续任务生效(DB 持久化)
|
||||
- **任务管理**:删除已完成/失败任务及其产物,进度条按批次细分
|
||||
- **任务状态机**:`queued -> uploading -> extracting -> transcribing -> segmenting -> translating -> done`
|
||||
- **实时日志页**:按级别分层(debug=详细子步骤 / info=阶段转换 / error=完整 traceback)
|
||||
- **定时缓存清理**:任务产物默认保留 7 天,超期连同 DB 记录一并删除
|
||||
- **SQLite 持久化**(自包含,无需外部 DB)
|
||||
- **离线运行**:模型缓存就位后完全离线,无需访问 HuggingFace
|
||||
- `/docs`(Swagger UI)公开访问
|
||||
|
||||
---
|
||||
|
||||
@@ -54,11 +47,13 @@ Spring 风格分层,HTTP 边界(controllers)与业务逻辑(services)
|
||||
```
|
||||
audio2text/
|
||||
├── Dockerfile # 一份 Dockerfile,ARG VARIANT=cpu|gpu 出两个镜像
|
||||
├── docker-compose.yml # cpu / gpu 两个 profile
|
||||
├── docker-compose.yml # cpu / gpu / dev 三个 profile
|
||||
├── setup.sh / start.sh / stop.sh # 安装 / 启动 / 停止(包装 docker 命令)
|
||||
├── scripts/
|
||||
│ └── prefetch_models.{sh,py} # 预拉模型权重到 ./models volume(避免首次启动下载)
|
||||
├── requirements.txt
|
||||
├── config.example.yaml # 配置模板(复制为 config.yaml 后填值)
|
||||
├── README.md
|
||||
├── docs/ # 详细文档(见下方索引)
|
||||
└── app/
|
||||
├── main.py # FastAPI 应用工厂
|
||||
├── config.py # 从 config.yaml 加载的类型化 Settings(pydantic)
|
||||
@@ -75,6 +70,7 @@ audio2text/
|
||||
│ ├── segmenter.py # 断句 + 时间戳重算(纯算法,零模型依赖)
|
||||
│ ├── translate_service.py # NLLB 翻译
|
||||
│ ├── model_manager.py # 模型加载/卸载(不共驻核心)
|
||||
│ ├── scheduler.py # ffmpeg 串行队列 + GPU 调度线程
|
||||
│ ├── pipeline.py # 编排:提取→识别→断句→翻译→写SRT
|
||||
│ ├── srt_writer.py # SRT 写入 + 双语合并
|
||||
│ ├── log_buffer.py # 内存日志缓冲(供 /logs 页面查询)
|
||||
@@ -101,581 +97,102 @@ audio2text/
|
||||
upload_router ──► upload_service ──► UploadSession(SQLite) + 分片落盘
|
||||
│ complete
|
||||
▼
|
||||
创建 Task(queued) ──► pipeline 后台线程
|
||||
创建 Task(queued) ──► scheduler
|
||||
│
|
||||
├─ 1. ffmpeg_service.extract_audio → 16k mono wav
|
||||
├─ ffmpeg 串行队列(最多 1 个并发,其余排队)→ 16k mono wav
|
||||
│ (按配置删原始视频)
|
||||
├─ 2. model_manager.get_asr → asr_service.transcribe → segments(带词级时间戳)
|
||||
├─ 3. segmenter.resegment → 规范字幕条目(精确/估算两路)
|
||||
├─ 4. model_manager.unload_asr → get_translator
|
||||
├─ GPU 调度线程(单线程,模型复用):
|
||||
│ get_asr → asr_service.transcribe → segments(带词级时间戳)
|
||||
│ unload_asr → get_translator
|
||||
│ translate_service.translate → 中文译文(独占显存大 batch)
|
||||
└─ 5. srt_writer → en.srt / zh.srt / bilingual.srt
|
||||
└─ srt_writer → en.srt / zh.srt / bilingual.srt
|
||||
更新 Task(done) + 写 output_dir
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 部署:CPU 开发环境
|
||||
## 快速开始
|
||||
|
||||
CPU 模式用于本地开发与流程验证,模型选同系列最小尺寸,2GB 内存开发机即可跑通完整流程。
|
||||
|
||||
### 前置要求
|
||||
|
||||
- Docker(用于构建镜像 + 运行容器)
|
||||
- 约 500 MB 磁盘(模型缓存)+ 上传视频空间
|
||||
|
||||
CPU 模式**不需要** NVIDIA 驱动,普通 Linux / macOS / WSL 均可。
|
||||
|
||||
### 步骤
|
||||
### CPU 开发环境(2GB 内存即可)
|
||||
|
||||
```bash
|
||||
cd /root/zikai/audio2text
|
||||
|
||||
# 1. 构建 CPU 镜像 + 复制 config.cpu.yaml → config.yaml
|
||||
./setup.sh # 默认 AUDIO2TEXT_VARIANT=cpu
|
||||
|
||||
# 2. 启动容器(默认端口 8000)
|
||||
./start.sh
|
||||
|
||||
# 3. 停止 / 重启
|
||||
./stop.sh
|
||||
./start.sh
|
||||
./setup.sh # 构建镜像 + 生成 config.yaml
|
||||
./start.sh # 启动容器(端口 8000)
|
||||
```
|
||||
|
||||
`setup.sh` 做三件事:检查 docker → 构建 `audio2text:cpu` 镜像 → 把 `config.cpu.yaml`
|
||||
复制为 `config.yaml`(运行时实际读取的文件)。可重复执行;改完配置后重新 `cp` 并重启即可,
|
||||
无需重建镜像。
|
||||
|
||||
首次启动会下载模型(Whisper `tiny.en` ~39M + opus-mt ~300MB)到 `./models` volume,
|
||||
之后秒起。启动后浏览器打开 `http://127.0.0.1:8000/`,拖入视频或音频文件即可。
|
||||
|
||||
### 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 内存)。
|
||||
|
||||
### 自定义端口
|
||||
### GPU 生产环境(需 NVIDIA GPU + nvidia runtime)
|
||||
|
||||
```bash
|
||||
AUDIO2TEXT_PORT=9000 ./start.sh
|
||||
AUDIO2TEXT_VARIANT=gpu ./setup.sh
|
||||
./start.sh # 自动检测 GPU 镜像 + nvidia-smi,端口 8001
|
||||
```
|
||||
|
||||
启动后打开 `http://127.0.0.1:8000/`,拖入视频即可。详细部署流程、模型选型、CPU↔GPU 切换
|
||||
见 [部署指南](./docs/DEPLOYMENT.md)。
|
||||
|
||||
---
|
||||
|
||||
## 部署:GPU 生产环境
|
||||
## 离线部署(导入预构建镜像)
|
||||
|
||||
GPU 模式用于生产,模型质量优先,NVIDIA 3090 24G 上几 GB 视频几分钟出字幕。
|
||||
|
||||
### 前置要求
|
||||
|
||||
- Docker
|
||||
- **NVIDIA GPU 驱动**(宿主机)
|
||||
- **nvidia container runtime**(让容器能用 GPU;安装 `nvidia-container-toolkit`)
|
||||
- 约 6 GB 磁盘(模型缓存:large-v3-turbo ~3GB + NLLB-1.3B ~2.5GB)
|
||||
|
||||
验证 GPU 可用:
|
||||
已有 `audio2text-gpu.tar` 镜像文件时,新机器无需构建,直接导入即可启动(仍需 NVIDIA 驱动 +
|
||||
nvidia container runtime + 模型缓存 `./models`):
|
||||
|
||||
```bash
|
||||
nvidia-smi # 宿主能看到 GPU
|
||||
docker run --rm --gpus all nvidia/cuda:12.1.0-runtime-ubuntu22.04 nvidia-smi
|
||||
# 上面容器内也能列出 GPU 即说明 nvidia runtime 已就绪
|
||||
# 1. 导入镜像
|
||||
docker load -i audio2text-gpu.tar
|
||||
|
||||
# 2. 准备配置 + 数据目录
|
||||
mkdir -p data-gpu/uploads data-gpu/.work data-gpu/outputs models
|
||||
|
||||
# 3. 启动容器(config.gpu.yaml 需自行准备,或从项目仓库取)
|
||||
docker run -d --name audio2text-gpu \
|
||||
--gpus all \
|
||||
-p 8001:8000 \
|
||||
-v "$(pwd)/data-gpu:/data" \
|
||||
-v "$(pwd)/models:/models" \
|
||||
-v "$(pwd)/config.gpu.yaml:/app/config.yaml:ro" \
|
||||
--restart unless-stopped \
|
||||
audio2text:gpu
|
||||
```
|
||||
|
||||
### 步骤
|
||||
> **模型缓存**:`./models` 目录需包含 Whisper `large-v3-turbo` + NLLB `distilled-1.3B` 权重
|
||||
> (约 5.5GB)。首次部署时从源机器拷贝 `models/` 目录,或联网用 `prefetch_models.sh` 预拉。
|
||||
> 镜像内置 `HF_HUB_OFFLINE=1`,模型就位后完全离线运行,无需访问 HuggingFace。
|
||||
|
||||
```bash
|
||||
cd /root/zikai/audio2text
|
||||
详细步骤见 [Docker 说明 - 导入预构建镜像](./docs/DOCKER.md#导入预构建镜像离线部署)。
|
||||
|
||||
# 1. 构建 GPU 镜像 + 复制 config.gpu.yaml → config.yaml
|
||||
AUDIO2TEXT_VARIANT=gpu ./setup.sh
|
||||
---
|
||||
|
||||
# 2. 启动容器(start.sh 检测到 gpu 镜像 + nvidia-smi 自动加 --gpus all)
|
||||
./start.sh
|
||||
|
||||
# 3. 停止 / 重启
|
||||
./stop.sh
|
||||
./start.sh
|
||||
```
|
||||
|
||||
`start.sh` 的镜像选择逻辑:若本机存在 `audio2text:gpu` 镜像**且**有 `nvidia-smi`,自动用
|
||||
GPU 模式(`--gpus all`);否则回退 CPU 镜像。也可用 docker compose 显式启动:
|
||||
|
||||
```bash
|
||||
docker compose --profile gpu up -d --build # GPU
|
||||
docker compose --profile cpu up -d --build # CPU
|
||||
```
|
||||
|
||||
### GPU 模型选型
|
||||
|
||||
| 组件 | 模型 | 显存 | 说明 |
|
||||
|---|---|---|---|
|
||||
| ASR | `large-v3-turbo` | ~3GB(FP16) | 8x 速度,质量接近 large-v3 |
|
||||
| 翻译 | `facebook/nllb-200-distilled-1.3B` | ~2.5GB(FP16) | 质量最好的蒸馏版 |
|
||||
|
||||
ASR 与翻译**不共驻**:翻译阶段先卸载 Whisper 释放显存,独占跑大 batch(`batch_size=16`),
|
||||
两者峰值显存互不叠加,远低于 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 # 重新启动
|
||||
```
|
||||
|
||||
两套配置的差异仅在 6 项(其余字段完全一致):
|
||||
|
||||
| 字段 | CPU(`config.cpu.yaml`) | GPU(`config.gpu.yaml`) |
|
||||
|---|---|---|
|
||||
| `asr.model` | `tiny.en` | `large-v3-turbo` |
|
||||
| `asr.device` | `cpu` | `cuda` |
|
||||
| `asr.compute_type` | `int8` | `float16` |
|
||||
| `translation.model` | `Helsinki-NLP/opus-mt-en-zh` | `facebook/nllb-200-distilled-1.3B` |
|
||||
| `translation.device` | `cpu` | `cuda` |
|
||||
| `translation.batch_size` | `8` | `16` |
|
||||
|
||||
### 启动后的入口
|
||||
|
||||
两种模式通用:
|
||||
## 入口
|
||||
|
||||
| 入口 | 地址 |
|
||||
|---|---|
|
||||
| 主页 | `http://127.0.0.1:8000/`(上传入口 + 最近 10 任务进度卡片) |
|
||||
| 历史任务 | `http://127.0.0.1:8000/history`(分页查看所有任务,可按文件名搜索、下载字幕) |
|
||||
| 日志页 | `http://127.0.0.1:8000/logs`(按级别分层、自动刷新) |
|
||||
| API 文档 | `http://127.0.0.1:8000/docs`(Basic Auth,凭据见 config.yaml `docs` 段) |
|
||||
| 主页(上传 + 最近任务) | `http://127.0.0.1:8000/` |
|
||||
| 历史任务 | `http://127.0.0.1:8000/history` |
|
||||
| 实时日志 | `http://127.0.0.1:8000/logs` |
|
||||
| API 文档 | `http://127.0.0.1:8000/docs` |
|
||||
| 健康检查 | `http://127.0.0.1:8000/health` |
|
||||
| 任务列表 | `http://127.0.0.1:8000/api/tasks` |
|
||||
|
||||
---
|
||||
|
||||
## 配置文件说明
|
||||
|
||||
项目预置两份配置文件,`setup.sh` 按 `AUDIO2TEXT_VARIANT` 自动复制对应文件为
|
||||
`config.yaml`(运行时实际读取的文件,不入库):
|
||||
|
||||
| 文件 | 激活方式 | 说明 |
|
||||
|---|---|---|
|
||||
| `config.cpu.yaml` | `./setup.sh`(默认) | CPU 开发,最小模型 |
|
||||
| `config.gpu.yaml` | `AUDIO2TEXT_VARIANT=gpu ./setup.sh` | GPU 生产,质量优先 |
|
||||
| `config.example.yaml` | — | 带完整注释的字段参考模板 |
|
||||
|
||||
也可手动切换:`cp config.gpu.yaml config.yaml` 后重启容器即可,无需重建镜像(镜像不含配置)。
|
||||
运行时通过环境变量 `CONFIG_PATH` 指定路径(容器内默认 `/app/config.yaml`)。所有路径相对
|
||||
容器内文件系统。`config.py` 用 pydantic 做类型校验,缺字段时回退默认值。
|
||||
|
||||
### CPU / GPU 两份配置的差异
|
||||
|
||||
其余字段(存储、断句、日志、docs)两份配置完全一致,仅以下 6 项不同:
|
||||
|
||||
| 字段 | `config.cpu.yaml` | `config.gpu.yaml` |
|
||||
|---|---|---|
|
||||
| `asr.model` | `tiny.en` | `large-v3-turbo` |
|
||||
| `asr.device` | `cpu` | `cuda` |
|
||||
| `asr.compute_type` | `int8` | `float16` |
|
||||
| `translation.model` | `Helsinki-NLP/opus-mt-en-zh` | `facebook/nllb-200-distilled-1.3B` |
|
||||
| `translation.device` | `cpu` | `cuda` |
|
||||
| `translation.batch_size` | `8` | `16` |
|
||||
|
||||
### 完整字段
|
||||
|
||||
#### `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` | 过滤静音段,提升识别质量与速度 |
|
||||
|
||||
#### `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 | `16` | 翻译批量大小。不与 ASR 共驻时显存独占,可用大 batch |
|
||||
| `max_length` | int | `256` | 单条翻译最大 token 数 |
|
||||
|
||||
#### `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
|
||||
|
||||
translation:
|
||||
model: facebook/nllb-200-distilled-1.3B
|
||||
device: cpu # GPU: cuda
|
||||
src_lang: eng_Latn
|
||||
tgt_lang: zho_Hans
|
||||
batch_size: 16
|
||||
max_length: 256
|
||||
|
||||
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,互不叠加,远低于显存上限。
|
||||
|
||||
---
|
||||
|
||||
## 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`。
|
||||
|
||||
### Volume 挂载
|
||||
|
||||
| 容器路径 | 宿主路径 | 用途 |
|
||||
|---|---|---|
|
||||
| `/data` | `./data` | 上传视频、中间音频、输出字幕、SQLite 数据库 |
|
||||
| `/models` | `./models` | 模型缓存(HF + ctranslate2),跨容器复用避免重下 |
|
||||
| `/app/config.yaml` | `./config.yaml` | 配置文件(只读挂载) |
|
||||
|
||||
镜像本身无状态、无敏感数据。
|
||||
|
||||
### docker-compose
|
||||
|
||||
`docker-compose.yml` 提供 `audio2text-cpu` / `audio2text-gpu` 两个 profile:
|
||||
|
||||
```bash
|
||||
docker compose --profile cpu up -d # CPU
|
||||
docker compose --profile gpu up -d # GPU(需 nvidia runtime)
|
||||
```
|
||||
|
||||
### ctranslate2 可执行栈修复
|
||||
|
||||
ctranslate2 的 `.so`(在 `ctranslate2.libs/` 隐藏目录)带 PT_GNU_STACK 可执行栈标志,
|
||||
在某些内核 + Docker 组合下会报 `cannot enable executable stack as shared object requires`。
|
||||
Dockerfile 在构建时用 `patchelf --clear-execstack` 清掉该标志,无需放宽容器安全策略。
|
||||
构建末尾有 `python -c "import ctranslate2"` 验证。
|
||||
## 文档索引
|
||||
|
||||
详细文档按主题拆分,主页只保留核心速览:
|
||||
|
||||
| 文档 | 内容 |
|
||||
|---|---|
|
||||
| [部署指南](./docs/DEPLOYMENT.md) | CPU / GPU 完整部署流程、前置要求、模型选型、CPU↔GPU 切换、自定义端口 |
|
||||
| [配置文件说明](./docs/CONFIG.md) | CPU/GPU 配置差异表、全部字段说明(server/storage/asr/translation/...)、配置示例 |
|
||||
| [Docker 说明](./docs/DOCKER.md) | 镜像构建、新建/重建/改配置/改依赖四种场景、**缓存分层与删除边界**、⚠️ until filter 失效根因、Volume 挂载 |
|
||||
| [HTTP 接口](./docs/API.md) | 接口一览表、分片上传协议、请求/响应示例 |
|
||||
| [架构与原理](./docs/ARCHITECTURE.md) | 断句算法、模型不共驻显存策略、GPU 利用率优化、缓存清理机制 |
|
||||
| [常见问题](./docs/FAQ.md) | CPU 跑 NLLB、模型下载、断点续传、保留原始视频、自动清理等 |
|
||||
|
||||
---
|
||||
|
||||
## 依赖
|
||||
|
||||
### Python(`requirements.txt`)
|
||||
- **Python**:FastAPI + uvicorn + SQLAlchemy + faster-whisper + transformers(torch 按 VARIANT 分叉,CPU/GPU 装不同 wheel)。完整列表见 `requirements.txt`
|
||||
- **系统**:ffmpeg(镜像内 apt 装)、patchelf(修复 ctranslate2 可执行栈)。GPU 需宿主 NVIDIA 驱动 + nvidia container runtime
|
||||
|
||||
| 包 | 用途 |
|
||||
|---|---|
|
||||
| `fastapi` + `uvicorn[standard]` + `python-multipart` | Web 服务 |
|
||||
| `pydantic` + `pydantic-settings` | 配置类型校验 |
|
||||
| `PyYAML` | 读 config.yaml |
|
||||
| `SQLAlchemy` | SQLite ORM |
|
||||
| `faster-whisper` + `ctranslate2` | 语音识别 |
|
||||
| `transformers` + `sentencepiece` + `accelerate` | NLLB 翻译 |
|
||||
| `psutil` | 进程信息 |
|
||||
|
||||
torch 单独安装(CPU / CUDA 轮子不同),不在 requirements.txt 中。
|
||||
|
||||
### 系统
|
||||
|
||||
- `ffmpeg`(镜像内 apt 装)—— 提取音频
|
||||
- `patchelf`(镜像内 apt 装)—— 修复 ctranslate2 可执行栈
|
||||
- GPU 镜像额外需要宿主 NVIDIA 驱动 + nvidia container runtime
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: CPU 开发机能跑 NLLB 吗?
|
||||
|
||||
`config.cpu.yaml` 默认用 opus-mt-en-zh(~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` 显式启动。
|
||||
镜像构建与依赖安装细节见 [Docker 说明](./docs/DOCKER.md)。
|
||||
|
||||
124
app/config.py
124
app/config.py
@@ -1,6 +1,7 @@
|
||||
"""运行时配置:所有参数从 config.yaml 读取,对齐 server/config.py 的风格。
|
||||
"""运行时配置:所有参数从 config.yaml 读取。
|
||||
|
||||
CPU dev / GPU prod 仅靠 device / model / compute_type 三项切换,代码完全不变。
|
||||
默认值与 config.example.yaml 对齐,确保无 yaml 时也能用最小配置启动。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -40,21 +41,24 @@ class ProcessingConfig(BaseModel):
|
||||
|
||||
|
||||
class AsrConfig(BaseModel):
|
||||
model: str = "small"
|
||||
model: str = "tiny.en" # CPU: tiny.en;GPU: large-v3-turbo
|
||||
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 # BatchedInferencePipeline 的音频块批大小;GPU 建议 16
|
||||
beam_size: int = 5 # beam search 宽度;GPU turbo 可降到 2 加速(候选数↓ 解码步数↓),质量损失小
|
||||
|
||||
|
||||
class TranslationConfig(BaseModel):
|
||||
model: str = "facebook/nllb-200-distilled-1.3B"
|
||||
model: str = "Helsinki-NLP/opus-mt-en-zh" # CPU: opus-mt(轻量);GPU: facebook/nllb-200-distilled-1.3B
|
||||
device: str = "cpu" # cpu | cuda
|
||||
src_lang: str = "eng_Latn"
|
||||
tgt_lang: str = "zho_Hans"
|
||||
batch_size: int = 16
|
||||
max_length: int = 256
|
||||
src_lang: str = "eng_Latn" # NLLB 语言码:英语
|
||||
tgt_lang: str = "zho_Hans" # NLLB 语言码:简体中文
|
||||
batch_size: int = 8 # 排序后单批最大条数;CPU: 8,GPU: 32(显存独占可用大 batch)
|
||||
max_length: int = 256 # 单条最大生成 token 数
|
||||
sort_by_length: bool = True # 按句子长度排序后分批,减少批内 padding 浪费(GPU 收益大)
|
||||
|
||||
|
||||
class SegmentationConfig(BaseModel):
|
||||
@@ -68,8 +72,8 @@ class LoggingConfig(BaseModel):
|
||||
"""日志配置:控制台 + 内存缓冲的最低级别,以及缓冲条数。
|
||||
|
||||
分层语义:
|
||||
- debug:详细(ffmpeg 命令、模型加载/卸载、转写逐段、翻译逐批进度)
|
||||
- info:简略(仅任务阶段转换,如 "任务 N [transcribing 55%]")
|
||||
- debug:进度详情(任务 [status pct%]、转写/翻译逐批统计、ffmpeg 命令)
|
||||
- info:任务流转里程碑(音频提取/ASR/翻译 的开始与完成、模型加载与卸载)
|
||||
- error:详细错误(完整 traceback,由 logger.exception 自带)
|
||||
"""
|
||||
|
||||
@@ -124,13 +128,111 @@ def _load_yaml(path: Path) -> dict:
|
||||
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
|
||||
|
||||
# ---------------- 运行时覆盖 ----------------
|
||||
# 允许通过设置页修改的配置项(点分路径 -> 类型)。config.yaml 是只读挂载,
|
||||
# 改它需重启容器;运行时覆盖存 DB,进程重启后自动加载,无需重建镜像。
|
||||
# 设置页保存时调 save_setting() 写 DB + 清 lru_cache,下次 get_settings() 生效。
|
||||
_applying_overrides = False # 防递归标志:_apply_overrides 内部 DB 初始化会回调 get_settings()
|
||||
_OVERIDEABLE_FIELDS: dict[str, type] = {
|
||||
"asr.batch_size": int,
|
||||
"asr.beam_size": int,
|
||||
"translation.batch_size": int,
|
||||
"translation.sort_by_length": bool,
|
||||
}
|
||||
|
||||
|
||||
def _apply_overrides(settings: Settings) -> Settings:
|
||||
"""从 DB 读取覆盖值并应用到 Settings 对象。
|
||||
|
||||
在 lru_cache 的 get_settings() 内部调用,保证缓存的对象已含覆盖。
|
||||
DB 还没初始化时(首次 import)静默跳过,用 YAML 原值。
|
||||
|
||||
注意:get_session_local() -> get_engine() -> _db_path() -> get_settings()
|
||||
会形成递归。用 _applying_overrides 标志阻断:递归调用直接返回当前 settings
|
||||
(此时 DB 路径只需 work_dir,无覆盖也无妨)。
|
||||
"""
|
||||
global _applying_overrides
|
||||
if _applying_overrides:
|
||||
return settings # 递归调用(_db_path 触发),直接返回 YAML 原值
|
||||
_applying_overrides = True
|
||||
try:
|
||||
from .database import get_session_local
|
||||
from .models.setting import Setting
|
||||
import json
|
||||
db = get_session_local()()
|
||||
try:
|
||||
rows = db.query(Setting).all()
|
||||
overrides = {r.key: r.value for r in rows}
|
||||
finally:
|
||||
db.close()
|
||||
for key, type_ in _OVERIDEABLE_FIELDS.items():
|
||||
if key not in overrides:
|
||||
continue
|
||||
try:
|
||||
val = json.loads(overrides[key])
|
||||
val = type_(val)
|
||||
except (json.JSONDecodeError, ValueError, TypeError):
|
||||
continue
|
||||
_set_nested(settings, key, val)
|
||||
except Exception:
|
||||
# DB 未就绪(首次 import 时 database.py 可能还在初始化)-> 跳过,用 YAML 原值
|
||||
pass
|
||||
finally:
|
||||
_applying_overrides = False
|
||||
return settings
|
||||
|
||||
|
||||
def _set_nested(settings: Settings, key: str, val) -> None:
|
||||
"""按点分路径设置嵌套属性,如 'asr.batch_size' -> settings.asr.batch_size"""
|
||||
parts = key.split(".")
|
||||
obj = settings
|
||||
for p in parts[:-1]:
|
||||
obj = getattr(obj, p)
|
||||
setattr(obj, parts[-1], val)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
"""读取 config.yaml + 应用 DB 覆盖,返回完整 Settings。
|
||||
|
||||
结果被 lru_cache 缓存。修改设置后调 reload_settings() 清缓存,
|
||||
下次调用返回含新值的 Settings。
|
||||
"""
|
||||
path = Path(os.getenv("CONFIG_PATH", str(DEFAULT_CONFIG_PATH)))
|
||||
return Settings.model_validate(_load_yaml(path))
|
||||
settings = Settings.model_validate(_load_yaml(path))
|
||||
return _apply_overrides(settings)
|
||||
|
||||
|
||||
def reload_settings() -> Settings:
|
||||
"""清缓存并重新读取,供脚本与测试使用。"""
|
||||
"""清缓存并重新读取(含 DB 覆盖),供设置页保存后调用。"""
|
||||
get_settings.cache_clear()
|
||||
return get_settings()
|
||||
|
||||
|
||||
def save_setting(key: str, value) -> None:
|
||||
"""保存单个配置项覆盖到 DB + 清 lru_cache。
|
||||
|
||||
Args:
|
||||
key: 点分路径,必须在 _OVERIDEABLE_FIELDS 中
|
||||
value: 要保存的值(自动 JSON 编码)
|
||||
"""
|
||||
import json
|
||||
if key not in _OVERIDEABLE_FIELDS:
|
||||
raise ValueError(f"不允许修改的配置项:{key}")
|
||||
from .database import get_session_local
|
||||
from .models.setting import Setting
|
||||
type_ = _OVERIDEABLE_FIELDS[key]
|
||||
encoded = json.dumps(type_(value))
|
||||
db = get_session_local()()
|
||||
try:
|
||||
row = db.get(Setting, key)
|
||||
if row is None:
|
||||
row = Setting(key=key, value=encoded)
|
||||
db.add(row)
|
||||
else:
|
||||
row.value = encoded
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
# 清缓存,让后续 get_settings() 读到新值
|
||||
get_settings.cache_clear()
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""路由聚合:导出各 controller 的 router,供 main.py include。"""
|
||||
|
||||
from .log_router import router as log_router
|
||||
from .settings_router import router as settings_router
|
||||
from .task_router import router as task_router
|
||||
from .upload_router import router as upload_router
|
||||
|
||||
__all__ = ["log_router", "task_router", "upload_router"]
|
||||
__all__ = ["log_router", "settings_router", "task_router", "upload_router"]
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""日志路由:查询内存日志缓冲,供 /logs 页面消费。
|
||||
|
||||
分层语义:
|
||||
- debug:详细(ffmpeg 命令、模型加载/卸载、转写逐段、翻译逐批进度)
|
||||
- info:简略(仅任务阶段转换,如 "任务 N [transcribing 55%]")
|
||||
- warning / error:更详细,error 含完整 traceback
|
||||
- debug:进度详情(任务 [status pct%]、转写逐段统计、翻译逐批进度、ffmpeg 命令)
|
||||
- info:任务流转里程碑(音频提取/ASR/翻译 的开始与完成、模型加载与卸载、批次汇总)
|
||||
- warning / error:异常与失败,error 含完整 traceback
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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,10 @@
|
||||
"""任务路由:列表 / 状态 / 下载字幕。"""
|
||||
"""任务路由:列表 / 状态 / 下载字幕 / 删除。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
@@ -10,9 +13,11 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from ..config import get_settings
|
||||
from ..database import get_db
|
||||
from ..models.task import Task
|
||||
from ..models.task import Task, STATUS_UPLOADING, STATUS_DONE, STATUS_FAILED
|
||||
from ..models.upload_session import UploadSession
|
||||
from ..schemas.task import TaskListResponse, TaskResponse
|
||||
|
||||
logger = logging.getLogger("audio2text.tasks")
|
||||
router = APIRouter(prefix="/api/tasks", tags=["task"])
|
||||
|
||||
|
||||
@@ -28,6 +33,31 @@ def _to_resp(task: Task) -> TaskResponse:
|
||||
)
|
||||
|
||||
|
||||
def _upload_to_resp(session: UploadSession) -> TaskResponse:
|
||||
"""把上传中的 UploadSession 映射为虚拟 TaskResponse。
|
||||
|
||||
is_upload=true 让前端走上传状态轮询而非任务轮询。
|
||||
progress = 已传分片数 / 总分片数 × 100(映射到 0-5 区间,与 extract 阶段衔接)。
|
||||
"""
|
||||
uploaded = len(session.uploaded_chunks or [])
|
||||
total = session.total_chunks or 1
|
||||
# 上传进度映射到 0-4%(extract 从 5% 开始,留 1% 给 complete 拼接)
|
||||
progress = min(4.0, uploaded / total * 4.0)
|
||||
now = datetime.now(timezone.utc)
|
||||
return TaskResponse(
|
||||
id=0, # 虚拟 id,前端用 upload_id 轮询
|
||||
filename=session.filename,
|
||||
status=STATUS_UPLOADING,
|
||||
progress=progress,
|
||||
error=None,
|
||||
created_at=session.created_at,
|
||||
updated_at=session.updated_at or now,
|
||||
size_bytes=session.size_bytes,
|
||||
is_upload=True,
|
||||
upload_id=session.upload_id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=TaskListResponse, summary="任务列表")
|
||||
def list_tasks(
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
@@ -35,14 +65,30 @@ def list_tasks(
|
||||
q: str = Query("", description="按文件名模糊搜索(大小写不敏感,匹配子串)"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> TaskListResponse:
|
||||
q_base = db.query(Task).order_by(Task.id.desc())
|
||||
# 上传中的会话(pending 状态)也作为虚拟任务返回,让前端能看到上传进度
|
||||
upload_q = db.query(UploadSession).filter(UploadSession.status == "pending")
|
||||
if q.strip():
|
||||
# SQLite 的 LIKE 默认大小写不敏感(ASCII),ilike 等价于 LIKE
|
||||
like = f"%{q.strip()}%"
|
||||
q_base = q_base.filter(Task.filename.ilike(like))
|
||||
total = q_base.count()
|
||||
tasks = q_base.offset(offset).limit(limit).all()
|
||||
return TaskListResponse(tasks=[_to_resp(t) for t in tasks], total=total)
|
||||
upload_q = upload_q.filter(UploadSession.filename.ilike(like))
|
||||
uploads = upload_q.order_by(UploadSession.created_at.desc()).all()
|
||||
|
||||
# 已创建的 Task(含 extracting/transcribing/.../done/failed)
|
||||
task_q = db.query(Task).order_by(Task.created_at.desc(), Task.id.desc())
|
||||
if q.strip():
|
||||
like = f"%{q.strip()}%"
|
||||
task_q = task_q.filter(Task.filename.ilike(like))
|
||||
total_tasks = task_q.count()
|
||||
tasks = task_q.offset(offset).limit(limit).all()
|
||||
|
||||
# 合并:上传会话 + Task,按 created_at 倒序
|
||||
upload_resps = [_upload_to_resp(s) for s in uploads]
|
||||
task_resps = [_to_resp(t) for t in tasks]
|
||||
all_resps = upload_resps + task_resps
|
||||
all_resps.sort(key=lambda r: r.created_at, reverse=True)
|
||||
|
||||
# 分页:offset/limit 作用于合并后列表(上传会话通常很少,主要影响首页前几条)
|
||||
paged = all_resps[offset:offset + limit]
|
||||
return TaskListResponse(tasks=paged, total=len(all_resps))
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=TaskResponse, summary="任务状态")
|
||||
@@ -86,3 +132,47 @@ def download_subtitle(
|
||||
media_type="application/x-subrip",
|
||||
filename=download_name,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{task_id}", summary="删除任务(仅允许已完成/失败)")
|
||||
def delete_task(task_id: int, db: Session = Depends(get_db)) -> dict:
|
||||
"""删除任务及其产物(字幕 / 中间音频 / 保留的原始视频)+ DB 记录。
|
||||
|
||||
仅允许删除已完成(done)或失败(failed)的任务,进行中的任务不可删。
|
||||
"""
|
||||
task = db.get(Task, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(404, f"任务不存在:{task_id}")
|
||||
if task.status not in (STATUS_DONE, STATUS_FAILED):
|
||||
raise HTTPException(409, f"任务进行中,无法删除(当前状态:{task.status})")
|
||||
|
||||
s = get_settings()
|
||||
deleted: list[str] = []
|
||||
|
||||
# 删字幕输出目录
|
||||
out_dir = s.output_dir() / f"task_{task.id}"
|
||||
if out_dir.is_dir():
|
||||
shutil.rmtree(out_dir, ignore_errors=True)
|
||||
deleted.append("outputs")
|
||||
|
||||
# 删中间音频
|
||||
if task.wav_path:
|
||||
wav = Path(task.wav_path)
|
||||
if wav.is_file():
|
||||
wav.unlink(missing_ok=True)
|
||||
deleted.append("audio")
|
||||
|
||||
# 删保留的原始视频
|
||||
if task.source_path:
|
||||
src = s.upload_dir() / task.source_path
|
||||
if src.is_file():
|
||||
src.unlink(missing_ok=True)
|
||||
deleted.append("video")
|
||||
|
||||
# 删 DB 记录(先删关联的 UploadSession,再删 Task)
|
||||
db.query(UploadSession).filter(UploadSession.task_id == task.id).delete()
|
||||
db.delete(task)
|
||||
db.commit()
|
||||
|
||||
logger.info("删除任务 %d(%s):%s", task_id, task.filename, ", ".join(deleted) or "无产物")
|
||||
return {"status": "deleted", "task_id": task_id, "cleaned": deleted}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""分片上传路由:建会话 / 查状态 / 传分片 / complete。
|
||||
|
||||
协议与 server 完全一致,区别仅在 complete 后创建的是转写 Task 而非 UploadedFile。
|
||||
complete 成功后创建转写 Task 并交由 scheduler 入队。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
@@ -48,7 +49,10 @@ async def upload_chunk(
|
||||
request: Request,
|
||||
service: UploadService = Depends(_service),
|
||||
) -> ChunkUploadResponse:
|
||||
uploaded = service.write_chunk(upload_id, index, await request.body())
|
||||
# write_chunk 做文件 fsync + DB commit(阻塞 I/O),必须放到线程池跑,
|
||||
# 否则会阻塞 uvicorn 事件循环,导致并发分片上传被串行化、web 请求卡顿。
|
||||
body = await request.body()
|
||||
uploaded = await run_in_threadpool(service.write_chunk, upload_id, index, body)
|
||||
return ChunkUploadResponse(upload_id=upload_id, index=index, uploaded_chunks=uploaded)
|
||||
|
||||
|
||||
@@ -60,10 +64,11 @@ def complete_session(
|
||||
"""拼接分片 + 创建转写任务 + 入队管线。
|
||||
|
||||
controller 负责编排:service.complete 只管存储(拼接 + 建 Task),
|
||||
管线触发由 controller 调用,service 不依赖 pipeline(避免循环依赖)。
|
||||
管线触发由 controller 调用 scheduler(ffmpeg 异步 + GPU 串行调度),service 不依赖
|
||||
scheduler(避免循环依赖)。
|
||||
"""
|
||||
resp = service.complete(upload_id)
|
||||
# 仅新建任务时入队(幂等 complete 返回的也是同一 task_id,enqueue 幂等无副作用)
|
||||
from ..services.pipeline import enqueue_task
|
||||
from ..services.scheduler import enqueue_task
|
||||
enqueue_task(resp.task_id)
|
||||
return resp
|
||||
|
||||
@@ -51,12 +51,37 @@ class Base(DeclarativeBase):
|
||||
|
||||
|
||||
def init_db_schema() -> None:
|
||||
"""建表(幂等)。"""
|
||||
"""建表(幂等)+ 旧库迁移(给 task 表补新字段)。
|
||||
|
||||
SQLAlchemy 的 create_all 只建新表不改旧表。对已存在的 task 表,
|
||||
需手动 ALTER TABLE ADD COLUMN 补 wav_path / segments_json(nullable)。
|
||||
"""
|
||||
from .models.task import Task # noqa: F401
|
||||
from .models.upload_session import UploadSession # noqa: F401
|
||||
from .models.setting import Setting # noqa: F401
|
||||
|
||||
get_engine()
|
||||
Base.metadata.create_all(get_engine())
|
||||
engine = get_engine()
|
||||
Base.metadata.create_all(engine)
|
||||
_migrate_task_columns(engine)
|
||||
|
||||
|
||||
def _migrate_task_columns(engine) -> None:
|
||||
"""检测 task 表缺失的列并 ALTER TABLE 补上(nullable,向后兼容)。"""
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
insp = inspect(engine)
|
||||
if "task" not in insp.get_table_names():
|
||||
return # 新库,create_all 已建好完整表
|
||||
existing = {c["name"] for c in insp.get_columns("task")}
|
||||
# 新增字段:(列名, 列定义)
|
||||
additions = [
|
||||
("wav_path", "VARCHAR(1024)"),
|
||||
("segments_json", "TEXT"),
|
||||
]
|
||||
with engine.begin() as conn:
|
||||
for col, coltype in additions:
|
||||
if col not in existing:
|
||||
conn.execute(text(f"ALTER TABLE task ADD COLUMN {col} {coltype}"))
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
|
||||
54
app/main.py
54
app/main.py
@@ -19,21 +19,21 @@ import logging
|
||||
import threading
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi import FastAPI
|
||||
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .config import get_settings
|
||||
from .controllers import log_router, task_router, upload_router
|
||||
from .controllers import log_router, settings_router, task_router, upload_router
|
||||
from .database import get_db, init_db_schema
|
||||
from .security import require_docs_auth
|
||||
from .services.cache_cleaner import purge_expired_cache, run_forever as run_cache_cleaner
|
||||
from .services.log_buffer import init_log_buffer
|
||||
from .services.reaper import reap_stale_sessions
|
||||
from .views.history_html import render as render_history_html
|
||||
from .views.home_html import render as render_home_html
|
||||
from .views.logs_html import render as render_logs_html
|
||||
from .views.settings_html import render as render_settings_html
|
||||
|
||||
# 日志分层:
|
||||
# - audio2text logger 始终设 DEBUG,确保所有记录(含子步骤)都能产生。
|
||||
@@ -76,6 +76,12 @@ async def lifespan(app: FastAPI):
|
||||
await asyncio.to_thread(reap_stale_sessions)
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.warning("启动 reaper 失败:%s", exc)
|
||||
# 启动 GPU 调度线程(常驻,串行处理 ASR+翻译,模型复用)
|
||||
try:
|
||||
from .services.scheduler import start_scheduler
|
||||
start_scheduler()
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.warning("启动 GPU 调度线程失败:%s", exc)
|
||||
# 缓存清理:启动时跑一次 + 后台定时循环(守护线程,随进程退出)
|
||||
s = get_settings()
|
||||
try:
|
||||
@@ -120,20 +126,21 @@ def create_app() -> FastAPI:
|
||||
app.include_router(upload_router)
|
||||
app.include_router(task_router)
|
||||
app.include_router(log_router)
|
||||
app.include_router(settings_router)
|
||||
|
||||
# 受 Basic Auth 保护的文档接口
|
||||
# 文档接口(无认证,直接公开)
|
||||
@app.get("/openapi.json")
|
||||
def protected_openapi(_: str = Depends(require_docs_auth)) -> JSONResponse:
|
||||
def openapi_endpoint() -> JSONResponse:
|
||||
return JSONResponse(app.openapi())
|
||||
|
||||
@app.get("/docs")
|
||||
def protected_docs(_: str = Depends(require_docs_auth)):
|
||||
def docs_endpoint():
|
||||
return get_swagger_ui_html(
|
||||
openapi_url="/openapi.json", title="audio2text docs", swagger_favicon_url=""
|
||||
)
|
||||
|
||||
@app.get("/redoc")
|
||||
def protected_redoc(_: str = Depends(require_docs_auth)):
|
||||
def redoc_endpoint():
|
||||
return get_redoc_html(
|
||||
openapi_url="/openapi.json", title="audio2text docs", redoc_favicon_url=""
|
||||
)
|
||||
@@ -144,7 +151,34 @@ def create_app() -> FastAPI:
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
"""存活探针 + 设备信息。
|
||||
|
||||
返回 torch 版本、cuda 可用性、GPU 名称、配置的 device,
|
||||
便于一眼区分 CPU/GPU 容器是否正确调度到对应硬件。
|
||||
torch 导入失败时(理论上不会,因为镜像已装 torch)降级为仅 status。
|
||||
"""
|
||||
info: dict = {"status": "ok"}
|
||||
try:
|
||||
import torch
|
||||
info["torch"] = torch.__version__
|
||||
info["cuda_available"] = torch.cuda.is_available()
|
||||
if torch.cuda.is_available():
|
||||
info["gpu"] = torch.cuda.get_device_name(0)
|
||||
info["gpu_count"] = torch.cuda.device_count()
|
||||
except Exception as e: # pragma: no cover
|
||||
info["torch_error"] = str(e)
|
||||
s = get_settings()
|
||||
info["asr_device"] = s.asr.device
|
||||
info["asr_model"] = s.asr.model
|
||||
info["asr_compute_type"] = s.asr.compute_type
|
||||
info["asr_batch_size"] = s.asr.batch_size
|
||||
info["asr_beam_size"] = s.asr.beam_size
|
||||
info["asr_language"] = s.asr.language
|
||||
info["translation_device"] = s.translation.device
|
||||
info["translation_model"] = s.translation.model
|
||||
info["translation_batch_size"] = s.translation.batch_size
|
||||
info["translation_sort_by_length"] = s.translation.sort_by_length
|
||||
return info
|
||||
|
||||
@app.get("/history", response_class=HTMLResponse)
|
||||
def history_page() -> HTMLResponse:
|
||||
@@ -154,6 +188,10 @@ def create_app() -> FastAPI:
|
||||
def logs_page() -> HTMLResponse:
|
||||
return HTMLResponse(render_logs_html())
|
||||
|
||||
@app.get("/settings", response_class=HTMLResponse)
|
||||
def settings_page() -> HTMLResponse:
|
||||
return HTMLResponse(render_settings_html())
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
36
app/models/setting.py
Normal file
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})"
|
||||
@@ -14,6 +14,17 @@ def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# 任务状态枚举(单一真源,供 scheduler / cache_cleaner / views 引用)
|
||||
STATUS_UPLOADING = "uploading" # 虚拟状态:分片上传中(不在 Task 表,由 UploadSession 映射)
|
||||
STATUS_QUEUED = "queued"
|
||||
STATUS_EXTRACTING = "extracting"
|
||||
STATUS_TRANSCRIBING = "transcribing"
|
||||
STATUS_SEGMENTING = "segmenting"
|
||||
STATUS_TRANSLATING = "translating"
|
||||
STATUS_DONE = "done"
|
||||
STATUS_FAILED = "failed"
|
||||
|
||||
|
||||
class Task(Base):
|
||||
__tablename__ = "task"
|
||||
|
||||
@@ -23,7 +34,7 @@ class Task(Base):
|
||||
# 视频在 upload_dir 下的相对路径(提取音频前后可能被删)
|
||||
source_path: Mapped[str] = mapped_column(String(1024), nullable=False)
|
||||
# 状态机:queued → extracting → transcribing → segmenting → translating → done | failed
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="queued")
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default=STATUS_QUEUED, index=True)
|
||||
# 0-100 进度
|
||||
progress: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
|
||||
# 失败原因
|
||||
@@ -33,7 +44,13 @@ class Task(Base):
|
||||
en_srt_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
zh_srt_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now)
|
||||
# ---- 流水线阶段间传递的中间数据(scheduler 调度用)----
|
||||
# ffmpeg 提取出的 wav 绝对路径(extract 阶段写,asr 阶段读)
|
||||
wav_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
# ASR + 断句结果(asr 阶段写 JSON,translate 阶段读)。JSON 序列化的 list[Subtitle]
|
||||
segments_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now, index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=_now, onupdate=_now)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""分片上传会话 ORM:支撑断点续传。对齐 server 的 UploadSession 形态(SQLite 版)。"""
|
||||
"""分片上传会话 ORM:支撑断点续传。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
@@ -11,6 +12,8 @@ from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from ..database import Base
|
||||
|
||||
logger = logging.getLogger("audio2text.models")
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
@@ -26,7 +29,14 @@ class _IntList(TypeDecorator):
|
||||
return json.dumps(value) if value is not None else None
|
||||
|
||||
def process_result_value(self, value: Any, dialect) -> list[int]:
|
||||
return json.loads(value) if value else []
|
||||
if not value:
|
||||
return []
|
||||
try:
|
||||
return json.loads(value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
# DB 脏数据(手工修改/并发写截断)不应让整行读取失败
|
||||
logger.warning("uploaded_chunks JSON 解析失败,回退空列表:%r", value[:80])
|
||||
return []
|
||||
|
||||
|
||||
class UploadSession(Base):
|
||||
@@ -39,11 +49,13 @@ class UploadSession(Base):
|
||||
total_chunks: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
uploaded_chunks: Mapped[list[int]] = mapped_column(_IntList, default=list)
|
||||
# pending → completed(complete 成功)| abandoned(reaper 清理)
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending")
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending", index=True)
|
||||
# 拼接完成后的视频相对 upload_dir 路径
|
||||
final_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
# complete 后关联的 Task.id(直接引用,避免反向查找 source_path)
|
||||
task_id: Mapped[int | None] = mapped_column(ForeignKey("task.id"), nullable=True)
|
||||
# complete 后关联的 Task.id。ondelete=SET NULL:Task 被删时 session 保留(task_id 置空)
|
||||
task_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("task.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now, index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=_now, onupdate=_now)
|
||||
|
||||
@@ -62,6 +62,11 @@ class TaskResponse(BaseModel):
|
||||
error: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
# 上传会话虚拟任务用:is_upload=true 时 id 无意义(固定 0),
|
||||
# 前端用 upload_id 轮询 /api/tasks/chunk-uploads/{upload_id}/status
|
||||
size_bytes: int | None = None
|
||||
is_upload: bool = False
|
||||
upload_id: str | None = None
|
||||
|
||||
|
||||
class TaskListResponse(BaseModel):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""/docs Basic Auth:对齐 server/security.py。明文密码,常量时间比较。"""
|
||||
"""/docs Basic Auth:明文密码,常量时间比较。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ CPU dev: tiny.en + int8;GPU prod: large-v3-turbo + float16。同一份代码
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from ..config import get_settings
|
||||
from .model_manager import get_model_manager
|
||||
@@ -14,12 +16,20 @@ from .types import Segment, Word
|
||||
|
||||
logger = logging.getLogger("audio2text.asr")
|
||||
|
||||
# Whisper 默认 chunk_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(含词级时间戳)。
|
||||
|
||||
Args:
|
||||
wav_path: 16kHz mono PCM wav
|
||||
on_progress: 可选进度回调 (current_chunk, total_chunks)。
|
||||
每 transcribe 完一个 30s chunk 调一次,用于细分进度条。
|
||||
|
||||
Returns:
|
||||
list[Segment],每个 Segment 带词级 words(若 word_timestamps 启用)。
|
||||
@@ -29,21 +39,31 @@ def transcribe(wav_path: Path) -> list[Segment]:
|
||||
raise FileNotFoundError(f"音频不存在:{wav_path}")
|
||||
|
||||
model = get_model_manager().get_asr()
|
||||
logger.debug("开始转写 %s(model=%s language=%s)", wav_path.name, s.model, s.language)
|
||||
logger.debug("开始转写 %s(model=%s language=%s batch_size=%d beam_size=%d)",
|
||||
wav_path.name, s.model, s.language, s.batch_size, s.beam_size)
|
||||
|
||||
segments_gen, info = model.transcribe(
|
||||
str(wav_path),
|
||||
language=s.language,
|
||||
word_timestamps=s.word_timestamps,
|
||||
vad_filter=s.vad_filter,
|
||||
beam_size=5,
|
||||
)
|
||||
logger.debug(
|
||||
"音频时长 %.1fs,检测语言=%s(置信度 %.2f)",
|
||||
info.duration, info.language, info.language_probability,
|
||||
beam_size=s.beam_size, # beam search 宽度(config 可调);GPU turbo 建议降到 2 加速
|
||||
batch_size=s.batch_size, # 批量解码:多音频块一次性送 GPU
|
||||
without_timestamps=False, # BatchedInferencePipeline 默认 True,需显式关闭以生成段级时间戳
|
||||
)
|
||||
|
||||
# VAD 过滤后的实际语音时长 → 算总 chunk 数(进度颗粒度细分用)
|
||||
duration = info.duration_after_vad or info.duration
|
||||
total_chunks = max(1, math.ceil(duration / _CHUNK_SECONDS))
|
||||
logger.debug(
|
||||
"音频时长 %.1fs(VAD 后 %.1fs),检测语言=%s(置信度 %.2f),约 %d 个 chunk",
|
||||
info.duration, duration, info.language, info.language_probability, total_chunks,
|
||||
)
|
||||
if on_progress is not None:
|
||||
on_progress(0, total_chunks)
|
||||
|
||||
segments: list[Segment] = []
|
||||
last_chunk = 0 # 已报进度的 chunk 序号(避免同 chunk 内多个 segment 重复回调)
|
||||
for seg in segments_gen:
|
||||
words: list[Word] = []
|
||||
if s.word_timestamps and getattr(seg, "words", None):
|
||||
@@ -60,6 +80,15 @@ def transcribe(wav_path: Path) -> list[Segment]:
|
||||
end=float(seg.end),
|
||||
words=words,
|
||||
))
|
||||
|
||||
# 按 30s chunk 边界报进度:seg.end 跨过 chunk 边界时回调
|
||||
if on_progress is not None:
|
||||
cur_chunk = min(total_chunks, int(seg.end / _CHUNK_SECONDS) + 1)
|
||||
if cur_chunk > last_chunk:
|
||||
last_chunk = cur_chunk
|
||||
on_progress(cur_chunk, total_chunks)
|
||||
|
||||
logger.debug("转写完成:%d 段,%d 词。",
|
||||
len(segments), sum(len(s.words) for s in segments))
|
||||
len(segments), sum(len(seg.words) for seg in segments))
|
||||
return segments
|
||||
|
||||
|
||||
@@ -54,15 +54,19 @@ class ModelManager:
|
||||
if self._translator is not None:
|
||||
self._unload_translator_locked()
|
||||
s = get_settings().asr
|
||||
logger.debug("加载 ASR 模型 model=%s device=%s compute_type=%s",
|
||||
logger.info("加载 ASR 模型 model=%s device=%s compute_type=%s",
|
||||
s.model, s.device, s.compute_type)
|
||||
from faster_whisper import WhisperModel
|
||||
from faster_whisper import WhisperModel, BatchedInferencePipeline
|
||||
# device/compute_type 组合:cpu+int8 / cuda+float16
|
||||
self._asr = WhisperModel(
|
||||
# BatchedInferencePipeline 包装 WhisperModel,使 transcribe() 支持 batch_size,
|
||||
# 多个音频块(chunk_length=30s)一次性送 GPU 解码,配合内部 prefill 提高利用率。
|
||||
whisper = WhisperModel(
|
||||
s.model, device=s.device, compute_type=s.compute_type,
|
||||
)
|
||||
self._asr = BatchedInferencePipeline(model=whisper)
|
||||
self._current = "asr"
|
||||
logger.debug("ASR 模型已就绪。")
|
||||
logger.info("ASR 模型已就绪(batched, batch_size=%d, beam_size=%d)。",
|
||||
s.batch_size, s.beam_size)
|
||||
return self._asr
|
||||
|
||||
def unload_asr(self) -> None:
|
||||
@@ -72,7 +76,7 @@ class ModelManager:
|
||||
def _unload_asr_locked(self) -> None:
|
||||
if self._asr is None:
|
||||
return
|
||||
logger.debug("卸载 ASR 模型(释放显存供翻译器独占)。")
|
||||
logger.info("卸载 ASR 模型(释放显存供翻译器独占)。")
|
||||
# faster-whisper 模型无显式 close,del 即可
|
||||
del self._asr
|
||||
self._asr = None
|
||||
@@ -89,7 +93,7 @@ class ModelManager:
|
||||
if self._asr is not None:
|
||||
self._unload_asr_locked()
|
||||
s = get_settings().translation
|
||||
logger.debug("加载翻译模型 model=%s device=%s", s.model, s.device)
|
||||
logger.info("加载翻译模型 model=%s device=%s", s.model, s.device)
|
||||
from transformers import pipeline
|
||||
self._translator = pipeline(
|
||||
"translation",
|
||||
@@ -97,9 +101,11 @@ class ModelManager:
|
||||
device=s.device,
|
||||
src_lang=s.src_lang,
|
||||
tgt_lang=s.tgt_lang,
|
||||
batch_size=s.batch_size, # pipeline 内部批大小,与 translate_service 分块对齐
|
||||
)
|
||||
self._current = "translator"
|
||||
logger.debug("翻译模型已就绪(独占显存,可用大 batch)。")
|
||||
logger.info("翻译模型已就绪(显存独占,batch_size=%d,sort_by_length=%s)。",
|
||||
s.batch_size, s.sort_by_length)
|
||||
return self._translator
|
||||
|
||||
def unload_translator(self) -> None:
|
||||
@@ -109,7 +115,7 @@ class ModelManager:
|
||||
def _unload_translator_locked(self) -> None:
|
||||
if self._translator is None:
|
||||
return
|
||||
logger.debug("卸载翻译模型。")
|
||||
logger.info("卸载翻译模型。")
|
||||
# 释放 pipeline 持有的 model + tokenizer
|
||||
mdl = getattr(self._translator, "model", None)
|
||||
tok = getattr(self._translator, "tokenizer", None)
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
"""转写管线编排:提取音频 → ASR → 断句 → 翻译 → 写 SRT。
|
||||
"""转写管线各阶段:提取音频 → ASR → 断句 → 翻译 → 写 SRT。
|
||||
|
||||
任务状态机:
|
||||
queued → extracting → transcribing → segmenting → translating → done
|
||||
任一步失败 → failed
|
||||
阶段拆分供 scheduler 调度:ffmpeg 阶段独立线程(CPU),GPU 阶段(ASR+翻译)
|
||||
由 scheduler 串行化并在切换模型前查队列复用已加载模型。
|
||||
|
||||
模型不共驻:ASR 与翻译分阶段加载,翻译时先卸载 Whisper 释放显存跑大 batch。
|
||||
管线在后台线程跑(每个任务一个线程),通过 DB 更新状态与进度。
|
||||
每个阶段函数接收 db Session + Task,更新状态/进度,写入中间产物:
|
||||
extract_phase: queued → extracting → (写 wav_path, status 置 transcribing)
|
||||
asr_phase: transcribing → segmenting → (写 segments_json, status 置 translating)
|
||||
translate_phase: translating → done (写 SRT)
|
||||
|
||||
阶段间传递的中间数据存在 Task.wav_path / Task.segments_json,避免跨线程传对象。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import traceback
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from ..config import get_settings
|
||||
from ..database import get_session_local
|
||||
from ..models.task import Task
|
||||
from . import ffmpeg_service, asr_service, segmenter, translate_service, srt_writer
|
||||
from ..models.task import (
|
||||
STATUS_EXTRACTING, STATUS_TRANSCRIBING, STATUS_SEGMENTING,
|
||||
STATUS_TRANSLATING, STATUS_DONE, STATUS_FAILED,
|
||||
)
|
||||
from . import asr_service, ffmpeg_service, segmenter, srt_writer, translate_service
|
||||
from .types import Subtitle
|
||||
|
||||
logger = logging.getLogger("audio2text.pipeline")
|
||||
@@ -35,35 +41,17 @@ P_TRANSLATE_END = 98.0
|
||||
P_DONE = 100.0
|
||||
|
||||
|
||||
def enqueue_task(task_id: int) -> None:
|
||||
"""把任务交给后台线程处理(非阻塞,供 upload_service.complete 调用)。"""
|
||||
t = threading.Thread(target=_run_task, args=(task_id,), daemon=True)
|
||||
t.start()
|
||||
logger.info("任务 %d 已入队(后台线程 %s)。", task_id, t.name)
|
||||
# ---------------- 阶段 1:提取音频(CPU,可并行)----------------
|
||||
|
||||
def extract_phase(db, task) -> None:
|
||||
"""ffmpeg 提取 16k mono wav,写 task.wav_path,状态置 transcribing。
|
||||
|
||||
def _run_task(task_id: int) -> None:
|
||||
"""后台执行完整管线。所有异常都被捕获并写入 task.error。"""
|
||||
db = get_session_local()()
|
||||
try:
|
||||
task = db.get(Task, task_id)
|
||||
if task is None:
|
||||
logger.error("任务 %d 不存在。", task_id)
|
||||
return
|
||||
_pipeline(db, task)
|
||||
except Exception as exc:
|
||||
logger.exception("任务 %d 失败:%s", task_id, exc)
|
||||
_mark_failed(db, task_id, str(exc))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _pipeline(db, task: Task) -> None:
|
||||
由 scheduler 在独立线程调用(与 GPU 阶段并行)。提取完即可让 GPU 调度线程接管。
|
||||
"""
|
||||
s = get_settings()
|
||||
src = s.upload_dir() / task.source_path
|
||||
|
||||
# ---------- 1. 提取音频 ----------
|
||||
_set_status(db, task, "extracting", P_EXTRACT)
|
||||
logger.info("任务 %d [音频提取开始] %s", task.id, src.name)
|
||||
_set_status(db, task, STATUS_EXTRACTING, P_EXTRACT)
|
||||
wav = s.work_dir() / f"task_{task.id}.wav"
|
||||
ffmpeg_service.extract_audio(src, wav)
|
||||
|
||||
@@ -75,61 +63,125 @@ def _pipeline(db, task: Task) -> None:
|
||||
except OSError as exc:
|
||||
logger.warning("删除原始视频失败 %s: %s", src, exc)
|
||||
|
||||
# ---------- 2. 语音识别 ----------
|
||||
_set_status(db, task, "transcribing", P_TRANSCRIBE_START)
|
||||
segments = asr_service.transcribe(wav)
|
||||
_set_status(db, task, "transcribing", P_TRANSCRIBE_END,
|
||||
# 记录 wav 路径,状态置 transcribing(待 GPU 调度线程接管 ASR)
|
||||
task.wav_path = str(wav)
|
||||
task.status = STATUS_TRANSCRIBING
|
||||
task.progress = P_TRANSCRIBE_START
|
||||
task.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
logger.info("任务 %d [音频提取完成] → 待 ASR:%s", task.id, wav.name)
|
||||
|
||||
|
||||
# ---------------- 阶段 2:ASR + 断句(GPU)----------------
|
||||
|
||||
def asr_phase(db, task) -> None:
|
||||
"""加载 Whisper 转写 + 断句,写 task.segments_json,状态置 translating。
|
||||
|
||||
由 GPU 调度线程调用。model_manager 保证 ASR 与翻译器不共驻。
|
||||
"""
|
||||
wav_path = Path(task.wav_path) if task.wav_path else None
|
||||
if wav_path is None or not wav_path.is_file():
|
||||
raise FileNotFoundError(f"音频不存在:{wav_path}(task {task.id})")
|
||||
|
||||
logger.info("任务 %d [ASR 开始] %s", task.id, wav_path.name)
|
||||
_set_status(db, task, STATUS_TRANSCRIBING, P_TRANSCRIBE_START)
|
||||
|
||||
# 进度回调:按 30s chunk 细分 ASR 进度(5%→55% 区间)
|
||||
# current=已处理 chunk 数, total=总 chunk 数
|
||||
def on_asr_progress(current: int, total: int) -> None:
|
||||
frac = current / total if total else 0.0
|
||||
progress = P_TRANSCRIBE_START + (P_TRANSCRIBE_END - P_TRANSCRIBE_START) * frac
|
||||
_set_status(db, task, STATUS_TRANSCRIBING, progress)
|
||||
|
||||
segments = asr_service.transcribe(wav_path, on_progress=on_asr_progress)
|
||||
_set_status(db, task, STATUS_TRANSCRIBING, P_TRANSCRIBE_END,
|
||||
note=f"识别出 {len(segments)} 段")
|
||||
|
||||
# ---------- 3. 断句 + 时间戳重算 ----------
|
||||
_set_status(db, task, "segmenting", P_SEGMENT_START)
|
||||
_set_status(db, task, STATUS_SEGMENTING, P_SEGMENT_START)
|
||||
subs = segmenter.resegment(segments)
|
||||
_set_status(db, task, "segmenting", P_SEGMENT_END,
|
||||
_set_status(db, task, STATUS_SEGMENTING, P_SEGMENT_END,
|
||||
note=f"重组为 {len(subs)} 条字幕")
|
||||
|
||||
# ---------- 4. 翻译 ----------
|
||||
_set_status(db, task, "translating", P_TRANSLATE_START)
|
||||
# 翻译阶段:model_manager 会自动卸载 ASR、加载翻译器(独占显存)
|
||||
zh_texts = translate_service.translate(subs)
|
||||
_set_status(db, task, "translating", P_TRANSLATE_END,
|
||||
# 序列化断句结果供翻译阶段用(dataclass → JSON)
|
||||
task.segments_json = json.dumps([asdict(s) for s in subs], ensure_ascii=False)
|
||||
task.status = STATUS_TRANSLATING
|
||||
task.progress = P_TRANSLATE_START
|
||||
task.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
logger.info("任务 %d [ASR 完成] → 待翻译:%d 条字幕", task.id, len(subs))
|
||||
|
||||
|
||||
# ---------------- 阶段 3:翻译 + 写 SRT(GPU)----------------
|
||||
|
||||
def translate_phase(db, task) -> None:
|
||||
"""加载 NLLB 翻译 + 写 SRT,状态置 done。由 GPU 调度线程调用。"""
|
||||
if not task.segments_json:
|
||||
raise ValueError(f"任务 {task.id} 无 segments_json,无法翻译")
|
||||
|
||||
logger.info("任务 %d [翻译开始] %d 条字幕", task.id, len(json.loads(task.segments_json)))
|
||||
_set_status(db, task, STATUS_TRANSLATING, P_TRANSLATE_START)
|
||||
subs = [_dict_to_subtitle(d) for d in json.loads(task.segments_json)]
|
||||
|
||||
# 进度回调:按批次细分翻译进度(60%→98% 区间)
|
||||
# done=已翻译条数, total=总条数
|
||||
def on_translate_progress(done: int, total: int) -> None:
|
||||
frac = done / total if total else 0.0
|
||||
progress = P_TRANSLATE_START + (P_TRANSLATE_END - P_TRANSLATE_START) * frac
|
||||
_set_status(db, task, STATUS_TRANSLATING, progress)
|
||||
|
||||
zh_texts = translate_service.translate(subs, on_progress=on_translate_progress)
|
||||
_set_status(db, task, STATUS_TRANSLATING, P_TRANSLATE_END,
|
||||
note=f"翻译 {len(zh_texts)} 条")
|
||||
|
||||
# ---------- 5. 写 SRT ----------
|
||||
out_dir = s.output_dir()
|
||||
# 写 SRT
|
||||
out_dir = s_output_dir()
|
||||
stem = Path(task.filename).stem
|
||||
en_path = out_dir / f"task_{task.id}/{stem}.en.srt"
|
||||
zh_path = out_dir / f"task_{task.id}/{stem}.zh.srt"
|
||||
bi_path = out_dir / f"task_{task.id}/{stem}.srt"
|
||||
|
||||
srt_writer.write_srt(subs, en_path)
|
||||
# 中文 SRT(用译文 + 同时间戳)
|
||||
zh_subs = [Subtitle(text=zh, start=sub.start, end=sub.end)
|
||||
for zh, sub in zip(zh_texts, subs)]
|
||||
srt_writer.write_srt(zh_subs, zh_path)
|
||||
srt_writer.write_bilingual_srt(subs, zh_texts, bi_path)
|
||||
|
||||
# 记录相对路径
|
||||
task.en_srt_path = str(en_path.relative_to(out_dir))
|
||||
task.zh_srt_path = str(zh_path.relative_to(out_dir))
|
||||
task.bilingual_srt_path = str(bi_path.relative_to(out_dir))
|
||||
task.status = "done"
|
||||
task.status = STATUS_DONE
|
||||
task.progress = P_DONE
|
||||
task.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
# 清理中间音频
|
||||
if not s.processing.keep_audio and wav.is_file():
|
||||
s = get_settings()
|
||||
if not s.processing.keep_audio and task.wav_path:
|
||||
try:
|
||||
wav.unlink()
|
||||
Path(task.wav_path).unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
logger.info("任务 %d 完成:%s", task.id, bi_path.name)
|
||||
logger.info("任务 %d [完成] %s", task.id, bi_path.name)
|
||||
|
||||
|
||||
# ---------------- DB 状态更新 ----------------
|
||||
# ---------------- 工具函数 ----------------
|
||||
|
||||
def _set_status(db, task: Task, status: str, progress: float, note: str = "") -> None:
|
||||
def s_output_dir() -> Path:
|
||||
return get_settings().output_dir()
|
||||
|
||||
|
||||
def _dict_to_subtitle(d: dict) -> Subtitle:
|
||||
return Subtitle(text=d["text"], start=d["start"], end=d["end"])
|
||||
|
||||
|
||||
def _set_status(db, task, status: str, progress: float, note: str = "") -> None:
|
||||
"""更新任务状态/进度并落库。
|
||||
|
||||
日志级别策略:
|
||||
- 带 note 的(如“识别出 N 段”)是阶段内里程碑 → INFO
|
||||
- 仅进度百分比更新(同状态同阶段)→ DEBUG,避免 INFO 被进度刷屏
|
||||
"""
|
||||
task.status = status
|
||||
task.progress = progress
|
||||
task.updated_at = datetime.now(timezone.utc)
|
||||
@@ -137,15 +189,17 @@ def _set_status(db, task: Task, status: str, progress: float, note: str = "") ->
|
||||
if note:
|
||||
logger.info("任务 %d [%s %.0f%%] %s", task.id, status, progress, note)
|
||||
else:
|
||||
logger.info("任务 %d [%s %.0f%%]", task.id, status, progress)
|
||||
logger.debug("任务 %d [%s %.0f%%]", task.id, status, progress)
|
||||
|
||||
|
||||
def _mark_failed(db, task_id: int, error: str) -> None:
|
||||
def mark_failed(db, task_id: int, error: str) -> None:
|
||||
"""标记任务失败(对外公开,供 scheduler 调用)。"""
|
||||
from ..models.task import Task
|
||||
try:
|
||||
task = db.get(Task, task_id)
|
||||
if task is None:
|
||||
return
|
||||
task.status = "failed"
|
||||
task.status = STATUS_FAILED
|
||||
task.error = error[:2000]
|
||||
task.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
254
app/services/scheduler.py
Normal file
254
app/services/scheduler.py
Normal file
@@ -0,0 +1,254 @@
|
||||
"""任务调度器:ffmpeg 串行提取 + GPU 阶段串行 + 模型复用。
|
||||
|
||||
设计动机:多任务时不应串行等一个任务全跑完才下一个。ffmpeg 是纯 CPU,可与 GPU 阶段
|
||||
并行;GPU 阶段(ASR + 翻译)串行化(共享显存),但卸载模型前查队列,有同类待处理
|
||||
任务就继续用当前模型,减少重复加载/卸载。
|
||||
|
||||
数据流:
|
||||
enqueue_task ──► ffmpeg 队列(串行,最多 1 个同时跑)
|
||||
│ 提取音频 → task.wav_path → status=transcribing
|
||||
▼(唤醒 GPU 线程)
|
||||
GPU 调度线程(单线程,常驻)
|
||||
① 取 status=transcribing 的任务,get_asr()
|
||||
while 还有 transcribing 任务: asr_phase → status=translating
|
||||
(ASR 队列空,切翻译)
|
||||
② 取 status=translating 的任务,get_translator()
|
||||
while 还有 translating 任务: translate_phase → status=done
|
||||
(翻译队列空,回到 ① 等待)
|
||||
|
||||
并发约束:
|
||||
- 文件上传:无限制(web 请求 + 线程池接收,不阻塞 ffmpeg/GPU)
|
||||
- ffmpeg 提取:最多 1 个同时运行(串行队列),避免 CPU/磁盘 IO 争抢
|
||||
- GPU 阶段:串行(单 GPU 调度线程),模型不共驻
|
||||
|
||||
模型复用:N 个任务的模型切换次数从 2N 降到最优 2 次(一批 ASR 全做完 → 切翻译 → 一批翻译全做完)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..database import get_session_local
|
||||
from ..models.task import (
|
||||
Task, STATUS_EXTRACTING, STATUS_TRANSCRIBING, STATUS_SEGMENTING,
|
||||
STATUS_TRANSLATING, STATUS_QUEUED, STATUS_FAILED,
|
||||
)
|
||||
from . import pipeline
|
||||
from .model_manager import get_model_manager
|
||||
|
||||
logger = logging.getLogger("audio2text.scheduler")
|
||||
|
||||
# 唤醒 GPU 调度线程的事件(新任务入队或 ffmpeg 完成时 set)
|
||||
_wake_event = threading.Event()
|
||||
# ffmpeg 任务队列(串行执行,最多 1 个同时跑)
|
||||
_ffmpeg_queue: queue.Queue[int | None] = queue.Queue()
|
||||
# GPU 调度线程单例
|
||||
_scheduler_thread: threading.Thread | None = None
|
||||
_ffmpeg_thread: threading.Thread | None = None
|
||||
_scheduler_started = False
|
||||
|
||||
|
||||
def enqueue_task(task_id: int) -> None:
|
||||
"""任务入队:放入 ffmpeg 队列(串行提取) + 唤醒 GPU 调度线程。
|
||||
|
||||
ffmpeg 最多 1 个同时运行(CPU/磁盘 IO 限制),其余排队。
|
||||
上传接收不受限——complete 创建 Task 后立即返回,不等待 ffmpeg。
|
||||
"""
|
||||
_ensure_scheduler_running()
|
||||
_ffmpeg_queue.put(task_id)
|
||||
logger.info("任务 %d 已入队,等待音频提取。", task_id)
|
||||
|
||||
|
||||
def start_scheduler() -> None:
|
||||
"""启动 ffmpeg + GPU 调度线程(应用启动时调一次,幂等)。"""
|
||||
global _scheduler_thread, _ffmpeg_thread, _scheduler_started
|
||||
if _scheduler_started:
|
||||
return
|
||||
_scheduler_started = True
|
||||
_reset_stuck_tasks()
|
||||
_ffmpeg_thread = threading.Thread(
|
||||
target=_ffmpeg_worker, name="ffmpeg-worker", daemon=True,
|
||||
)
|
||||
_ffmpeg_thread.start()
|
||||
_scheduler_thread = threading.Thread(
|
||||
target=_gpu_scheduler, name="gpu-scheduler", daemon=True,
|
||||
)
|
||||
_scheduler_thread.start()
|
||||
logger.info("ffmpeg + GPU 调度线程已启动。")
|
||||
|
||||
|
||||
def _reset_stuck_tasks() -> None:
|
||||
"""启动时清理卡在中间状态的任务(进程上次崩溃残留)。
|
||||
|
||||
transcribing 但无 wav_path、translating 但无 segments_json 的任务,
|
||||
是上次进程异常退出留下的孤儿。标记为 failed 避免调度线程反复尝试。
|
||||
"""
|
||||
db = get_session_local()()
|
||||
try:
|
||||
stuck = (
|
||||
db.query(Task)
|
||||
.filter(Task.status.in_([
|
||||
STATUS_EXTRACTING, STATUS_TRANSCRIBING, STATUS_SEGMENTING, STATUS_TRANSLATING,
|
||||
]))
|
||||
.all()
|
||||
)
|
||||
n = 0
|
||||
for task in stuck:
|
||||
reason = ""
|
||||
if task.status in (STATUS_TRANSCRIBING, STATUS_SEGMENTING) and not task.wav_path:
|
||||
reason = f"重启时发现 {task.status} 状态但无 wav_path"
|
||||
elif task.status == STATUS_TRANSLATING and not task.segments_json:
|
||||
reason = f"重启时发现 translating 状态但无 segments_json"
|
||||
elif task.status == STATUS_EXTRACTING:
|
||||
reason = "重启时发现 extracting 状态(ffmpeg 未完成)"
|
||||
if reason:
|
||||
task.status = STATUS_FAILED
|
||||
task.error = reason[:2000]
|
||||
task.updated_at = datetime.now(timezone.utc)
|
||||
n += 1
|
||||
logger.warning("清理卡住的任务 %d:%s", task.id, reason)
|
||||
if n:
|
||||
db.commit()
|
||||
logger.info("共清理 %d 个卡住的任务。", n)
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.error("清理卡住任务时出错:%s", exc)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _ensure_scheduler_running() -> None:
|
||||
"""确保 GPU 调度线程在跑(enqueue 时调,防止 lifespan 未启动的边界情况)。"""
|
||||
if not _scheduler_started:
|
||||
start_scheduler()
|
||||
|
||||
|
||||
# ---------------- ffmpeg 串行提取 ----------------
|
||||
|
||||
def _ffmpeg_worker() -> None:
|
||||
"""常驻 ffmpeg 工作线程:从队列取任务,串行提取音频(最多 1 个同时跑)。
|
||||
|
||||
队列收到 None 为停机信号(当前不使用,保留用于优雅关闭)。
|
||||
"""
|
||||
logger.info("ffmpeg 工作线程开始运行。")
|
||||
while True:
|
||||
task_id = _ffmpeg_queue.get()
|
||||
if task_id is None:
|
||||
break # 停机信号
|
||||
db = get_session_local()()
|
||||
try:
|
||||
task = db.get(Task, task_id)
|
||||
if task is None:
|
||||
logger.error("任务 %d 不存在,ffmpeg 跳过。", task_id)
|
||||
continue
|
||||
pipeline.extract_phase(db, task)
|
||||
_wake_event.set() # 通知 GPU 线程有新任务
|
||||
except Exception as exc:
|
||||
logger.exception("任务 %d ffmpeg 提取失败:%s", task_id, exc)
|
||||
pipeline.mark_failed(db, task_id, str(exc))
|
||||
finally:
|
||||
db.close()
|
||||
_ffmpeg_queue.task_done()
|
||||
|
||||
|
||||
# ---------------- GPU 调度线程 ----------------
|
||||
|
||||
def _gpu_scheduler() -> None:
|
||||
"""常驻 GPU 调度线程:串行处理 ASR + 翻译,切换模型前查队列复用。
|
||||
|
||||
循环逻辑:
|
||||
1. 处理所有待 ASR 任务(Whisper 只加载一次)
|
||||
2. 处理所有待翻译任务(NLLB 只加载一次)
|
||||
3. 都空了 → 等待唤醒
|
||||
每个阶段失败的任务标记 failed,不影响其他任务。
|
||||
"""
|
||||
logger.info("GPU 调度线程开始运行。")
|
||||
while True:
|
||||
try:
|
||||
# 优先处理 ASR 队列:把所有待 ASR 的任务一次性做完(模型复用)
|
||||
asr_count = _drain_asr_queue()
|
||||
# 再处理翻译队列:把所有待翻译的任务一次性做完(模型复用)
|
||||
trans_count = _drain_translate_queue()
|
||||
|
||||
if asr_count == 0 and trans_count == 0:
|
||||
# 两队列都空,等待新任务唤醒
|
||||
_wake_event.wait(timeout=60)
|
||||
_wake_event.clear()
|
||||
except Exception as exc: # pragma: no cover
|
||||
# 调度线程不能死,任何异常都捕获后继续
|
||||
logger.exception("GPU 调度线程异常(已恢复):%s", exc)
|
||||
|
||||
|
||||
def _drain_asr_queue() -> int:
|
||||
"""连续处理所有 status=transcribing 的任务,Whisper 只加载一次。
|
||||
|
||||
Returns: 本轮处理的任务数
|
||||
"""
|
||||
n = 0
|
||||
mm = get_model_manager()
|
||||
while True:
|
||||
db = get_session_local()()
|
||||
task = None # 预定义,防 query 抛异常时 except 引用未定义变量
|
||||
try:
|
||||
# 取最早一个待 ASR 的任务(按 id 升序,FIFO)
|
||||
task = (
|
||||
db.query(Task)
|
||||
.filter(Task.status == STATUS_TRANSCRIBING)
|
||||
.order_by(Task.id.asc())
|
||||
.first()
|
||||
)
|
||||
if task is None:
|
||||
break # ASR 队列空
|
||||
# 加载 ASR 模型(若翻译器在内存,model_manager 自动卸载它,并记 INFO)
|
||||
mm.get_asr()
|
||||
# 执行 ASR + 断句
|
||||
pipeline.asr_phase(db, task)
|
||||
n += 1
|
||||
except Exception as exc:
|
||||
tid = task.id if task is not None else -1
|
||||
logger.exception("任务 %d ASR 阶段失败:%s", tid, exc)
|
||||
if task is not None:
|
||||
pipeline.mark_failed(db, task.id, str(exc))
|
||||
finally:
|
||||
db.close()
|
||||
if n > 0:
|
||||
logger.info("ASR 批次完成:处理 %d 个任务。", n)
|
||||
return n
|
||||
|
||||
|
||||
def _drain_translate_queue() -> int:
|
||||
"""连续处理所有 status=translating 的任务,NLLB 只加载一次。
|
||||
|
||||
Returns: 本轮处理的任务数
|
||||
"""
|
||||
n = 0
|
||||
mm = get_model_manager()
|
||||
while True:
|
||||
db = get_session_local()()
|
||||
task = None # 预定义,防 query 抛异常时 except 引用未定义变量
|
||||
try:
|
||||
task = (
|
||||
db.query(Task)
|
||||
.filter(Task.status == STATUS_TRANSLATING)
|
||||
.order_by(Task.id.asc())
|
||||
.first()
|
||||
)
|
||||
if task is None:
|
||||
break # 翻译队列空
|
||||
# 加载翻译模型(若 ASR 在内存,model_manager 自动卸载它,并记 INFO)
|
||||
mm.get_translator()
|
||||
# 执行翻译 + 写 SRT
|
||||
pipeline.translate_phase(db, task)
|
||||
n += 1
|
||||
except Exception as exc:
|
||||
tid = task.id if task is not None else -1
|
||||
logger.exception("任务 %d 翻译阶段失败:%s", tid, exc)
|
||||
if task is not None:
|
||||
pipeline.mark_failed(db, task.id, str(exc))
|
||||
finally:
|
||||
db.close()
|
||||
if n > 0:
|
||||
logger.info("翻译批次完成:处理 %d 个任务。", n)
|
||||
return n
|
||||
@@ -1,12 +1,20 @@
|
||||
"""翻译服务:NLLB-200,英译中。
|
||||
|
||||
通过 model_manager 加载,确保 ASR 已卸载、翻译器独占显存,从而可用大 batch_size。
|
||||
按字幕条目批量翻译,保留索引对应。
|
||||
|
||||
GPU 优化:按长度排序后分批翻译。
|
||||
- 同一批内句子长度相近 → padding 浪费最小化 → GPU 有效计算占比提升
|
||||
- 翻译完按原始下标散回,保证 zh_texts[i] 对应 subs[i](时间戳对齐不变)
|
||||
- 批切分用 token 预算 + 条数上限双重约束:短句自动攒大批,长句自动拆小批
|
||||
|
||||
单条翻译失败时该位置回退为原英文。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Callable
|
||||
|
||||
from ..config import get_settings
|
||||
from .model_manager import get_model_manager
|
||||
@@ -14,12 +22,19 @@ from .types import Subtitle
|
||||
|
||||
logger = logging.getLogger("audio2text.translate")
|
||||
|
||||
# 估算每条字幕的 token 数:英文约 1 token/词,留 20% 余量覆盖标点/子词拆分
|
||||
_TOKENS_PER_WORD = 1.2
|
||||
|
||||
def translate(subtitles: list[Subtitle]) -> list[str]:
|
||||
|
||||
def translate(
|
||||
subtitles: list[Subtitle],
|
||||
on_progress: Callable[[int, int], None] | None = None,
|
||||
) -> list[str]:
|
||||
"""批量翻译英文字幕为中文。
|
||||
|
||||
Args:
|
||||
subtitles: 断句后的英文字幕条目
|
||||
subtitles: 断句后的英文字幕条目(按时间顺序)
|
||||
on_progress: 可选进度回调 (done_count, total_count),每批完成时调一次。
|
||||
|
||||
Returns:
|
||||
list[str],与 subtitles 等长、顺序对应的中文译文。
|
||||
@@ -30,31 +45,148 @@ def translate(subtitles: list[Subtitle]) -> list[str]:
|
||||
|
||||
s = get_settings().translation
|
||||
pipe = get_model_manager().get_translator()
|
||||
batch = s.batch_size
|
||||
batch_size = s.batch_size
|
||||
max_len = s.max_length
|
||||
|
||||
# 取纯文本(去掉折行),避免翻译把换行符当语义
|
||||
texts = [sub.text.replace("\n", " ").strip() for sub in subtitles]
|
||||
logger.debug("开始翻译 %d 条字幕(batch_size=%d)...", len(texts), batch)
|
||||
sort_by_length = s.sort_by_length
|
||||
|
||||
results: list[str] = []
|
||||
for i in range(0, len(texts), batch):
|
||||
chunk = texts[i:i + batch]
|
||||
try:
|
||||
out = pipe(chunk, max_length=max_len)
|
||||
for item in out:
|
||||
# pipeline 返回 [{"translation_text": "..."}]
|
||||
results.append(item.get("translation_text", "").strip())
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.warning("第 %d-%d 批翻译失败,逐条重试:%s", i, i + len(chunk), exc)
|
||||
for t in chunk:
|
||||
try:
|
||||
out = pipe([t], max_length=max_len)
|
||||
results.append(out[0].get("translation_text", "").strip())
|
||||
except Exception:
|
||||
results.append(t) # 回退原文
|
||||
if (i // batch + 1) % 5 == 0:
|
||||
logger.debug("已翻译 %d/%d 条。", min(i + len(chunk), len(texts)), len(texts))
|
||||
# 环境变量覆盖:便于 A/B 基准对比(test/bench_translate.py 用)
|
||||
if os.environ.get("TRANSLATE_NO_SORT") == "1":
|
||||
sort_by_length = False
|
||||
|
||||
if sort_by_length:
|
||||
results = _translate_sorted(pipe, texts, batch_size, max_len, on_progress)
|
||||
else:
|
||||
results = _translate_sequential(pipe, texts, batch_size, max_len, on_progress)
|
||||
|
||||
logger.debug("翻译完成:%d 条。", len(results))
|
||||
return results
|
||||
|
||||
|
||||
# ---------------- 长度排序批处理(默认)----------------
|
||||
|
||||
def _translate_sorted(
|
||||
pipe, texts: list[str], batch_size: int, max_len: int,
|
||||
on_progress: Callable[[int, int], None] | None = None,
|
||||
) -> list[str]:
|
||||
"""按长度排序后分批翻译,翻译完按原序散回。
|
||||
|
||||
1. 记录 (orig_idx, text, est_tokens)
|
||||
2. 按 est_tokens 升序排序 → 相近长度的聚到同一批
|
||||
3. token 预算 + 条数上限双重约束切批:短句攒大批,长句拆小批
|
||||
4. 逐批翻译,按 orig_idx 把译文放回 results[orig_idx]
|
||||
"""
|
||||
n = len(texts)
|
||||
# 估算每条 token 数(用词数 × 1.2,至少 1 避免除零)
|
||||
items = [
|
||||
(i, texts[i], max(1, int(len(texts[i].split()) * _TOKENS_PER_WORD)))
|
||||
for i in range(n)
|
||||
]
|
||||
# 按 token 长度升序:短句在前,长句在后
|
||||
items.sort(key=lambda x: x[2])
|
||||
|
||||
# token 预算上限:一批的总 token 不超过 batch_size * max_len
|
||||
# 短句(每条 ~10 token)可攒到 batch_size 条;长句(~200 token)自动拆成更小批
|
||||
token_budget = batch_size * max_len
|
||||
|
||||
batches: list[list[tuple[int, str, int]]] = [] # [(orig_idx, text, tok), ...]
|
||||
cur_batch: list[tuple[int, str, int]] = []
|
||||
cur_max = 0 # 当前批内最长句的 token 数
|
||||
|
||||
for orig_idx, text, tok in items:
|
||||
new_max = max(cur_max, tok)
|
||||
new_tokens = (len(cur_batch) + 1) * new_max # 批内所有句都 pad 到 new_max
|
||||
if cur_batch and (len(cur_batch) >= batch_size or new_tokens > token_budget):
|
||||
batches.append(cur_batch)
|
||||
cur_batch = []
|
||||
cur_max = 0
|
||||
new_max = tok
|
||||
cur_batch.append((orig_idx, text, tok))
|
||||
cur_max = new_max
|
||||
if cur_batch:
|
||||
batches.append(cur_batch)
|
||||
|
||||
# padding 浪费对比(DEBUG 日志量化收益)
|
||||
pad_sorted = sum(len(b) * max(t for _, _, t in b) - sum(t for _, _, t in b) for b in batches)
|
||||
pad_seq = _estimate_sequential_padding(texts, batch_size)
|
||||
saving = (1 - pad_sorted / pad_seq) * 100 if pad_seq else 0
|
||||
logger.debug(
|
||||
"翻译分批:%d 条 → %d 批(长度排序)。padding 浪费:顺序 %d → 排序 %d token(节省 %.0f%%)",
|
||||
n, len(batches), pad_seq, pad_sorted, saving,
|
||||
)
|
||||
|
||||
results: list[str | None] = [None] * n
|
||||
done = 0
|
||||
for batch in batches:
|
||||
orig_indices = [b[0] for b in batch]
|
||||
batch_texts = [b[1] for b in batch]
|
||||
translated = _translate_batch(pipe, batch_texts, max_len)
|
||||
for idx, zh in zip(orig_indices, translated):
|
||||
results[idx] = zh
|
||||
done += len(batch)
|
||||
if on_progress is not None:
|
||||
on_progress(done, n)
|
||||
elif (done // batch_size + 1) % 5 == 0:
|
||||
logger.debug("已翻译 %d/%d 条。", done, n)
|
||||
|
||||
# None(理论不会发生,_translate_batch 保证返回等长)→ 回退原文
|
||||
return [results[i] or texts[i] for i in range(n)]
|
||||
|
||||
|
||||
def _estimate_sequential_padding(texts: list[str], batch_size: int) -> int:
|
||||
"""估算按原序分批的 padding 浪费(token 数)。"""
|
||||
total = 0
|
||||
for i in range(0, len(texts), batch_size):
|
||||
chunk = texts[i:i + batch_size]
|
||||
toks = [max(1, int(len(t.split()) * _TOKENS_PER_WORD)) for t in chunk]
|
||||
batch_max = max(toks)
|
||||
total += batch_max * len(chunk) - sum(toks)
|
||||
return total
|
||||
|
||||
|
||||
# ---------------- 顺序批处理(A/B 对比用 / sort_by_length=false)----------------
|
||||
|
||||
def _translate_sequential(
|
||||
pipe, texts: list[str], batch_size: int, max_len: int,
|
||||
on_progress: Callable[[int, int], None] | None = None,
|
||||
) -> list[str]:
|
||||
"""按原序分批翻译(旧行为,便于 A/B 对比)。"""
|
||||
results: list[str] = []
|
||||
n = len(texts)
|
||||
for i in range(0, n, batch_size):
|
||||
chunk = texts[i:i + batch_size]
|
||||
translated = _translate_batch(pipe, chunk, max_len)
|
||||
results.extend(translated)
|
||||
done = min(i + len(chunk), n)
|
||||
if on_progress is not None:
|
||||
on_progress(done, n)
|
||||
elif (i // batch_size + 1) % 5 == 0:
|
||||
logger.debug("已翻译 %d/%d 条。", done, n)
|
||||
return results
|
||||
|
||||
|
||||
# ---------------- 单批翻译 + 逐条重试回退 ----------------
|
||||
|
||||
def _translate_batch(pipe, chunk: list[str], max_len: int) -> list[str]:
|
||||
"""翻译一个批次,失败时降级到逐条重试。
|
||||
|
||||
Args:
|
||||
chunk: 本批的文本列表
|
||||
max_len: 单条最大生成长度
|
||||
"""
|
||||
try:
|
||||
out = pipe(chunk, max_length=max_len, truncation=True)
|
||||
return [item.get("translation_text", "").strip() for item in out]
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.warning("批次翻译失败(%d 条),逐条重试:%s", len(chunk), exc)
|
||||
results: list[str] = []
|
||||
for t in chunk:
|
||||
try:
|
||||
out = pipe([t], max_length=max_len, truncation=True)
|
||||
results.append(out[0].get("translation_text", "").strip())
|
||||
except Exception as exc2:
|
||||
logger.warning("单条翻译失败,回退原文:%s", exc2)
|
||||
results.append(t) # 回退原文
|
||||
return results
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
...
|
||||
<upload_dir>/<yyyy>/<mm>/<uuid>.<ext> complete 后的正式视频
|
||||
|
||||
与 server 的区别:视频无需 sha256 去重(每个视频都转写),complete 直接创建 Task。
|
||||
管线触发由 controller 调用 pipeline.enqueue_task,本服务不依赖 pipeline。
|
||||
complete 创建转写 Task,管线触发由 controller 调用 scheduler.enqueue_task,
|
||||
本服务不依赖 scheduler(避免循环依赖)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -16,6 +16,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
@@ -36,6 +37,20 @@ from ..schemas.task import (
|
||||
|
||||
logger = logging.getLogger("audio2text.upload")
|
||||
|
||||
# 按 upload_id 串行化 uploaded_chunks 的读-改-写,避免并发分片写入丢失更新。
|
||||
# SQLite 无行锁,JSON 列的 append 操作不是原子的,必须进程内加锁。
|
||||
_chunk_locks: dict[str, threading.Lock] = {}
|
||||
_chunk_locks_guard = threading.Lock()
|
||||
|
||||
|
||||
def _get_chunk_lock(upload_id: str) -> threading.Lock:
|
||||
with _chunk_locks_guard:
|
||||
lock = _chunk_locks.get(upload_id)
|
||||
if lock is None:
|
||||
lock = threading.Lock()
|
||||
_chunk_locks[upload_id] = lock
|
||||
return lock
|
||||
|
||||
|
||||
class UploadService:
|
||||
def __init__(self, db: Session) -> None:
|
||||
@@ -93,6 +108,7 @@ class UploadService:
|
||||
session_dir.mkdir(parents=True, exist_ok=True)
|
||||
chunk_path = session_dir / f"{index}.part"
|
||||
|
||||
# 先落盘(无锁,IO 可并行),再持锁更新 DB 计数
|
||||
try:
|
||||
with chunk_path.open("wb") as out:
|
||||
out.write(data)
|
||||
@@ -102,6 +118,10 @@ class UploadService:
|
||||
chunk_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
# 持锁重读+更新:避免并发分片各自 read old list → append → write,后者覆盖前者
|
||||
lock = _get_chunk_lock(upload_id)
|
||||
with lock:
|
||||
self.db.refresh(session) # 拿最新 uploaded_chunks,不被其他并发请求的旧值覆盖
|
||||
uploaded = list(session.uploaded_chunks or [])
|
||||
if index not in uploaded:
|
||||
uploaded.append(index)
|
||||
@@ -112,6 +132,14 @@ class UploadService:
|
||||
|
||||
# ---------------- 拼接 + 创建任务 ----------------
|
||||
|
||||
# 允许的音视频扩展名白名单(防可执行文件落盘到上传目录)
|
||||
_ALLOWED_EXTS = frozenset({
|
||||
".mp4", ".mkv", ".avi", ".mov", ".webm", ".flv",
|
||||
".mp3", ".wav", ".flac", ".aac", ".m4a", ".ogg", ".wma",
|
||||
})
|
||||
|
||||
# ---------------- 拼接 + 创建任务 ----------------
|
||||
|
||||
def complete(self, upload_id: str) -> CompleteResponse:
|
||||
session = self._require_session(upload_id)
|
||||
|
||||
@@ -135,6 +163,8 @@ class UploadService:
|
||||
final_path = self._assemble(session)
|
||||
rel = str(final_path.relative_to(self.upload_root))
|
||||
|
||||
# 单事务:建 Task + 更新 session 状态 + 关联 task_id 一次 commit
|
||||
# 避免双 commit 之间崩溃产生孤儿 Task(Task 已建但 session.task_id 为空)
|
||||
task = Task(
|
||||
filename=session.filename,
|
||||
source_path=rel,
|
||||
@@ -144,15 +174,18 @@ class UploadService:
|
||||
self.db.add(task)
|
||||
session.status = "completed"
|
||||
session.final_path = rel
|
||||
session.task_id = None # 占位,flush 后用 task.id 赋值
|
||||
session.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(task)
|
||||
# 正向关联:session → task(替代旧的 source_path 反向查找)
|
||||
self.db.flush() # 拿到 task.id(不 commit,仍在事务内)
|
||||
session.task_id = task.id
|
||||
self.db.commit()
|
||||
self.db.refresh(task)
|
||||
|
||||
# 清理分片暂存
|
||||
# 清理分片暂存(commit 后,即使清理失败也不影响已建任务)
|
||||
self._cleanup_session_dir(upload_id)
|
||||
# 清理进程内锁,避免长期运行后 _chunk_locks 无限增长
|
||||
with _chunk_locks_guard:
|
||||
_chunk_locks.pop(upload_id, None)
|
||||
|
||||
logger.info("上传完成 task_id=%s file=%s size=%d", task.id, session.filename, session.size_bytes)
|
||||
return CompleteResponse(
|
||||
@@ -215,7 +248,10 @@ class UploadService:
|
||||
|
||||
def _assemble(self, session: UploadSession) -> Path:
|
||||
"""按 index 顺序拼接全部分片为正式视频文件。"""
|
||||
ext = Path(session.filename).suffix or ".mp4"
|
||||
# 扩展名取自客户端 filename,但做白名单净化:不在允许列表内则回退 .bin
|
||||
ext = Path(session.filename).suffix.lower()
|
||||
if ext not in self._ALLOWED_EXTS:
|
||||
ext = ".bin"
|
||||
now = datetime.now(timezone.utc)
|
||||
sub = self.upload_root / f"{now:%Y}" / f"{now:%m}"
|
||||
sub.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -10,7 +10,8 @@ from __future__ import annotations
|
||||
|
||||
# 分片上传参数(home / upload 共用)
|
||||
DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024
|
||||
DEFAULT_CONCURRENCY = 3
|
||||
DEFAULT_CONCURRENCY = 3 # 单文件内分片并发数
|
||||
DEFAULT_FILE_CONCURRENCY = 5 # 同时上传的文件数(不阻塞 web 请求/ffmpeg/gpu)
|
||||
MAX_RETRY = 2
|
||||
POLL_INTERVAL = 2000
|
||||
|
||||
@@ -18,6 +19,7 @@ _NAV_ITEMS = [
|
||||
("/", "主页", "home"),
|
||||
("/history", "历史", "history"),
|
||||
("/logs", "日志", "logs"),
|
||||
("/settings", "设置", "settings"),
|
||||
]
|
||||
|
||||
|
||||
@@ -202,10 +204,11 @@ a { color: var(--accent); text-decoration: none; }
|
||||
SHARED_JS = """
|
||||
// 任务状态中文标签
|
||||
const STATUS_LABEL = {
|
||||
queued: "排队中", extracting: "提取音频", transcribing: "语音识别",
|
||||
segmenting: "断句重算", translating: "翻译中", done: "完成", failed: "失败"
|
||||
uploading: "上传中", queued: "排队中", extracting: "提取音频",
|
||||
transcribing: "语音识别", segmenting: "断句重算", translating: "翻译中",
|
||||
done: "完成", failed: "失败"
|
||||
};
|
||||
const ACTIVE_STATES = ["queued","extracting","transcribing","segmenting","translating"];
|
||||
const ACTIVE_STATES = ["uploading","queued","extracting","transcribing","segmenting","translating"];
|
||||
|
||||
// HTML 转义(防 XSS)
|
||||
function escapeHtml(s) {
|
||||
@@ -222,6 +225,16 @@ function fmtBytes(n) {
|
||||
return u === 0 ? x + " B" : x.toFixed(1) + " " + units[u];
|
||||
}
|
||||
|
||||
// 24 小时制时间格式化(不受浏览器 locale 影响,避免 am/pm 混淆)
|
||||
function _pad2(n) { return n < 10 ? "0" + n : "" + n; }
|
||||
function fmtTime24(d) {
|
||||
return _pad2(d.getHours()) + ":" + _pad2(d.getMinutes()) + ":" + _pad2(d.getSeconds());
|
||||
}
|
||||
function fmtDateTime24(d) {
|
||||
return d.getFullYear() + "-" + _pad2(d.getMonth()+1) + "-" + _pad2(d.getDate())
|
||||
+ " " + fmtTime24(d);
|
||||
}
|
||||
|
||||
// 并发池:indices 中的每个元素交给 worker,最多 concurrency 个并发
|
||||
async function runPool(indices, concurrency, worker) {
|
||||
let cursor = 0;
|
||||
@@ -248,7 +261,8 @@ def render_upload_js(on_complete: str) -> str:
|
||||
"""
|
||||
return f"""
|
||||
const CHUNK_SIZE = {DEFAULT_CHUNK_SIZE};
|
||||
const CONCURRENCY = {DEFAULT_CONCURRENCY};
|
||||
const CHUNK_CONCURRENCY = {DEFAULT_CONCURRENCY}; // 单文件内分片并发
|
||||
const FILE_CONCURRENCY = {DEFAULT_FILE_CONCURRENCY}; // 同时上传的文件数
|
||||
const MAX_RETRY = {MAX_RETRY};
|
||||
const UPLOAD_API = "/api/tasks/chunk-uploads";
|
||||
|
||||
@@ -304,13 +318,13 @@ function setUploadProgress(t, pct) {{
|
||||
}}
|
||||
|
||||
function pump() {{
|
||||
// 统计正在上传的文件数,启动等待中的文件直到达到 FILE_CONCURRENCY
|
||||
const active = pending.filter(t => t.state === "running").length;
|
||||
for (const t of pending) {{
|
||||
if (active >= CONCURRENCY) break;
|
||||
if (active >= FILE_CONCURRENCY) break;
|
||||
if (t.state === "pending") {{
|
||||
t.state = "running";
|
||||
setUploadState(t, "running");
|
||||
startUpload(t);
|
||||
active++;
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
@@ -331,7 +345,7 @@ async function startUpload(t) {{
|
||||
|
||||
const need = [];
|
||||
for (let i = 0; i < t.totalChunks; i++) if (!t.uploaded.has(i)) need.push(i);
|
||||
await runPool(need, CONCURRENCY, i => uploadChunk(t, i));
|
||||
await runPool(need, CHUNK_CONCURRENCY, i => uploadChunk(t, i));
|
||||
if (t.uploaded.size < t.totalChunks) throw new Error("部分分片未能上传");
|
||||
|
||||
setUploadState(t, "hashing");
|
||||
@@ -348,6 +362,9 @@ async function startUpload(t) {{
|
||||
meta.className = "task-meta fail-msg";
|
||||
meta.textContent = String(e.message || e);
|
||||
t.el.appendChild(meta);
|
||||
}} finally {{
|
||||
// 无论成功还是失败,都触发 pump 让队列中下一个文件开始上传
|
||||
pump();
|
||||
}}
|
||||
}}
|
||||
|
||||
|
||||
@@ -68,7 +68,11 @@ function renderTable(tasks) {{
|
||||
const tr = document.createElement("tr");
|
||||
const label = STATUS_LABEL[task.status] || task.status;
|
||||
const stateClass = "st-" + (task.status === "done" ? "done" : task.status === "failed" ? "fail" : "running");
|
||||
const created = new Date(task.created_at).toLocaleString();
|
||||
let created;
|
||||
try {{ created = fmtDateTime24(new Date(task.created_at + "Z")); }}
|
||||
catch (e) {{ created = task.created_at; }}
|
||||
|
||||
let idDisplay = task.is_upload ? "—" : "#" + task.id;
|
||||
|
||||
let action;
|
||||
if (task.status === "done") {{
|
||||
@@ -78,16 +82,24 @@ function renderTable(tasks) {{
|
||||
}} else if (task.status === "failed") {{
|
||||
action = `<span class="err-tip" title="${{escapeHtml(task.error || "")}}">查看错误</span>`;
|
||||
}} else {{
|
||||
action = `<span class="muted">—</span>`;
|
||||
action = `<span class="muted">-</span>`;
|
||||
}}
|
||||
// done/failed 且非上传中:加删除按钮
|
||||
const canDelete = (task.status === "done" || task.status === "failed") && !task.is_upload;
|
||||
if (canDelete) {{
|
||||
action += ` <button class="btn-sm" onclick="deleteTask(${{task.id}})">删除</button>`;
|
||||
}}
|
||||
|
||||
let progress;
|
||||
if (task.status === "done") progress = "100%";
|
||||
else if (task.status === "failed") progress = "—";
|
||||
else progress = `<div class="mini-bar"><div class="mini-fill" style="width:${{task.progress}}%"></div></div>${{task.progress.toFixed(0)}}%`;
|
||||
else {{
|
||||
const pct = (task.progress == null) ? 0 : task.progress;
|
||||
progress = `<div class="mini-bar"><div class="mini-fill" style="width:${{pct}}%"></div></div>${{pct.toFixed(0)}}%`;
|
||||
}}
|
||||
|
||||
tr.innerHTML = `
|
||||
<td class="muted">#${{task.id}}</td>
|
||||
<td class="muted">${{idDisplay}}</td>
|
||||
<td>${{escapeHtml(task.filename)}}</td>
|
||||
<td><span class="status-tag ${{stateClass}}">${{label}}</span></td>
|
||||
<td>${{progress}}</td>
|
||||
@@ -110,6 +122,17 @@ function renderPagination() {{
|
||||
paginationEl.innerHTML = html;
|
||||
}}
|
||||
|
||||
async function deleteTask(taskId) {{
|
||||
if (!confirm("确认删除任务 #" + taskId + "?字幕和中间文件将被清除。")) return;
|
||||
try {{
|
||||
const r = await fetch("/api/tasks/" + taskId, {{ method: "DELETE" }});
|
||||
if (!r.ok) {{ alert("删除失败:" + await r.text()); return; }}
|
||||
load(currentOffset);
|
||||
}} catch (e) {{
|
||||
alert("删除失败:" + e);
|
||||
}}
|
||||
}}
|
||||
|
||||
load(0);
|
||||
"""
|
||||
|
||||
|
||||
@@ -42,12 +42,13 @@ function renderTasks(tasks) {{
|
||||
for (const task of tasks) {{
|
||||
tasksEl.appendChild(makeTaskCard(task));
|
||||
}}
|
||||
// 对进行中的任务启动轮询
|
||||
// 对进行中的任务启动轮询(上传会话用 upload_id 去重,Task 用 id 去重)
|
||||
for (const task of tasks) {{
|
||||
if (ACTIVE_STATES.includes(task.status) && !pollingIds.has(task.id)) {{
|
||||
pollingIds.add(task.id);
|
||||
pollTask(task.id);
|
||||
}}
|
||||
if (!ACTIVE_STATES.includes(task.status)) continue;
|
||||
const pollKey = task.is_upload ? task.upload_id : String(task.id);
|
||||
if (pollingIds.has(pollKey)) continue;
|
||||
pollingIds.add(pollKey);
|
||||
pollTask(task);
|
||||
}}
|
||||
}}
|
||||
|
||||
@@ -61,6 +62,7 @@ function makeTaskCard(task) {{
|
||||
const el = document.createElement("div");
|
||||
el.className = "card task server-task";
|
||||
el.dataset.taskId = task.id;
|
||||
if (task.is_upload) el.dataset.uploadId = task.upload_id;
|
||||
el.innerHTML = renderTaskInner(task);
|
||||
return el;
|
||||
}}
|
||||
@@ -69,7 +71,17 @@ function renderTaskInner(task) {{
|
||||
const label = STATUS_LABEL[task.status] || task.status;
|
||||
const stateClass = task.status === "done" ? "state-done"
|
||||
: task.status === "failed" ? "state-fail" : "state-running";
|
||||
const created = new Date(task.created_at).toLocaleString();
|
||||
|
||||
// 上传中:created_at 可能是 naive UTC,前端按 UTC 解析
|
||||
let created;
|
||||
try {{ created = fmtDateTime24(new Date(task.created_at + "Z")); }}
|
||||
catch (e) {{ created = task.created_at; }}
|
||||
|
||||
// 删除按钮:仅 done/failed 且非上传中任务显示
|
||||
const canDelete = (task.status === "done" || task.status === "failed") && !task.is_upload;
|
||||
const delBtn = canDelete
|
||||
? `<button class="btn-sm del-btn" onclick="deleteTask(${{task.id}}, this)">删除</button>`
|
||||
: "";
|
||||
|
||||
let body;
|
||||
if (task.status === "done") {{
|
||||
@@ -77,35 +89,91 @@ function renderTaskInner(task) {{
|
||||
}} else if (task.status === "failed") {{
|
||||
body = `<div class="task-meta fail-msg">${{escapeHtml(task.error || "未知错误")}}</div>`;
|
||||
}} else {{
|
||||
body = `<div class="bar"><div class="fill" style="width:${{task.progress}}%"></div><span class="pct">${{task.progress.toFixed(0)}}%</span></div>`;
|
||||
const pct = (task.progress == null) ? 0 : task.progress;
|
||||
const sizeInfo = task.size_bytes ? ` · ${{fmtBytes(task.size_bytes)}}` : "";
|
||||
body = `<div class="bar"><div class="fill" style="width:${{pct}}%"></div><span class="pct">${{pct.toFixed(0)}}%${{sizeInfo}}</span></div>`;
|
||||
}}
|
||||
return `
|
||||
<div class="task-head">
|
||||
<span class="fname">#${{task.id}} ${{escapeHtml(task.filename)}}</span>
|
||||
<span class="fname">${{task.is_upload ? "" : "#" + task.id + " "}}${{escapeHtml(task.filename)}}</span>
|
||||
<span class="fstate ${{stateClass}}">${{label}}</span>
|
||||
${{delBtn}}
|
||||
</div>
|
||||
${{body}}
|
||||
<div class="task-time">${{created}}</div>`;
|
||||
}}
|
||||
|
||||
async function pollTask(taskId) {{
|
||||
const tick = async () => {{
|
||||
async function deleteTask(taskId, btn) {{
|
||||
if (!confirm("确认删除任务 #" + taskId + "?字幕和中间文件将被清除。")) return;
|
||||
btn.disabled = true;
|
||||
try {{
|
||||
const r = await fetch("/api/tasks/" + taskId);
|
||||
if (!r.ok) {{ pollingIds.delete(taskId); return; }}
|
||||
const task = await r.json();
|
||||
const card = tasksEl.querySelector('.server-task[data-task-id="' + taskId + '"]');
|
||||
if (!card) {{ pollingIds.delete(taskId); return; }}
|
||||
|
||||
card.innerHTML = renderTaskInner(task);
|
||||
|
||||
if (task.status === "done" || task.status === "failed") {{
|
||||
pollingIds.delete(taskId);
|
||||
const r = await fetch("/api/tasks/" + taskId, {{ method: "DELETE" }});
|
||||
if (!r.ok) {{
|
||||
const err = await r.text();
|
||||
alert("删除失败:" + err);
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}}
|
||||
setTimeout(tick, POLL_INTERVAL);
|
||||
refreshList();
|
||||
}} catch (e) {{
|
||||
setTimeout(tick, POLL_INTERVAL);
|
||||
alert("删除失败:" + e);
|
||||
btn.disabled = false;
|
||||
}}
|
||||
}}
|
||||
|
||||
async function pollTask(task) {{
|
||||
// 上传会话:轮询 upload status 接口;Task:轮询 task 接口
|
||||
const isUpload = task.is_upload === true && task.upload_id;
|
||||
const url = isUpload
|
||||
? "/api/tasks/chunk-uploads/" + task.upload_id + "/status"
|
||||
: "/api/tasks/" + task.id;
|
||||
let backoff = POLL_INTERVAL;
|
||||
const tick = async () => {{
|
||||
try {{
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) {{ pollingIds.delete(task.id); pollingIds.delete(task.upload_id); return; }}
|
||||
const data = await r.json();
|
||||
|
||||
if (isUpload) {{
|
||||
// 上传会话:complete 后 task_id 出现,切换为 Task 轮询
|
||||
if (data.completed && data.task_id) {{
|
||||
pollingIds.delete(task.upload_id);
|
||||
pollingIds.add(data.task_id);
|
||||
pollTask({{ id: data.task_id, is_upload: false }});
|
||||
// 刷新列表让新 Task 卡片出现
|
||||
refreshList();
|
||||
return;
|
||||
}}
|
||||
// 更新上传进度
|
||||
const card = tasksEl.querySelector('.server-task[data-upload-id="' + task.upload_id + '"]');
|
||||
if (!card) {{ pollingIds.delete(task.upload_id); return; }}
|
||||
const uploaded = (data.uploaded_chunks || []).length;
|
||||
const total = data.total_chunks || 1;
|
||||
const pct = Math.min(4, uploaded / total * 4);
|
||||
const fakeTask = {{
|
||||
status: "uploading", progress: pct, is_upload: true,
|
||||
upload_id: task.upload_id, filename: data.filename,
|
||||
size_bytes: data.size_bytes, created_at: task.created_at,
|
||||
}};
|
||||
card.innerHTML = renderTaskInner(fakeTask);
|
||||
backoff = POLL_INTERVAL;
|
||||
setTimeout(tick, backoff);
|
||||
}} else {{
|
||||
// Task 轮询
|
||||
const card = tasksEl.querySelector('.server-task[data-task-id="' + task.id + '"]');
|
||||
if (!card) {{ pollingIds.delete(task.id); return; }}
|
||||
card.innerHTML = renderTaskInner(data);
|
||||
if (data.status === "done" || data.status === "failed") {{
|
||||
pollingIds.delete(task.id);
|
||||
return;
|
||||
}}
|
||||
backoff = POLL_INTERVAL;
|
||||
setTimeout(tick, backoff);
|
||||
}}
|
||||
}} catch (e) {{
|
||||
// 网络错误:指数退避,上限 30s
|
||||
backoff = Math.min(backoff * 1.6, 30000);
|
||||
setTimeout(tick, backoff);
|
||||
}}
|
||||
}};
|
||||
tick();
|
||||
|
||||
@@ -2,25 +2,34 @@
|
||||
|
||||
共享 _shared.py 的 BASE_CSS / SHARED_JS。
|
||||
页面专属:级别过滤按钮、自动刷新开关、清空、traceback 折叠。
|
||||
|
||||
显示策略:
|
||||
- 默认 INFO(仅阶段转换/模型加载卸载/任务流转开始结束),可切 DEBUG 看进度详情。
|
||||
- 最新日志在顶部;用户向上滚动浏览历史时不会被自动刷新拉走(仅当停在顶部时跟随)。
|
||||
- 轮询带指数退避,连续失败时拉长间隔,避免服务不可达时打爆。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import render_page
|
||||
|
||||
POLL_INTERVAL_LOGS = 2000
|
||||
POLL_INTERVAL_LOGS = 2000 # 正常轮询间隔(ms)
|
||||
POLL_MAX_INTERVAL = 30000 # 退避上限(ms)
|
||||
DEFAULT_TAIL = 200
|
||||
|
||||
_PAGE_JS = f"""
|
||||
const POLL_INTERVAL = {POLL_INTERVAL_LOGS};
|
||||
const POLL_MIN_INTERVAL = {POLL_INTERVAL_LOGS};
|
||||
const POLL_MAX_INTERVAL = {POLL_MAX_INTERVAL};
|
||||
const DEFAULT_TAIL = {DEFAULT_TAIL};
|
||||
const API = "/api/logs";
|
||||
|
||||
let currentLevel = "debug";
|
||||
let currentLevel = "info"; // 默认 INFO
|
||||
let autoRefresh = true;
|
||||
let timer = null;
|
||||
let pollInterval = POLL_MIN_INTERVAL; // 动态退避
|
||||
let userScrolled = false; // 用户是否主动向下浏览历史
|
||||
|
||||
const logsEl = document.getElementById("logs");
|
||||
const containerEl = document.getElementById("logs");
|
||||
const emptyEl = document.getElementById("empty");
|
||||
const statusEl = document.getElementById("status");
|
||||
|
||||
@@ -29,26 +38,34 @@ const LEVEL_CLASS = {{
|
||||
CRITICAL: "st-fail"
|
||||
}};
|
||||
|
||||
// 监听滚动:用户向下(往历史方向)浏览时暂停自动跟随,回到顶部则恢复
|
||||
containerEl.addEventListener("scroll", () => {{
|
||||
// scrollTop 越小越靠近顶部(最新)。接近顶部 = 用户在看最新
|
||||
userScrolled = containerEl.scrollTop > 4;
|
||||
}});
|
||||
|
||||
document.querySelectorAll(".filter").forEach(btn => {{
|
||||
btn.addEventListener("click", () => {{
|
||||
document.querySelectorAll(".filter").forEach(b => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
currentLevel = btn.dataset.level;
|
||||
logsEl.innerHTML = "";
|
||||
containerEl.innerHTML = "";
|
||||
pollInterval = POLL_MIN_INTERVAL;
|
||||
fetchLogs();
|
||||
}});
|
||||
}});
|
||||
|
||||
document.getElementById("autorefresh").addEventListener("change", e => {{
|
||||
autoRefresh = e.target.checked;
|
||||
if (autoRefresh) fetchLogs(); else if (timer) {{ clearTimeout(timer); timer = null; }}
|
||||
if (autoRefresh) {{ pollInterval = POLL_MIN_INTERVAL; fetchLogs(); }}
|
||||
else if (timer) {{ clearTimeout(timer); timer = null; }}
|
||||
}});
|
||||
|
||||
document.getElementById("clear-btn").addEventListener("click", async () => {{
|
||||
if (!confirm("确定清空所有日志缓冲?")) return;
|
||||
try {{
|
||||
await fetch(API, {{ method: "DELETE" }});
|
||||
logsEl.innerHTML = "";
|
||||
containerEl.innerHTML = "";
|
||||
statusEl.textContent = "已清空";
|
||||
}} catch (e) {{ statusEl.textContent = "清空失败"; }}
|
||||
}});
|
||||
@@ -56,28 +73,57 @@ document.getElementById("clear-btn").addEventListener("click", async () => {{
|
||||
async function fetchLogs() {{
|
||||
try {{
|
||||
const r = await fetch(`${{API}}?level=${{currentLevel}}&tail=${{DEFAULT_TAIL}}`);
|
||||
if (!r.ok) {{ statusEl.textContent = "HTTP " + r.status; scheduleNext(); return; }}
|
||||
if (!r.ok) {{ statusEl.textContent = "HTTP " + r.status; scheduleNext(true); return; }}
|
||||
const data = await r.json();
|
||||
renderLogs(data.logs);
|
||||
statusEl.textContent = `${{data.count}} 条 · 更新 ${{new Date().toLocaleTimeString()}}`;
|
||||
scheduleNext();
|
||||
statusEl.textContent = `${{data.count}} 条 · 更新 ${{fmtTime24(new Date())}}`;
|
||||
pollInterval = POLL_MIN_INTERVAL; // 成功,重置间隔
|
||||
scheduleNext(false);
|
||||
}} catch (e) {{
|
||||
statusEl.textContent = "获取失败";
|
||||
scheduleNext();
|
||||
scheduleNext(true); // 失败,退避
|
||||
}}
|
||||
}}
|
||||
|
||||
function renderLogs(logs) {{
|
||||
if (!logs || logs.length === 0) {{
|
||||
if (logsEl.children.length === 0) emptyEl.style.display = "block";
|
||||
if (containerEl.children.length === 0) emptyEl.style.display = "block";
|
||||
return;
|
||||
}}
|
||||
emptyEl.style.display = "none";
|
||||
const existing = new Set(Array.from(logsEl.children).map(el => el.dataset.key));
|
||||
|
||||
// 服务端已按时间升序返回(旧→新)。倒序渲染:最新在最上面。
|
||||
// 用 key=ts|level|logger|msg 去重(含 logger 名,避免不同模块同消息被误判重复)
|
||||
const existing = new Set(Array.from(containerEl.children).map(el => el.dataset.key));
|
||||
const wasAtTop = containerEl.scrollTop <= 4;
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const log of logs) {{
|
||||
const key = `${{log.ts}}|${{log.level}}|${{log.msg}}`;
|
||||
|
||||
// 服务端按时间升序返回(旧→新)。倒序遍历 + appendChild → frag 内最新在前:
|
||||
// logs[N-1](最新)先 append → frag 第一个;logs[0](最旧)最后 → frag 最后
|
||||
for (let i = logs.length - 1; i >= 0; i--) {{
|
||||
const log = logs[i];
|
||||
const key = `${{log.ts}}|${{log.level}}|${{log.logger}}|${{log.msg}}`;
|
||||
if (existing.has(key)) continue;
|
||||
frag.appendChild(buildRow(log, key));
|
||||
}}
|
||||
|
||||
if (frag.children.length > 0) {{
|
||||
// 整块插入到容器顶部:最新日志在最上面
|
||||
containerEl.insertBefore(frag, containerEl.firstChild);
|
||||
}}
|
||||
|
||||
// 裁剪:超出上限删最旧(底部)
|
||||
while (containerEl.children.length > DEFAULT_TAIL) {{
|
||||
containerEl.removeChild(containerEl.lastChild);
|
||||
}}
|
||||
|
||||
// 仅当用户停在顶部(看最新)时保持滚定在顶,否则不打扰浏览历史
|
||||
if (wasAtTop && !userScrolled) {{
|
||||
containerEl.scrollTop = 0;
|
||||
}}
|
||||
}}
|
||||
|
||||
function buildRow(log, key) {{
|
||||
const row = document.createElement("div");
|
||||
row.className = "log-row " + (LEVEL_CLASS[log.level] || "st-running");
|
||||
row.dataset.key = key;
|
||||
@@ -98,15 +144,16 @@ function renderLogs(logs) {{
|
||||
pre.style.display = pre.style.display === "none" ? "block" : "none";
|
||||
}});
|
||||
}}
|
||||
frag.appendChild(row);
|
||||
}}
|
||||
logsEl.appendChild(frag);
|
||||
while (logsEl.children.length > DEFAULT_TAIL) logsEl.removeChild(logsEl.firstChild);
|
||||
logsEl.scrollTop = logsEl.scrollHeight;
|
||||
return row;
|
||||
}}
|
||||
|
||||
function scheduleNext() {{
|
||||
if (autoRefresh) timer = setTimeout(fetchLogs, POLL_INTERVAL);
|
||||
function scheduleNext(failed) {{
|
||||
if (!autoRefresh) return;
|
||||
if (failed) {{
|
||||
// 指数退避:每次失败 ×1.6,上限 30s
|
||||
pollInterval = Math.min(pollInterval * 1.6, POLL_MAX_INTERVAL);
|
||||
}}
|
||||
timer = setTimeout(fetchLogs, pollInterval);
|
||||
}}
|
||||
|
||||
fetchLogs();
|
||||
@@ -136,12 +183,12 @@ _PAGE_CSS = """
|
||||
|
||||
_BODY = """
|
||||
<h1>日志</h1>
|
||||
<p class="sub">实时查看服务日志。debug=详细子步骤,info=仅阶段转换,error=完整错误。自动刷新每 2 秒。</p>
|
||||
<p class="sub">默认显示 INFO(任务流转/模型加载卸载)。切 DEBUG 看进度详情,切 警告+/仅错误 过滤问题。自动刷新每 2 秒,失败自动退避。最新日志在顶部,向下浏览历史时不会被拉走。</p>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="filters">
|
||||
<button class="filter active" data-level="debug">全部 (DEBUG)</button>
|
||||
<button class="filter" data-level="info">简略 (INFO)</button>
|
||||
<button class="filter" data-level="debug">全部 (DEBUG)</button>
|
||||
<button class="filter active" data-level="info">简略 (INFO)</button>
|
||||
<button class="filter" data-level="warning">警告+</button>
|
||||
<button class="filter" data-level="error">仅错误</button>
|
||||
</div>
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -30,6 +30,8 @@ asr:
|
||||
language: en
|
||||
word_timestamps: true
|
||||
vad_filter: true
|
||||
batch_size: 8 # CPU 无 GPU 并行收益,保持小批
|
||||
beam_size: 5 # tiny.en CPU 质量优先,保持默认 beam(无加速诉求)
|
||||
|
||||
translation:
|
||||
model: Helsinki-NLP/opus-mt-en-zh # 最轻量英译中(~300MB;NLLB-600M 需 ~2.4GB,2GB 机 OOM)
|
||||
@@ -38,6 +40,7 @@ translation:
|
||||
tgt_lang: zho_Hans
|
||||
batch_size: 8 # opus-mt 轻量,batch 适中
|
||||
max_length: 256
|
||||
sort_by_length: true # 排序分批(CPU 收益小,但无害)
|
||||
|
||||
segmentation:
|
||||
max_words_per_line: 14
|
||||
@@ -47,7 +50,7 @@ segmentation:
|
||||
|
||||
|
||||
logging:
|
||||
level: info # debug=详细子步骤, info=仅阶段转换, error=完整 traceback
|
||||
level: info # debug | info | warning | error(控制台最低级别)
|
||||
buffer_size: 2000
|
||||
|
||||
docs:
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# audio2text 运行时配置。复制为 config.yaml 后填值。所有路径相对容器内 /app。
|
||||
# CPU dev / GPU prod 仅靠 device + model + compute_type 三项切换,代码不变。
|
||||
# audio2text 运行时配置模板。
|
||||
# 复制为 config.yaml 后填值。所有路径相对容器内 /app。
|
||||
#
|
||||
# CPU dev / GPU prod 仅靠 asr + translation 两段的 device/model/compute_type/batch_size 切换。
|
||||
# 下方注释标注了 CPU(开发)与 GPU(生产)的推荐值对照。
|
||||
# 完整对照:CPU 用 ./setup.sh(默认),GPU 用 AUDIO2TEXT_VARIANT=gpu ./setup.sh。
|
||||
|
||||
server:
|
||||
host: 0.0.0.0 # 容器内对外监听(由 docker -p 映射到宿主)
|
||||
@@ -10,9 +14,9 @@ storage:
|
||||
upload_dir: /data/uploads # 上传视频落盘根目录
|
||||
work_dir: /data/.work # 分片会话暂存 + 中间音频
|
||||
output_dir: /data/outputs # 生成的 SRT 字幕输出
|
||||
chunk_bytes: 1048576 # 1 MiB 流式分片
|
||||
chunk_bytes: 1048576 # 1 MiB 服务端流式读缓冲(前端分片大小由前端定)
|
||||
chunk_session_ttl_seconds: 300 # 被放弃会话的存活秒数(后台 reaper 据此清理)
|
||||
cache_retention_days: 7 # 任务产物保留天数,超期清理(字幕/中间音频/保留的原始视频+DB记录)
|
||||
cache_retention_days: 7 # 任务产物保留天数,超期清理(0=禁用)
|
||||
cache_cleanup_interval_hours: 24 # 定时清理间隔(启动时跑一次,之后循环)
|
||||
|
||||
processing:
|
||||
@@ -20,22 +24,28 @@ processing:
|
||||
keep_audio: false # 完成后是否保留中间 wav(默认删,只留字幕)
|
||||
|
||||
asr:
|
||||
# CPU dev:tiny.en + int8(Whisper 同系列最小,英文专用)
|
||||
# GPU prod:large-v3-turbo + float16,3090 上几 GB 视频几分钟出字幕
|
||||
model: tiny.en
|
||||
# CPU dev:tiny.en + int8(Whisper 同系列最小,英文专用,~39M)
|
||||
# GPU prod:large-v3-turbo + float16(8x 速度,质量接近 large-v3,~3GB 显存)
|
||||
model: tiny.en # CPU: tiny.en | GPU: large-v3-turbo
|
||||
device: cpu # cpu | cuda
|
||||
compute_type: int8 # cpu: int8;gpu: float16
|
||||
compute_type: int8 # CPU: int8 | GPU: float16
|
||||
language: en # 仅英语
|
||||
word_timestamps: true # 词级时间戳:让断句精确而非纯匀速估算
|
||||
vad_filter: true # 过滤静音段,提升质量与速度
|
||||
batch_size: 8 # CPU: 8 | GPU: 32(BatchedInferencePipeline 批量解码音频块,拉长单次 GPU 解码掩盖 CPU 特征提取间隙)
|
||||
beam_size: 5 # CPU: 5(默认)| GPU: 2(turbo 鲁棒可降,候选数↓解码步数↓,加速明显质量损失小)
|
||||
|
||||
translation:
|
||||
model: facebook/nllb-200-distilled-1.3B
|
||||
# CPU dev:Helsinki-NLP/opus-mt-en-zh(~300MB,2GB 内存可跑)
|
||||
# GPU prod:facebook/nllb-200-distilled-1.3B(质量最好,~2.5GB 显存)
|
||||
# 注意:model 与 device 必须配套——GPU 模型配 CPU device 会 OOM,反之浪费硬件。
|
||||
model: Helsinki-NLP/opus-mt-en-zh # CPU: opus-mt-en-zh | GPU: facebook/nllb-200-distilled-1.3B
|
||||
device: cpu # cpu | cuda
|
||||
src_lang: eng_Latn # NLLB 语言码:英语
|
||||
tgt_lang: zho_Hans # NLLB 语言码:简体中文
|
||||
batch_size: 16 # 不与 ASR 共驻:翻译时显存独占,可用大 batch
|
||||
batch_size: 8 # CPU: 8 | GPU: 32(不共驻时显存独占可用大 batch)
|
||||
max_length: 256 # 单条翻译最大 token
|
||||
sort_by_length: true # 按长度排序后分批,减少 padding 浪费(GPU 收益大)
|
||||
|
||||
segmentation:
|
||||
max_words_per_line: 14 # 单行最多词数,超出按逗号拆
|
||||
@@ -43,13 +53,12 @@ segmentation:
|
||||
min_duration_seconds: 1.0 # 单条字幕最短 1 秒(太短则合并)
|
||||
max_chars_per_line: 42 # SRT 规范:每行 ≤42 字符
|
||||
|
||||
|
||||
logging:
|
||||
level: info # debug | info | warning | error(控制台 + 内存缓冲最低级别)
|
||||
level: info # debug | info | warning | error(控制台最低级别)
|
||||
buffer_size: 2000 # /logs 页面内存缓冲条数
|
||||
|
||||
docs:
|
||||
enabled: true
|
||||
username: admin
|
||||
password: "CHANGE_ME" # /docs Basic Auth,明文(root 持有,常量时间比较)
|
||||
password: "CHANGE_ME" # 部署前务必修改;留空则禁用 /docs
|
||||
realm: "audio2text docs"
|
||||
|
||||
@@ -29,14 +29,17 @@ asr:
|
||||
language: en
|
||||
word_timestamps: true
|
||||
vad_filter: true
|
||||
batch_size: 32 # BatchedInferencePipeline:每批解码 32 个 30s 音频块,拉长单次 GPU 解码掩盖 CPU 特征提取间隙
|
||||
beam_size: 2 # turbo 鲁棒,beam=2 留一个候选做保险(vs beam=5 候选数↓ 解码步数↓,加速明显质量损失小)
|
||||
|
||||
translation:
|
||||
model: facebook/nllb-200-distilled-1.3B # 质量最好
|
||||
device: cuda
|
||||
src_lang: eng_Latn
|
||||
tgt_lang: zho_Hans
|
||||
batch_size: 16 # 不共驻时显存独占,大 batch
|
||||
batch_size: 32 # 不共驻时显存独占,大 batch 填充 GPU
|
||||
max_length: 256
|
||||
sort_by_length: true # 按句子长度排序后分批,减少批内 padding 浪费
|
||||
|
||||
segmentation:
|
||||
max_words_per_line: 14
|
||||
@@ -46,7 +49,7 @@ segmentation:
|
||||
|
||||
|
||||
logging:
|
||||
level: info # debug=详细子步骤, info=仅阶段转换, error=完整 traceback
|
||||
level: info # debug | info | warning | error(控制台最低级别)
|
||||
buffer_size: 2000
|
||||
|
||||
docs:
|
||||
|
||||
@@ -1,19 +1,46 @@
|
||||
# audio2text — 双语字幕生成服务
|
||||
# CPU dev / GPU prod 两套配置,按需切换 service。
|
||||
#
|
||||
# CPU 开发:
|
||||
# docker compose up -d # 默认 cpu
|
||||
# GPU 生产:
|
||||
# docker compose -f docker-compose.yml up -d # 本机有 nvidia runtime 时自动用 gpu
|
||||
# 开发(CPU,热重载,改代码零重建):
|
||||
# docker compose --profile dev up -d --build
|
||||
# CPU 生产:
|
||||
# docker compose --profile cpu up -d --build
|
||||
# GPU 生产(本机有 nvidia runtime 时):
|
||||
# docker compose --profile gpu up -d --build
|
||||
#
|
||||
# 模型缓存(/models)跨容器复用,首次启动下载、之后秒起。
|
||||
# pip wheel 缓存由 BuildKit --mount=type=cache 管理,跨构建复用。
|
||||
|
||||
services:
|
||||
# ---------------- 开发:CPU + 源码挂载 + uvicorn reload ----------------
|
||||
# 改 app/ 下代码无需重建镜像,保存即热重载。
|
||||
audio2text-cpu-dev:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
VARIANT: cpu
|
||||
target: dev
|
||||
image: audio2text:cpu-dev
|
||||
container_name: audio2text-dev
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./app:/app/app
|
||||
- ./data:/data
|
||||
- ./models:/models
|
||||
- ./config.yaml:/app/config.yaml:ro
|
||||
environment:
|
||||
CONFIG_PATH: /app/config.yaml
|
||||
restart: unless-stopped
|
||||
profiles: ["dev"]
|
||||
|
||||
# ---------------- CPU 生产 ----------------
|
||||
audio2text-cpu:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
VARIANT: cpu
|
||||
target: final
|
||||
image: audio2text:cpu
|
||||
container_name: audio2text
|
||||
ports:
|
||||
@@ -27,19 +54,21 @@ services:
|
||||
restart: unless-stopped
|
||||
profiles: ["cpu", ""]
|
||||
|
||||
# ---------------- GPU 生产 ----------------
|
||||
audio2text-gpu:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
VARIANT: gpu
|
||||
target: final
|
||||
image: audio2text:gpu
|
||||
container_name: audio2text
|
||||
container_name: audio2text-gpu
|
||||
ports:
|
||||
- "8000:8000"
|
||||
- "8001:8000"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./models:/models
|
||||
- ./config.yaml:/app/config.yaml:ro
|
||||
- ./config.gpu.yaml:/app/config.yaml:ro
|
||||
environment:
|
||||
CONFIG_PATH: /app/config.yaml
|
||||
deploy:
|
||||
|
||||
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` 显式启动。
|
||||
137
scripts/prefetch_models.py
Normal file
137
scripts/prefetch_models.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""预拉模型权重到本地缓存,避免容器启动时才下载(首次启动慢)。
|
||||
|
||||
在容器内执行(docker run + 挂载 ./models volume),复用镜像里的 huggingface_hub。
|
||||
读 config.yaml 拿 asr.model / translation.model,下载到 HF_HOME(= /models/huggingface)。
|
||||
|
||||
幂等:已下过的模型跳过(HF cache 命中检测)。
|
||||
|
||||
用法(经 scripts/prefetch_models.sh 包装):
|
||||
./scripts/prefetch_models.sh # 读 config.yaml
|
||||
./scripts/prefetch_models.sh config.gpu.yaml # 读指定配置
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
"""带时间戳的日志(容器内无项目 logger,直接 print)。"""
|
||||
ts = time.strftime("%H:%M:%S")
|
||||
print(f"[{ts}] {msg}", flush=True)
|
||||
|
||||
|
||||
def load_config(config_path: str) -> dict:
|
||||
"""读 YAML 配置,返回 asr.model / translation.model。"""
|
||||
import yaml
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
def resolve_asr_repo(model_name: str) -> str:
|
||||
"""faster-whisper 模型名 -> HF repo。
|
||||
|
||||
预定义名(tiny.en 等)走 _MODELS 映射;已是 repo 路径(org/name)直接用。
|
||||
"""
|
||||
from faster_whisper.utils import _MODELS
|
||||
if "/" in model_name:
|
||||
return model_name # 已是完整 repo 路径
|
||||
repo = _MODELS.get(model_name)
|
||||
if repo is None:
|
||||
raise ValueError(f"未知 faster-whisper 模型名:{model_name}(不在 _MODELS 里)")
|
||||
return repo
|
||||
|
||||
|
||||
def is_cached(repo_id: str, hf_home: str) -> bool:
|
||||
"""检测 HF cache 是否已有该模型(snapshot 目录存在且非空)。"""
|
||||
# HF 缓存布局:<hf_home>/hub/models--<org>--<name>/snapshots/<hash>/
|
||||
cache_dir = Path(hf_home) / "hub" / f"models--{repo_id.replace('/', '--')}"
|
||||
snapshots = cache_dir / "snapshots"
|
||||
if not snapshots.is_dir():
|
||||
return False
|
||||
return any(snap.is_dir() and any(snap.iterdir()) for snap in snapshots.iterdir())
|
||||
|
||||
|
||||
def download_model(repo_id: str, hf_home: str) -> None:
|
||||
"""用 huggingface_hub 下载模型所有文件到 HF cache。"""
|
||||
from huggingface_hub import snapshot_download
|
||||
log(f" 下载 {repo_id} ...")
|
||||
snapshot_download(
|
||||
repo_id=repo_id,
|
||||
local_dir=None, # 走标准 cache
|
||||
cache_dir=Path(hf_home) / "hub",
|
||||
)
|
||||
|
||||
|
||||
def prefetch_asr(model_name: str, hf_home: str) -> None:
|
||||
"""预拉 faster-whisper ASR 模型。"""
|
||||
repo = resolve_asr_repo(model_name)
|
||||
log(f"[ASR] model={model_name} repo={repo}")
|
||||
if is_cached(repo, hf_home):
|
||||
log(f" ✓ 已缓存,跳过")
|
||||
return
|
||||
download_model(repo, hf_home)
|
||||
log(f" ✓ 完成")
|
||||
|
||||
|
||||
def prefetch_translation(model_name: str, hf_home: str) -> None:
|
||||
"""预拉翻译模型(transformers pipeline 用的 HF repo)。"""
|
||||
log(f"[翻译] model={model_name}")
|
||||
if is_cached(model_name, hf_home):
|
||||
log(f" ✓ 已缓存,跳过")
|
||||
return
|
||||
download_model(model_name, hf_home)
|
||||
log(f" ✓ 完成")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
config_path = os.environ.get("CONFIG_PATH", "/app/config.yaml")
|
||||
if len(sys.argv) > 1:
|
||||
config_path = sys.argv[1]
|
||||
hf_home = os.environ.get("HF_HOME", "/models/huggingface")
|
||||
|
||||
log(f"配置文件: {config_path}")
|
||||
log(f"HF 缓存目录: {hf_home}")
|
||||
|
||||
if not Path(config_path).is_file():
|
||||
log(f"✗ 配置文件不存在: {config_path}")
|
||||
return 1
|
||||
|
||||
cfg = load_config(config_path)
|
||||
asr_model = cfg.get("asr", {}).get("model", "tiny.en")
|
||||
tr_model = cfg.get("translation", {}).get("model", "Helsinki-NLP/opus-mt-en-zh")
|
||||
|
||||
log(f"ASR 模型: {asr_model}")
|
||||
log(f"翻译模型: {tr_model}")
|
||||
log("-" * 50)
|
||||
|
||||
# 预拉 ASR
|
||||
try:
|
||||
prefetch_asr(asr_model, hf_home)
|
||||
except Exception as e:
|
||||
log(f"✗ ASR 模型预拉失败: {e}")
|
||||
return 2
|
||||
|
||||
# 预拉翻译
|
||||
try:
|
||||
prefetch_translation(tr_model, hf_home)
|
||||
except Exception as e:
|
||||
log(f"✗ 翻译模型预拉失败: {e}")
|
||||
return 3
|
||||
|
||||
log("-" * 50)
|
||||
log(f"全部完成。缓存位于: {hf_home}")
|
||||
# 打印缓存大小
|
||||
try:
|
||||
total = sum(f.stat().st_size for f in Path(hf_home).rglob("*") if f.is_file())
|
||||
log(f"缓存总大小: {total / 1024 / 1024 / 1024:.2f} GB")
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
58
scripts/prefetch_models.sh
Normal file
58
scripts/prefetch_models.sh
Normal file
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
# 预拉模型权重到 ./models volume,避免容器启动时才下载。
|
||||
#
|
||||
# 用法:
|
||||
# ./scripts/prefetch_models.sh # 读 config.yaml(当前激活配置)
|
||||
# ./scripts/prefetch_models.sh config.gpu.yaml # 读指定配置文件
|
||||
#
|
||||
# 原理:用已构建的 audio2text 镜像跑一次性容器,挂载 ./models volume,
|
||||
# 执行 scripts/prefetch_models.py 把模型下到 HF cache。
|
||||
# 容器运行时 HF_HOME=/models/huggingface 命中缓存,秒级加载。
|
||||
#
|
||||
# 幂等:已下过的模型跳过。换 config 的 model 后重跑即可补下新模型。
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
ROOT="$(pwd)"
|
||||
|
||||
CONFIG="${1:-config.yaml}"
|
||||
VARIANT="${AUDIO2TEXT_VARIANT:-cpu}"
|
||||
|
||||
# 选镜像:优先用 gpu 镜像(GPU 模型大,可能要 cuda 才能 correctly 下载部分文件),
|
||||
# 否则 cpu。两者都能下 HF 模型(下载是纯网络 IO,不依赖 GPU)。
|
||||
IMAGE="audio2text:cpu"
|
||||
if docker image inspect audio2text:gpu >/dev/null 2>&1 && [ "$VARIANT" = "gpu" ]; then
|
||||
IMAGE="audio2text:gpu"
|
||||
fi
|
||||
|
||||
if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then
|
||||
echo "✗ 镜像 $IMAGE 不存在,请先 ./setup.sh 构建。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$ROOT/$CONFIG" ]; then
|
||||
echo "✗ 配置文件不存在: $ROOT/$CONFIG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$ROOT/models"
|
||||
|
||||
echo "==> 预拉模型"
|
||||
echo " 镜像: $IMAGE"
|
||||
echo " 配置: $CONFIG"
|
||||
echo " 缓存: $ROOT/models (volume)"
|
||||
echo
|
||||
|
||||
# 注意:CONFIG_PATH 指向容器内路径,挂载配置文件为只读
|
||||
MSYS_NO_PATHCONV=1 docker run --rm \
|
||||
-v "$ROOT/models:/models" \
|
||||
-v "$ROOT/$CONFIG:/app/config.yaml:ro" \
|
||||
-e HF_HOME=/models/huggingface \
|
||||
-e CT2_CACHE=/models/ctranslate2 \
|
||||
-e CONFIG_PATH=/app/config.yaml \
|
||||
"$IMAGE" \
|
||||
python /app/scripts/prefetch_models.py /app/config.yaml
|
||||
|
||||
echo
|
||||
echo "==> 完成。模型已缓存到 $ROOT/models/huggingface"
|
||||
echo " 容器启动时会命中缓存,无需联网下载。"
|
||||
Reference in New Issue
Block a user