博客

Paxa STT Lite Realtime 实时转写泰语,边说边按轮次返回

Paxa STT Lite Realtime 通过一条保持打开的 WebSocket 转写泰语,自行判断每一轮在哪里结束,并按接收的音频计费。

· Paxa Labs · 阅读约 3 分钟

泰语语音智能体必须先知道来电者已经说完,才能回答,而泰语给出的线索很少。泰文书写不用句号,空格落在短语之间,说话人的停顿落在句中和落在句末的次数差不多。直到 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 秒音频。轮次边界、转写稿和扣费都是连接实际报告的结果,未经任何编辑。

轮次秒积分转写稿
10 至 2.70.57สวัสดีค่ะขอบคุณที่ติดต่อบริษัทของเรา
22.7 至 81.1กด 1 เพื่อติดต่อฝ่ายขายกด 2 ฝ่ายบริการลูกค้าหรือกด 0 เพื่อติดต่อเจ้าหน้าที่ค่ะ
39.11 至 18.052.1สวัสดีค่ะคุณสมชายยอดชำระของท่านจำนวน 1,250 บาทครบกำหนดวันที่ 15 สิงหาคมนี้ชำระผ่านแอปได้ตลอด 24 ชั่วโมงค่ะ
419.16 至 22.91.01ขณะนี้อยู่นอกเวลาทำการเราเปิดให้บริการวันจันทร์ถึงศุกร์
522.9 至 23.710.16เวลา
623.92 至 26.440.579 โมงเช้าถึง 6 โมงเย็น
726.44 至 28.550.44ฝากข้อความหลังเสียงสัญญาณได้เลยค่ะ

把第 4 到第 6 轮连起来读,它们是一句关于营业时间的话。说话人在表示时间的词前停了一下,在数字前又停了一下,连接在每次停顿处都结束了一轮。这就是泰语的边界问题在真实采集中的样子。智能体如果等来电者安静片刻再回复,就能把这三轮一起读完,只回答一次。

这几轮合计扣费 5.95 积分。最后一轮结束后,连接又继续收听了 1.4 秒,追加 0.29 积分。连接报告的总额为 6.24 积分。

连接上传输什么#

上行三种帧,下行七种事件。音频是二进制,其余都是 JSON,全部走同一条连接。

线路上的第 1 轮
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 中接收一次,并在整条连接期间保持。这些选项都不影响费用。

字段作用
languageBCP 47 语言提示。不填时,模型会从音频中识别语言,并用各语言自己的文字书写。
timestamps"word" 会为每条最终转写附加一个时间段数组,每个词一项,时间以连接的时钟为准。泰语会自动分词。
styleverbatim 按原样写出语气词和说错重来的部分。clean 删去这些内容,读起来更顺。默认是 verbatim,它与音频贴合得最紧。
conventionspoken 按来电者的说法写数字。written 把数字写成阿拉伯数字,带上单位和泰文重复符号 ๆ,这正是预订或支付字段读取的格式。
vocabulary最多 50 个词条,每条不超过 50 个字符,填入对话中可能出现的词,并按希望呈现的写法拼写。

说话人编号和字幕文件仍只在录音接口提供。给说话人编号、切分字幕,都要通读整段录音,而实时连接没有完整的录音可读。

连接接收的音频#

编码采样率来源
pcm_s16le16 或 48 kHz16 位有符号小端 PCM,单声道。来自经过 audio worklet 的浏览器麦克风,或以原生采样率工作的采集设备。
mulaw8 kHzG.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 时无法连接后端。没有任何扣费,请重试。

完整的关闭码列表和所有帧字段见实时接口参考。error 事件可能携带的所有代码见错误参考。

限制#

  • 一条连接最多接收 4 小时音频,之后会像收到 end 帧一样以 done 结束。
  • 打开的连接在保持连接期间,会一直占用套餐的一个并发转写名额。
  • 一个二进制帧最大 64 KiB。
  • 连接如果 300 秒内既没有收到帧,也没有发出事件,就会关闭。

录音还是实时#

两个模型解决的是不同的问题。已经存在的文件应交给 POST /v1/stt。它先读完整段录音再作答,因此每分钟的价格也更低。

Paxa STT LitePaxa STT Lite Realtime
传输方式POST /v1/sttWS /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();
  }
};
Server events
{"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。产品名、泰国地名和员工姓名正是它的用途。
  • 等来电者安静片刻再回复。泰语会在短语之间停顿,一轮可能在句子中间结束。

下一步#

接口约定本身写在文档中。

继续阅读

博客的其他文章