泰语语音智能体必须先知道来电者已经说完,才能回答,而泰语给出的线索很少。泰文书写不用句号,空格落在短语之间,说话人的停顿落在句中和落在句末的次数差不多。直到 9 月 23 日,基于我们 API 开发的团队都只能转写已经录完的音频,再自己编写这套判断。
现在,Paxa STT Lite Realtime 会在音频经由一条 WebSocket 传入时同步转写,判断每一轮在哪里结束,并为这一轮返回一份最终转写。模型 ID 为 paxa-stt-lite-realtime-v1-preview,端点为 WS /v1/stt/live。
发布内容#
- 实时模型
paxa-stt-lite-realtime-v1-preview,通过WS /v1/stt/live提供服务。一条连接承载整场对话。 - 轮次检测在连接内完成。
speech_started开启一轮,speech_ended结束这一轮,随后返回一条最终transcript。 - 音频可以是 16 或 48 kHz 的 16 位有符号 PCM,也可以是 8 kHz 的电话 mu-law。
POST /v1/stt的语言提示、词级时间戳、风格、书写规范和词汇表,都在start时设定,并在整条连接期间保持不变。- 按接收的音频计费,静音也计算在内,每分钟 12.5 积分。对话进行期间会陆续收到
charged回执。 - 转写工具新增实时模式,无需编写任何代码,即可中转浏览器麦克风的音频。
一次未经剪辑的会话#
下表是 2026-09-23 的一次采集。我们把三段用 Paxa TTS Flash 生成的呼叫中心提示语播放进同一条连接,每段之间留有停顿,共计 29.95 秒音频。轮次边界、转写稿和扣费都是连接实际报告的结果,未经任何编辑。
| 轮次 | 秒 | 积分 | 转写稿 |
|---|---|---|---|
| 1 | 0 至 2.7 | 0.57 | สวัสดีค่ะขอบคุณที่ติดต่อบริษัทของเรา |
| 2 | 2.7 至 8 | 1.1 | กด 1 เพื่อติดต่อฝ่ายขายกด 2 ฝ่ายบริการลูกค้าหรือกด 0 เพื่อติดต่อเจ้าหน้าที่ค่ะ |
| 3 | 9.11 至 18.05 | 2.1 | สวัสดีค่ะคุณสมชายยอดชำระของท่านจำนวน 1,250 บาทครบกำหนดวันที่ 15 สิงหาคมนี้ชำระผ่านแอปได้ตลอด 24 ชั่วโมงค่ะ |
| 4 | 19.16 至 22.9 | 1.01 | ขณะนี้อยู่นอกเวลาทำการเราเปิดให้บริการวันจันทร์ถึงศุกร์ |
| 5 | 22.9 至 23.71 | 0.16 | เวลา |
| 6 | 23.92 至 26.44 | 0.57 | 9 โมงเช้าถึง 6 โมงเย็น |
| 7 | 26.44 至 28.55 | 0.44 | ฝากข้อความหลังเสียงสัญญาณได้เลยค่ะ |
把第 4 到第 6 轮连起来读,它们是一句关于营业时间的话。说话人在表示时间的词前停了一下,在数字前又停了一下,连接在每次停顿处都结束了一轮。这就是泰语的边界问题在真实采集中的样子。智能体如果等来电者安静片刻再回复,就能把这三轮一起读完,只回答一次。
这几轮合计扣费 5.95 积分。最后一轮结束后,连接又继续收听了 1.4 秒,追加 0.29 积分。连接报告的总额为 6.24 积分。
连接上传输什么#
上行三种帧,下行七种事件。音频是二进制,其余都是 JSON,全部走同一条连接。
client -> {"type":"start","model":"paxa-stt-lite-realtime-v1-preview",
"audio":{"encoding":"pcm_s16le","sample_rate":16000},
"language":"th","convention":"written"}
server <- {"type":"started","connection":"7d9c2b7e","model":"paxa-stt-lite-realtime-v1-preview"}
client -> <binary audio frames>
server <- {"type":"speech_started","turn":1,"time":0.000}
server <- {"type":"transcript","turn":1,"is_final":false,"text":"สวัสดีค่ะ"}
server <- {"type":"speech_ended","turn":1,"time":2.700}
server <- {"type":"charged","seq":1,"seconds":2.700,"credits":0.57}
server <- {"type":"transcript","turn":1,"is_final":true,"text":"สวัสดีค่ะขอบคุณที่ติดต่อบริษัทของเรา"}
client -> {"type":"end"}
server <- {"type":"done","turns":7,"total_credits":6.24}start指定模型和音频编码,并携带本次会话的所有选项。它是第一帧,每条连接恰好一个。playback告诉连接,您的智能体自己的声音何时正在麦克风所在的房间里播放。end结束进行中的这一轮,返回它的转写,并请求done。
下行的事件是这份约定的另一半。
started确认会话并给出连接 ID。提交支持请求时请附上这个 ID。speech_started开启一轮,并给出语音在音频时钟上的起点。is_final为 false 的transcript是这一轮到目前为止的中间结果,会被下一条替换。speech_ended结束这一轮。charged是一个计费区间的回执,包含该区间覆盖的秒数和累计总额。is_final为 true 的transcript就是这一轮的结果,程序应据此执行操作。error指明失败的轮次,或在关闭前报告致命故障。done携带连接的各项总量,随后连接正常关闭。
轮次检测与打断信号#
连接要听到足够的语音、能够确定之后,才会发出 speech_started。因此它报告的时间早于事件到达您这一侧的时刻。请把这个事件当作打断信号:正在说话的智能体应停止自己的音频,并取消正在生成的回复。
speech_ended 表示这一轮已经结束,最终转写即将到来。语音智能体可以从这时起显示正在思考。最终结果要等这一轮的计费区间落定后才到达,因此转写不会先于回执送达。
说明
playback 帧可以防止智能体转写自己的声音。智能体的音频开始播放时发送 {"type":"playback","speaking":true},停止时发送 false。值为 true 期间,来电者需要持续说更久才会开启新的一轮,真正的打断仍然会开启一轮。
轮次在每条连接内从 1 开始计数,编号越大,轮次越靠后。已经回答过第 3 轮的智能体会丢弃第 3 轮,回答第 4 轮。判断结果是否过时,靠这一次比较就够了。
选项原样沿用#
POST /v1/stt 作为请求字段接收的每个选项,连接都在 start 中接收一次,并在整条连接期间保持。这些选项都不影响费用。
| 字段 | 作用 |
|---|---|
language | BCP 47 语言提示。不填时,模型会从音频中识别语言,并用各语言自己的文字书写。 |
timestamps | "word" 会为每条最终转写附加一个时间段数组,每个词一项,时间以连接的时钟为准。泰语会自动分词。 |
style | verbatim 按原样写出语气词和说错重来的部分。clean 删去这些内容,读起来更顺。默认是 verbatim,它与音频贴合得最紧。 |
convention | spoken 按来电者的说法写数字。written 把数字写成阿拉伯数字,带上单位和泰文重复符号 ๆ,这正是预订或支付字段读取的格式。 |
vocabulary | 最多 50 个词条,每条不超过 50 个字符,填入对话中可能出现的词,并按希望呈现的写法拼写。 |
说话人编号和字幕文件仍只在录音接口提供。给说话人编号、切分字幕,都要通读整段录音,而实时连接没有完整的录音可读。
连接接收的音频#
| 编码 | 采样率 | 来源 |
|---|---|---|
pcm_s16le | 16 或 48 kHz | 16 位有符号小端 PCM,单声道。来自经过 audio worklet 的浏览器麦克风,或以原生采样率工作的采集设备。 |
mulaw | 8 kHz | G.711 mu-law,单声道,即电话平台交出的格式。您这一侧无需转码。 |
- 二进制帧可以按任意大小分块传输字节,每帧最多 64 KiB。麦克风常用 20 毫秒一帧。
- 任何发送节奏都可以。发送速度快于实时也没有问题,事件报告的时间始终以音频时钟为准。
- 字节会严格按照
start的声明读取。采样率写错,转写出来的就是噪声。
注意
API 密钥绝不能放在浏览器里。升级请求与其他所有端点一样,用 Authorization 请求头认证,而浏览器的 WebSocket 无法发送这个请求头。请通过您自己的服务器中转麦克风音频。
一次会话的费用#
连接按接收的音频计费,静音也计算在内,每分钟 12.5 积分,即每小时 750 积分,约合 $0.75。换成更小的单位,线路上每 48 毫秒计百分之一积分。
费用按区间扣除。每一轮结束时、一分钟音频里没有出现任何轮次时,以及会话关闭时,都会结束一个区间。每个区间都以一条 charged 回执送达。0.1 积分的单次请求下限每条连接只计一次,计在第一条回执上。
模型未能转写的轮次不收费。这一轮的音频不计入累计总额,并会有一个 error 事件指明该轮次和退回的积分。一轮都没有返回的连接会全额退款。
| 工作量 | 接收的音频 | 积分 | 费用 |
|---|---|---|---|
| 一通 3 分钟的电话 | 3 分钟 | 37.5 | $0.04 |
| 一天 200 通电话 | 10 小时 | 7,500 | $7.50 |
| 这样持续 30 天的一个月 | 300 小时 | 225,000 | $225 |
上面那段 29.95 秒的采集花费 6.24 积分。新账户的 100 免费积分约够实时连接使用 8 分钟。
在请求日志中,整条连接只占一行,无论它包含多少轮。这一行记录接收的音频、扣除的积分,以及连接在 started 时公布的连接 ID。
出现故障时#
带有 turn 的 error 不会关闭连接。这一轮不返回转写,它的音频不计入账单,下一轮照常进行。智能体可以像没听清来电者时那样处理,请对方再说一遍。
致命故障会带着关闭码关闭连接。其中有三个值得在代码里处理。
1001表示我们这边在重启。重新连接后继续即可,已送达区间扣除的费用保持不变。4429表示账户持有的实时连接已经太多。关闭一条后重新连接。4503表示在start时无法连接后端。没有任何扣费,请重试。
限制#
- 一条连接最多接收 4 小时音频,之后会像收到
end帧一样以done结束。 - 打开的连接在保持连接期间,会一直占用套餐的一个并发转写名额。
- 一个二进制帧最大 64 KiB。
- 连接如果 300 秒内既没有收到帧,也没有发出事件,就会关闭。
录音还是实时#
两个模型解决的是不同的问题。已经存在的文件应交给 POST /v1/stt。它先读完整段录音再作答,因此每分钟的价格也更低。
| Paxa STT Lite | Paxa STT Lite Realtime | |
|---|---|---|
| 传输方式 | POST /v1/stt | WS /v1/stt/live |
| 输入 | 不超过 60 分钟、25 MiB 的文件 | 最长 4 小时的二进制音频 |
| 返回 | 整段录音一份转写 | 每轮一份最终转写 |
| 费率 | 每分钟 8.33 积分 | 每分钟 12.5 积分 |
| 说话人编号 | 支持,从 0 开始计数 | 仅录音接口提供 |
| 字幕文件 | srt 和 vtt | 仅录音接口提供 |
| 扣费 | 转写前一次性扣除 | 对话进行期间分区间扣除 |
两个模型都处于研究预览阶段。评估期间按公布的费率销售,输出和选项可能在版本之间变化。模型 ID 在提供服务期间保持稳定。
打开一个会话#
一个 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}写代码之前#
转写工具提供实时模式。它会请求使用麦克风,通过我们的服务器中转音频,每一轮结束时就显示这一轮,旁边附上累计费用。对着它说几句您自己的泰语,看看边界落在哪里。
上线之前#
- 按最终结果执行操作。中间结果只是草稿,会被下一条替换。
- 在智能体自己的音频前后发送
playback。否则智能体会听到自己,并开启一轮。 - 丢弃已经回答过的轮次。调用方排序时,依据轮次编号就够了。
- 声明字节实际使用的编码。采样率写错,传到模型那里就是噪声。
- 收到
1001时重新连接。我们这边的部署不应让线路上的人察觉。 - 为来电者会说的名称设置
vocabulary。产品名、泰国地名和员工姓名正是它的用途。 - 等来电者安静片刻再回复。泰语会在短语之间停顿,一轮可能在句子中间结束。
下一步#
接口约定本身写在文档中。