PAYATHON 2026

如何拼接 Twilio 增量转写结果,在前端 UI 中显示完整且不重复的句子?

技术老齐

把转写文本分成“已确认文本”和“当前临时文本”:收到临时结果时覆盖草稿,收到 isFinal === true 时再追加到已确认文本。前端始终显示两部分合并后的内容,不要把每次回调中的 transcript 都追加到页面。

从 client.streamingRecognize(request) 和返回字段判断,这里按 Google Cloud Speech-to-Text 的 Node.js V1 客户端说明,通话音频由 Twilio 传入。如果实际使用其他识别服务,需要按对应协议调整字段和结果更新规则。

为什么直接拼接会重复

"hey"、"hey i"、"hey i am" 通常是识别服务对同一段音频陆续给出的识别假设。新结果可能补充前文,也可能修正已经识别出的词。

Google 的流式响应通过 isFinal 区分两种结果:

  • false:临时结果,后续仍可能变化。
  • true:该段音频的最终结果,可以保存。在同一识别流中,服务不会再对这段音频返回新的识别假设。

一次响应可能包含多个 results,因此要遍历整个数组,不能只读取 data.results[0]。具体语义见 StreamingRecognizeResponse 接口说明。

假设服务把示例中的话分成两段,处理过程如下:

收到的文本isFinal已确认文本当前临时文本
heyfalse空hey
hey i am foofalse空hey i am foo
hey i am foo bartruehey i am foo bar空
andfalsehey i am foo barand
and i am a software developertruehey i am foo bar and i am a software developer空

这里的分段只是示意。仅凭题目列出的字符串,无法判断哪次更新是一段识别的最终结果,因此需要保留服务返回的 isFinal 等元数据。

后端实现:确认后追加,临时结果覆盖

下面的示例处理单个通话的 inbound 音轨。client 是已初始化的 Google Speech 客户端;publishToFrontend 是应用自行实现的推送函数,负责向对应通话的浏览器发送 JSON。

每个新连接调用一次该函数,状态保存在函数内部,避免不同通话共用文本。

function attachTranscription(ws, client, publishToFrontend) {
  const finalizedParts = [];
  let recognizeStream;
  let revision = 0;

  const request = {
    config: {
      encoding: "MULAW",
      sampleRateHertz: 8000,
      languageCode: "en-US",
      enableAutomaticPunctuation: true,
    },
    interimResults: true,
  };

  // 适用于示例中的英文;其他语言需要相应的连接规则。
  function joinParts(parts) {
    return parts.filter(Boolean).join(" ");
  }

  function handleRecognition(data) {
    const results = data.results ?? [];

    // 事件通知等响应可能不包含识别文本。
    if (results.length === 0) return;

    const interimParts = [];

    for (const result of results) {
      const text =
        result.alternatives?.[0]?.transcript?.trim() ?? "";

      if (result.isFinal === true) {
        if (text) finalizedParts.push(text);
      } else {
        if (text) interimParts.push(text);
      }
    }

    // 使用本次响应中的临时结果,替换上一版草稿。
    publishToFrontend({
      type: "transcript",
      revision: ++revision,
      finalText: joinParts(finalizedParts),
      interimText: joinParts(interimParts),
    });
  }

  ws.on("message", function incoming(message) {
    const msg = JSON.parse(message.toString());

    switch (msg.event) {
      case "start":
        recognizeStream = client
          .streamingRecognize(request)
          .on("error", console.error)
          .on("data", handleRecognition);
        break;

      case "media":
        // inbound、outbound 应分别处理,避免混入同一识别流。
        if (msg.media.track !== "inbound") break;

        if (recognizeStream?.writable) {
          recognizeStream.write(
            Buffer.from(msg.media.payload, "base64")
          );
        }
        break;

      case "stop":
        // 结束音频写入,继续接收剩余的识别响应。
        recognizeStream?.end();
        break;
    }
  });
}

Twilio Media Streams 的 media.payload 是 Base64 编码的音频,消息格式规定了 μ-law、8000 Hz 等音频属性,所以示例先解码,再写入识别流。如果应用已经对音频转码,request.config 必须与转码后的音频一致。参见 Twilio WebSocket 消息格式。

publishToFrontend 应通过应用与浏览器之间的连接发送结果。代码中的 ws 用来接收 Twilio 音频消息,不应把自定义转写 JSON 发回这个连接。

前端实现:用快照替换显示内容

后端每次发送当前的完整状态,前端收到后直接替换显示内容。下面假设 transcriptSocket 是浏览器与应用后端建立的 WebSocket。

<div id="transcript"></div>
const transcriptElement = document.querySelector("#transcript");
let lastRevision = 0;

transcriptSocket.addEventListener("message", (event) => {
  const msg = JSON.parse(event.data);

  if (msg.type !== "transcript") return;
  if (msg.revision <= lastRevision) return;

  lastRevision = msg.revision;

  transcriptElement.textContent = [
    msg.finalText,
    msg.interimText,
  ].filter(Boolean).join(" ");
});

这里用 textContent = ... 替换内容。如果改成 textContent += ...,即使后端处理正确,前端仍会重复显示。

revision 是示例自定义的状态版本号,用于忽略重复或过期的快照,只在当前通话内有效。切换通话时要重置它。如果多个通话共用一个连接,还需要携带通话标识,并分别维护状态。

拼接能否还原标点、大小写和完整句子

消除重复后可以得到连续文本,但不能保证输出恰好是:

Hey, I am Foo Bar, and I am a software developer.

enableAutomaticPunctuation: true 可以请求识别服务自动推断标点,是否支持取决于语言等条件,识别出的标点也未必与预期一致。相关配置见 Google 自动标点说明。人名和大小写也依赖识别服务的准确结果,字符串拼接无法自行恢复这些信息。

isFinal 表示一段识别结果已确定,不代表一个语法完整的句子已经结束。如果 UI 需要逐句展示,可以按句末标点对已确认文本分句,并保留尚未成句的尾部文本。

实际接入时还要注意什么

  • 不要按词去重。用户可能确实说了 very very good,全局去重会删掉真实内容。
  • 前缀长度或最长重叠无法可靠判断分段。临时结果可能缩短或改写,这些算法难以区分识别修正和用户重复说话。
  • 收到 stop 后,先结束音频写入,继续处理识别流返回的数据,等响应结束后再释放相关状态。立即销毁识别流或关闭前端结果通道,可能丢失最后一段结果。Google 的流式识别说明也说明了关闭写入端与最终结果的关系。
  • 通话异常中断时,尚未确认的草稿应保留“未确认”状态,不要直接保存为最终文本。
  • 示例每次发送完整快照,接入比较简单。长通话可以只推送新增的最终片段和当前草稿,并为片段分配稳定 ID,让前端按 ID 更新,减少历史文本的重复传输。
  • 重建识别流并重放部分音频时,应结合流标识、音轨和音频时间范围处理重叠。不要仅因两段文字相同就删除其中一段。

备注:内容仅供参考。