适用场景
这套方案适合要做「边说边译」的场景:跨国会议同传字幕、跨境客服坐席辅助、线下多语种会谈的耳机翻译。它解决的核心问题不是「翻得准不准」,而是三件事——音频流怎么稳定接进去、端到端延迟怎么压到人能接受的范围、对方开口时怎么立刻打断上一段播报。如果你只是要批量翻译已录好的音频文件,用离线任务更省事,不必上实时链路。
环境与前置条件
- 操作系统:macOS、Ubuntu 22.04 及以上、Windows 11 均可;服务端建议 Linux。
- 运行时:Node.js 18 及以上(需要原生
fetch与WebSocket客户端能力,网关代码里用ws库更稳)。浏览器端建议 Chrome / Edge 最新版,Safari 需要额外处理音频解锁。 - 内存与磁盘:网关本身很轻,2 核 2GB 足够跑几十路并发;磁盘主要是日志,预留 10GB 滚动。若做本地回声消除或本地 VAD 模型,按模型体积另算,通常再加 500MB 内存。
- 网络:这是延迟的第一决定因素。网关要部署在离上游服务接入区域近的机房,客户端要能直连网关的长连接端口。跨国链路会直接吃掉几百毫秒,靠代码优化补不回来。
- 账号与密钥:ElevenLabs 的 API Key,放在服务端环境变量里,不要下发到浏览器。上游的接入地址、鉴权头名称、音频格式与采样率要求,一律以官方文档当前版本为准,本文代码里用环境变量占位。
- 前端:需要一个能跑
AudioWorklet的页面;如果用 Nginx 反代,必须支持 WebSocket Upgrade。
分步骤部署
第 1 步:初始化项目与依赖
```bash
mkdir rt-translate && cd rt-translate
npm init -y
npm i ws dotenv
mkdir -p server public scripts
```
创建 .env(加入 .gitignore):
```bash
PORT=8080
ELEVENLABS_API_KEY=你的密钥
上游实时接入地址:以官方文档当前版本为准
UPSTREAM_URL=wss://你的上游接入地址
目标语言、音色等参数按官方文档的参数名填写
TARGET_LANG=zh
```
这一步是搭骨架。成功的标志是 npm i 没有报错,node -e "console.log(process.version)" 输出版本号。
第 2 步:写网关,把浏览器和上游连起来
网关的作用是「密钥隔离 + 协议转换 + 打断广播」。浏览器只说一种简单的协议,网关负责翻译成上游要求的格式。
server/gateway.js:
```js
import { WebSocketServer, WebSocket } from 'ws';
import 'dotenv/config';
const PORT = Number(process.env.PORT || 8080);
const UPSTREAM_URL = process.env.UPSTREAM_URL;
const API_KEY = process.env.ELEVENLABS_API_KEY;
const wss = new WebSocketServer({
port: PORT,
path: '/translate',
perMessageDeflate: false, // 实时音频不要压缩,省掉一次缓冲
});
wss.on('connection', (client) => {
const upstream = new WebSocket(UPSTREAM_URL, {
// 鉴权头名称以官方文档当前版本为准
headers: { 'xi-api-key': API_KEY },
});
const pending = []; // 上游没连上前的音频先缓存
let upstreamReady = false;
const t0 = Date.now();
upstream.on('open', () => {
upstreamReady = true;
client.send(JSON.stringify({ type: 'ready' }));
for (const chunk of pending.splice(0)) upstream.send(chunk);
});
// 浏览器 -> 上游
client.on('message', (data, isBinary) => {
if (isBinary) {
if (upstreamReady) upstream.send(data);
else pending.push(data);
return;
}
const msg = JSON.parse(data.toString());
if (msg.type === 'interrupt') {
// 关键:把打断信号转成上游能理解的截断/清除指令
// 具体消息格式以官方文档当前版本为准
upstream.send(JSON.stringify({ type: 'clear' }));
return;
}
if (msg.type === 'config') {
// 切换目标语言、语速等,同样按官方文档的参数名构造
upstream.send(JSON.stringify({ type: 'config', ...msg.payload }));
}
});
// 上游 -> 浏览器
upstream.on('message', (data, isBinary) => {
if (isBinary) {
if (!t0.firstAudioAt) {
t0.firstAudioAt = Date.now();
client.send(JSON.stringify({
type: 'metric',
firstAudioMs: t0.firstAudioAt - t0,
}));
}
client.send(data, { binary: true });
return;
}
client.send(data.toString());
});
const close = () => {
try { upstream.close(); } catch {}
try { client.close(); } catch {}
};
upstream.on('error', (e) => {
client.send(JSON.stringify({ type: 'error', message: String(e.message) }));
close();
});
client.on('close', close);
upstream.on('close', close);
});
console.log(gateway listening on :${PORT});
```
启动:node server/gateway.js。看到 gateway listening on :8080 就算成功。
第 3 步:浏览器端采集音频
用 AudioWorklet 在音频线程里做降采样,避免主线程卡顿导致丢帧。public/pcm-worklet.js:
```js
class PCMCapture extends AudioWorkletProcessor {
constructor(options) {
super();
this.target = options.processorOptions.targetRate || 16000;
this.step = sampleRate / this.target; // sampleRate 是 Worklet 全局变量
this.pos = 0;
this.out = [];
}
process(inputs) {
const ch = inputs[0] && inputs[0][0];
if (!ch) return true;
for (let i = 0; i < ch.length; i++) {
// 简单线性插值降采样,够用且开销低
this.out.push(ch[i]);
}
// 按目标采样率抽帧
const frame = [];
while (this.pos < this.out.length) {
const v = this.out[Math.floor(this.pos)];
frame.push(Math.max(-1, Math.min(1, v)) * 0x7fff);
this.pos += this.step;
}
this.out = this.out.slice(Math.floor(this.pos)).slice(-4096);
this.pos = this.pos % 1;
if (frame.length) {
const buf = new Int16Array(frame);
this.port.postMessage(buf.buffer, [buf.buffer]);
}
return true;
}
}
registerProcessor('pcm-capture', PCMCapture);
```
主线程里按 20~40ms 一帧发出去:
```js
const ctx = new AudioContext();
await ctx.resume(); // 必须由用户手势触发,否则移动端没声音
await ctx.audioWorklet.addModule('/pcm-worklet.js');
const stream = await navigator.mediaDevices.getUserMedia({
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
});
const src = ctx.createMediaStreamSource(stream);
const node = new AudioWorkletNode(ctx, 'pcm-capture', {
processorOptions: { targetRate: 16000 },
});
src.connect(node);
node.connect(ctx.destination); // 保持节点活跃,不产生实际输出
const ws = new WebSocket(wss://你的网关域名/translate);
ws.binaryType = 'arraybuffer';
let sendBuf = [];
let sendLen = 0;
const FRAME_BYTES = 16000 * 2 * 0.03; // 30ms 的 s16le
node.port.onmessage = (e) => {
sendBuf.push(new Uint8Array(e.data));
sendLen += e.data.byteLength;
if (sendLen >= FRAME_BYTES && ws.readyState === 1) {
const merged = new Uint8Array(sendLen);
let off = 0;
for (const b of sendBuf) { merged.set(b, off); off += b.length; }
ws.send(merged);
sendBuf = []; sendLen = 0;
}
};
```
第 4 步:下行播放队列
下行音频不能来一包播一包,必须排队,否则网络抖动会让声音断续。但队列也不能深,深了就会增加延迟。
```js
class Player {
constructor(sampleRate) {
this.ctx = new AudioContext({ sampleRate });
this.queue = [];
this.playing = false;
this.stopped = false;
}
enqueue(arrayBuffer) {
if (this.stopped) return;
this.queue.push(new Int16Array(arrayBuffer));
if (!this.playing) this._drain();
}
_drain() {
if (this.stopped) return;
if (!this.queue.length) { this.playing = false; return; }
this.playing = true;
const pcm = this.queue.shift();
const buf = this.ctx.createBuffer(1, pcm.length, this.ctx.sampleRate);
const ch = buf.getChannelData(0);
for (let i = 0; i < pcm.length; i++) ch[i] = pcm[i] / 0x8000;
const s = this.ctx.createBufferSource();
s.buffer = buf;
s.connect(this.ctx.destination);
s.onended = () => this._drain();
s.start();
}
stopNow() { // 打断:立刻静音并清空
this.queue = [];
this.stopped = true;
this.playing = false;
this.ctx.close().catch(() => {});
}
resume() { this.stopped = false; }
}
```
队列深度建议控制在 2~4 个分片,也就是 60~120ms 的缓冲。再深就开始明显「慢半拍」。
第 5 步:打断处理(barge-in)
打断是实时翻译里最容易做砸的一环。判断逻辑分本地和服务端两层:
1. 本地能量检测:每帧算一下 RMS,维护一个滑动噪声门限(比如取最近 2 秒 RMS 的中位数 × 2.5)。超过门限且持续 3 帧以上,判定为「有人开始说话」。
2. 立刻停播:调用 player.stopNow(),同时重建一个 Player 实例,避免旧队列残留。
3. 通知上游:ws.send(JSON.stringify({ type: 'interrupt' })),网关再转成上游的截断指令。
4. 重置发送缓冲:把还没发出去的 sendBuf 丢掉,从当前帧重新开始,否则上游会先收到一段「过去的你」。
```js
function rms(int16) {
let s = 0;
for (let i = 0; i < int16.length; i++) {
const v = int16[i] / 0x8000;
s += v * v;
}
return Math.sqrt(s / int16.length);
}
let hotFrames = 0;
function maybeInterrupt(int16, player, ws) {
const level = rms(int16);
noiseFloor = noiseFloor * 0.995 + level * 0.005;
const speak = level > Math.max(0.02, noiseFloor * 2.5);
hotFrames = speak ? hotFrames + 1 : 0;
if (hotFrames === 3 && player.playing) {
player.stopNow();
ws.send(JSON.stringify({ type: 'interrupt' }));
sendBuf = []; sendLen = 0;
}
}
```
注意「打断自己」和「打断别人」要分开:如果房间里是 A 在讲、B 在听,B 的麦克风噪声不应该打断 A 的播放。所以打断信号要带上说话人 ID,只对同一路会话生效。
第 6 步:多人对话与说话人分流
多人场景的难点不是翻译,是「谁说的话对应哪一路音频」。建议按会话维度组织:
- 每个参会者在网关上开一条独立连接,带
roomId和speakerId。 - 网关维护
roomId -> Set<client>的映射。某一路收到的翻译结果,广播给同房间的其他成员,附上speakerId。 - 客户端按
speakerId维护独立的播放队列。打断时只清掉「当前说话人」对应的队列。 - 服务端开启轮次检测(turn detection)时,尽量用它给出的说话人边界作为分片依据,比纯本地 VAD 稳。具体字段名以官方文档当前版本为准。
- 房间人数超过 4 人时,建议只广播「当前发言人」的翻译音频,其他人的音频不做实时播报,改为字幕流,否则会出现多路声音叠在一起。
第 7 步:延迟预算与调优
把各段耗时打点记录,才谈得上优化:
| 环节 | 典型值 | 优化手段 |
|---|---|---|
| 采集分片 | 20~40ms | 分片太小会增加包数量,太大直接加延迟 |
| 上行网络 | 视链路 | 网关就近部署,避免跨国 |
| 上游首包 | 以服务端返回为准 | 预热连接,避免每次新建握手 |
| 下行播放缓冲 | 60~120ms | 队列深度控制在 2~4 片 |
几个具体动作:
- 网关启动时先建一条空闲连接做预热,避免第一个用户吃握手时间。
- WebSocket 关闭
perMessageDeflate,音频压缩反而增加缓冲。 - 不要在链路上再加一层 HTTP 轮询或消息队列,实时语音加一层缓冲就是加几十毫秒。
- 用
ctx.outputLatency(支持的浏览器)读出设备输出延迟,一并计入预算。
验证部署是否成功
1. 网关存活:node server/gateway.js 后另开终端执行
```bash
curl -i http://127.0.0.1:8080/translate
```
返回 426 Upgrade Required 或握手失败都属正常——说明端口通了、只是不是 WebSocket 请求。
2. 构造测试音频喂进去:
```bash
ffmpeg -f lavfi -i "sine=frequency=440:duration=3" \
-ar 16000 -ac 1 -f s16le scripts/test.pcm
```
写个 scripts/feed.js 按 30ms 切片节奏 ws.send,浏览器端或日志里应看到 firstAudioMs 打点,正常在几百毫秒量级。
3. 落地检查:把收到的下行音频存成 out.pcm,用
```bash
ffplay -f s16le -ar 24000 -ac 1 out.pcm
```
播放,能听清内容且没有电流声、没有明显断续,就说明采样率和位深对上了。
4. 打断验证:播放过程中对着麦克风说一句短促的话,预期是播放在几十毫秒内停止,且不会在停顿后又把上一段播完。如果还会播完,说明播放队列没清干净。
常见报错与解决
1. 401 Unauthorized / 403 Forbidden
原因:密钥没加载、请求头名字写错、或把密钥放在了前端。
解决:
```bash
node -e "require('dotenv').config(); console.log(process.env.ELEVENLABS_API_KEY ? 'key ok' : 'key missing')"
```
确认输出 key ok,并核对网关里的请求头名称与官方文档当前版本一致。
2. WebSocket 连接后立刻 1006 断开
原因:反向代理不支持 Upgrade,或超时时间太短。
解决:Nginx 里补上
```nginx
location /translate {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
```
改完 nginx -t && nginx -s reload。
3. 翻出来的声音是噪音、音调异常
原因:采样率不一致(采集 48k 却按 16k 发送),或 Int16Array 与 Float32Array 混用、字节序没对齐。
解决:在网关日志里打印每帧字节数,30ms × 16000Hz × 2 字节 = 960 字节,对不上就回去检查降采样。下行同理,先确认上游返回的采样率再建 AudioContext。
4. 移动端 / Safari 完全没有声音
原因:AudioContext 未在用户手势中 resume(),处于 suspended 状态。
解决:把「开始翻译」做成按钮,点击回调里先 await ctx.resume(),再申请麦克风权限。
5. 打断后上游还在继续吐音频
原因:只停了本地播放,没把打断信号送到上游;或上游缓冲的音频还在路上。
解决:确认 interrupt 消息确实发到了网关,网关也确实转发了截断指令;同时在收到打断后,把下行音频丢弃 300ms 左右,等管道里的残包排空再恢复接收。
6. 播放断断续续、有「咔哒」声
原因:队列太浅、每段之间没有对齐、或 AudioContext 被反复重建。
解决:把队列深度提高到 3 片;用同一个 AudioContext,通过 start(when) 指定精确开始时间,让相邻分片首尾相接,而不是各自从「现在」开始。
后续维护
备份:把网关代码、.env.example(不含真实密钥)、术语表、目标语言配置、VAD 阈值一起纳入 Git;真实密钥走密钥管理服务或部署平台的加密变量,不进仓库。术语表和音色配置建议按客户/项目分文件,改动留痕。
升级:上游的实时接口会演进,升级前先看官方 changelog,注意鉴权头、消息类型、音频格式有没有变化。做法上先在一套独立环境跑通,再用灰度把一小部分流量切过去,观察首包延迟和错误率没有劣化再全量。
日志与监控:至少埋这几个指标——连接建立成功率、上游首包延迟 P50/P95、端到端延迟、打断响应时间、异常关闭率(1006 占比)、每分钟音频帧数。日志里不要打原始音频和完整密钥。告警阈值建议设在「首包延迟 P95 翻倍」和「异常关闭率超过 5%」这两条上,它们通常最先暴露链路问题。
容量与成本:并发路数主要受网关出口带宽和上游配额限制,不是 CPU。按「每路 16kHz 单声道约 256kbps 上下行合计」估算带宽,并在网关上做每 IP 的连接数限流,防止单个页面反复重连把配额打满。计费方式与配额以官方页面为准。
体验迭代:上线后收集真实对话录音(需获授权),重点看三类问题——打断误触发(安静环境下自己打断自己)、打断漏触发(对方开口了这边还在念)、多人场景说话人错配。这三类问题调阈值往往解决不了,需要回到房间模型和轮次检测逻辑上改。
