声转文 · 多提供商语音转文字服务

上传音频 → 服务端自动切块 → 逐块转写 → 时间戳偏移合并 → 导出 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),因为单块已在上限内,占用有界。
  • 无认证 → 请勿在未设护栏的情况下直接暴露到公网。
S
Description
No description provided
Readme
101 KiB
Languages
JavaScript 68.7%
HTML 29.1%
Shell 1.2%
Dockerfile 1%