推出 Eleven v4认识 Eleven v4:迄今情感表现最丰富的模型。

跳至内容

实时语音转文本,延迟低于 200 毫秒:架构指南

发布时间
最近更新

收听收听本文

实时语音转文字(STT)会在人说话时主动转写音频,并在数百毫秒内将语音以文本形式返回。但要保持低 STT 延迟,架构与模型同样重要。工程师需要统筹传输、分块、端点检测和采集路径,这些环节都会增加延迟。其中任一环节效率不足,都可能耗尽 200ms 的延迟预算。

本指南提供一套实用方法,帮助你从传输层开始构建实时语音转文字流程。我们将以 Scribe v2 Realtime 为例。它的模型延迟约为 150ms,可生成部分转写,支持 90 多种语言,接受 PCM(8kHz-48kHz)和 mu-law 音频,并提供 语音活动检测 及手动提交控制,用于完成片段定稿。

我们将梳理音频如何到达服务器、假设文本如何演变为已确认文本、流内功能的代价,以及如何正确采集和转发音频。

摘要

  • 构建实时语音转文字系统需要细致调整架构,确保整个流程始终保持低延迟。
  • WebSocket 是大多数流程的合适默认选择;WebRTC 虽有多项优势,但也更复杂。
  • 语音活动检测可实现免手动分段;当应用知道当前轮次已结束时,手动提交可进行覆盖。
  • 部分结果是暂定的,最终结果则已确认,因此应以不同方式呈现。
  • 约 100ms 的小 PCM 块可尽量缩短首次部分结果的延迟。

实时语音转文字:WebSocket 与 WebRTC

在转写开始前,音频必须从源端传到识别器。所选通道决定了后续所有环节的延迟下限。音频抵达转写层有两种可行方式。

WebSocket 是基于 TCP 的长连接、有序、可靠、双向通道。你可以建立连接、向上发送二进制音频帧,并向下读取转写事件。它在客户端和服务器端都很直接,能穿过已允许 HTTPS 的企业代理和防火墙,且所有浏览器和服务器运行时均支持。

WebSocket 的限制在于它基于 TCP。数据包丢失时,TCP 会重传,并在缺口补齐前阻塞后续数据。网络状况良好时,这并不明显;出现丢包时,则会产生队头阻塞:音频短暂积压,随后成批到达。

WebRTC 专为实时媒体构建。它通过 UDP(经由 SRTP)传输媒体,因此丢包不会让流停滞,流程会继续运行。它内置吸收数据包到达时间波动的抖动缓冲区,通过 ICE/STUN/TURN 协商 NAT 穿透以连接路由器后的对端,并自带音频采集和编码机制。

对于无法直接连接的客户端,通常需要 TURN 服务器;服务器端也必须终止媒体流,而不是读取字节流。

简要比较如下:

WebSocket
Transport
TCP (reliable, ordered)
Behavior under packet loss
Head-of-line blocking, bursty recovery
Jitter handling
Your responsibility
NAT traversal
Not needed (client-initiated)
Browser support
Universal, trivial
Server complexity
Low
WebRTC
Transport
UDP/SRTP (real-time, loss-tolerant)
Behavior under packet loss
Graceful degradation
Jitter handling
Built-in jitter buffer
NAT traversal
Requires ICE/STUN/TURN
Browser support
Universal, but more API surface
Server complexity
High (media server or SFU)

对大多数用例而言,WebSocket 是合适选择。当客户端连接质量不错且你能控制采集路径时可使用它:服务器到服务器的流程、桌面应用、宽带网络上的浏览器应用,以及大多数音频已通过其他方式到达服务器的呼叫中心后端。

当直接从网络不稳定的消费者移动设备采集音频、已为双向音频运行 WebRTC 技术栈(例如还能语音回复的智能体),或低丢失实时行为比实现简洁性更重要时,选择 WebRTC。

本指南其余部分使用 WebSocket 连接识别器,因为它能让各组件更清晰,也是大多数团队的合适起点。其中没有内容专属于 WebSocket;之后可在前端加入 WebRTC 媒体链路,在服务器将音频解码为 PCM,再将相同分块转入流程。

部分结果与最终转写:临时结果说明

实时识别器不会等完整句子说完才输出,而是持续输出猜测结果;随着更多音频到达,结果逐步变得准确,随后锁定。理解这两种状态的区别,是让转写看起来鲜活而非残缺的关键。

部分(临时)假设是模型根据目前收到的音频给出的最佳猜测。部分结果天生不稳定。随着更多音频到达,模型会修改前面的词:后续上下文消除歧义后,“我想要”可能变成“我想要两张票”。它们很快到达(约 150ms 延迟指的就是这一点),且设计上会被覆盖。

最终假设是不会再改变的已确认片段。片段定稿后,识别器会继续处理后续内容,之后的假设对应更晚的音频。最终结果可用于持久化、发送给 LLM 或保存为转写记录。

部分结果与最终结果的区别会影响以下三点;若混淆它们,就容易出错:

  • 用户体验:显示部分结果会让转写显得实时:用户会看到文字随说话出现,从而确认麦克风正常且系统正在监听。
  • 端点检测:部分结果可持续反映语音活动。结合 VAD,可判断说话者是否真的停止。
  • 下游时序:在 语音智能体流程 中,步骤依次是音频输入、语音转文字、LLM、文本转语音,再到音频输出。你可以基于部分结果开始推测性处理,并在最终结果时确认;这样能缩短感知响应时间,但偶尔需要丢弃推测性工作。

应以不同方式呈现部分结果和最终结果。一种简单有效的模式是:维护一条与最新部分结果绑定、可变的“当前行”,在收到最终结果时将其提交至仅追加的转写记录:

type TranscriptState = {
  committed: string[]; // finalized segments, never rewritten
  current: string;     // latest partial, overwritten on each update
};

const onPartial = (s: TranscriptState, text: string): TranscriptState =>
  ({ ...s, current: text });

const onFinal = (s: TranscriptState, text: string): TranscriptState =>
  ({ committed: [...s.committed, text], current: "" });

在视觉上,将已确认内容呈现为稳定文本,将当前内容使用较浅颜色或斜体,让用户知道它仍可能变化。

端点检测与语音活动检测(VAD)

知道说了什么只是问题的一半。识别器还必须知道一句话何时结束。这一判断决定了何时将片段定稿,以及在智能体中系统何时开始响应。

端点检测是判断一次话语已结束的过程。过早定稿会在用户句中截断;过晚定稿则会让智能体在用户明显说完后仍保持沉默。

Scribe v2 Realtime 提供两种互补机制:

  • 基于静音的语音活动检测分段:识别器会检测语音何时转为持续静音,并以此边界自动将片段定稿。对于对话界面,VAD 是合适默认选项,因为它能适应自然说话节奏,无需手动跟踪时间。
  • 手动提交控制:手动提交控制可让应用独立于静音状态,决定何时将当前片段定稿。发送提交信号后,识别器会关闭当前片段并输出最终结果。当应用已知当前轮次结束时,这正是合适工具:例如松开按住说话按钮、执行“发送”操作,或遵循外部轮次策略。

两者可以很好地配合。典型的 语音智能体使用 VAD 实现免手动操作,并将手动提交作为覆盖机制:思考时暂停的用户不会被打断,而点击按钮的用户可立即得到边界。

静音阈值确实存在取舍,没有放之四海而皆准的值:

  • 较短的语音结束超时(例如静音约 200-400ms 后定稿)可让系统响应更快,但也可能截断在分句之间自然停顿的用户,将一个想法拆分为多个片段,并在智能体中触发过早响应。
  • 较长的超时(例如约 800-1200ms)可容忍自然停顿并保持话语完整,但系统响应前会有明显延迟。

这里没有可直接套用的全局常量;应针对交互方式调整阈值:

  • 听写和记笔记可容忍更长停顿,因为用户会在句中思考。应倾向于更长超时,并主要依赖 VAD。
  • 命令控制和事务型智能体适合较短超时结合手动提交,因为轮次简短明确。
  • 多语言使用者或非母语说话者停顿更多,因此定稿前应预留更长静音时间。

利用这些建议,可构建有效的端点检测系统,并逐步实现实时语音转文字。

流内功能:语言检测与说话人分离

流式识别不只能输出文字。但每增加一种所需信号,都会影响延迟和稳定性。经验法则是:只开启实时体验所需的功能,其余留给批处理。

自动语言识别可让 Scribe v2 Realtime 在支持的 90 多种语言中识别口语语言,无需你预先指定。代价是模型需要一小段音频才能做出可靠判断,因此在语言确定前,流中的前几个部分结果可能不太稳定。若已知语言,指定它可消除这种不确定性,通常能让早期部分结果更稳定。

说话人分离会将语音归属到不同说话者,以识别谁说了什么。在 批量转写 中,这相对容易,因为模型能看到整个文件。流式场景则更难:识别器只能根据目前的音频分配说话人标签,而随着听到更多该说话者的声音,早期音频的标签可能需要修订。对待流式说话人标签应和对待部分文本相同:在片段定稿前都是暂定的。

词级时间戳和实体上下文也是同样道理。请求的每个 token 元数据越多,模型和传输通道的负担就越大。对大多数实时 UI 而言,只需实时获得文本和片段边界;细粒度元数据可在通话后通过 Scribe v2 批处理获取。

流式音频格式:PCM 与 mu-law

传输和识别逻辑通常最受关注,但现实中相当多的 Bug 源于更底层的音频编码和分块方式。正确设置格式和分块大小,是降低语音转文字延迟成本最低的方式。

当你能控制采集时,应使用 PCM(线性、16 位有符号、小端序)。更高采样率携带更多声学细节:16kHz 是语音识别的标准下限,通常已足够;8kHz 为电话级,会丢失高频内容。应使用与源端匹配的采样率。将 8kHz 电话音频上采样到 48kHz 没有益处,因为缺失的信息无法恢复。

8kHz mu-law 是电话音频格式。若从 Twilio 等提供商接入通话,音频会以 8kHz mu-law 到达,应直接以该格式转发,而非进行两次转码。匹配源格式可避免重采样伪影和不必要的转换步骤。

分块大小最直接影响感知延迟。音频按块发送,识别器在收到分块时生成部分结果。更小的块意味着更新更频繁、首次部分结果延迟更低;更大的块意味着消息更少,每次推理上下文略多。实用范围是每块 20-250ms 音频。具体而言,16kHz 单声道 16 位 PCM 每秒为 32,000 字节,因此 100ms 分块约为 3,200 字节。

在浏览器中采集麦克风输入

在浏览器中,合适的工具是 Web Audio API 与 AudioWorklet。该 worklet 运行在音频渲染线程上,以小帧接收音频,不会像旧版 ScriptProcessorNode 那样受主线程卡顿影响。它负责将浏览器原生浮点采样转换为 16 位 PCM,并交给主线程通过 WebSocket 转发。

worklet 处理器的核心是浮点数转 PCM:

// pcm-worklet.ts - registered via audioContext.audioWorklet.addModule()
class PCMWorklet extends AudioWorkletProcessor {
  process(inputs: Float32Array[][]) {
    const channel = inputs[0]?.[0]; // mono; Float32, range [-1, 1]
    if (!channel) return true;
    const pcm = new Int16Array(channel.length);
    for (let i = 0; i < channel.length; i++) {
      const s = Math.max(-1, Math.min(1, channel[i]));
      pcm[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
    }
    // Transfer the buffer to the main thread without copying.
    this.port.postMessage(pcm.buffer, [pcm.buffer]);
    return true;
  }
}
registerProcessor("pcm-worklet", PCMWorklet);

代码中的流程

该流程包含三个部分:采集麦克风并将 PCM 流式发送至服务器的浏览器客户端、将音频转发至 Scribe v2 Realtime 并将转写结果回传的 Node 服务器,以及从文件或电话桥接中流式发送 PCM 的脚本化客户端。

服务器采用转发而非将识别器直接暴露给浏览器,原因很重要:ElevenLabs API 密钥是机密,绝不能出现在客户端代码中。密钥应保留在服务器上。若确实需要浏览器直接与识别器通信,应在服务器端创建短期单次使用令牌,并将其而非 API 密钥交给客户端。

浏览器客户端

客户端向服务器建立 WebSocket,通过上述 worklet 采集麦克风,并在每个 PCM 帧生成时转发。传入事件已由服务器规范化为 { type, text },用于驱动前文的部分结果/最终结果状态:

// client.ts - runs in the browser. ws is an open WebSocket to your server.
const audioContext = new AudioContext({ sampleRate: 16000 });
await audioContext.audioWorklet.addModule("pcm-worklet.js");

const mediaStream = await navigator.mediaDevices.getUserMedia({
  audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
});

const source = audioContext.createMediaStreamSource(mediaStream);
const worklet = new AudioWorkletNode(audioContext, "pcm-worklet");

// Forward each PCM frame to the server the moment it is produced.
worklet.port.onmessage = (e: MessageEvent<ArrayBuffer>) => {
  if (ws.readyState === WebSocket.OPEN) ws.send(e.data);
};
source.connect(worklet);

// Manual commit: tell the server to finalize the current segment.
const commit = () => ws.send(JSON.stringify({ type: "commit" }));

服务器转发

服务器为每个客户端建立一个识别器连接,将 API 密钥保留在服务器端,直接转发二进制 PCM,并将识别器事件规范化为客户端使用的稳定 { type, text } 结构:

// server.ts - Node, using the `ws` library. ELEVENLABS_API_KEY and the
// recognizer URL come from the environment; see the Speech to Text reference
// for the exact path and query parameters.
import { WebSocketServer, WebSocket } from "ws";

new WebSocketServer({ port: 8080 }).on("connection", (client) => {
  // The API key stays on the server, never on the wire to the browser.
  const recognizer = new WebSocket(process.env.RECOGNIZER_WSS_URL!, {
    headers: { "xi-api-key": process.env.ELEVENLABS_API_KEY! },
  });

  // Browser -> recognizer: forward binary PCM, translate control messages.
  client.on("message", (data, isBinary) => {
    if (recognizer.readyState !== WebSocket.OPEN) return;
    if (isBinary) recognizer.send(data); // raw PCM bytes
    else if (JSON.parse(data.toString()).type === "commit")
      recognizer.send(sendCommit());
  });

  // Recognizer -> browser: normalize events into a stable shape.
  recognizer.on("message", (raw) => {
    const event = parseRecognizerEvent(raw.toString());
    if (event && client.readyState === WebSocket.OPEN)
      client.send(JSON.stringify(event));
  });

  // ... open handshake, queueing pre-open audio, and teardown on close/error
});

所有端点专属逻辑都集中在下面两个适配器函数中。请将字段名替换为语音转文字参考文档中的准确字段名;其余流程无需改变:

// The single place that knows the recognizer's wire format.
const sendCommit = (): string => JSON.stringify({ type: "commit" });

type NormalizedEvent =
  | { type: "partial"; text: string }
  | { type: "final"; text: string }
  | { type: "vad"; speaking: boolean };

function parseRecognizerEvent(raw: string): NormalizedEvent | null {
  const msg = JSON.parse(raw);
  if (msg.is_final === true || msg.type === "final")
    return { type: "final", text: msg.text ?? "" };
  if (msg.type === "vad") return { type: "vad", speaking: !!msg.speaking };
  if (typeof msg.text === "string")
    return { type: "partial", text: msg.text };
  return null;
}

脚本化后端客户端

后端流程及下方基准测试均可使用同一识别器连接,无需浏览器:从任意来源读取 PCM,按实时分块节奏发送,并读取返回事件。与服务器一样,API 密钥和 URL 均来自环境变量。

// stream-stt.ts - pace ~100ms chunks at real time, then commit the tail.
const SAMPLE_RATE = 16000, CHUNK_MS = 100;
const CHUNK_BYTES = (SAMPLE_RATE * 2 * CHUNK_MS) / 1000; // 3200 bytes
const ws = new WebSocket(process.env.RECOGNIZER_WSS_URL!, {
  headers: { "xi-api-key": process.env.ELEVENLABS_API_KEY! },
});

// Send: walk the PCM buffer in 100ms chunks, sleeping between to mimic a
// live source. For audio that already arrives in real time, drop the sleep.
async function sendAudio(pcm: Buffer) {
  for (let off = 0; off < pcm.length; off += CHUNK_BYTES) {
    ws.send(pcm.subarray(off, off + CHUNK_BYTES));
    await new Promise((r) => setTimeout(r, CHUNK_MS));
  }
  ws.send(JSON.stringify({ type: "commit" })); // finalize the trailing segment
}

// Receive: print partials in place, append finals.
ws.on("message", (raw) => {
  const e = parseRecognizerEvent(raw.toString());
  if (e?.type === "final") console.log(`[final]   ${e.text}`);
  else if (e?.type === "partial") process.stdout.write(`[partial] ${e.text}\r`);
});

评测语音转文字延迟与词错误率

延迟和词错误率都会因说话者、语言、声学环境、音频长度、到各提供商最近区域的网络路径,以及各服务当前负载而变化。

在一个城市的笔记本电脑上测得的结果,无法直接推广至另一城市的生产环境。应在接近生产环境的基础设施上,使用接近真实输入的音频运行测试工具,并报告范围和分布,而非单个数值。

真正重要的延迟和准确率数据,是在接近生产环境的基础设施上,使用自身音频测得的数据。以下是语音转文字延迟的基准测试指南。

语音转文字延迟的测量指标

评测实时语音转文字延迟时,应测量以下主要指标:

  • 首次部分结果时间:从发送首个音频块到收到第一个非空部分结果的时间。
  • 部分结果到最终结果的延迟:从话语最后一个音频块到最终假设的时间。
  • 词错误率(WER):将最终转写与人工参考文本对比得出的 WER,所有系统均按相同方式计算。
  • 稳定性变动:定稿前被改写了多少次部分结果。该指标可反映实时 UI 看起来会有多大变化。

控制项

为避免数据不可靠,应在实验中设置多项控制条件以保持一致。

以下是用于 语音转文字 延迟基准测试的主要控制项:

  • 相同音频:向每个系统输入相同文件、相同采样率和相同编码。
  • 相同发送节奏:以相同实时分块节奏向每个系统传输(例如 100ms 分块)。
  • 重复测试并报告分布:全天多次运行每个文件;报告中位数和尾部值(p50/p95)。
  • 相同参考文本和评分:计算 WER 前,以相同方式规范化文本(大小写、标点、数字)。
  • 披露区域和网络:说明测试工具的运行地点及到各提供商的网络路径。

保持所有这些要素一致,才能得到更准确的指标。

测试工具骨架

测量核心接收提供商适配器,并记录首次部分结果时间、定稿延迟和部分结果变动:

// benchmark.ts - measurement core; one StreamFn adapter per provider.
type StreamFn = (
  audioPath: string,
  onEvent: (kind: "partial" | "final", text: string) => void,
  result: RunResult
) => Promise<void>; // adapter sets result.lastChunkSentAt on the final chunk

interface RunResult {
  firstPartialMs?: number;
  finalLagMs?: number;
  hypothesis: string;
  partialEdits: number;
  lastChunkSentAt: number;
  startedAt: number;
}

async function measure(streamFn: StreamFn, audioPath: string): Promise<RunResult> {
  const result: RunResult = {
    hypothesis: "", partialEdits: 0, lastChunkSentAt: 0,
    startedAt: performance.now(),
  };
  let prevPartial = "";

  await streamFn(audioPath, (kind, text) => {
    const now = performance.now();
    if (kind === "partial") {
      if (text && result.firstPartialMs === undefined)
        result.firstPartialMs = now - result.startedAt;
      if (text !== prevPartial) { result.partialEdits++; prevPartial = text; }
    } else { // final
      result.hypothesis = result.hypothesis ? `${result.hypothesis} ${text}` : text;
      if (result.lastChunkSentAt)
        result.finalLagMs = now - result.lastChunkSentAt;
    }
  }, result);

  return result;
}

词错误率是基于规范化文本、按 token 计算的标准 Levenshtein 距离。计算前,应对参考文本和假设文本以完全相同的方式转小写并移除标点,否则测量的将是规范化器而非模型。应将该指标置于循环中,每个提供商对每个文件运行约 10 次,并报告首次部分结果时间中位数和 WER 中位数(p50/p95),因为单个样本会主要受网络波动影响。

要运行它,需要提供两项内容。首先,为每个系统编写一个 StreamFn 适配器。上面的脚本化客户端已是其中之一;其他适配器遵循相同的 (audioPath, onEvent, result) 契约,并在最后一个音频块发出时设置 result.lastChunkSentAt。其次,加载音频文件和参考文本,并对它们调用 measure。在代表部署环境的机器上,使用代表用户的音频运行,即可得到可复现的比较结果。

回顾如何实现实时语音转文字

本文介绍了许多架构调整方法,可帮助你逐步改进系统,向实时语音转文字迈进。

生产级实时 STT 系统归根结底取决于几个关键决策:

  • 传输:为简洁性和可控网络选择 WebSocket;在需要容忍丢包且从消费者设备采集时选择 WebRTC。
  • 部分结果与最终结果:将部分结果视为暂定、最终结果视为已确认,并以不同方式呈现,让用户信任实时文本。
  • 端点检测:使用 VAD 进行免手动分段,以手动提交作为覆盖机制,并按实际交互调整静音阈值,而非使用固定常量。
  • 流内功能:只在实时体验需要时开启流内功能,其余留给使用 Scribe v2 的批处理。
  • 音频格式:以小 PCM 帧采集,发送约 100ms 分块,并为电话音频匹配源格式。
  • 基准测试:针对自身音频和目标指标,以实测方式设置准确率与延迟的平衡参数。
  • API 安全:将 API 密钥保留在服务器端,或为客户端直连创建单次使用令牌。

如果想了解如何 优化语音智能体延迟,我们也准备了一份指南。

使用 Scribe v2 Realtime 构建实时语音转文字系统

Scribe v2 Realtime 的模型延迟约为 150ms,可生成部分结果。用户实际感受到的是这一数值还是更高,取决于周边架构,而这正是你可以控制的部分。采用本文策略,可构建延迟更低、客户体验更好的流程架构。

如需深入了解,请查看 语音转文字功能 概览,阅读我们的 模型参考文档 以了解完整功能和语言列表,并访问实时产品页面:实时语音转文字 API 和 实时语音转文字。

准备开始构建时,创建免费的 ElevenLabs 账户,立即开始流式生成首份转写。

实时语音转文字延迟常见问题

作者

Tadas Petra 是一名开发者关系工程师,致力于帮助开发者使用 ElevenLabs 产品。他还是开发者教育平台 Hungrimind 的创始人。此前曾任 Agora 首席开发者布道师和 Zero To Mastery 讲师。

相关内容

用高质量 AI 音频创作