声转文 · 多提供商语音转文字服务
上传音频 → 服务端自动切块 → 逐块转写 → 时间戳偏移合并 → 导出 TXT/SRT。
面向多人共用、BYOK(自带 API Key)、无认证的部署场景。
版本发布注意:
package.json的version与docker-compose.yml的image标签必须一致。npm test会校验这一点,不一致会直接失败。
核心特性
- 长音频无上限:超过提供商单发上限时,用 ffmpeg 无损切块(
-c copy),逐块转写后按块偏移时间戳合并。小时级音频可用。 - 提供商能力开关:硅基流动只接受
file+model,多传一个language就 400 —— 字段按开关裁剪,不会发多余字段。 - 容器自动转码:不被某家接受的格式(如硅基流动的 m4a / aac / wma / caf / aiff)先本地转码再上传,用户不用自己转格式。
- 重试与部分成功:429 / 5xx / 网络错误退避重试;单块失败不丢整体结果,会标注失败块数。
- 密钥不落盘:Key 由浏览器通过
X-Provider-Key头传入,只在单次请求内存中存活,转发后即丢弃。不写日志、不入库、不回显。 - 公网护栏:上传体积 / 时长 / 全局并发 / 单 IP 并发 / 单 IP 每小时提交数 / 磁盘配额 / 队列长度 / TTL 自动清理。
快速开始
npm install
npm start # http://localhost:3000
npm test # 30 项断言,本地 mock 上游,不需要真实 Key
Docker:
docker compose up -d --build
部署
无认证意味着任何人都能使用你的服务器 CPU 跑 ffmpeg,所以护栏必须收紧。部署前确认这些环境变量:
| 变量 | 默认 | 说明 |
|---|---|---|
MAX_UPLOAD_MB |
500 | 单文件上传上限,超过直接 413,不落盘 |
MAX_DURATION_MIN |
360 | 单文件时长上限,ffprobe 探测后拒绝 |
MAX_CONCURRENT_JOBS |
4 | 全局同时运行的转写任务数 |
MAX_JOBS_PER_IP |
2 | 单 IP 并发,防单点占满队列 |
MAX_SUBMISSIONS_PER_IP_HOUR |
20 | 单 IP 每小时提交数 |
MAX_QUEUE_LENGTH |
50 | 排队上限,超出直接拒绝 |
MAX_TMP_GB |
20 | 临时目录配额,到顶拒绝新任务 |
JOB_TTL_MIN |
60 | 任务产物保留时长,到期自动清理 |
CHUNK_CONCURRENCY |
2 | 单个任务内部的块并发 |
RETRY_ATTEMPTS / RETRY_BASE_MS |
3 / 5000 | 上游重试次数与退避基数 |
UPSTREAM_TIMEOUT_MS |
900000 | 整文件单发的等待上限(900s) |
CHUNK_TIMEOUT_MS |
300000 | 切块后单块的等待上限(300s) |
超时设置为什么是两个
UPSTREAM_TIMEOUT_MS 和 CHUNK_TIMEOUT_MS 分开,是因为两条路径的耗时量级完全不同:
- 整文件单发(硅基流动走这条路):37 分钟音频实测需要 236 秒,加上跨境上传很容易接近 300 秒。这里设成 300s 会导致请求被本地掐断,而错误看起来像上游故障。
- 切块后单块(OpenRouter 走这条路):每块默认 ≤750 秒音频,响应快得多,300s 足够。
超时是本服务自己的等待上限。错误信息会明确写「本服务超时,非上游错误」并带 code: CLIENT_TIMEOUT,不会伪装成上游的 504 —— 否则排查方向会被完全带偏。
如果确实遇到超时,两个选择:调大 UPSTREAM_TIMEOUT_MS,或改用 OpenRouter(自动切块,同样长度的音频只要几十秒)。
反向代理(nginx)需要给 SSE 关掉缓冲:
location / {
proxy_pass http://127.0.0.1:3000;
proxy_buffering off; # 关键:否则进度条不动
proxy_read_timeout 3600s;
client_max_body_size 500m; # 要和 MAX_UPLOAD_MB 对齐
}
API
GET /api/health
服务与 ffmpeg 状态、当前队列、护栏值。
GET /api/providers
提供商能力清单(模型、开关、上限),前端据此渲染界面。
POST /api/transcribe
multipart/form-data,返回 SSE 事件流。
| 字段 / 头 | 说明 |
|---|---|
X-Provider-Key(头) |
用户的 API Key,必填 |
file |
音频文件 |
provider |
openrouter / siliconflow |
model |
模型名,缺省用提供商默认 |
language |
auto / zh / en / yue / ja / ko |
timestamps |
true 时输出带全局时间戳的分段 |
事件类型:job → stage(probe / plan / split / transcode / start / merge / done)→ chunk(running / retry / done)→ result 或 error。
Key 走请求头而不是 query / body —— URL 会进访问日志,body 可能被中间件落盘。
提供商差异(实测,非文档推测)
| OpenRouter | 硅基流动 | |
|---|---|---|
| 端点 | 原生 multipart | 原生 multipart |
| 默认模型 | openai/whisper-large-v3-turbo |
FunAudioLLM/SenseVoiceSmall |
| 响应 | verbose_json + segments |
只有 {"text": ...} |
| 时间戳 | ✓ | ✗ |
language |
✓ | ✗ 传了 400 |
| 单次上限 | 25MB / 30 分钟(上游 60s 超时) | 50MB / 1 小时 |
| 计费 | usage.cost(美元) |
只有 usage.seconds |
| 拒绝的容器 | 无 | mp4 / aac / wma / caf / aiff / amr |
base64 JSON 路径不可用:OpenRouter 文档称 base64 支持大文件,但实测返回 403(连 31KB 文件也是),所以只能走 multipart + 切块。
硅基流动的实际长度上限是 20 分钟,不是文档写的 1 小时
官方文档写「duration not exceeding 1 hour」,但实测不成立:
| 样本 | 耗时 | 结果 |
|---|---|---|
| 37 分 44 秒 / 36MB | 224s | 500(空响应体) |
| 37 分 44 秒 / 36MB | 226s | 500(空响应体) |
两次耗时高度一致(224s / 226s),说明这是确定性的资源上限,不是随机过载。所以 maxSeconds 设成 1200(20 分钟),超过就由 ffmpeg 切块后逐块提交。
以 37 分 44 秒的音频为例:切成 4 块(每块 12.5 分钟),单块约 75 秒完成,远离崩溃线。
响应体为空是硅基流动的已知行为(备份项目里 aac 格式也是「400 空响应体」),所以日志里的 上游响应: 那一行可能为空 —— 这是正常的,不代表日志功能失效。
识别效果也不是纯代码差异:OpenRouter 标点少(适合配字幕定位),硅基流动标点完整(适合读正文)。
项目结构
server/
index.js HTTP 层:上传、SSE、护栏、代理转发
providers.js 提供商定义:能力开关、字段裁剪、容器兼容
ffmpeg.js ffprobe 探测、自适应块长、无损切块、转码
transcribe.js 编排:预检 → 规划 → 切块 → 限并发 → 偏移 → 合并
queue.js 任务队列、限流、磁盘配额、TTL 清理
config.js 环境变量配置
public/
index.html 单页前端(无构建步骤)
test/
run.js 30 项断言,本地 mock 上游
verify-offset.js 切块时间戳偏移的正确性验证
已知边界
- 切块中间产物落在
TMP_DIR,任务结束立即删除;异常退出留下的由 TTL 清理兜底。 - 无损切块要求容器可
-c copy;不支持的容器重编码为 16kHz 单声道 96kbps mp3。 - 单块内容会在上传前读进内存(
hydrateChunk),因为单块已在上限内,占用有界。 - 无认证 → 请勿在未设护栏的情况下直接暴露到公网。