# 口语训练视频服务部署指南

本文部署以下两个进程：

- `oral-trainer-api`：视频上传、管理后台、边界查询、Range 视频播放和朗读评分。
- GPU 转写服务（二选一，只在服务器内网监听）：
  - `whisper/`：faster-whisper（Speaches），推荐，部署最简单，见第 2A 节。
  - `MOSS-Transcribe-Diarize`：带说话人分离，见第 2 节。

推荐 Ubuntu 22.04/24.04、Python 3.12、FFmpeg、NVIDIA GPU 和已安装的 NVIDIA 驱动。MOSS 0.9B 的实际显存占用受推理框架、并发和音频长度影响，生产环境建议从 16 GB 以上显存开始验证。

## 1. 目录与端口

本文假设代码位于 `/opt/oral-trainer`：

```text
/opt/oral-trainer/
  sentence_api/
    data/
      v/                 上传的视频
      work/              临时转码文件
      attempts/          可选的学生录音
      oral_trainer.sqlite3
```

端口规划：

| 服务 | 监听地址 | 用途 |
| --- | --- | --- |
| MOSS | `127.0.0.1:8001` | 内部语音转写 |
| Whisper | `127.0.0.1:9000` | 内部语音转写（Whisper 方案，见第 2A 节） |
| FastAPI | `127.0.0.1:8000` | 内部应用服务 |
| Nginx | `0.0.0.0:80` | 对内 HTTP 回源（HTTPS 由总出口终结） |

不要把 MOSS/Whisper 的转写端口直接暴露到公网。

## 2A. 部署 Whisper（推荐）

基于 faster-whisper（Speaches）的 OpenAI 兼容转写服务，接口与 MOSS 相同
（`POST /v1/audio/transcriptions`，支持 `verbose_json`），因此接入 `sentence_api`
时应用代码无需改动。部署文件在仓库的 [`whisper/`](../whisper/) 目录。

前置条件：Docker、NVIDIA 驱动（≥ 535，支持 CUDA 12.x）和 NVIDIA Container Toolkit。
镜像自带 CUDA 12.6 运行时，宿主机不需要再装 CUDA。

```bash
sudo mkdir -p /opt/whisper
sudo chown "$USER":"$USER" /opt/whisper
cp -a whisper/. /opt/whisper/   # 用 whisper/. 而不是 whisper/*，否则不会复制 .env.example 等隐藏文件
cd /opt/whisper

cp .env.example .env          # 按需修改 WHISPER_MODEL
docker compose up -d
docker compose logs -f whisper   # 首次启动下载模型（约 3 GB）
```

验证：

```bash
/opt/whisper/verify.sh
```

注册为 systemd 服务（可选，模板位于 `whisper/systemd/`）：

```bash
sudo cp whisper/systemd/whisper-transcribe.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now whisper-transcribe
sudo journalctl -u whisper-transcribe -f
```

接入 API 时，把 `.env` 改为：

```dotenv
MOSS_TRANSCRIBE_URL=http://127.0.0.1:9000/v1/audio/transcriptions
MOSS_MODEL=Systran/faster-whisper-large-v3
```

变量名沿用 `MOSS_*` 前缀，实际指向 Whisper 即可。模型选型与常见问题见
[`whisper/README.md`](../whisper/README.md)。

## 2. 部署 MOSS

官方模型为 `OpenMOSS-Team/MOSS-Transcribe-Diarize`。CUDA 12 环境可使用官方当前说明中的 vLLM 构建：

```bash
sudo apt update
sudo apt install -y ffmpeg git curl
curl -LsSf https://astral.sh/uv/install.sh | sh

sudo mkdir -p /opt/moss-transcribe
sudo chown "$USER":"$USER" /opt/moss-transcribe
cd /opt/moss-transcribe

uv venv --python 3.12 .venv
. .venv/bin/activate
uv pip install -U vllm \
  --torch-backend=auto \
  --extra-index-url https://wheels.vllm.ai/68b4a1d582818e67adc903bf1b8fc5a5447da2fa/cu129
```

启动模型：

```bash
. /opt/moss-transcribe/.venv/bin/activate
vllm serve OpenMOSS-Team/MOSS-Transcribe-Diarize \
  --host 127.0.0.1 \
  --port 8001 \
  --trust-remote-code
```

生产环境可把它注册为 systemd 服务：

```ini
# /etc/systemd/system/moss-transcribe.service
[Unit]
Description=MOSS Transcribe Service
After=network-online.target

[Service]
Type=simple
User=oraltrainer
WorkingDirectory=/opt/moss-transcribe
ExecStart=/opt/moss-transcribe/.venv/bin/vllm serve OpenMOSS-Team/MOSS-Transcribe-Diarize --host 127.0.0.1 --port 8001 --trust-remote-code
Restart=on-failure
RestartSec=10
TimeoutStartSec=1800

[Install]
WantedBy=multi-user.target
```

```bash
sudo systemctl daemon-reload
sudo systemctl enable --now moss-transcribe
sudo journalctl -u moss-transcribe -f
```

模型第一次启动会下载权重。CUDA 13 服务器也可以按照 MOSS 官方仓库说明改用 SGLang Omni。推理框架的安装地址可能随上游版本更新，正式部署前应对照 [MOSS 官方仓库](https://github.com/OpenMOSS/MOSS-Transcribe-Diarize) 的 Quickstart。

验证：

```bash
curl -X POST http://127.0.0.1:8001/v1/audio/transcriptions \
  -F model=OpenMOSS-Team/MOSS-Transcribe-Diarize \
  -F file=@/path/to/test.wav \
  -F response_format=verbose_json \
  -F temperature=0
```

响应应包含 `text`；开启 `verbose_json` 后应包含带 `start`、`end`、`text` 的 `segments`。

### 断句规则

`sentence_api` 不以 Whisper 返回的 segment 直接作为句子：文本会跨 segment 累积，
只有碰到句号（`.`）、问号（`?`）或逗号前的单词数达到 50 时才算一句结束；
逗号前内容不足 50 个单词时不在此处断句，会继续与后续内容合并。Whisper 在没有
标点处断开的 segment 会自动合并成同一句。管理后台编辑句子文本时，换行并保存会把
该句拆成多句；拆分点优先使用对该句音频再次转写得到的词级时间戳，转写不可用时按
文本长度估算。管理后台的结束时间旁支持微调按钮；调整后下一句的起始时间联动更新，
并保证相邻句子不重叠。若转写服务支持词级时间戳
（`timestamp_granularities[]=word`，Whisper/Speaches 支持），句子的结束时间用
标点所在单词的时间戳；不支持时自动按文字长度在 segment 内估算，请求失败会回退为
不带词级时间戳的普通请求。MOSS 不支持该参数时同样自动回退，不影响转写。
小数点和常见缩写（如 `0.4%`、`a.m.`、`p.m.`、`Mr.`、`Dr.`、`U.S.`、`no.`）不会被
当作句子结束；`No.` 作为否定回答时仍会正常断句。
每句结束时间默认再向后顺延 300ms（`MOSS_END_PADDING_MS`，可在 200–500 之间调整），
避免句子末尾发音被切掉；顺延不会越过下一句的开头。顺延后还会用音频检测句子间的
停顿，把结束时间拉回到下一句语音真正开始之前，避免偶尔切到下一句的开头。每句的
开始时间也会用同样的音频检测对齐到真实语音起点，避免 Whisper 起始时间戳偏早时
上一句的尾部语音混进下一句开头，保证相邻两句不共享音频内容。

## 3. 部署 API

### Docker Compose 方式

```bash
cd /opt/oral-trainer/sentence_api
cp .env.example .env
openssl rand -hex 32
```

分别生成两个随机值，填入 `.env` 的 `ADMIN_API_KEY` 和 `CLIENT_API_KEY`，并确认：

```dotenv
PUBLIC_BASE_URL=https://videoservice.d1kt.cn
MOSS_TRANSCRIBE_URL=http://127.0.0.1:8001/v1/audio/transcriptions
```

若使用 Whisper 方案（第 2A 节），把 `MOSS_TRANSCRIBE_URL` 改为
`http://127.0.0.1:9000/v1/audio/transcriptions`，并把 `MOSS_MODEL` 设为
`Systran/faster-whisper-large-v3`。

启动：

```bash
docker compose up -d --build
docker compose logs -f oral-trainer-api
```

Compose 配置使用 Linux 的 host network，使容器可以访问只监听本机的
转写服务（MOSS `127.0.0.1:8001` 或 Whisper `127.0.0.1:9000`），同时 API 也只监听 `127.0.0.1:8000`。如果在
Docker Desktop 上做本地测试，可移除 `network_mode: host`，恢复端口映射，并把
转写服务地址改为 `host.docker.internal`。

检查：

```bash
curl http://127.0.0.1:8000/healthz
```

`moss_configured` 应为 `true`。这里只表示地址已配置，实际连通性会在上传视频或评分时验证。

### 不使用 Docker

```bash
cd /opt/oral-trainer
python3.12 -m venv .venv-api
. .venv-api/bin/activate
python -m pip install -r sentence_api/requirements.txt

export ORAL_TRAINER_DATA_DIR=/opt/oral-trainer-data
export PUBLIC_BASE_URL=https://videoservice.d1kt.cn
export ADMIN_API_KEY='替换为随机密钥'
export MOSS_TRANSCRIBE_URL=http://127.0.0.1:8001/v1/audio/transcriptions

python -m uvicorn sentence_api.main:app \
  --host 127.0.0.1 --port 8000 --workers 1 \
  --proxy-headers --forwarded-allow-ips='*'
```

不使用 Docker 时可创建 API 的 systemd 服务：

```ini
# /etc/systemd/system/oral-trainer-api.service
[Unit]
Description=Oral Trainer Video API
After=network-online.target moss-transcribe.service

[Service]
Type=simple
User=oraltrainer
WorkingDirectory=/opt/oral-trainer
EnvironmentFile=/opt/oral-trainer/sentence_api/.env
ExecStart=/opt/oral-trainer/.venv-api/bin/python -m uvicorn sentence_api.main:app --host 127.0.0.1 --port 8000 --workers 1 --proxy-headers --forwarded-allow-ips=*
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
```

```bash
sudo systemctl daemon-reload
sudo systemctl enable --now oral-trainer-api
sudo journalctl -u oral-trainer-api -f
```

当前上传后的处理任务运行在 API 进程中，因此只能使用一个 Uvicorn worker。需要多机或多 worker 时，应把 `VideoProcessor.process` 迁移到 Celery、RQ 或其他持久化任务队列。

### 3A. 内网直连（可选）

默认 FastAPI 只监听 `127.0.0.1:8000`，由 nginx 对外提供访问。如果只想在可信内网
（例如 `192.168.0.124` 这台虚拟机）直接上传，有两种方式：

方式一（推荐，不改后端）：nginx 已监听 `0.0.0.0:80`，直接访问
`http://192.168.0.124/admin` 或在浏览器上传即可，上传地址为
`http://192.168.0.124/api/v1/admin/videos/raw`。

方式二（把 API 绑定到所有网卡）：在 `sentence_api/.env` 增加一行：

```dotenv
ORAL_TRAINER_BIND_HOST=0.0.0.0
```

然后重建容器：

```bash
cd /opt/oral-trainer/sentence_api
docker compose up -d
ss -tlnp | grep 8000    # 应显示 0.0.0.0:8000
```

之后即可访问 `http://192.168.0.124:8000/healthz` 和内网上传接口。

安全提醒：`0.0.0.0` 会把 API 暴露给整个内网（管理接口受 `X-Admin-Key` 保护，但公开的
视频列表和 `/admin` 页面没有密钥）。仅建议在可信内网使用；公网部署务必保持
`127.0.0.1` + nginx，或用防火墙限制来源 IP。

## 4. 配置 Nginx（HTTP 回源）

本机 Nginx 只监听 80 端口提供 HTTP，不在本机终结 HTTPS。总出口（线上边缘网关）
在 `https://videoservice.d1kt.cn` 终结 TLS 证书后，把请求以 HTTP 转发到本机
内网 IP 的 80 端口。因此本机不需要证书，也不需要安装 certbot。

```bash
sudo cp sentence_api/nginx.conf.example /etc/nginx/sites-available/oral-trainer
sudo ln -s /etc/nginx/sites-available/oral-trainer /etc/nginx/sites-enabled/oral-trainer
sudo nginx -t
sudo systemctl reload nginx
```

配置文件中的关键项：

- `client_max_body_size 12g`：允许超过 1 GB 的视频。
- `proxy_request_buffering off`：上传时直接流向 FastAPI，避免 Nginx 再完整缓存一份。
- `proxy_force_ranges on`：保留 Android 随机拖动播放所需的 HTTP Range。
- `proxy_read_timeout 7200s`：允许长视频处理和慢速上传。
- `X-Forwarded-Proto`：透传边缘网关标记的原始协议（https），让 API 在需要时能识别
  用户实际走的 HTTPS；若网关不设置该头，可把配置改回 `$scheme`。

本地验证（走本机 Nginx）：

```bash
curl -H 'Host: videoservice.d1kt.cn' http://127.0.0.1/healthz
```

公网验证（经总出口映射，需先在网关配置好 `https://videoservice.d1kt.cn`
到本机内网 IP:80 的映射）：

```bash
curl https://videoservice.d1kt.cn/healthz
```

证书的申请与续期都在总出口完成，本机无需任何证书配置。

管理后台地址：

```text
https://videoservice.d1kt.cn/admin
```

后台的管理密钥保存在当前浏览器的 `localStorage`，接口请求通过 `X-Admin-Key` 发送。
`CLIENT_API_KEY` 应通过 Android 构建配置注入 `OralTrainerSdkConfig.assessmentApiKey`，
不要硬编码进公开代码仓库。SDK 会在评分请求中发送 `X-Client-Key`。公开视频列表和播放
接口仍可交给 CDN 缓存，GPU 评分接口则受到密钥保护。

### 4A. 如果要在服务器本机终结 HTTPS（可选）

若总出口不做 TLS 终结，而是由服务器本机 nginx 提供 HTTPS，443 的 `server` 块必须
包含与 80 块相同的上传相关指令，否则大文件上传会被掐断：

```nginx
server {
    listen 443 ssl;
    server_name videoservice.d1kt.cn;

    ssl_certificate     /etc/letsencrypt/live/videoservice.d1kt.cn/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/videoservice.d1kt.cn/privkey.pem;

    client_max_body_size 12g;
    client_body_timeout 7200s;
    send_timeout 7200s;

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_request_buffering off;
        proxy_read_timeout 7200s;
        proxy_send_timeout 7200s;
    }

    location ~ "^/api/v1/videos/[0-9a-fA-F]{64}/content$" {
        proxy_pass http://127.0.0.1:8000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header Range $http_range;
        proxy_set_header If-Range $http_if_range;
        proxy_force_ranges on;
        proxy_buffering off;
    }
}
```

签发证书并应用：

```bash
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d videoservice.d1kt.cn
```

### 4B. 上传中断（ClientDisconnect）排查

后台日志出现 `starlette.requests.ClientDisconnect` 表示浏览器到后端之间某处的连接在
请求体传完之前被断开。先确认实际生效的 nginx 配置：

```bash
sudo nginx -T | grep -E "client_max_body_size|client_body_timeout|proxy_request_buffering"
```

最常见的原因是新加的 HTTPS `server` 块漏掉了上传指令：nginx 默认
`client_max_body_size` 只有 1 MB，超过会直接返回 413 并断开连接，后端表现为
`ClientDisconnect`。其他常见原因：

- 总出口（网关/CDN）有更小的请求体大小或更短的上传超时，需在网关侧放行。
- `client_body_timeout`（默认 60s）过短：网络慢时上传暂停过久会被掐断，按上文设
  为 7200s。
- 浏览器/客户端主动取消或网络中断。

绕过网关在本机回源验证（能通则问题在网关）：

```bash
curl -X PUT -H 'Host: videoservice.d1kt.cn' -H "X-Admin-Key: $ADMIN_KEY" \
  --data-binary @large.mp4 \
  'http://127.0.0.1/api/v1/admin/videos/raw?filename=large.mp4' \
  -o /dev/null -w '%{http_code}\n'
```

API 已对 `ClientDisconnect` 做优雅处理：连接中断时返回 400 并记录一条 WARNING，
不再产生 500 堆栈日志。

## 5. 上传与处理流程

后台上传后，服务会：

1. 管理后台通过 raw body 流式写入临时文件并同步计算 SHA-256，不会先在系统临时目录保留完整副本。兼容的 multipart 接口仍以 4 MB 分块复制。
2. 保存到 `data/v/{sha256}.{extension}`。
3. 使用 FFmpeg 提取 16 kHz 单声道 WAV。
4. 请求转写服务（MOSS 或 Whisper）的 `verbose_json` 接口。
5. 以转写时间戳生成句子边界，并计算每句原音有效语音时长。
6. 把视频和句子写入 SQLite，状态变为 `ready`。

查询处理状态：

```bash
curl https://videoservice.d1kt.cn/api/v1/videos
curl https://videoservice.d1kt.cn/api/v1/videos/{sha256}
```

如果转写服务未配置，上传仍会使用原来的静音检测生成边界，但句子文本为空，无法开始测验。配置好转写服务后可在后台点击“重新处理”。

## 6. 视频随机播放

移动端播放地址为：

```text
GET /api/v1/videos/{sha256}/content
```

服务支持 HTTP Range，Media3/ExoPlayer 可以随机 seek，并继续使用 SDK 现有的本地缓存。第一阶段不必强制改成 HLS。

上传前建议把 MP4 处理成 H.264/AAC 并把 `moov` 元数据移动到文件头：

```bash
ffmpeg -i input.mp4 -c copy -movflags +faststart output.mp4
```

如果原始编码不被 Android 广泛支持，再转码：

```bash
ffmpeg -i input.mkv \
  -c:v libx264 -preset medium -crf 22 \
  -c:a aac -b:a 128k -movflags +faststart output.mp4
```

当并发量明显增长或网络波动较大时，再增加 HLS/DASH 转码和对象存储/CDN。

## 7. 朗读评分接口

```bash
curl -X POST \
  https://videoservice.d1kt.cn/api/v1/videos/{sha256}/sentences/0/assessments \
  -H 'X-Client-Key: 替换为客户端密钥' \
  -F audio=@student.wav \
  -F language=en
```

当前 `asr-fluency-v1` 评分为：

```text
总分 = 内容分 * 80% + 流畅度 * 20%
流畅度 = 有效语音时长分 * 35% + 停顿分 * 40% + 语速分 * 25%
```

总分不低于 `ASSESSMENT_PASS_SCORE`（默认 70）即通过。`pronunciation_score` 和 `prosody_score` 目前返回 `null`，避免把 ASR 内容匹配误报为音素发音质量。

服务只向转写服务发送语言提示，不发送标准句子作为 prompt 或热词，避免标准答案诱导识别结果。

## 8. 运维与备份

需要持久化备份：

```text
sentence_api/data/v/
sentence_api/data/oral_trainer.sqlite3
```

`work/` 可以清理。`KEEP_ATTEMPT_AUDIO=false` 时学生录音在评分完成后自动删除；设为 `true` 时还需备份和定期清理 `attempts/`，并在产品隐私政策中说明保存期限。

SQLite 在线备份示例：

```bash
sqlite3 sentence_api/data/oral_trainer.sqlite3 \
  ".backup '/backup/oral_trainer-$(date +%F).sqlite3'"
```

升级前先备份数据库和 `data/v/`。API 重启期间被中断的视频会保留 `processing` 状态，可从后台执行“重新处理”。

## 9. 更新与回滚

日常代码更新、镜像升级与回滚流程见 [`UPDATE.md`](UPDATE.md)。
