泰语的句子末尾没有标点。书面泰语只在短语和分句之间留空格,其余部分连写,说出口的话更是没有任何标点。处理录制完成的文件时,转写系统可以先读完整个文件,再做任何判断。实时转写则必须在对方还在说话时就下结论。
Paxa STT Lite Realtime 的做法是按轮次工作。WebSocket 连接持续接收音频,判断一段语音在哪里结束,然后为这一段发送一份完整的转写。下面依次说明轮次本身、泰语为何让这条边界难以判断、边界之前的中间结果有什么用,以及一次会话的费用。
什么是轮次#
一个轮次是同一位说话人连续的一段音频,起点是开口的那一刻,终点是停下的那一刻。连接会通知这两个时刻。在两者之间,连接发送中间结果,每一份都是同一轮次更完整的草稿。轮次结束后,连接发送一份最终结果,程序应当依据的就是这份最终结果。
调用方当然也可以逐词接收识别结果,自己判断一句话说到哪里算完。这个判断是实时流程的难点,在泰语里,它比识别本身更难。
speech_started turn 2 transcript turn 2 interim ผม transcript turn 2 interim ผมโอนไปแล้ว transcript turn 2 interim ผมโอนไปแล้วเมื่อเช้า speech_ended turn 2 transcript turn 2 final ผมโอนไปแล้วเมื่อเช้านี้ครับ
这一轮最后到达的词是 ครับ,它不包含任何关于这笔付款的信息,只表示说话人已经说完。如果智能体在第三份中间结果上就采取行动,它回应的是一句还没说完的话。
泰语为何让这条边界难以判断#
泰语口语有三个特点,与为英语编写的端点判断规则相冲突。
- 没有句号。泰语句子说完了就是结束,书面上至多留一个空格。等待句末标点的规则会一直等下去。
- 礼貌的话语以一个词收尾。ครับ 和 ค่ะ 会结束一轮发言,นะคะ 和 แล้วครับ 也一样。这些词出现在内容之后,也就是说,句子中承载信息的部分在说话人说完之前就已经完整了。
- 停顿落在短语之间。泰语在短语之间留空格,说话人在句子中间也会在同样的位置停下。这个间隙离这句话真正的结尾还很远。
只靠静音判断边界的规则,正是被第三点打破的。切分会落在名词和它的量词之间,或者落在动词和它所支配的数量之间。切开后的每一半读起来都是残句,意思比整句少,下游模型回应的也只是它拿到的残句。
语码转换让同一个问题更加突出。泰语使用者会在一个分句内部切换到英语。英语词前后出现的任何间隙,都位于一句尚未说完的话中间。转写结果必须把两种文字保留在同一行,并把这个间隙算作该轮次的一部分。
中间结果有什么用#
中间结果是草稿。每一份都会替换前一份,随着更多音频到达,任何一份都可能改变。可以把它们显示在屏幕上,供观看字幕滚动的人阅读。任何会执行操作的环节都不应使用它们:依据中间结果完成的预订,依据的只是半句话。
最终结果是定稿。它包含轮次编号、文本,以及该轮次在音频中的位置。语音智能体在收到最终结果后作答。主管设置的关键词提醒也在最终结果到达时触发。实时字幕先显示中间结果,最后落定为最终结果。
说明
轮次边界也是停止说话的信号。连接一报告 speech_started,语音智能体就取消正在播放的回复。来电者正是这样打断助手,并让自己的话被听到。
实时会话如何计费#
计费依据连接收到的音频,静音也计入,每分钟 12.5 积分。连接在每个轮次处切分为计费时段,边进行边扣费,因此长时间的会话可以随时报告累计金额。模型未能转写的轮次不收费,报告的累计金额已经考虑了这一点。
在请求日志中,无论包含多少轮次,整个连接都只占一行。计费时段只是计算费用的方式;调用方请求的是会话本身。
已经录好的音频应当交给整段转写接口。Paxa STT Lite 接受最长 60 分钟的文件,一次返回完整转写,每分钟费用也更低,因为它不必跟上说话人的节奏。
打开 WebSocket 连接#
一个 start 帧指定模型和音频编码。此后,客户端上传二进制音频,服务端下发事件,连接在整个对话期间保持打开。
import { open } from "node:fs/promises";
// Runs on Bun as-is; on Node, `import WebSocket from "ws"` accepts
// the same constructor options. Browsers cannot send this header, and
// API keys never ship to browsers anyway: relay the audio through your
// server.
const socket = new WebSocket("wss://api.paxalabs.com/v1/stt/live", {
headers: { Authorization: `Bearer ${process.env.PAXA_API_KEY}` },
});
socket.onopen = async () => {
socket.send(JSON.stringify({
type: "start",
model: "paxa-stt-lite-realtime-v1-preview",
audio: { encoding: "pcm_s16le", sample_rate: 16000 },
language: "th",
}));
// 16 kHz signed 16-bit mono is 32,000 bytes a second. A microphone
// delivers frames at this pace on its own; a file has to be paced.
const file = await open("caller.pcm");
const frame = new Uint8Array(640); // 20 ms
for (;;) {
const { bytesRead } = await file.read(frame, 0, frame.length);
if (bytesRead === 0) break;
socket.send(frame.subarray(0, bytesRead));
await new Promise((resolve) => setTimeout(resolve, 20));
}
socket.send(JSON.stringify({ type: "end" }));
};
socket.onmessage = (event) => {
const message = JSON.parse(String(event.data));
switch (message.type) {
case "speech_started":
// The user is talking: stop the agent's audio, cancel its reply.
break;
case "transcript":
if (message.is_final) console.log(`turn ${message.turn}: ${message.text}`);
break;
case "done":
socket.close();
}
};import json
import os
import time
from websockets.sync.client import connect # pip install websockets
with connect(
"wss://api.paxalabs.com/v1/stt/live",
additional_headers={"Authorization": f"Bearer {os.environ['PAXA_API_KEY']}"},
) as socket:
socket.send(json.dumps({
"type": "start",
"model": "paxa-stt-lite-realtime-v1-preview",
"audio": {"encoding": "pcm_s16le", "sample_rate": 16000},
"language": "th",
}))
# 16 kHz signed 16-bit mono is 32,000 bytes a second; a file has to
# be paced the way a microphone paces itself.
with open("caller.pcm", "rb") as audio:
while frame := audio.read(640): # 20 ms
socket.send(frame)
time.sleep(0.02)
socket.send(json.dumps({"type": "end"}))
for message in socket:
event = json.loads(message)
if event["type"] == "transcript" and event["is_final"]:
print(event["turn"], event["text"])
elif event["type"] == "done":
break{"type":"started","connection":"7d9c2b7e-4a41-4c8e-9a35-1f26d8c04b7a","model":"paxa-stt-lite-realtime-v1-preview"}
{"type":"speech_started","turn":1,"time":0.3}
{"type":"speech_ended","turn":1,"time":12.252}
{"type":"charged","seq":1,"seconds":12.252,"credits":2.56,"total_credits":2.56}
{"type":"transcript","turn":1,"is_final":true,"text":"สวัสดีค่ะคุณสมชายยอดชำระของท่านจำนวน พันสองร้อยห้าสิบ บาทครบกำหนดวันที่ สิบห้า สิงหาคมนี้ชำระผ่านแอปได้ตลอด ยี่สิบสี่ ชั่วโมงค่ะ","start":0.3,"end":12.252}
{"type":"done","total_seconds":12.252,"turns":1,"total_credits":2.56}