ภาษาไทยไม่ได้ปิดประโยคด้วยเครื่องหมาย การเขียนใส่ช่องว่างตรงรอยต่อของวลีและอนุประโยค ส่วนที่เหลือเขียนติดกัน และเสียงพูดไม่มีเครื่องหมายวรรคตอนเลย ระบบที่ทำงานกับไฟล์ที่บันทึกเสร็จแล้วอ่านทั้งไฟล์ก่อนตัดสินใจได้ ส่วนระบบที่ทำงานสดต้องตัดสินใจระหว่างที่คนยังพูดอยู่
Paxa STT Lite Realtime แก้เรื่องนี้ด้วยการทำงานเป็นรอบพูด ซ็อกเก็ตเฝ้าดูเสียง ตัดสินว่าช่วงการพูดหนึ่งช่วงจบตรงไหน แล้วส่งบทถอดที่จบแล้วหนึ่งชุดของช่วงนั้นออกมา ด้านล่างคือรอบพูดคืออะไร ทำไมภาษาไทยทำให้ขอบเขตนี้ยาก ข้อความระหว่างทางก่อนหน้านั้นมีไว้ทำอะไร และเซสชันหนึ่งคิดเงินเท่าไร
รอบพูดคืออะไร#
รอบพูดคือช่วงเสียงต่อเนื่องหนึ่งช่วงของผู้พูดคนหนึ่ง มีขอบเขตคือจังหวะที่เริ่มพูดและจังหวะที่หยุดพูด ซ็อกเก็ตประกาศทั้งสองจุด ระหว่างนั้นจะส่งบทถอดระหว่างทางออกมาเรื่อย ๆ แต่ละชุดเป็นร่างที่สมบูรณ์ขึ้นของรอบพูดเดียวกัน หลังจุดจบจะส่งบทถอดฉบับจบหนึ่งชุด และฉบับจบคือข้อความที่โปรแกรมควรเอาไปทำงานต่อ
ผู้เรียกจะรับคำที่ถอดได้ทีละคำแล้วตัดสินเองว่าความคิดจบตรงไหนก็ได้ การตัดสินนั้นคือส่วนที่ยากที่สุดของไปป์ไลน์สด และในภาษาไทยมันยากกว่าตัวการรู้จำเสียงเสียอีก
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 นาที ตอบครั้งเดียวพร้อมบทถอดทั้งหมด และคิดค่าบริการต่อนาทีถูกกว่า เพราะไม่ต้องเดินตามจังหวะของผู้พูด
เปิดซ็อกเก็ต#
เฟรม 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}ขั้นต่อไป#
- อ่านเอกสารอ้างอิงแบบเรียลไทม์ สำหรับเฟรม อีเวนต์ และรหัสปิดการเชื่อมต่อ
- อ่านคู่มือการถอดเสียง สำหรับตัวเลือกที่เฟรม start พาไปได้
- อ่านหน้าถอดเสียงสด เพื่อดูว่าปริมาณงานระดับคอลเซ็นเตอร์คิดเป็นเท่าไร
- เปิดเครื่องมือถอดเสียง แล้วสลับไปโหมดสด เบราว์เซอร์ส่งเสียงจากไมโครโฟนผ่านรีเลย์ และแต่ละรอบพูดลงมาทันทีที่ปิด