Compare commits

..

12 Commits

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

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

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

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

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

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

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

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

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

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

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

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

每个子文档顶部加「← 返回主页」链接,相关处加交叉引用
(如 DEPLOYMENT 提到缓存时链接 DOCKER.md)。无内容丢失。
2026-07-06 22:59:05 +08:00
audio2text dev
4625650fc8 docs: README 补全新建/重建流程 + 缓存删除边界 + GPU 利用率优化
- 新增「新建/重建容器」子节:首次新建、重建镜像(改代码)、改配置、改依赖
  四种场景的明确操作,区分何时需要重建镜像、何时只需重启
- 新增「缓存分层与删除边界」:BuildKit/pip/模型/运行时数据四类缓存的删除
  影响对照表 + 何时主动清缓存的指引
- 记录之前踩的缓存失效根因:docker builder prune --filter until=Nm 会清掉
  稳定 base 层(CACHED 跳过的层访问时间不刷新 → 被误判可回收)。明确禁止
  使用 until filter,给出正确替代写法
- 新增「GPU 利用率优化」章节:faster-whisper 尖刺波成因(CPU Mel 特征提取
  与 GPU 解码未重叠)+ batch_size 16→32 / beam_size 5→2 的参数选择依据 +
  为什么不能关 word_timestamps(segmenter 强依赖)+ 验证方法
- 配置差异表从 6 项补到 8 项(加 asr.batch_size / asr.beam_size),修正
  translation.batch_size 16→32
- asr/translation 字段表补齐 batch_size / beam_size / sort_by_length 行
- 配置示例同步更新(加 beam_size、sort_by_length,修正 batch_size)
2026-07-06 22:35:33 +08:00
audio2text dev
a6b5c7231c perf: ASR GPU 利用率优化 — batch_size 16→32 + beam_size 5→2
faster-whisper 的 GPU 利用率呈尖刺波(峰=批量解码满载,谷=CPU 提取 Mel
特征 + 处理结果时 GPU 空闲),平均利用率低。瓶颈不在算力而在 CPU/GPU
未重叠。

- batch_size 16→32:拉长单次 GPU 解码时间,相对掩盖 CPU 特征提取间隙,
  尖刺变宽变平,平均利用率上升。turbo FP16 仅 ~1.6GB,3090 24G 充裕。
- beam_size 5→2:turbo 模型鲁棒,候选数 5→2 大幅减少解码步数,让 GPU
  峰更密、间隙更短。保留 1 个候选做歧义发音保险,质量损失小。
- beam_size 从硬编码提到 config 可调,CPU/CPU 模板/GPU/示例 四份配置对齐
- /health 增加 asr_beam_size,模型加载日志同步输出 batch+beam

word_timestamps 保留 True:segmenter 强依赖词级时间戳做精确断句,
关闭会触发匀速估算退化路径,得不偿失。
2026-07-06 22:28:37 +08:00
audio2text dev
e0dd987dba fix: ffmpeg 串行化(最多1个同时运行),上传不限制
问题:enqueue_task 每任务起一个 ffmpeg 线程,4 个文件同时跑 ffmpeg,
CPU/磁盘 IO 争抢导致整体变慢。

修复:
- scheduler.py: ffmpeg 改为单工作线程 + queue.Queue 串行队列
  enqueue_task 只入队不入线程,最多 1 个 ffmpeg 同时运行
- 上传接收不受限:complete 立即返回,不等待 ffmpeg
- GPU 调度线程不变(仍串行,与 ffmpeg 并行)

验证:4 文件并发上传,日志确认提取严格串行(40→41→42→43 无重叠),
排队任务显示 queued 状态,4/4 成功(21.0s)
2026-07-06 22:08:59 +08:00
audio2text dev
2f68c7e1f8 fix: 并发上传5文件刷新后丢失 + 分片写入阻塞事件循环
根因:
1. pump() 仅在 addFiles() 调用一次,上传完成/失败后不重新触发,
   导致 CONCURRENCY=3 之后的文件(4、5)永远不启动 → 无 DB 会话 → 刷新后消失
2. write_chunk 的 uploaded_chunks 是 read-modify-write,并发分片写入
   后者覆盖前者 → 分片记录丢失 → complete 报 409
3. upload_chunk 是 async 但同步调 write_chunk(fsync+DB commit),
   阻塞 uvicorn 事件循环 → 所有 web 请求被串行化

修复:
- _shared.py: pump() 加 finally 块,上传完成/失败后都触发下一文件;
  文件并发(FILE_CONCURRENCY=5)与分片并发(CHUNK_CONCURRENCY=3)分离
- upload_service.py: 按 upload_id 的进程级锁串行化 uploaded_chunks 更新,
  持锁后 db.refresh 重读最新值再 append,杜绝丢失更新;complete 后清理锁
- upload_router.py: upload_chunk 的 write_chunk 调用改用 run_in_threadpool,
  阻塞 I/O 移出事件循环,web 请求不再被分片写入阻塞

验证:5 文件并发上传后刷新全部可见;4 任务并发处理 4/4 成功(19.2s)
2026-07-06 22:04:06 +08:00
audio2text dev
73110848f4 feat: 调度器+并发管线+GPU优化+日志分级+前端修复
- scheduler: ffmpeg 异步线程 + GPU 串行调度 + 模型复用(2N→2 次加载)
- pipeline: 阶段拆分(extract/asr/translate),中间数据存 Task 字段
- translate_service: 长度排序批处理,padding 浪费减少 91%
- model_manager: ASR/翻译不共驻,BatchedInferencePipeline 批量解码
- 日志分级: INFO=任务流转里程碑,DEBUG=进度详情;默认 INFO
- 前端: 日志最新在上+滚动感知+退避轮询;24h 时间;上传中状态显示
- /health: 返回完整 Whisper/NLLB 配置
- upload_service: 单事务 complete + 扩展名白名单
- task_router: 合并 UploadSession 虚拟任务到列表
- Dockerfile: CPU/GPU 独立构建链,deps 缓存稳定
- prefetch_models: 安装时预下载模型权重
2026-07-06 21:59:59 +08:00
40 changed files with 2846 additions and 861 deletions

View File

@@ -8,3 +8,5 @@ logs/
*.pid
.git/
.gitignore
test/
build/

9
.gitignore vendored
View File

@@ -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/

View File

@@ -1,44 +1,81 @@
# syntax=docker/dockerfile:1.7
# audio2text — 一份 DockerfileCPU(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()=FalseCPU 预期)
# - GPU 版 torch.cuda.is_available()=TrueGPU 可调度)
# 因此 torch 必须按 VARIANT 分叉装不同 wheel绝不能跨 variant 共享依赖层。
# deps 阶段用 FROM base-${VARIANT}CPU/GPU 是两条独立构建链,各自装对应 torch。
ARG VARIANT=cpu
# ---------------------------------------------------------------------------
# 1. 基础镜像分叉CPU 用 slim PythonGPU 用 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 版 torchGPU 走默认 index带 CUDA 的轮子)
RUN if [ "$VARIANT" = "cpu" ]; then \
pip install --upgrade pip && \
# 【顺序关键】必须先装 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()=FalseGPU 版 =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. finalprod从 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
View File

@@ -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 # 一份 DockerfileARG 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 加载的类型化 Settingspydantic
@@ -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.4GB2GB 机 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` | ~3GBFP16 | 8x 速度,质量接近 large-v3 |
| 翻译 | `facebook/nllb-200-distilled-1.3B` | ~2.5GBFP16 | 质量最好的蒸馏版 |
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`~300MB2GB 机可跑。NLLB 同系列最小为 `distilled-600M`~1.2GB,需 ≥4GB 内存) |
| `device` | str | `cpu` | `cpu``cuda` |
| `src_lang` | str | `eng_Latn` | NLLB 语言码:英语 |
| `tgt_lang` | str | `zho_Hans` | NLLB 语言码:简体中文 |
| `batch_size` | int | `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 规范化
最后统一处理:单条 17 秒过短合并、≤2 行、每行 ≤42 字符(按词折行)。
---
## 模型不共驻(显存策略)
ASR 与翻译模型**不会同时驻留 GPU**。`model_manager.py` 单例跟踪当前加载的模型类型:
- `get_translator()`:若 ASR 在内存 → 先 `del WhisperModel` + `gc.collect()` +
`torch.cuda.empty_cache()` 释放显存 → 再加载 NLLB。
- `get_asr()`:若翻译器在内存 → 先卸载 → 再加载 Whisper。
翻译阶段独占显存,因此可用大 `batch_size`。24G 3090 上Whisper large-v3-turbo FP16
~3GB / NLLB-1.3B FP16 ~2.5GB,互不叠加,远低于显存上限。
---
## 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 + transformerstorch 按 VARIANT 分叉CPU/GPU 装不同 wheel。完整列表见 `requirements.txt`
- **系统**ffmpeg镜像内 apt 装、patchelf修复 ctranslate2 可执行栈。GPU 需宿主 NVIDIA 驱动 + nvidia container runtime
| 包 | 用途 |
|---|---|
| `fastapi` + `uvicorn[standard]` + `python-multipart` | Web 服务 |
| `pydantic` + `pydantic-settings` | 配置类型校验 |
| `PyYAML` | 读 config.yaml |
| `SQLAlchemy` | SQLite ORM |
| `faster-whisper` + `ctranslate2` | 语音识别 |
| `transformers` + `sentencepiece` + `accelerate` | NLLB 翻译 |
| `psutil` | 进程信息 |
torch 单独安装CPU / CUDA 轮子不同),不在 requirements.txt 中。
### 系统
- `ffmpeg`(镜像内 apt 装)—— 提取音频
- `patchelf`(镜像内 apt 装)—— 修复 ctranslate2 可执行栈
- GPU 镜像额外需要宿主 NVIDIA 驱动 + nvidia container runtime
---
## 常见问题
### Q: CPU 开发机能跑 NLLB 吗?
`config.cpu.yaml` 默认用 opus-mt-en-zh~300MB2GB 内存开发机即可跑通完整流程。
若想在 CPU 上验证 NLLB 翻译质量,可手动改 `translation.model`
- `facebook/nllb-200-distilled-600M`~1.2GB,同系列最小)——需 ≥4GB 内存2GB 机会 OOM。
- `facebook/nllb-200-distilled-1.3B`~2.5GBGPU 生产同款)——需 ~5GB 内存。
生产环境3090 24G用 NLLB-1.3B 质量最好。
### Q: 模型下载到哪里?每次启动都重下吗?
模型缓存到 `/models` volume`HF_HOME=/models/huggingface``CT2_CACHE=/models/ctranslate2`)。
首次启动下载,之后跨容器复用秒起。删除 `./models` 目录会强制重下。
### Q: 上传大视频中断了怎么办?
分片上传支持断点续传。重新上传同一文件时,前端先调 `status` 接口查已传分片,只补传缺失的。
分片可乱序、可重传覆盖。
### Q: 怎么保留原始视频不删?
`config.yaml``processing.delete_original_after_extract` 改为 `false`
注意:保留的视频仍受缓存清理策略约束——任务超期(默认 7 天)后会被 `cache_cleaner`
连同字幕一起删除。想永久保留请把 `storage.cache_retention_days` 设为 `0`(禁用清理)。
### Q: 字幕 / 任务记录多久会被自动清理?能禁用吗?
默认保留 7 天(`storage.cache_retention_days`)。超期任务的字幕、中间音频、保留的原始
视频连同 DB 记录一并删除,启动时跑一次 + 每 `cache_cleanup_interval_hours`(默认 24h
循环一次。设 `cache_retention_days: 0` 可禁用自动清理(产物永久保留,需自行管理磁盘)。
手动触发:`docker exec audio2text python -m app.services.cache_cleaner`
### Q: GPU 镜像构建好了但 start.sh 还是用 CPU
`start.sh` 检测到 `audio2text:gpu` 镜像**且**本机有 `nvidia-smi` 才用 GPU。确认宿主装了
NVIDIA 驱动 + nvidia container runtime。也可用 `docker compose --profile gpu up -d` 显式启动。
镜像构建与依赖安装细节见 [Docker 说明](./docs/DOCKER.md)。

View File

@@ -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"
device: str = "cpu" # cpu | cuda
compute_type: str = "int8" # cpu: int8gpu: float16
model: str = "tiny.en" # CPU: tiny.enGPU: large-v3-turbo
device: str = "cpu" # cpu | cuda
compute_type: str = "int8" # cpu: int8gpu: 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"
device: str = "cpu" # cpu | cuda
src_lang: str = "eng_Latn"
tgt_lang: str = "zho_Hans"
batch_size: int = 16
max_length: int = 256
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" # NLLB 语言码:英语
tgt_lang: str = "zho_Hans" # NLLB 语言码:简体中文
batch_size: int = 8 # 排序后单批最大条数CPU: 8GPU: 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()

View File

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

View File

@@ -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

View File

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

View File

@@ -1,7 +1,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 默认大小写不敏感ASCIIilike 等价于 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}

View File

@@ -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 调用 schedulerffmpeg 异步 + GPU 串行调度)service 不依赖
scheduler避免循环依赖
"""
resp = service.complete(upload_id)
# 仅新建任务时入队(幂等 complete 返回的也是同一 task_idenqueue 幂等无副作用)
from ..services.pipeline import enqueue_task
from ..services.scheduler import enqueue_task
enqueue_task(resp.task_id)
return resp

View File

@@ -51,12 +51,37 @@ class Base(DeclarativeBase):
def init_db_schema() -> None:
"""建表(幂等)。"""
"""建表(幂等)+ 旧库迁移(给 task 表补新字段)
SQLAlchemy 的 create_all 只建新表不改旧表。对已存在的 task 表,
需手动 ALTER TABLE ADD COLUMN 补 wav_path / segments_jsonnullable
"""
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]:

View File

@@ -19,21 +19,21 @@ import logging
import threading
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI
from fastapi import FastAPI
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.responses import HTMLResponse, JSONResponse
from sqlalchemy.orm import Session
from .config import get_settings
from .controllers import log_router, task_router, upload_router
from .controllers import log_router, settings_router, task_router, upload_router
from .database import get_db, init_db_schema
from .security import require_docs_auth
from .services.cache_cleaner import purge_expired_cache, run_forever as run_cache_cleaner
from .services.log_buffer import init_log_buffer
from .services.reaper import reap_stale_sessions
from .views.history_html import render as render_history_html
from .views.home_html import render as render_home_html
from .views.logs_html import render as render_logs_html
from .views.settings_html import render as render_settings_html
# 日志分层:
# - audio2text logger 始终设 DEBUG确保所有记录含子步骤都能产生。
@@ -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
View File

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

View File

@@ -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 阶段写 JSONtranslate 阶段读。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:

View File

@@ -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 → completedcomplete 成功)| abandonedreaper 清理)
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 NULLTask 被删时 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)

View File

@@ -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):

View File

@@ -1,4 +1,4 @@
"""/docs Basic Auth对齐 server/security.py。明文密码,常量时间比较。"""
"""/docs Basic Auth明文密码常量时间比较。"""
from __future__ import annotations

View File

@@ -6,7 +6,9 @@ CPU dev: tiny.en + int8GPU prod: large-v3-turbo + float16。同一份代码
from __future__ import annotations
import logging
import math
from pathlib import Path
from typing import Callable
from ..config import get_settings
from .model_manager import get_model_manager
@@ -14,12 +16,20 @@ from .types import Segment, Word
logger = logging.getLogger("audio2text.asr")
# Whisper 默认 chunk_lengthBatchedInferencePipeline 按 30s 窗口切音频
_CHUNK_SECONDS = 30.0
def transcribe(wav_path: Path) -> list[Segment]:
def transcribe(
wav_path: Path,
on_progress: Callable[[int, int], None] | None = None,
) -> list[Segment]:
"""转写 wav返回 segments含词级时间戳
Args:
wav_path: 16kHz mono PCM wav
on_progress: 可选进度回调 (current_chunk, total_chunks)。
每 transcribe 完一个 30s chunk 调一次,用于细分进度条。
Returns:
list[Segment],每个 Segment 带词级 words若 word_timestamps 启用)。
@@ -29,21 +39,31 @@ def transcribe(wav_path: Path) -> list[Segment]:
raise FileNotFoundError(f"音频不存在:{wav_path}")
model = get_model_manager().get_asr()
logger.debug("开始转写 %smodel=%s language=%s", wav_path.name, s.model, s.language)
logger.debug("开始转写 %smodel=%s language=%s batch_size=%d beam_size=%d",
wav_path.name, s.model, s.language, s.batch_size, s.beam_size)
segments_gen, info = model.transcribe(
str(wav_path),
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(
"音频时长 %.1fsVAD 后 %.1fs检测语言=%s(置信度 %.2f),约 %d 个 chunk",
info.duration, duration, info.language, info.language_probability, total_chunks,
)
if on_progress is not None:
on_progress(0, total_chunks)
segments: list[Segment] = []
last_chunk = 0 # 已报进度的 chunk 序号(避免同 chunk 内多个 segment 重复回调)
for seg in segments_gen:
words: list[Word] = []
if s.word_timestamps and getattr(seg, "words", None):
@@ -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

View File

@@ -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 模型无显式 closedel 即可
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=%dsort_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)

View File

@@ -1,25 +1,31 @@
"""转写管线编排:提取音频 → ASR → 断句 → 翻译 → 写 SRT。
"""转写管线各阶段:提取音频 → ASR → 断句 → 翻译 → 写 SRT。
任务状态机:
queued → extracting → transcribing → segmenting → translating → done
任一步失败 → failed
阶段拆分供 scheduler 调度ffmpeg 阶段独立线程CPUGPU 阶段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)
# ---------------- 阶段 2ASR + 断句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翻译 + 写 SRTGPU----------------
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
View 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

View File

@@ -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

View File

@@ -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,16 +118,28 @@ class UploadService:
chunk_path.unlink(missing_ok=True)
raise
uploaded = list(session.uploaded_chunks or [])
if index not in uploaded:
uploaded.append(index)
session.uploaded_chunks = uploaded
session.updated_at = datetime.now(timezone.utc)
self.db.commit()
# 持锁重读+更新:避免并发分片各自 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)
session.uploaded_chunks = uploaded
session.updated_at = datetime.now(timezone.utc)
self.db.commit()
return sorted(uploaded)
# ---------------- 拼接 + 创建任务 ----------------
# 允许的音视频扩展名白名单(防可执行文件落盘到上传目录)
_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 之间崩溃产生孤儿 TaskTask 已建但 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)

View File

@@ -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();
}}
}}

View File

@@ -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);
"""

View File

@@ -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) {{
async function deleteTask(taskId, btn) {{
if (!confirm("确认删除任务 #" + taskId + "?字幕和中间文件将被清除。")) return;
btn.disabled = true;
try {{
const r = await fetch("/api/tasks/" + taskId, {{ method: "DELETE" }});
if (!r.ok) {{
const err = await r.text();
alert("删除失败:" + err);
btn.disabled = false;
return;
}}
refreshList();
}} catch (e) {{
alert("删除失败:" + e);
btn.disabled = false;
}}
}}
async function pollTask(task) {{
// 上传会话:轮询 upload status 接口Task轮询 task 接口
const isUpload = task.is_upload === true && task.upload_id;
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("/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; }}
const r = await fetch(url);
if (!r.ok) {{ pollingIds.delete(task.id); pollingIds.delete(task.upload_id); return; }}
const data = await r.json();
card.innerHTML = renderTaskInner(task);
if (task.status === "done" || task.status === "failed") {{
pollingIds.delete(taskId);
return;
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);
}}
setTimeout(tick, POLL_INTERVAL);
}} catch (e) {{
setTimeout(tick, POLL_INTERVAL);
// 网络错误:指数退避,上限 30s
backoff = Math.min(backoff * 1.6, 30000);
setTimeout(tick, backoff);
}}
}};
tick();

View File

@@ -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,57 +73,87 @@ 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;
const row = document.createElement("div");
row.className = "log-row " + (LEVEL_CLASS[log.level] || "st-running");
row.dataset.key = key;
const hasTrace = !!log.traceback;
row.innerHTML = `
<span class="log-ts">${{escapeHtml(log.ts)}}</span>
<span class="status-tag ${{LEVEL_CLASS[log.level] || 'st-running'}}">${{escapeHtml(log.level)}}</span>
<span class="log-logger">${{escapeHtml(log.logger)}}</span>
<span class="log-msg">${{escapeHtml(log.msg)}}${{hasTrace ? ' <span class="trace-toggle">[traceback]</span>' : ''}}</span>`;
if (hasTrace) {{
const pre = document.createElement("pre");
pre.className = "trace";
pre.textContent = log.traceback;
pre.style.display = "none";
row.appendChild(pre);
row.querySelector(".trace-toggle").addEventListener("click", e => {{
e.stopPropagation();
pre.style.display = pre.style.display === "none" ? "block" : "none";
}});
}}
frag.appendChild(row);
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;
}}
logsEl.appendChild(frag);
while (logsEl.children.length > DEFAULT_TAIL) logsEl.removeChild(logsEl.firstChild);
logsEl.scrollTop = logsEl.scrollHeight;
}}
function scheduleNext() {{
if (autoRefresh) timer = setTimeout(fetchLogs, POLL_INTERVAL);
function buildRow(log, key) {{
const row = document.createElement("div");
row.className = "log-row " + (LEVEL_CLASS[log.level] || "st-running");
row.dataset.key = key;
const hasTrace = !!log.traceback;
row.innerHTML = `
<span class="log-ts">${{escapeHtml(log.ts)}}</span>
<span class="status-tag ${{LEVEL_CLASS[log.level] || 'st-running'}}">${{escapeHtml(log.level)}}</span>
<span class="log-logger">${{escapeHtml(log.logger)}}</span>
<span class="log-msg">${{escapeHtml(log.msg)}}${{hasTrace ? ' <span class="trace-toggle">[traceback]</span>' : ''}}</span>`;
if (hasTrace) {{
const pre = document.createElement("pre");
pre.className = "trace";
pre.textContent = log.traceback;
pre.style.display = "none";
row.appendChild(pre);
row.querySelector(".trace-toggle").addEventListener("click", e => {{
e.stopPropagation();
pre.style.display = pre.style.display === "none" ? "block" : "none";
}});
}}
return row;
}}
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
View File

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

View File

@@ -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 # 最轻量英译中(~300MBNLLB-600M 需 ~2.4GB2GB 机 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:

View File

@@ -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,36 +24,41 @@ processing:
keep_audio: false # 完成后是否保留中间 wav默认删只留字幕
asr:
# CPU devtiny.en + int8Whisper 同系列最小,英文专用)
# GPU prodlarge-v3-turbo + float163090 上几 GB 视频几分钟出字幕
model: tiny.en
device: cpu # cpu | cuda
compute_type: int8 # cpu: int8gpu: float16
language: en # 仅英语
word_timestamps: true # 词级时间戳:让断句精确而非纯匀速估算
vad_filter: true # 过滤静音段,提升质量与速度
# CPU devtiny.en + int8Whisper 同系列最小,英文专用~39M
# GPU prodlarge-v3-turbo + float168x 速度,质量接近 large-v3~3GB 显存)
model: tiny.en # CPU: tiny.en | GPU: large-v3-turbo
device: cpu # cpu | cuda
compute_type: int8 # CPU: int8 | GPU: float16
language: en # 仅英语
word_timestamps: true # 词级时间戳:让断句精确而非纯匀速估算
vad_filter: true # 过滤静音段,提升质量与速度
batch_size: 8 # CPU: 8 | GPU: 32BatchedInferencePipeline 批量解码音频块,拉长单次 GPU 解码掩盖 CPU 特征提取间隙)
beam_size: 5 # CPU: 5默认| GPU: 2turbo 鲁棒可降,候选数↓解码步数↓,加速明显质量损失小)
translation:
model: 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
max_length: 256 # 单条翻译最大 token
# CPU devHelsinki-NLP/opus-mt-en-zh~300MB2GB 内存可跑)
# GPU prodfacebook/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: 8 # CPU: 8 | GPU: 32(不共驻时显存独占可用大 batch
max_length: 256 # 单条翻译最大 token
sort_by_length: true # 按长度排序后分批,减少 padding 浪费GPU 收益大)
segmentation:
max_words_per_line: 14 # 单行最多词数,超出按逗号拆
max_duration_seconds: 7.0 # 单条字幕最长 7 秒
min_duration_seconds: 1.0 # 单条字幕最短 1 秒(太短则合并)
max_chars_per_line: 42 # SRT 规范:每行 ≤42 字符
max_words_per_line: 14 # 单行最多词数,超出按逗号拆
max_duration_seconds: 7.0 # 单条字幕最长 7 秒
min_duration_seconds: 1.0 # 单条字幕最短 1 秒(太短则合并)
max_chars_per_line: 42 # SRT 规范:每行 ≤42 字符
logging:
level: info # debug | info | warning | error控制台 + 内存缓冲最低级别)
buffer_size: 2000 # /logs 页面内存缓冲条数
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"

View File

@@ -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:

View File

@@ -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
View File

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

140
docs/ARCHITECTURE.md Normal file
View File

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

187
docs/CONFIG.md Normal file
View File

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

170
docs/DEPLOYMENT.md Normal file
View File

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

273
docs/DOCKER.md Normal file
View File

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

42
docs/FAQ.md Normal file
View File

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

137
scripts/prefetch_models.py Normal file
View 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())

View 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 " 容器启动时会命中缓存,无需联网下载。"