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 *.pid
.git/ .git/
.gitignore .gitignore
test/
build/

9
.gitignore vendored
View File

@@ -7,6 +7,7 @@ __pycache__/
# 运行时数据(走 docker volume不入库 # 运行时数据(走 docker volume不入库
data/ data/
data-gpu/
/models/ /models/
config.yaml config.yaml
*.pid *.pid
@@ -15,3 +16,11 @@ logs/
# 编辑器 # 编辑器
.vscode/ .vscode/
.idea/ .idea/
# 测试数据与脚本(本地测试用,不入库)
/test/
# 导出的 Docker 镜像 tar太大不入库
*.tar
build/

View File

@@ -1,44 +1,81 @@
# syntax=docker/dockerfile:1.7
# audio2text — 一份 DockerfileCPU(dev) / GPU(prod) 双形态。 # audio2text — 一份 DockerfileCPU(dev) / GPU(prod) 双形态。
# docker build --build-arg VARIANT=cpu -t audio2text:cpu . # docker build --build-arg VARIANT=cpu -t audio2text:cpu .
# docker build --build-arg VARIANT=gpu -t audio2text:gpu . # 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 ARG VARIANT=cpu
# ---------------------------------------------------------------------------
# 1. 基础镜像分叉CPU 用 slim PythonGPU 用 CUDA runtime + 手动装 python3.12
# ---------------------------------------------------------------------------
FROM python:3.12-slim AS base-cpu FROM python:3.12-slim AS base-cpu
# GPU 基础镜像带 CUDA 运行时torch 可装 CUDA 轮子
FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04 AS base-gpu 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 \ 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/* \ && 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/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 ARG VARIANT
ENV VARIANT=${VARIANT} \ ENV PYTHONUNBUFFERED=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \ PIP_NO_CACHE_DIR=1 \
HF_HOME=/models/huggingface \ HF_HOME=/models/huggingface \
CT2_CACHE=/models/ctranslate2 CT2_CACHE=/models/ctranslate2
# ffmpeg 是核心系统依赖,必须装 # ffmpeg 是核心系统依赖patchelf 用于修复 ctranslate2 可执行栈
RUN apt-get update -y && apt-get install -y --no-install-recommends \ 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 \ ffmpeg ca-certificates patchelf \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY requirements.txt /app/requirements.txt COPY requirements.txt /app/requirements.txt
# 【顺序关键】必须先装 torch按 VARIANT 分叉),再装 requirements。
# CPU 装 CPU 版 torchGPU 走默认 index带 CUDA 的轮子) # 原因requirements 里的 transformers / accelerate 依赖 torch若先装 requirements
RUN if [ "$VARIANT" = "cpu" ]; then \ # 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 && \ pip install --upgrade pip && \
if [ "$VARIANT" = "cpu" ]; then \
pip install torch --index-url https://download.pytorch.org/whl/cpu ; \ pip install torch --index-url https://download.pytorch.org/whl/cpu ; \
else \ else \
pip install --upgrade pip && \
pip install torch ; \ pip install torch ; \
fi 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 组合下 # ctranslate2 的 .so 带可执行栈标志PT_GNU_STACK X在某些内核 + Docker 组合下
# 会触发 "cannot enable executable stack as shared object requires"。用 patchelf # 会触发 "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; \ done; \
python -c "import ctranslate2; print('ctranslate2 stack fix verified', ctranslate2.__version__)" 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 app /app/app
COPY scripts /app/scripts
COPY config.example.yaml /app/config.example.yaml COPY config.example.yaml /app/config.example.yaml
# 运行时数据:上传 / 中间产物 / 输出字幕 / 模型缓存 # 运行时数据:上传 / 中间产物 / 输出字幕 / 模型缓存
# 全部走 volume镜像本身无状态、无敏感数据 # 全部走 volume镜像本身无状态、无敏感数据
VOLUME ["/data", "/models"] VOLUME ["/data", "/models"]
ENV CONFIG_PATH=/app/config.yaml ENV CONFIG_PATH=/app/config.yaml \
LD_LIBRARY_PATH=/usr/local/lib/python3.12/site-packages/nvidia/cudnn/lib:/usr/local/lib/python3.12/dist-packages/nvidia/cudnn/lib:/usr/local/lib/python3.12/site-packages/nvidia/cublas/lib:/usr/local/lib/python3.12/dist-packages/nvidia/cublas/lib \
HF_HUB_OFFLINE=1 \
TRANSFORMERS_OFFLINE=1
EXPOSE 8000 EXPOSE 8000
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

655
README.md
View File

@@ -9,41 +9,34 @@
--- ---
## 目录 ## 快速开始
- [功能特性](#功能特性) ### GPU 环境(需 NVIDIA GPU + nvidia runtime
- [架构](#架构)
- [部署CPU 开发环境](#部署cpu-开发环境) ```bash
- [部署GPU 生产环境](#部署gpu-生产环境) AUDIO2TEXT_VARIANT=gpu ./setup.sh
- [配置文件说明](#配置文件说明) ./start.sh # 端口 8001
- [缓存清理与定时任务](#缓存清理与定时任务) ```
- [HTTP 接口](#http-接口)
- [断句与时间戳重算原理](#断句与时间戳重算原理) 启动后打开 `http://127.0.0.1:8000/`拖入视频即可。详细部署流程、模型选型、CPU↔GPU 切换
- [模型不共驻(显存策略)](#模型不共驻显存策略) 见 [部署指南](./docs/DEPLOYMENT.md)
- [Docker 说明](#docker-说明)
- [依赖](#依赖)
- [常见问题](#常见问题)
--- ---
## 功能特性 ## 功能特性
- **主页** `/`:上传入口(拖拽 / 选择文件多文件、4 MiB 分片、断点续传+ 最近 10 个任务的实时进度卡片,完成的可直接下载字幕。 - **网页上传**拖拽 / 选择文件,多文件并发、4 MiB 分片、断点续传
- **历史任务页** `/history`:分页查看所有历史任务,可下载完成的字幕 - **双语字幕**:英文在上、中文在下,亦可单独下载英文 / 中文字幕
- **实时日志页** `/logs`按级别分层查看——debug=详细子步骤、info=仅阶段转换、error=完整 traceback。 - **faster-whisper 转写**:词级时间戳,断句精确(取首末词时间戳)
- **大视频处理**:接收完成后用 ffmpeg 提取 16 kHz 单声道 PCM 音频;是否删原始视频由配置决定。 - **NLLB-200 英译中**ASR 与翻译模型不共驻,翻译时独占显存跑大 batch
- **faster-whisper 转写英语**,带词级时间戳。 - **设置页**:运行时调整 batch_size / beam_size保存后对后续任务生效DB 持久化)
- **断句 + 时间戳重算**:按句末标点(`. ! ? ;`)切句、超长句按逗号拆,时间戳取首末词精确值; - **任务管理**:删除已完成/失败任务及其产物,进度条按批次细分
无词级时间戳时退化为段内匀速估算。 - **任务状态机**`queued -> uploading -> extracting -> transcribing -> segmenting -> translating -> done`
- **NLLB-200 英译中**ASR 与翻译模型**不共驻**,翻译时卸载 Whisper 独占显存跑大 batch。 - **实时日志页**按级别分层debug=详细子步骤 / info=阶段转换 / error=完整 traceback
- **双语合并 SRT** 输出(英文在上、中文在下),亦可单独下载英文 / 中文字幕。 - **定时缓存清理**:任务产物默认保留 7 天,超期连同 DB 记录一并删除
- **任务状态机**`queued → extracting → transcribing → segmenting → translating → done` - **SQLite 持久化**(自包含,无需外部 DB
页面自动轮询进度。 - **离线运行**:模型缓存就位后完全离线,无需访问 HuggingFace
- **定时缓存清理**:任务产物(字幕 / 中间音频 / 保留的原始视频)默认保留 7 天,超期后 - `/docs`Swagger UI公开访问
连同 DB 记录一并删除;容器内后台线程定时执行(启动时跑一次,默认每 24 小时一次),
保留期与间隔均可配置。
- **SQLite 持久化**(自包含,无需外部 DB
- `/docs`Swagger UI受 Basic Auth 保护。
--- ---
@@ -54,11 +47,13 @@ Spring 风格分层HTTP 边界controllers与业务逻辑services
``` ```
audio2text/ audio2text/
├── Dockerfile # 一份 DockerfileARG VARIANT=cpu|gpu 出两个镜像 ├── 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 命令) ├── setup.sh / start.sh / stop.sh # 安装 / 启动 / 停止(包装 docker 命令)
├── scripts/
│ └── prefetch_models.{sh,py} # 预拉模型权重到 ./models volume避免首次启动下载
├── requirements.txt ├── requirements.txt
├── config.example.yaml # 配置模板(复制为 config.yaml 后填值) ├── config.example.yaml # 配置模板(复制为 config.yaml 后填值)
├── README.md ├── docs/ # 详细文档(见下方索引)
└── app/ └── app/
├── main.py # FastAPI 应用工厂 ├── main.py # FastAPI 应用工厂
├── config.py # 从 config.yaml 加载的类型化 Settingspydantic ├── config.py # 从 config.yaml 加载的类型化 Settingspydantic
@@ -75,6 +70,7 @@ audio2text/
│ ├── segmenter.py # 断句 + 时间戳重算(纯算法,零模型依赖) │ ├── segmenter.py # 断句 + 时间戳重算(纯算法,零模型依赖)
│ ├── translate_service.py # NLLB 翻译 │ ├── translate_service.py # NLLB 翻译
│ ├── model_manager.py # 模型加载/卸载(不共驻核心) │ ├── model_manager.py # 模型加载/卸载(不共驻核心)
│ ├── scheduler.py # ffmpeg 串行队列 + GPU 调度线程
│ ├── pipeline.py # 编排提取→识别→断句→翻译→写SRT │ ├── pipeline.py # 编排提取→识别→断句→翻译→写SRT
│ ├── srt_writer.py # SRT 写入 + 双语合并 │ ├── srt_writer.py # SRT 写入 + 双语合并
│ ├── log_buffer.py # 内存日志缓冲(供 /logs 页面查询) │ ├── log_buffer.py # 内存日志缓冲(供 /logs 页面查询)
@@ -101,581 +97,102 @@ audio2text/
upload_router ──► upload_service ──► UploadSession(SQLite) + 分片落盘 upload_router ──► upload_service ──► UploadSession(SQLite) + 分片落盘
│ complete │ complete
创建 Task(queued) ──► pipeline 后台线程 创建 Task(queued) ──► scheduler
├─ 1. ffmpeg_service.extract_audio → 16k mono wav ├─ ffmpeg 串行队列(最多 1 个并发,其余排队)→ 16k mono wav
│ (按配置删原始视频) │ (按配置删原始视频)
├─ 2. model_manager.get_asr → asr_service.transcribe → segments(带词级时间戳) ├─ GPU 调度线程(单线程,模型复用):
├─ 3. segmenter.resegment → 规范字幕条目(精确/估算两路) │ get_asr → asr_service.transcribe → segments(带词级时间戳)
├─ 4. model_manager.unload_asr → get_translator unload_asr → get_translator
│ translate_service.translate → 中文译文(独占显存大 batch │ translate_service.translate → 中文译文(独占显存大 batch
└─ 5. srt_writer → en.srt / zh.srt / bilingual.srt └─ srt_writer → en.srt / zh.srt / bilingual.srt
更新 Task(done) + 写 output_dir 更新 Task(done) + 写 output_dir
``` ```
--- ---
## 部署CPU 开发环境 ## 快速开始
CPU 模式用于本地开发与流程验证模型选同系列最小尺寸2GB 内存开发机即可跑通完整流程。 ### CPU 开发环境2GB 内存即可)
### 前置要求
- Docker用于构建镜像 + 运行容器)
- 约 500 MB 磁盘(模型缓存)+ 上传视频空间
CPU 模式**不需要** NVIDIA 驱动,普通 Linux / macOS / WSL 均可。
### 步骤
```bash ```bash
cd /root/zikai/audio2text ./setup.sh # 构建镜像 + 生成 config.yaml
./start.sh # 启动容器(端口 8000
# 1. 构建 CPU 镜像 + 复制 config.cpu.yaml → config.yaml
./setup.sh # 默认 AUDIO2TEXT_VARIANT=cpu
# 2. 启动容器(默认端口 8000
./start.sh
# 3. 停止 / 重启
./stop.sh
./start.sh
``` ```
`setup.sh` 做三件事:检查 docker → 构建 `audio2text:cpu` 镜像 → 把 `config.cpu.yaml` ### GPU 生产环境(需 NVIDIA GPU + nvidia runtime
复制为 `config.yaml`(运行时实际读取的文件)。可重复执行;改完配置后重新 `cp` 并重启即可,
无需重建镜像。
首次启动会下载模型Whisper `tiny.en` ~39M + opus-mt ~300MB`./models` volume
之后秒起。启动后浏览器打开 `http://127.0.0.1:8000/`,拖入视频或音频文件即可。
### 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 ```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 视频几分钟出字幕。 已有 `audio2text-gpu.tar` 镜像文件时,新机器无需构建,直接导入即可启动(仍需 NVIDIA 驱动 +
nvidia container runtime + 模型缓存 `./models`
### 前置要求
- Docker
- **NVIDIA GPU 驱动**(宿主机)
- **nvidia container runtime**(让容器能用 GPU安装 `nvidia-container-toolkit`
- 约 6 GB 磁盘模型缓存large-v3-turbo ~3GB + NLLB-1.3B ~2.5GB
验证 GPU 可用:
```bash ```bash
nvidia-smi # 宿主能看到 GPU # 1. 导入镜像
docker run --rm --gpus all nvidia/cuda:12.1.0-runtime-ubuntu22.04 nvidia-smi docker load -i audio2text-gpu.tar
# 上面容器内也能列出 GPU 即说明 nvidia runtime 已就绪
# 2. 准备配置 + 数据目录
mkdir -p data-gpu/uploads data-gpu/.work data-gpu/outputs models
# 3. 启动容器config.gpu.yaml 需自行准备,或从项目仓库取)
docker run -d --name audio2text-gpu \
--gpus all \
-p 8001:8000 \
-v "$(pwd)/data-gpu:/data" \
-v "$(pwd)/models:/models" \
-v "$(pwd)/config.gpu.yaml:/app/config.yaml:ro" \
--restart unless-stopped \
audio2text:gpu
``` ```
### 步骤 > **模型缓存**`./models` 目录需包含 Whisper `large-v3-turbo` + NLLB `distilled-1.3B` 权重
> (约 5.5GB)。首次部署时从源机器拷贝 `models/` 目录,或联网用 `prefetch_models.sh` 预拉。
> 镜像内置 `HF_HUB_OFFLINE=1`,模型就位后完全离线运行,无需访问 HuggingFace。
```bash 详细步骤见 [Docker 说明 - 导入预构建镜像](./docs/DOCKER.md#导入预构建镜像离线部署)。
cd /root/zikai/audio2text
# 1. 构建 GPU 镜像 + 复制 config.gpu.yaml → config.yaml ---
AUDIO2TEXT_VARIANT=gpu ./setup.sh
# 2. 启动容器start.sh 检测到 gpu 镜像 + nvidia-smi 自动加 --gpus all ## 入口
./start.sh
# 3. 停止 / 重启
./stop.sh
./start.sh
```
`start.sh` 的镜像选择逻辑:若本机存在 `audio2text:gpu` 镜像**且**有 `nvidia-smi`,自动用
GPU 模式(`--gpus all`);否则回退 CPU 镜像。也可用 docker compose 显式启动:
```bash
docker compose --profile gpu up -d --build # GPU
docker compose --profile cpu up -d --build # CPU
```
### GPU 模型选型
| 组件 | 模型 | 显存 | 说明 |
|---|---|---|---|
| ASR | `large-v3-turbo` | ~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/` |
| 历史任务 | `http://127.0.0.1:8000/history`(分页查看所有任务,可按文件名搜索、下载字幕) | | 历史任务 | `http://127.0.0.1:8000/history` |
| 日志 | `http://127.0.0.1:8000/logs`(按级别分层、自动刷新) | | 实时日志 | `http://127.0.0.1:8000/logs` |
| API 文档 | `http://127.0.0.1:8000/docs`Basic Auth凭据见 config.yaml `docs` 段) | | API 文档 | `http://127.0.0.1:8000/docs` |
| 健康检查 | `http://127.0.0.1:8000/health` | | 健康检查 | `http://127.0.0.1:8000/health` |
| 任务列表 | `http://127.0.0.1:8000/api/tasks` |
--- ---
## 配置文件说明 ## 文档索引
项目预置两份配置文件,`setup.sh``AUDIO2TEXT_VARIANT` 自动复制对应文件为 详细文档按主题拆分,主页只保留核心速览:
`config.yaml`(运行时实际读取的文件,不入库):
| 文档 | 内容 |
| 文件 | 激活方式 | 说明 | |---|---|
|---|---|---| | [部署指南](./docs/DEPLOYMENT.md) | CPU / GPU 完整部署流程、前置要求、模型选型、CPU↔GPU 切换、自定义端口 |
| `config.cpu.yaml` | `./setup.sh`(默认) | CPU 开发,最小模型 | | [配置文件说明](./docs/CONFIG.md) | CPU/GPU 配置差异表、全部字段说明server/storage/asr/translation/...)、配置示例 |
| `config.gpu.yaml` | `AUDIO2TEXT_VARIANT=gpu ./setup.sh` | GPU 生产,质量优先 | | [Docker 说明](./docs/DOCKER.md) | 镜像构建、新建/重建/改配置/改依赖四种场景、**缓存分层与删除边界**、⚠️ until filter 失效根因、Volume 挂载 |
| `config.example.yaml` | — | 带完整注释的字段参考模板 | | [HTTP 接口](./docs/API.md) | 接口一览表、分片上传协议、请求/响应示例 |
| [架构与原理](./docs/ARCHITECTURE.md) | 断句算法、模型不共驻显存策略、GPU 利用率优化、缓存清理机制 |
也可手动切换:`cp config.gpu.yaml config.yaml` 后重启容器即可,无需重建镜像(镜像不含配置)。 | [常见问题](./docs/FAQ.md) | CPU 跑 NLLB、模型下载、断点续传、保留原始视频、自动清理等 |
运行时通过环境变量 `CONFIG_PATH` 指定路径(容器内默认 `/app/config.yaml`)。所有路径相对
容器内文件系统。`config.py` 用 pydantic 做类型校验,缺字段时回退默认值。
### CPU / GPU 两份配置的差异
其余字段存储、断句、日志、docs两份配置完全一致仅以下 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"` 验证。
--- ---
## 依赖 ## 依赖
### 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
| 包 | 用途 | 镜像构建与依赖安装细节见 [Docker 说明](./docs/DOCKER.md)。
|---|---|
| `fastapi` + `uvicorn[standard]` + `python-multipart` | Web 服务 |
| `pydantic` + `pydantic-settings` | 配置类型校验 |
| `PyYAML` | 读 config.yaml |
| `SQLAlchemy` | SQLite ORM |
| `faster-whisper` + `ctranslate2` | 语音识别 |
| `transformers` + `sentencepiece` + `accelerate` | NLLB 翻译 |
| `psutil` | 进程信息 |
torch 单独安装CPU / CUDA 轮子不同),不在 requirements.txt 中。
### 系统
- `ffmpeg`(镜像内 apt 装)—— 提取音频
- `patchelf`(镜像内 apt 装)—— 修复 ctranslate2 可执行栈
- GPU 镜像额外需要宿主 NVIDIA 驱动 + nvidia container runtime
---
## 常见问题
### Q: CPU 开发机能跑 NLLB 吗?
`config.cpu.yaml` 默认用 opus-mt-en-zh~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` 显式启动。

View File

@@ -1,6 +1,7 @@
"""运行时配置:所有参数从 config.yaml 读取,对齐 server/config.py 的风格 """运行时配置:所有参数从 config.yaml 读取。
CPU dev / GPU prod 仅靠 device / model / compute_type 三项切换,代码完全不变。 CPU dev / GPU prod 仅靠 device / model / compute_type 三项切换,代码完全不变。
默认值与 config.example.yaml 对齐,确保无 yaml 时也能用最小配置启动。
""" """
from __future__ import annotations from __future__ import annotations
@@ -40,21 +41,24 @@ class ProcessingConfig(BaseModel):
class AsrConfig(BaseModel): class AsrConfig(BaseModel):
model: str = "small" model: str = "tiny.en" # CPU: tiny.enGPU: large-v3-turbo
device: str = "cpu" # cpu | cuda device: str = "cpu" # cpu | cuda
compute_type: str = "int8" # cpu: int8gpu: float16 compute_type: str = "int8" # cpu: int8gpu: float16
language: str = "en" language: str = "en"
word_timestamps: bool = True word_timestamps: bool = True
vad_filter: 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): class TranslationConfig(BaseModel):
model: str = "facebook/nllb-200-distilled-1.3B" model: str = "Helsinki-NLP/opus-mt-en-zh" # CPU: opus-mt轻量GPU: facebook/nllb-200-distilled-1.3B
device: str = "cpu" # cpu | cuda device: str = "cpu" # cpu | cuda
src_lang: str = "eng_Latn" src_lang: str = "eng_Latn" # NLLB 语言码:英语
tgt_lang: str = "zho_Hans" tgt_lang: str = "zho_Hans" # NLLB 语言码:简体中文
batch_size: int = 16 batch_size: int = 8 # 排序后单批最大条数CPU: 8GPU: 32显存独占可用大 batch
max_length: int = 256 max_length: int = 256 # 单条最大生成 token 数
sort_by_length: bool = True # 按句子长度排序后分批,减少批内 padding 浪费GPU 收益大)
class SegmentationConfig(BaseModel): class SegmentationConfig(BaseModel):
@@ -68,8 +72,8 @@ class LoggingConfig(BaseModel):
"""日志配置:控制台 + 内存缓冲的最低级别,以及缓冲条数。 """日志配置:控制台 + 内存缓冲的最低级别,以及缓冲条数。
分层语义: 分层语义:
- debug详细ffmpeg 命令、模型加载/卸载、转写逐段、翻译逐批进度 - debug进度详情(任务 [status pct%]、转写/翻译逐批统计、ffmpeg 命令
- info简略(仅任务阶段转换,如 "任务 N [transcribing 55%]" - info任务流转里程碑(音频提取/ASR/翻译 的开始与完成、模型加载与卸载
- error详细错误完整 traceback由 logger.exception 自带) - 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 {} return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
# ---------------- 运行时覆盖 ----------------
# 允许通过设置页修改的配置项(点分路径 -> 类型。config.yaml 是只读挂载,
# 改它需重启容器;运行时覆盖存 DB进程重启后自动加载无需重建镜像。
# 设置页保存时调 save_setting() 写 DB + 清 lru_cache下次 get_settings() 生效。
_applying_overrides = False # 防递归标志_apply_overrides 内部 DB 初始化会回调 get_settings()
_OVERIDEABLE_FIELDS: dict[str, type] = {
"asr.batch_size": int,
"asr.beam_size": int,
"translation.batch_size": int,
"translation.sort_by_length": bool,
}
def _apply_overrides(settings: Settings) -> Settings:
"""从 DB 读取覆盖值并应用到 Settings 对象。
在 lru_cache 的 get_settings() 内部调用,保证缓存的对象已含覆盖。
DB 还没初始化时(首次 import静默跳过用 YAML 原值。
注意get_session_local() -> get_engine() -> _db_path() -> get_settings()
会形成递归。用 _applying_overrides 标志阻断:递归调用直接返回当前 settings
(此时 DB 路径只需 work_dir无覆盖也无妨
"""
global _applying_overrides
if _applying_overrides:
return settings # 递归调用_db_path 触发),直接返回 YAML 原值
_applying_overrides = True
try:
from .database import get_session_local
from .models.setting import Setting
import json
db = get_session_local()()
try:
rows = db.query(Setting).all()
overrides = {r.key: r.value for r in rows}
finally:
db.close()
for key, type_ in _OVERIDEABLE_FIELDS.items():
if key not in overrides:
continue
try:
val = json.loads(overrides[key])
val = type_(val)
except (json.JSONDecodeError, ValueError, TypeError):
continue
_set_nested(settings, key, val)
except Exception:
# DB 未就绪(首次 import 时 database.py 可能还在初始化)-> 跳过,用 YAML 原值
pass
finally:
_applying_overrides = False
return settings
def _set_nested(settings: Settings, key: str, val) -> None:
"""按点分路径设置嵌套属性,如 'asr.batch_size' -> settings.asr.batch_size"""
parts = key.split(".")
obj = settings
for p in parts[:-1]:
obj = getattr(obj, p)
setattr(obj, parts[-1], val)
@lru_cache(maxsize=1) @lru_cache(maxsize=1)
def get_settings() -> Settings: def get_settings() -> Settings:
"""读取 config.yaml + 应用 DB 覆盖,返回完整 Settings。
结果被 lru_cache 缓存。修改设置后调 reload_settings() 清缓存,
下次调用返回含新值的 Settings。
"""
path = Path(os.getenv("CONFIG_PATH", str(DEFAULT_CONFIG_PATH))) path = Path(os.getenv("CONFIG_PATH", str(DEFAULT_CONFIG_PATH)))
return Settings.model_validate(_load_yaml(path)) settings = Settings.model_validate(_load_yaml(path))
return _apply_overrides(settings)
def reload_settings() -> Settings: def reload_settings() -> Settings:
"""清缓存并重新读取,供脚本与测试使用。""" """清缓存并重新读取(含 DB 覆盖),供设置页保存后调用。"""
get_settings.cache_clear() get_settings.cache_clear()
return get_settings() return get_settings()
def save_setting(key: str, value) -> None:
"""保存单个配置项覆盖到 DB + 清 lru_cache。
Args:
key: 点分路径,必须在 _OVERIDEABLE_FIELDS 中
value: 要保存的值(自动 JSON 编码)
"""
import json
if key not in _OVERIDEABLE_FIELDS:
raise ValueError(f"不允许修改的配置项:{key}")
from .database import get_session_local
from .models.setting import Setting
type_ = _OVERIDEABLE_FIELDS[key]
encoded = json.dumps(type_(value))
db = get_session_local()()
try:
row = db.get(Setting, key)
if row is None:
row = Setting(key=key, value=encoded)
db.add(row)
else:
row.value = encoded
db.commit()
finally:
db.close()
# 清缓存,让后续 get_settings() 读到新值
get_settings.cache_clear()

View File

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

View File

@@ -1,9 +1,9 @@
"""日志路由:查询内存日志缓冲,供 /logs 页面消费。 """日志路由:查询内存日志缓冲,供 /logs 页面消费。
分层语义: 分层语义:
- debug详细ffmpeg 命令、模型加载/卸载、转写逐段、翻译逐批进度) - debug进度详情(任务 [status pct%]、转写逐段统计、翻译逐批进度、ffmpeg 命令
- info简略(仅任务阶段转换,如 "任务 N [transcribing 55%]" - info任务流转里程碑(音频提取/ASR/翻译 的开始与完成、模型加载与卸载、批次汇总
- warning / error更详细error 含完整 traceback - warning / error异常与失败error 含完整 traceback
""" """
from __future__ import annotations 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 from __future__ import annotations
import logging
import shutil
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
@@ -10,9 +13,11 @@ from sqlalchemy.orm import Session
from ..config import get_settings from ..config import get_settings
from ..database import get_db from ..database import get_db
from ..models.task import Task from ..models.task import Task, STATUS_UPLOADING, STATUS_DONE, STATUS_FAILED
from ..models.upload_session import UploadSession
from ..schemas.task import TaskListResponse, TaskResponse from ..schemas.task import TaskListResponse, TaskResponse
logger = logging.getLogger("audio2text.tasks")
router = APIRouter(prefix="/api/tasks", tags=["task"]) router = APIRouter(prefix="/api/tasks", tags=["task"])
@@ -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="任务列表") @router.get("", response_model=TaskListResponse, summary="任务列表")
def list_tasks( def list_tasks(
limit: int = Query(100, ge=1, le=500), limit: int = Query(100, ge=1, le=500),
@@ -35,14 +65,30 @@ def list_tasks(
q: str = Query("", description="按文件名模糊搜索(大小写不敏感,匹配子串)"), q: str = Query("", description="按文件名模糊搜索(大小写不敏感,匹配子串)"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> TaskListResponse: ) -> TaskListResponse:
q_base = db.query(Task).order_by(Task.id.desc()) # 上传中的会话pending 状态)也作为虚拟任务返回,让前端能看到上传进度
upload_q = db.query(UploadSession).filter(UploadSession.status == "pending")
if q.strip(): if q.strip():
# SQLite 的 LIKE 默认大小写不敏感ASCIIilike 等价于 LIKE
like = f"%{q.strip()}%" like = f"%{q.strip()}%"
q_base = q_base.filter(Task.filename.ilike(like)) upload_q = upload_q.filter(UploadSession.filename.ilike(like))
total = q_base.count() uploads = upload_q.order_by(UploadSession.created_at.desc()).all()
tasks = q_base.offset(offset).limit(limit).all()
return TaskListResponse(tasks=[_to_resp(t) for t in tasks], total=total) # 已创建的 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="任务状态") @router.get("/{task_id}", response_model=TaskResponse, summary="任务状态")
@@ -86,3 +132,47 @@ def download_subtitle(
media_type="application/x-subrip", media_type="application/x-subrip",
filename=download_name, filename=download_name,
) )
@router.delete("/{task_id}", summary="删除任务(仅允许已完成/失败)")
def delete_task(task_id: int, db: Session = Depends(get_db)) -> dict:
"""删除任务及其产物(字幕 / 中间音频 / 保留的原始视频)+ DB 记录。
仅允许删除已完成done或失败failed的任务进行中的任务不可删。
"""
task = db.get(Task, task_id)
if task is None:
raise HTTPException(404, f"任务不存在:{task_id}")
if task.status not in (STATUS_DONE, STATUS_FAILED):
raise HTTPException(409, f"任务进行中,无法删除(当前状态:{task.status}")
s = get_settings()
deleted: list[str] = []
# 删字幕输出目录
out_dir = s.output_dir() / f"task_{task.id}"
if out_dir.is_dir():
shutil.rmtree(out_dir, ignore_errors=True)
deleted.append("outputs")
# 删中间音频
if task.wav_path:
wav = Path(task.wav_path)
if wav.is_file():
wav.unlink(missing_ok=True)
deleted.append("audio")
# 删保留的原始视频
if task.source_path:
src = s.upload_dir() / task.source_path
if src.is_file():
src.unlink(missing_ok=True)
deleted.append("video")
# 删 DB 记录(先删关联的 UploadSession再删 Task
db.query(UploadSession).filter(UploadSession.task_id == task.id).delete()
db.delete(task)
db.commit()
logger.info("删除任务 %d%s%s", task_id, task.filename, ", ".join(deleted) or "无产物")
return {"status": "deleted", "task_id": task_id, "cleaned": deleted}

View File

@@ -1,11 +1,12 @@
"""分片上传路由:建会话 / 查状态 / 传分片 / complete。 """分片上传路由:建会话 / 查状态 / 传分片 / complete。
协议与 server 完全一致,区别仅在 complete 后创建的是转写 Task 而非 UploadedFile complete 成功后创建转写 Task 并交由 scheduler 入队
""" """
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from starlette.concurrency import run_in_threadpool
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
@@ -48,7 +49,10 @@ async def upload_chunk(
request: Request, request: Request,
service: UploadService = Depends(_service), service: UploadService = Depends(_service),
) -> ChunkUploadResponse: ) -> 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) return ChunkUploadResponse(upload_id=upload_id, index=index, uploaded_chunks=uploaded)
@@ -60,10 +64,11 @@ def complete_session(
"""拼接分片 + 创建转写任务 + 入队管线。 """拼接分片 + 创建转写任务 + 入队管线。
controller 负责编排service.complete 只管存储(拼接 + 建 Task controller 负责编排service.complete 只管存储(拼接 + 建 Task
管线触发由 controller 调用service 不依赖 pipeline避免循环依赖 管线触发由 controller 调用 schedulerffmpeg 异步 + GPU 串行调度)service 不依赖
scheduler避免循环依赖
""" """
resp = service.complete(upload_id) resp = service.complete(upload_id)
# 仅新建任务时入队(幂等 complete 返回的也是同一 task_idenqueue 幂等无副作用) # 仅新建任务时入队(幂等 complete 返回的也是同一 task_idenqueue 幂等无副作用)
from ..services.pipeline import enqueue_task from ..services.scheduler import enqueue_task
enqueue_task(resp.task_id) enqueue_task(resp.task_id)
return resp return resp

View File

@@ -51,12 +51,37 @@ class Base(DeclarativeBase):
def init_db_schema() -> None: 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.task import Task # noqa: F401
from .models.upload_session import UploadSession # noqa: F401 from .models.upload_session import UploadSession # noqa: F401
from .models.setting import Setting # noqa: F401
get_engine() engine = get_engine()
Base.metadata.create_all(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]: def get_db() -> Generator[Session, None, None]:

View File

@@ -19,21 +19,21 @@ import logging
import threading import threading
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI from fastapi import FastAPI
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .config import get_settings from .config import get_settings
from .controllers import log_router, task_router, upload_router from .controllers import log_router, settings_router, task_router, upload_router
from .database import get_db, init_db_schema from .database import get_db, init_db_schema
from .security import require_docs_auth
from .services.cache_cleaner import purge_expired_cache, run_forever as run_cache_cleaner from .services.cache_cleaner import purge_expired_cache, run_forever as run_cache_cleaner
from .services.log_buffer import init_log_buffer from .services.log_buffer import init_log_buffer
from .services.reaper import reap_stale_sessions from .services.reaper import reap_stale_sessions
from .views.history_html import render as render_history_html from .views.history_html import render as render_history_html
from .views.home_html import render as render_home_html from .views.home_html import render as render_home_html
from .views.logs_html import render as render_logs_html from .views.logs_html import render as render_logs_html
from .views.settings_html import render as render_settings_html
# 日志分层: # 日志分层:
# - audio2text logger 始终设 DEBUG确保所有记录含子步骤都能产生。 # - audio2text logger 始终设 DEBUG确保所有记录含子步骤都能产生。
@@ -76,6 +76,12 @@ async def lifespan(app: FastAPI):
await asyncio.to_thread(reap_stale_sessions) await asyncio.to_thread(reap_stale_sessions)
except Exception as exc: # pragma: no cover except Exception as exc: # pragma: no cover
logger.warning("启动 reaper 失败:%s", exc) 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() s = get_settings()
try: try:
@@ -120,20 +126,21 @@ def create_app() -> FastAPI:
app.include_router(upload_router) app.include_router(upload_router)
app.include_router(task_router) app.include_router(task_router)
app.include_router(log_router) app.include_router(log_router)
app.include_router(settings_router)
# 受 Basic Auth 保护的文档接口 # 文档接口(无认证,直接公开)
@app.get("/openapi.json") @app.get("/openapi.json")
def protected_openapi(_: str = Depends(require_docs_auth)) -> JSONResponse: def openapi_endpoint() -> JSONResponse:
return JSONResponse(app.openapi()) return JSONResponse(app.openapi())
@app.get("/docs") @app.get("/docs")
def protected_docs(_: str = Depends(require_docs_auth)): def docs_endpoint():
return get_swagger_ui_html( return get_swagger_ui_html(
openapi_url="/openapi.json", title="audio2text docs", swagger_favicon_url="" openapi_url="/openapi.json", title="audio2text docs", swagger_favicon_url=""
) )
@app.get("/redoc") @app.get("/redoc")
def protected_redoc(_: str = Depends(require_docs_auth)): def redoc_endpoint():
return get_redoc_html( return get_redoc_html(
openapi_url="/openapi.json", title="audio2text docs", redoc_favicon_url="" openapi_url="/openapi.json", title="audio2text docs", redoc_favicon_url=""
) )
@@ -144,7 +151,34 @@ def create_app() -> FastAPI:
@app.get("/health") @app.get("/health")
def health() -> dict: 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) @app.get("/history", response_class=HTMLResponse)
def history_page() -> HTMLResponse: def history_page() -> HTMLResponse:
@@ -154,6 +188,10 @@ def create_app() -> FastAPI:
def logs_page() -> HTMLResponse: def logs_page() -> HTMLResponse:
return HTMLResponse(render_logs_html()) return HTMLResponse(render_logs_html())
@app.get("/settings", response_class=HTMLResponse)
def settings_page() -> HTMLResponse:
return HTMLResponse(render_settings_html())
return app return app

36
app/models/setting.py Normal file
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) 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): class Task(Base):
__tablename__ = "task" __tablename__ = "task"
@@ -23,7 +34,7 @@ class Task(Base):
# 视频在 upload_dir 下的相对路径(提取音频前后可能被删) # 视频在 upload_dir 下的相对路径(提取音频前后可能被删)
source_path: Mapped[str] = mapped_column(String(1024), nullable=False) source_path: Mapped[str] = mapped_column(String(1024), nullable=False)
# 状态机queued → extracting → transcribing → segmenting → translating → done | failed # 状态机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 进度 # 0-100 进度
progress: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) 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) en_srt_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
zh_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) updated_at: Mapped[datetime] = mapped_column(DateTime, default=_now, onupdate=_now)
def __repr__(self) -> str: def __repr__(self) -> str:

View File

@@ -1,8 +1,9 @@
"""分片上传会话 ORM支撑断点续传。对齐 server 的 UploadSession 形态SQLite 版)。""" """分片上传会话 ORM支撑断点续传。"""
from __future__ import annotations from __future__ import annotations
import json import json
import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
@@ -11,6 +12,8 @@ from sqlalchemy.orm import Mapped, mapped_column
from ..database import Base from ..database import Base
logger = logging.getLogger("audio2text.models")
def _now() -> datetime: def _now() -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
@@ -26,7 +29,14 @@ class _IntList(TypeDecorator):
return json.dumps(value) if value is not None else None return json.dumps(value) if value is not None else None
def process_result_value(self, value: Any, dialect) -> list[int]: 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): class UploadSession(Base):
@@ -39,11 +49,13 @@ class UploadSession(Base):
total_chunks: Mapped[int] = mapped_column(Integer, nullable=False) total_chunks: Mapped[int] = mapped_column(Integer, nullable=False)
uploaded_chunks: Mapped[list[int]] = mapped_column(_IntList, default=list) uploaded_chunks: Mapped[list[int]] = mapped_column(_IntList, default=list)
# pending → completedcomplete 成功)| abandonedreaper 清理) # 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 路径 # 拼接完成后的视频相对 upload_dir 路径
final_path: Mapped[str | None] = mapped_column(String(1024), nullable=True) final_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
# complete 后关联的 Task.id(直接引用,避免反向查找 source_path # complete 后关联的 Task.id。ondelete=SET NULLTask 被删时 session 保留task_id 置空
task_id: Mapped[int | None] = mapped_column(ForeignKey("task.id"), nullable=True) 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) updated_at: Mapped[datetime] = mapped_column(DateTime, default=_now, onupdate=_now)

View File

@@ -62,6 +62,11 @@ class TaskResponse(BaseModel):
error: str | None error: str | None
created_at: datetime created_at: datetime
updated_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): class TaskListResponse(BaseModel):

View File

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

View File

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

View File

@@ -54,15 +54,19 @@ class ModelManager:
if self._translator is not None: if self._translator is not None:
self._unload_translator_locked() self._unload_translator_locked()
s = get_settings().asr 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) 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 # 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, s.model, device=s.device, compute_type=s.compute_type,
) )
self._asr = BatchedInferencePipeline(model=whisper)
self._current = "asr" 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 return self._asr
def unload_asr(self) -> None: def unload_asr(self) -> None:
@@ -72,7 +76,7 @@ class ModelManager:
def _unload_asr_locked(self) -> None: def _unload_asr_locked(self) -> None:
if self._asr is None: if self._asr is None:
return return
logger.debug("卸载 ASR 模型(释放显存供翻译器独占)。") logger.info("卸载 ASR 模型(释放显存供翻译器独占)。")
# faster-whisper 模型无显式 closedel 即可 # faster-whisper 模型无显式 closedel 即可
del self._asr del self._asr
self._asr = None self._asr = None
@@ -89,7 +93,7 @@ class ModelManager:
if self._asr is not None: if self._asr is not None:
self._unload_asr_locked() self._unload_asr_locked()
s = get_settings().translation 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 from transformers import pipeline
self._translator = pipeline( self._translator = pipeline(
"translation", "translation",
@@ -97,9 +101,11 @@ class ModelManager:
device=s.device, device=s.device,
src_lang=s.src_lang, src_lang=s.src_lang,
tgt_lang=s.tgt_lang, tgt_lang=s.tgt_lang,
batch_size=s.batch_size, # pipeline 内部批大小,与 translate_service 分块对齐
) )
self._current = "translator" self._current = "translator"
logger.debug("翻译模型已就绪(独占显存,可用大 batch)。") logger.info("翻译模型已就绪(显存独占batch_size=%dsort_by_length=%s)。",
s.batch_size, s.sort_by_length)
return self._translator return self._translator
def unload_translator(self) -> None: def unload_translator(self) -> None:
@@ -109,7 +115,7 @@ class ModelManager:
def _unload_translator_locked(self) -> None: def _unload_translator_locked(self) -> None:
if self._translator is None: if self._translator is None:
return return
logger.debug("卸载翻译模型。") logger.info("卸载翻译模型。")
# 释放 pipeline 持有的 model + tokenizer # 释放 pipeline 持有的 model + tokenizer
mdl = getattr(self._translator, "model", None) mdl = getattr(self._translator, "model", None)
tok = getattr(self._translator, "tokenizer", None) tok = getattr(self._translator, "tokenizer", None)

View File

@@ -1,25 +1,31 @@
"""转写管线编排:提取音频 → ASR → 断句 → 翻译 → 写 SRT。 """转写管线各阶段:提取音频 → ASR → 断句 → 翻译 → 写 SRT。
任务状态机: 阶段拆分供 scheduler 调度ffmpeg 阶段独立线程CPUGPU 阶段ASR+翻译)
queued → extracting → transcribing → segmenting → translating → done 由 scheduler 串行化并在切换模型前查队列复用已加载模型。
任一步失败 → failed
模型不共驻ASR 与翻译分阶段加载,翻译时先卸载 Whisper 释放显存跑大 batch。 每个阶段函数接收 db Session + Task更新状态/进度,写入中间产物:
管线在后台线程跑(每个任务一个线程),通过 DB 更新状态与进度。 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 from __future__ import annotations
import json
import logging import logging
import threading
import traceback import traceback
from dataclasses import asdict
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from ..config import get_settings from ..config import get_settings
from ..database import get_session_local from ..models.task import (
from ..models.task import Task STATUS_EXTRACTING, STATUS_TRANSCRIBING, STATUS_SEGMENTING,
from . import ffmpeg_service, asr_service, segmenter, translate_service, srt_writer STATUS_TRANSLATING, STATUS_DONE, STATUS_FAILED,
)
from . import asr_service, ffmpeg_service, segmenter, srt_writer, translate_service
from .types import Subtitle from .types import Subtitle
logger = logging.getLogger("audio2text.pipeline") logger = logging.getLogger("audio2text.pipeline")
@@ -35,35 +41,17 @@ P_TRANSLATE_END = 98.0
P_DONE = 100.0 P_DONE = 100.0
def enqueue_task(task_id: int) -> None: # ---------------- 阶段 1提取音频CPU可并行----------------
"""把任务交给后台线程处理(非阻塞,供 upload_service.complete 调用)。"""
t = threading.Thread(target=_run_task, args=(task_id,), daemon=True)
t.start()
logger.info("任务 %d 已入队(后台线程 %s)。", task_id, t.name)
def extract_phase(db, task) -> None:
"""ffmpeg 提取 16k mono wav写 task.wav_path状态置 transcribing。
def _run_task(task_id: int) -> None: 由 scheduler 在独立线程调用(与 GPU 阶段并行)。提取完即可让 GPU 调度线程接管。
"""后台执行完整管线。所有异常都被捕获并写入 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:
s = get_settings() s = get_settings()
src = s.upload_dir() / task.source_path src = s.upload_dir() / task.source_path
logger.info("任务 %d [音频提取开始] %s", task.id, src.name)
# ---------- 1. 提取音频 ---------- _set_status(db, task, STATUS_EXTRACTING, P_EXTRACT)
_set_status(db, task, "extracting", P_EXTRACT)
wav = s.work_dir() / f"task_{task.id}.wav" wav = s.work_dir() / f"task_{task.id}.wav"
ffmpeg_service.extract_audio(src, wav) ffmpeg_service.extract_audio(src, wav)
@@ -75,61 +63,125 @@ def _pipeline(db, task: Task) -> None:
except OSError as exc: except OSError as exc:
logger.warning("删除原始视频失败 %s: %s", src, exc) logger.warning("删除原始视频失败 %s: %s", src, exc)
# ---------- 2. 语音识别 ---------- # 记录 wav 路径,状态置 transcribing待 GPU 调度线程接管 ASR
_set_status(db, task, "transcribing", P_TRANSCRIBE_START) task.wav_path = str(wav)
segments = asr_service.transcribe(wav) task.status = STATUS_TRANSCRIBING
_set_status(db, task, "transcribing", P_TRANSCRIBE_END, 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)}") note=f"识别出 {len(segments)}")
# ---------- 3. 断句 + 时间戳重算 ---------- _set_status(db, task, STATUS_SEGMENTING, P_SEGMENT_START)
_set_status(db, task, "segmenting", P_SEGMENT_START)
subs = segmenter.resegment(segments) 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)} 条字幕") note=f"重组为 {len(subs)} 条字幕")
# ---------- 4. 翻译 ---------- # 序列化断句结果供翻译阶段用dataclass → JSON
_set_status(db, task, "translating", P_TRANSLATE_START) task.segments_json = json.dumps([asdict(s) for s in subs], ensure_ascii=False)
# 翻译阶段model_manager 会自动卸载 ASR、加载翻译器独占显存 task.status = STATUS_TRANSLATING
zh_texts = translate_service.translate(subs) task.progress = P_TRANSLATE_START
_set_status(db, task, "translating", P_TRANSLATE_END, 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)}") note=f"翻译 {len(zh_texts)}")
# ---------- 5. 写 SRT ---------- # 写 SRT
out_dir = s.output_dir() out_dir = s_output_dir()
stem = Path(task.filename).stem stem = Path(task.filename).stem
en_path = out_dir / f"task_{task.id}/{stem}.en.srt" en_path = out_dir / f"task_{task.id}/{stem}.en.srt"
zh_path = out_dir / f"task_{task.id}/{stem}.zh.srt" zh_path = out_dir / f"task_{task.id}/{stem}.zh.srt"
bi_path = out_dir / f"task_{task.id}/{stem}.srt" bi_path = out_dir / f"task_{task.id}/{stem}.srt"
srt_writer.write_srt(subs, en_path) srt_writer.write_srt(subs, en_path)
# 中文 SRT用译文 + 同时间戳)
zh_subs = [Subtitle(text=zh, start=sub.start, end=sub.end) zh_subs = [Subtitle(text=zh, start=sub.start, end=sub.end)
for zh, sub in zip(zh_texts, subs)] for zh, sub in zip(zh_texts, subs)]
srt_writer.write_srt(zh_subs, zh_path) srt_writer.write_srt(zh_subs, zh_path)
srt_writer.write_bilingual_srt(subs, zh_texts, bi_path) srt_writer.write_bilingual_srt(subs, zh_texts, bi_path)
# 记录相对路径
task.en_srt_path = str(en_path.relative_to(out_dir)) task.en_srt_path = str(en_path.relative_to(out_dir))
task.zh_srt_path = str(zh_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.bilingual_srt_path = str(bi_path.relative_to(out_dir))
task.status = "done" task.status = STATUS_DONE
task.progress = P_DONE task.progress = P_DONE
task.updated_at = datetime.now(timezone.utc) task.updated_at = datetime.now(timezone.utc)
db.commit() 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: try:
wav.unlink() Path(task.wav_path).unlink()
except OSError: except OSError:
pass 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.status = status
task.progress = progress task.progress = progress
task.updated_at = datetime.now(timezone.utc) 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: if note:
logger.info("任务 %d [%s %.0f%%] %s", task.id, status, progress, note) logger.info("任务 %d [%s %.0f%%] %s", task.id, status, progress, note)
else: 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: try:
task = db.get(Task, task_id) task = db.get(Task, task_id)
if task is None: if task is None:
return return
task.status = "failed" task.status = STATUS_FAILED
task.error = error[:2000] task.error = error[:2000]
task.updated_at = datetime.now(timezone.utc) task.updated_at = datetime.now(timezone.utc)
db.commit() 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英译中。 """翻译服务NLLB-200英译中。
通过 model_manager 加载,确保 ASR 已卸载、翻译器独占显存,从而可用大 batch_size。 通过 model_manager 加载,确保 ASR 已卸载、翻译器独占显存,从而可用大 batch_size。
按字幕条目批量翻译,保留索引对应。
GPU 优化:按长度排序后分批翻译。
- 同一批内句子长度相近 → padding 浪费最小化 → GPU 有效计算占比提升
- 翻译完按原始下标散回,保证 zh_texts[i] 对应 subs[i](时间戳对齐不变)
- 批切分用 token 预算 + 条数上限双重约束:短句自动攒大批,长句自动拆小批
单条翻译失败时该位置回退为原英文。
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
from typing import Callable
from ..config import get_settings from ..config import get_settings
from .model_manager import get_model_manager from .model_manager import get_model_manager
@@ -14,12 +22,19 @@ from .types import Subtitle
logger = logging.getLogger("audio2text.translate") 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: Args:
subtitles: 断句后的英文字幕条目 subtitles: 断句后的英文字幕条目(按时间顺序)
on_progress: 可选进度回调 (done_count, total_count),每批完成时调一次。
Returns: Returns:
list[str],与 subtitles 等长、顺序对应的中文译文。 list[str],与 subtitles 等长、顺序对应的中文译文。
@@ -30,31 +45,148 @@ def translate(subtitles: list[Subtitle]) -> list[str]:
s = get_settings().translation s = get_settings().translation
pipe = get_model_manager().get_translator() pipe = get_model_manager().get_translator()
batch = s.batch_size batch_size = s.batch_size
max_len = s.max_length max_len = s.max_length
# 取纯文本(去掉折行),避免翻译把换行符当语义 # 取纯文本(去掉折行),避免翻译把换行符当语义
texts = [sub.text.replace("\n", " ").strip() for sub in subtitles] 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] = [] # 环境变量覆盖:便于 A/B 基准对比test/bench_translate.py 用)
for i in range(0, len(texts), batch): if os.environ.get("TRANSLATE_NO_SORT") == "1":
chunk = texts[i:i + batch] sort_by_length = False
try:
out = pipe(chunk, max_length=max_len) if sort_by_length:
for item in out: results = _translate_sorted(pipe, texts, batch_size, max_len, on_progress)
# pipeline 返回 [{"translation_text": "..."}] else:
results.append(item.get("translation_text", "").strip()) results = _translate_sequential(pipe, texts, batch_size, max_len, on_progress)
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))
logger.debug("翻译完成:%d 条。", len(results)) logger.debug("翻译完成:%d 条。", len(results))
return 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 后的正式视频 <upload_dir>/<yyyy>/<mm>/<uuid>.<ext> complete 后的正式视频
与 server 的区别:视频无需 sha256 去重(每个视频都转写),complete 直接创建 Task complete 创建转写 Task,管线触发由 controller 调用 scheduler.enqueue_task
管线触发由 controller 调用 pipeline.enqueue_task本服务不依赖 pipeline 本服务不依赖 scheduler避免循环依赖
""" """
from __future__ import annotations from __future__ import annotations
@@ -16,6 +16,7 @@ from __future__ import annotations
import logging import logging
import os import os
import shutil import shutil
import threading
import uuid import uuid
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
@@ -36,6 +37,20 @@ from ..schemas.task import (
logger = logging.getLogger("audio2text.upload") 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: class UploadService:
def __init__(self, db: Session) -> None: def __init__(self, db: Session) -> None:
@@ -93,6 +108,7 @@ class UploadService:
session_dir.mkdir(parents=True, exist_ok=True) session_dir.mkdir(parents=True, exist_ok=True)
chunk_path = session_dir / f"{index}.part" chunk_path = session_dir / f"{index}.part"
# 先落盘无锁IO 可并行),再持锁更新 DB 计数
try: try:
with chunk_path.open("wb") as out: with chunk_path.open("wb") as out:
out.write(data) out.write(data)
@@ -102,6 +118,10 @@ class UploadService:
chunk_path.unlink(missing_ok=True) chunk_path.unlink(missing_ok=True)
raise raise
# 持锁重读+更新:避免并发分片各自 read old list → append → write后者覆盖前者
lock = _get_chunk_lock(upload_id)
with lock:
self.db.refresh(session) # 拿最新 uploaded_chunks不被其他并发请求的旧值覆盖
uploaded = list(session.uploaded_chunks or []) uploaded = list(session.uploaded_chunks or [])
if index not in uploaded: if index not in uploaded:
uploaded.append(index) uploaded.append(index)
@@ -112,6 +132,14 @@ class UploadService:
# ---------------- 拼接 + 创建任务 ---------------- # ---------------- 拼接 + 创建任务 ----------------
# 允许的音视频扩展名白名单(防可执行文件落盘到上传目录)
_ALLOWED_EXTS = frozenset({
".mp4", ".mkv", ".avi", ".mov", ".webm", ".flv",
".mp3", ".wav", ".flac", ".aac", ".m4a", ".ogg", ".wma",
})
# ---------------- 拼接 + 创建任务 ----------------
def complete(self, upload_id: str) -> CompleteResponse: def complete(self, upload_id: str) -> CompleteResponse:
session = self._require_session(upload_id) session = self._require_session(upload_id)
@@ -135,6 +163,8 @@ class UploadService:
final_path = self._assemble(session) final_path = self._assemble(session)
rel = str(final_path.relative_to(self.upload_root)) rel = str(final_path.relative_to(self.upload_root))
# 单事务:建 Task + 更新 session 状态 + 关联 task_id 一次 commit
# 避免双 commit 之间崩溃产生孤儿 TaskTask 已建但 session.task_id 为空)
task = Task( task = Task(
filename=session.filename, filename=session.filename,
source_path=rel, source_path=rel,
@@ -144,15 +174,18 @@ class UploadService:
self.db.add(task) self.db.add(task)
session.status = "completed" session.status = "completed"
session.final_path = rel session.final_path = rel
session.task_id = None # 占位flush 后用 task.id 赋值
session.updated_at = datetime.now(timezone.utc) session.updated_at = datetime.now(timezone.utc)
self.db.commit() self.db.flush() # 拿到 task.id不 commit仍在事务内
self.db.refresh(task)
# 正向关联session → task替代旧的 source_path 反向查找)
session.task_id = task.id session.task_id = task.id
self.db.commit() self.db.commit()
self.db.refresh(task)
# 清理分片暂存 # 清理分片暂存commit 后,即使清理失败也不影响已建任务)
self._cleanup_session_dir(upload_id) 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) logger.info("上传完成 task_id=%s file=%s size=%d", task.id, session.filename, session.size_bytes)
return CompleteResponse( return CompleteResponse(
@@ -215,7 +248,10 @@ class UploadService:
def _assemble(self, session: UploadSession) -> Path: def _assemble(self, session: UploadSession) -> Path:
"""按 index 顺序拼接全部分片为正式视频文件。""" """按 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) now = datetime.now(timezone.utc)
sub = self.upload_root / f"{now:%Y}" / f"{now:%m}" sub = self.upload_root / f"{now:%Y}" / f"{now:%m}"
sub.mkdir(parents=True, exist_ok=True) sub.mkdir(parents=True, exist_ok=True)

View File

@@ -10,7 +10,8 @@ from __future__ import annotations
# 分片上传参数home / upload 共用) # 分片上传参数home / upload 共用)
DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024 DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024
DEFAULT_CONCURRENCY = 3 DEFAULT_CONCURRENCY = 3 # 单文件内分片并发数
DEFAULT_FILE_CONCURRENCY = 5 # 同时上传的文件数(不阻塞 web 请求/ffmpeg/gpu
MAX_RETRY = 2 MAX_RETRY = 2
POLL_INTERVAL = 2000 POLL_INTERVAL = 2000
@@ -18,6 +19,7 @@ _NAV_ITEMS = [
("/", "主页", "home"), ("/", "主页", "home"),
("/history", "历史", "history"), ("/history", "历史", "history"),
("/logs", "日志", "logs"), ("/logs", "日志", "logs"),
("/settings", "设置", "settings"),
] ]
@@ -202,10 +204,11 @@ a { color: var(--accent); text-decoration: none; }
SHARED_JS = """ SHARED_JS = """
// 任务状态中文标签 // 任务状态中文标签
const STATUS_LABEL = { const STATUS_LABEL = {
queued: "排队中", extracting: "提取音频", transcribing: "语音识别", uploading: "上传中", queued: "排队中", extracting: "提取音频",
segmenting: "断句重算", translating: "翻译中", done: "完成", failed: "失败" transcribing: "语音识别", segmenting: "断句重算", translating: "翻译中",
done: "完成", failed: "失败"
}; };
const ACTIVE_STATES = ["queued","extracting","transcribing","segmenting","translating"]; const ACTIVE_STATES = ["uploading","queued","extracting","transcribing","segmenting","translating"];
// HTML 转义(防 XSS // HTML 转义(防 XSS
function escapeHtml(s) { function escapeHtml(s) {
@@ -222,6 +225,16 @@ function fmtBytes(n) {
return u === 0 ? x + " B" : x.toFixed(1) + " " + units[u]; 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 个并发 // 并发池indices 中的每个元素交给 worker最多 concurrency 个并发
async function runPool(indices, concurrency, worker) { async function runPool(indices, concurrency, worker) {
let cursor = 0; let cursor = 0;
@@ -248,7 +261,8 @@ def render_upload_js(on_complete: str) -> str:
""" """
return f""" return f"""
const CHUNK_SIZE = {DEFAULT_CHUNK_SIZE}; 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 MAX_RETRY = {MAX_RETRY};
const UPLOAD_API = "/api/tasks/chunk-uploads"; const UPLOAD_API = "/api/tasks/chunk-uploads";
@@ -304,13 +318,13 @@ function setUploadProgress(t, pct) {{
}} }}
function pump() {{ function pump() {{
// 统计正在上传的文件数,启动等待中的文件直到达到 FILE_CONCURRENCY
const active = pending.filter(t => t.state === "running").length; const active = pending.filter(t => t.state === "running").length;
for (const t of pending) {{ for (const t of pending) {{
if (active >= CONCURRENCY) break; if (active >= FILE_CONCURRENCY) break;
if (t.state === "pending") {{ if (t.state === "pending") {{
t.state = "running"; setUploadState(t, "running");
startUpload(t); startUpload(t);
active++;
}} }}
}} }}
}} }}
@@ -331,7 +345,7 @@ async function startUpload(t) {{
const need = []; const need = [];
for (let i = 0; i < t.totalChunks; i++) if (!t.uploaded.has(i)) need.push(i); 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("部分分片未能上传"); if (t.uploaded.size < t.totalChunks) throw new Error("部分分片未能上传");
setUploadState(t, "hashing"); setUploadState(t, "hashing");
@@ -348,6 +362,9 @@ async function startUpload(t) {{
meta.className = "task-meta fail-msg"; meta.className = "task-meta fail-msg";
meta.textContent = String(e.message || e); meta.textContent = String(e.message || e);
t.el.appendChild(meta); t.el.appendChild(meta);
}} finally {{
// 无论成功还是失败,都触发 pump 让队列中下一个文件开始上传
pump();
}} }}
}} }}

View File

@@ -68,7 +68,11 @@ function renderTable(tasks) {{
const tr = document.createElement("tr"); const tr = document.createElement("tr");
const label = STATUS_LABEL[task.status] || task.status; const label = STATUS_LABEL[task.status] || task.status;
const stateClass = "st-" + (task.status === "done" ? "done" : task.status === "failed" ? "fail" : "running"); 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; let action;
if (task.status === "done") {{ if (task.status === "done") {{
@@ -78,16 +82,24 @@ function renderTable(tasks) {{
}} else if (task.status === "failed") {{ }} else if (task.status === "failed") {{
action = `<span class="err-tip" title="${{escapeHtml(task.error || "")}}">查看错误</span>`; action = `<span class="err-tip" title="${{escapeHtml(task.error || "")}}">查看错误</span>`;
}} else {{ }} else {{
action = `<span class="muted"></span>`; action = `<span class="muted">-</span>`;
}}
// done/failed 且非上传中:加删除按钮
const canDelete = (task.status === "done" || task.status === "failed") && !task.is_upload;
if (canDelete) {{
action += ` <button class="btn-sm" onclick="deleteTask(${{task.id}})">删除</button>`;
}} }}
let progress; let progress;
if (task.status === "done") progress = "100%"; if (task.status === "done") progress = "100%";
else if (task.status === "failed") progress = ""; 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 = ` tr.innerHTML = `
<td class="muted">#${{task.id}}</td> <td class="muted">${{idDisplay}}</td>
<td>${{escapeHtml(task.filename)}}</td> <td>${{escapeHtml(task.filename)}}</td>
<td><span class="status-tag ${{stateClass}}">${{label}}</span></td> <td><span class="status-tag ${{stateClass}}">${{label}}</span></td>
<td>${{progress}}</td> <td>${{progress}}</td>
@@ -110,6 +122,17 @@ function renderPagination() {{
paginationEl.innerHTML = html; paginationEl.innerHTML = html;
}} }}
async function deleteTask(taskId) {{
if (!confirm("确认删除任务 #" + taskId + "?字幕和中间文件将被清除。")) return;
try {{
const r = await fetch("/api/tasks/" + taskId, {{ method: "DELETE" }});
if (!r.ok) {{ alert("删除失败:" + await r.text()); return; }}
load(currentOffset);
}} catch (e) {{
alert("删除失败:" + e);
}}
}}
load(0); load(0);
""" """

View File

@@ -42,12 +42,13 @@ function renderTasks(tasks) {{
for (const task of tasks) {{ for (const task of tasks) {{
tasksEl.appendChild(makeTaskCard(task)); tasksEl.appendChild(makeTaskCard(task));
}} }}
// 对进行中的任务启动轮询 // 对进行中的任务启动轮询(上传会话用 upload_id 去重Task 用 id 去重)
for (const task of tasks) {{ for (const task of tasks) {{
if (ACTIVE_STATES.includes(task.status) && !pollingIds.has(task.id)) {{ if (!ACTIVE_STATES.includes(task.status)) continue;
pollingIds.add(task.id); const pollKey = task.is_upload ? task.upload_id : String(task.id);
pollTask(task.id); if (pollingIds.has(pollKey)) continue;
}} pollingIds.add(pollKey);
pollTask(task);
}} }}
}} }}
@@ -61,6 +62,7 @@ function makeTaskCard(task) {{
const el = document.createElement("div"); const el = document.createElement("div");
el.className = "card task server-task"; el.className = "card task server-task";
el.dataset.taskId = task.id; el.dataset.taskId = task.id;
if (task.is_upload) el.dataset.uploadId = task.upload_id;
el.innerHTML = renderTaskInner(task); el.innerHTML = renderTaskInner(task);
return el; return el;
}} }}
@@ -69,7 +71,17 @@ function renderTaskInner(task) {{
const label = STATUS_LABEL[task.status] || task.status; const label = STATUS_LABEL[task.status] || task.status;
const stateClass = task.status === "done" ? "state-done" const stateClass = task.status === "done" ? "state-done"
: task.status === "failed" ? "state-fail" : "state-running"; : 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; let body;
if (task.status === "done") {{ if (task.status === "done") {{
@@ -77,35 +89,91 @@ function renderTaskInner(task) {{
}} else if (task.status === "failed") {{ }} else if (task.status === "failed") {{
body = `<div class="task-meta fail-msg">${{escapeHtml(task.error || "未知错误")}}</div>`; body = `<div class="task-meta fail-msg">${{escapeHtml(task.error || "未知错误")}}</div>`;
}} else {{ }} 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 ` return `
<div class="task-head"> <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> <span class="fstate ${{stateClass}}">${{label}}</span>
${{delBtn}}
</div> </div>
${{body}} ${{body}}
<div class="task-time">${{created}}</div>`; <div class="task-time">${{created}}</div>`;
}} }}
async function pollTask(taskId) {{ async function deleteTask(taskId, btn) {{
const tick = async () => {{ if (!confirm("确认删除任务 #" + taskId + "?字幕和中间文件将被清除。")) return;
btn.disabled = true;
try {{ try {{
const r = await fetch("/api/tasks/" + taskId); const r = await fetch("/api/tasks/" + taskId, {{ method: "DELETE" }});
if (!r.ok) {{ pollingIds.delete(taskId); return; }} if (!r.ok) {{
const task = await r.json(); const err = await r.text();
const card = tasksEl.querySelector('.server-task[data-task-id="' + taskId + '"]'); alert("删除失败:" + err);
if (!card) {{ pollingIds.delete(taskId); return; }} btn.disabled = false;
card.innerHTML = renderTaskInner(task);
if (task.status === "done" || task.status === "failed") {{
pollingIds.delete(taskId);
return; return;
}} }}
setTimeout(tick, POLL_INTERVAL); refreshList();
}} catch (e) {{ }} catch (e) {{
setTimeout(tick, POLL_INTERVAL); alert("删除失败:" + e);
btn.disabled = false;
}}
}}
async function pollTask(task) {{
// 上传会话:轮询 upload status 接口Task轮询 task 接口
const isUpload = task.is_upload === true && task.upload_id;
const url = isUpload
? "/api/tasks/chunk-uploads/" + task.upload_id + "/status"
: "/api/tasks/" + task.id;
let backoff = POLL_INTERVAL;
const tick = async () => {{
try {{
const r = await fetch(url);
if (!r.ok) {{ pollingIds.delete(task.id); pollingIds.delete(task.upload_id); return; }}
const data = await r.json();
if (isUpload) {{
// 上传会话complete 后 task_id 出现,切换为 Task 轮询
if (data.completed && data.task_id) {{
pollingIds.delete(task.upload_id);
pollingIds.add(data.task_id);
pollTask({{ id: data.task_id, is_upload: false }});
// 刷新列表让新 Task 卡片出现
refreshList();
return;
}}
// 更新上传进度
const card = tasksEl.querySelector('.server-task[data-upload-id="' + task.upload_id + '"]');
if (!card) {{ pollingIds.delete(task.upload_id); return; }}
const uploaded = (data.uploaded_chunks || []).length;
const total = data.total_chunks || 1;
const pct = Math.min(4, uploaded / total * 4);
const fakeTask = {{
status: "uploading", progress: pct, is_upload: true,
upload_id: task.upload_id, filename: data.filename,
size_bytes: data.size_bytes, created_at: task.created_at,
}};
card.innerHTML = renderTaskInner(fakeTask);
backoff = POLL_INTERVAL;
setTimeout(tick, backoff);
}} else {{
// Task 轮询
const card = tasksEl.querySelector('.server-task[data-task-id="' + task.id + '"]');
if (!card) {{ pollingIds.delete(task.id); return; }}
card.innerHTML = renderTaskInner(data);
if (data.status === "done" || data.status === "failed") {{
pollingIds.delete(task.id);
return;
}}
backoff = POLL_INTERVAL;
setTimeout(tick, backoff);
}}
}} catch (e) {{
// 网络错误:指数退避,上限 30s
backoff = Math.min(backoff * 1.6, 30000);
setTimeout(tick, backoff);
}} }}
}}; }};
tick(); tick();

View File

@@ -2,25 +2,34 @@
共享 _shared.py 的 BASE_CSS / SHARED_JS。 共享 _shared.py 的 BASE_CSS / SHARED_JS。
页面专属级别过滤按钮、自动刷新开关、清空、traceback 折叠。 页面专属级别过滤按钮、自动刷新开关、清空、traceback 折叠。
显示策略:
- 默认 INFO仅阶段转换/模型加载卸载/任务流转开始结束),可切 DEBUG 看进度详情。
- 最新日志在顶部;用户向上滚动浏览历史时不会被自动刷新拉走(仅当停在顶部时跟随)。
- 轮询带指数退避,连续失败时拉长间隔,避免服务不可达时打爆。
""" """
from __future__ import annotations from __future__ import annotations
from ._shared import render_page from ._shared import render_page
POLL_INTERVAL_LOGS = 2000 POLL_INTERVAL_LOGS = 2000 # 正常轮询间隔ms
POLL_MAX_INTERVAL = 30000 # 退避上限ms
DEFAULT_TAIL = 200 DEFAULT_TAIL = 200
_PAGE_JS = f""" _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 DEFAULT_TAIL = {DEFAULT_TAIL};
const API = "/api/logs"; const API = "/api/logs";
let currentLevel = "debug"; let currentLevel = "info"; // 默认 INFO
let autoRefresh = true; let autoRefresh = true;
let timer = null; 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 emptyEl = document.getElementById("empty");
const statusEl = document.getElementById("status"); const statusEl = document.getElementById("status");
@@ -29,26 +38,34 @@ const LEVEL_CLASS = {{
CRITICAL: "st-fail" CRITICAL: "st-fail"
}}; }};
// 监听滚动:用户向下(往历史方向)浏览时暂停自动跟随,回到顶部则恢复
containerEl.addEventListener("scroll", () => {{
// scrollTop 越小越靠近顶部(最新)。接近顶部 = 用户在看最新
userScrolled = containerEl.scrollTop > 4;
}});
document.querySelectorAll(".filter").forEach(btn => {{ document.querySelectorAll(".filter").forEach(btn => {{
btn.addEventListener("click", () => {{ btn.addEventListener("click", () => {{
document.querySelectorAll(".filter").forEach(b => b.classList.remove("active")); document.querySelectorAll(".filter").forEach(b => b.classList.remove("active"));
btn.classList.add("active"); btn.classList.add("active");
currentLevel = btn.dataset.level; currentLevel = btn.dataset.level;
logsEl.innerHTML = ""; containerEl.innerHTML = "";
pollInterval = POLL_MIN_INTERVAL;
fetchLogs(); fetchLogs();
}}); }});
}}); }});
document.getElementById("autorefresh").addEventListener("change", e => {{ document.getElementById("autorefresh").addEventListener("change", e => {{
autoRefresh = e.target.checked; 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 () => {{ document.getElementById("clear-btn").addEventListener("click", async () => {{
if (!confirm("确定清空所有日志缓冲?")) return; if (!confirm("确定清空所有日志缓冲?")) return;
try {{ try {{
await fetch(API, {{ method: "DELETE" }}); await fetch(API, {{ method: "DELETE" }});
logsEl.innerHTML = ""; containerEl.innerHTML = "";
statusEl.textContent = "已清空"; statusEl.textContent = "已清空";
}} catch (e) {{ statusEl.textContent = "清空失败"; }} }} catch (e) {{ statusEl.textContent = "清空失败"; }}
}}); }});
@@ -56,28 +73,57 @@ document.getElementById("clear-btn").addEventListener("click", async () => {{
async function fetchLogs() {{ async function fetchLogs() {{
try {{ try {{
const r = await fetch(`${{API}}?level=${{currentLevel}}&tail=${{DEFAULT_TAIL}}`); 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(); const data = await r.json();
renderLogs(data.logs); renderLogs(data.logs);
statusEl.textContent = `${{data.count}} 条 · 更新 ${{new Date().toLocaleTimeString()}}`; statusEl.textContent = `${{data.count}} 条 · 更新 ${{fmtTime24(new Date())}}`;
scheduleNext(); pollInterval = POLL_MIN_INTERVAL; // 成功,重置间隔
scheduleNext(false);
}} catch (e) {{ }} catch (e) {{
statusEl.textContent = "获取失败"; statusEl.textContent = "获取失败";
scheduleNext(); scheduleNext(true); // 失败,退避
}} }}
}} }}
function renderLogs(logs) {{ function renderLogs(logs) {{
if (!logs || logs.length === 0) {{ if (!logs || logs.length === 0) {{
if (logsEl.children.length === 0) emptyEl.style.display = "block"; if (containerEl.children.length === 0) emptyEl.style.display = "block";
return; return;
}} }}
emptyEl.style.display = "none"; 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(); 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; if (existing.has(key)) continue;
frag.appendChild(buildRow(log, key));
}}
if (frag.children.length > 0) {{
// 整块插入到容器顶部:最新日志在最上面
containerEl.insertBefore(frag, containerEl.firstChild);
}}
// 裁剪:超出上限删最旧(底部)
while (containerEl.children.length > DEFAULT_TAIL) {{
containerEl.removeChild(containerEl.lastChild);
}}
// 仅当用户停在顶部(看最新)时保持滚定在顶,否则不打扰浏览历史
if (wasAtTop && !userScrolled) {{
containerEl.scrollTop = 0;
}}
}}
function buildRow(log, key) {{
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "log-row " + (LEVEL_CLASS[log.level] || "st-running"); row.className = "log-row " + (LEVEL_CLASS[log.level] || "st-running");
row.dataset.key = key; row.dataset.key = key;
@@ -98,15 +144,16 @@ function renderLogs(logs) {{
pre.style.display = pre.style.display === "none" ? "block" : "none"; pre.style.display = pre.style.display === "none" ? "block" : "none";
}}); }});
}} }}
frag.appendChild(row); return row;
}}
logsEl.appendChild(frag);
while (logsEl.children.length > DEFAULT_TAIL) logsEl.removeChild(logsEl.firstChild);
logsEl.scrollTop = logsEl.scrollHeight;
}} }}
function scheduleNext() {{ function scheduleNext(failed) {{
if (autoRefresh) timer = setTimeout(fetchLogs, POLL_INTERVAL); if (!autoRefresh) return;
if (failed) {{
// 指数退避:每次失败 ×1.6,上限 30s
pollInterval = Math.min(pollInterval * 1.6, POLL_MAX_INTERVAL);
}}
timer = setTimeout(fetchLogs, pollInterval);
}} }}
fetchLogs(); fetchLogs();
@@ -136,12 +183,12 @@ _PAGE_CSS = """
_BODY = """ _BODY = """
<h1>日志</h1> <h1>日志</h1>
<p class="sub">实时查看服务日志。debug=详细子步骤info=仅阶段转换error=完整错误。自动刷新每 2 秒。</p> <p class="sub">默认显示 INFO任务流转/模型加载卸载)。切 DEBUG 看进度详情,切 警告+/仅错误 过滤问题。自动刷新每 2 秒,失败自动退避。最新日志在顶部,向下浏览历史时不会被拉走。</p>
<div class="toolbar"> <div class="toolbar">
<div class="filters"> <div class="filters">
<button class="filter active" data-level="debug">全部 (DEBUG)</button> <button class="filter" data-level="debug">全部 (DEBUG)</button>
<button class="filter" data-level="info">简略 (INFO)</button> <button class="filter active" data-level="info">简略 (INFO)</button>
<button class="filter" data-level="warning">警告+</button> <button class="filter" data-level="warning">警告+</button>
<button class="filter" data-level="error">仅错误</button> <button class="filter" data-level="error">仅错误</button>
</div> </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 language: en
word_timestamps: true word_timestamps: true
vad_filter: true vad_filter: true
batch_size: 8 # CPU 无 GPU 并行收益,保持小批
beam_size: 5 # tiny.en CPU 质量优先,保持默认 beam无加速诉求
translation: translation:
model: Helsinki-NLP/opus-mt-en-zh # 最轻量英译中(~300MBNLLB-600M 需 ~2.4GB2GB 机 OOM model: Helsinki-NLP/opus-mt-en-zh # 最轻量英译中(~300MBNLLB-600M 需 ~2.4GB2GB 机 OOM
@@ -38,6 +40,7 @@ translation:
tgt_lang: zho_Hans tgt_lang: zho_Hans
batch_size: 8 # opus-mt 轻量batch 适中 batch_size: 8 # opus-mt 轻量batch 适中
max_length: 256 max_length: 256
sort_by_length: true # 排序分批CPU 收益小,但无害)
segmentation: segmentation:
max_words_per_line: 14 max_words_per_line: 14
@@ -47,7 +50,7 @@ segmentation:
logging: logging:
level: info # debug=详细子步骤, info=仅阶段转换, error=完整 traceback level: info # debug | info | warning | error控制台最低级别
buffer_size: 2000 buffer_size: 2000
docs: docs:

View File

@@ -1,5 +1,9 @@
# audio2text 运行时配置。复制为 config.yaml 后填值。所有路径相对容器内 /app # audio2text 运行时配置模板
# CPU dev / GPU prod 仅靠 device + model + compute_type 三项切换,代码不变 # 复制为 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: server:
host: 0.0.0.0 # 容器内对外监听(由 docker -p 映射到宿主) host: 0.0.0.0 # 容器内对外监听(由 docker -p 映射到宿主)
@@ -10,9 +14,9 @@ storage:
upload_dir: /data/uploads # 上传视频落盘根目录 upload_dir: /data/uploads # 上传视频落盘根目录
work_dir: /data/.work # 分片会话暂存 + 中间音频 work_dir: /data/.work # 分片会话暂存 + 中间音频
output_dir: /data/outputs # 生成的 SRT 字幕输出 output_dir: /data/outputs # 生成的 SRT 字幕输出
chunk_bytes: 1048576 # 1 MiB 流式分片 chunk_bytes: 1048576 # 1 MiB 服务端流式读缓冲(前端分片大小由前端定)
chunk_session_ttl_seconds: 300 # 被放弃会话的存活秒数(后台 reaper 据此清理) chunk_session_ttl_seconds: 300 # 被放弃会话的存活秒数(后台 reaper 据此清理)
cache_retention_days: 7 # 任务产物保留天数,超期清理(字幕/中间音频/保留的原始视频+DB记录 cache_retention_days: 7 # 任务产物保留天数,超期清理(0=禁用
cache_cleanup_interval_hours: 24 # 定时清理间隔(启动时跑一次,之后循环) cache_cleanup_interval_hours: 24 # 定时清理间隔(启动时跑一次,之后循环)
processing: processing:
@@ -20,22 +24,28 @@ processing:
keep_audio: false # 完成后是否保留中间 wav默认删只留字幕 keep_audio: false # 完成后是否保留中间 wav默认删只留字幕
asr: asr:
# CPU devtiny.en + int8Whisper 同系列最小,英文专用) # CPU devtiny.en + int8Whisper 同系列最小,英文专用~39M
# GPU prodlarge-v3-turbo + float163090 上几 GB 视频几分钟出字幕 # GPU prodlarge-v3-turbo + float168x 速度,质量接近 large-v3~3GB 显存)
model: tiny.en model: tiny.en # CPU: tiny.en | GPU: large-v3-turbo
device: cpu # cpu | cuda device: cpu # cpu | cuda
compute_type: int8 # cpu: int8gpu: float16 compute_type: int8 # CPU: int8 | GPU: float16
language: en # 仅英语 language: en # 仅英语
word_timestamps: true # 词级时间戳:让断句精确而非纯匀速估算 word_timestamps: true # 词级时间戳:让断句精确而非纯匀速估算
vad_filter: true # 过滤静音段,提升质量与速度 vad_filter: true # 过滤静音段,提升质量与速度
batch_size: 8 # CPU: 8 | GPU: 32BatchedInferencePipeline 批量解码音频块,拉长单次 GPU 解码掩盖 CPU 特征提取间隙)
beam_size: 5 # CPU: 5默认| GPU: 2turbo 鲁棒可降,候选数↓解码步数↓,加速明显质量损失小)
translation: translation:
model: facebook/nllb-200-distilled-1.3B # 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 device: cpu # cpu | cuda
src_lang: eng_Latn # NLLB 语言码:英语 src_lang: eng_Latn # NLLB 语言码:英语
tgt_lang: zho_Hans # NLLB 语言码:简体中文 tgt_lang: zho_Hans # NLLB 语言码:简体中文
batch_size: 16 # 不与 ASR 共驻:翻译时显存独占可用大 batch batch_size: 8 # CPU: 8 | GPU: 32(不共驻时显存独占可用大 batch
max_length: 256 # 单条翻译最大 token max_length: 256 # 单条翻译最大 token
sort_by_length: true # 按长度排序后分批,减少 padding 浪费GPU 收益大)
segmentation: segmentation:
max_words_per_line: 14 # 单行最多词数,超出按逗号拆 max_words_per_line: 14 # 单行最多词数,超出按逗号拆
@@ -43,13 +53,12 @@ segmentation:
min_duration_seconds: 1.0 # 单条字幕最短 1 秒(太短则合并) min_duration_seconds: 1.0 # 单条字幕最短 1 秒(太短则合并)
max_chars_per_line: 42 # SRT 规范:每行 ≤42 字符 max_chars_per_line: 42 # SRT 规范:每行 ≤42 字符
logging: logging:
level: info # debug | info | warning | error控制台 + 内存缓冲最低级别) level: info # debug | info | warning | error控制台最低级别
buffer_size: 2000 # /logs 页面内存缓冲条数 buffer_size: 2000 # /logs 页面内存缓冲条数
docs: docs:
enabled: true enabled: true
username: admin username: admin
password: "CHANGE_ME" # /docs Basic Auth明文root 持有,常量时间比较) password: "CHANGE_ME" # 部署前务必修改;留空则禁用 /docs
realm: "audio2text docs" realm: "audio2text docs"

View File

@@ -29,14 +29,17 @@ asr:
language: en language: en
word_timestamps: true word_timestamps: true
vad_filter: true vad_filter: true
batch_size: 32 # BatchedInferencePipeline每批解码 32 个 30s 音频块,拉长单次 GPU 解码掩盖 CPU 特征提取间隙
beam_size: 2 # turbo 鲁棒beam=2 留一个候选做保险vs beam=5 候选数↓ 解码步数↓,加速明显质量损失小)
translation: translation:
model: facebook/nllb-200-distilled-1.3B # 质量最好 model: facebook/nllb-200-distilled-1.3B # 质量最好
device: cuda device: cuda
src_lang: eng_Latn src_lang: eng_Latn
tgt_lang: zho_Hans tgt_lang: zho_Hans
batch_size: 16 # 不共驻时显存独占,大 batch batch_size: 32 # 不共驻时显存独占,大 batch 填充 GPU
max_length: 256 max_length: 256
sort_by_length: true # 按句子长度排序后分批,减少批内 padding 浪费
segmentation: segmentation:
max_words_per_line: 14 max_words_per_line: 14
@@ -46,7 +49,7 @@ segmentation:
logging: logging:
level: info # debug=详细子步骤, info=仅阶段转换, error=完整 traceback level: info # debug | info | warning | error控制台最低级别
buffer_size: 2000 buffer_size: 2000
docs: docs:

View File

@@ -1,19 +1,46 @@
# audio2text — 双语字幕生成服务 # audio2text — 双语字幕生成服务
# CPU dev / GPU prod 两套配置,按需切换 service。 # CPU dev / GPU prod 两套配置,按需切换 service。
# #
# CPU 开发: # 开发CPU热重载改代码零重建
# docker compose up -d # 默认 cpu # docker compose --profile dev up -d --build
# GPU 生产: # CPU 生产:
# docker compose -f docker-compose.yml up -d # 本机有 nvidia runtime 时自动用 gpu # docker compose --profile cpu up -d --build
# GPU 生产(本机有 nvidia runtime 时):
# docker compose --profile gpu up -d --build
# #
# 模型缓存(/models跨容器复用首次启动下载、之后秒起。 # 模型缓存(/models跨容器复用首次启动下载、之后秒起。
# pip wheel 缓存由 BuildKit --mount=type=cache 管理,跨构建复用。
services: 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: audio2text-cpu:
build: build:
context: . context: .
args: args:
VARIANT: cpu VARIANT: cpu
target: final
image: audio2text:cpu image: audio2text:cpu
container_name: audio2text container_name: audio2text
ports: ports:
@@ -27,19 +54,21 @@ services:
restart: unless-stopped restart: unless-stopped
profiles: ["cpu", ""] profiles: ["cpu", ""]
# ---------------- GPU 生产 ----------------
audio2text-gpu: audio2text-gpu:
build: build:
context: . context: .
args: args:
VARIANT: gpu VARIANT: gpu
target: final
image: audio2text:gpu image: audio2text:gpu
container_name: audio2text container_name: audio2text-gpu
ports: ports:
- "8000:8000" - "8001:8000"
volumes: volumes:
- ./data:/data - ./data:/data
- ./models:/models - ./models:/models
- ./config.yaml:/app/config.yaml:ro - ./config.gpu.yaml:/app/config.yaml:ro
environment: environment:
CONFIG_PATH: /app/config.yaml CONFIG_PATH: /app/config.yaml
deploy: 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 " 容器启动时会命中缓存,无需联网下载。"