如何拼接 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 | 已确认文本 | 当前临时文本 |
|---|---|---|---|
hey | false | 空 | hey |
hey i am foo | false | 空 | hey i am foo |
hey i am foo bar | true | hey i am foo bar | 空 |
and | false | hey i am foo bar | and |
and i am a software developer | true | hey 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 更新,减少历史文本的重复传输。
- 重建识别流并重放部分音频时,应结合流标识、音轨和音频时间范围处理重叠。不要仅因两段文字相同就删除其中一段。
备注:内容仅供参考。
