Thai subtitles from word timestamps
A Thai sentence has no spaces to cut a subtitle at. How word timestamps give a cue its cut points, the rules a Thai cue file needs, and the convention to ask for.
· 4 min read
A subtitle is a transcript cut into pieces that fit on a screen for the seconds they are spoken. In English the cutting is easy. A cue ends at a space, and a tool that knows nothing about the language produces readable lines. Thai writes a whole phrase as one run of characters. A tool that cuts at a character count cuts through a word, and the viewer reads half a word on one line and the other half two seconds later.
This post shows how to build Thai cues from the word timestamps Paxa STT Lite returns. Each span is a whole Thai word with its start and end, which is exactly the set of places a cue may be cut. The rules for turning spans into cues fit on one page, and the result is a subtitle file any player reads.
What a span is#
Ask for timestamps set to "word" and the response carries a words array beside the transcript. Each entry is one word with its start and end in seconds. For Thai the words are segmented on the way out. A span covers a whole word and never a character. The spans join back into the transcript with nothing lost.
AUDIO=$(base64 < meeting.m4a | tr -d '\n')
curl -X POST https://api.paxalabs.com/v1/stt \
--max-time 300 \
-H "Authorization: Bearer $PAXA_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"audio\": \"$AUDIO\", \"model\": \"paxa-stt-lite-v1-preview\", \"timestamps\": \"word\"}"{
"text": "สวัสดีค่ะคุณสมชายยอดชำระของท่านจำนวน พันสองร้อยห้าสิบ บาทครบกำหนดวันที่ สิบห้า สิงหาคมนี้ชำระผ่านแอปได้ตลอด ยี่สิบสี่ ชั่วโมงค่ะ",
"words": [
{
"text": "สวัสดี",
"start": 0.12,
"end": 0.52
},
{
"text": "ค่ะ",
"start": 0.6,
"end": 0.739
},
{
"text": "คุณ",
"start": 1.64,
"end": 1.8
},
{
"text": "สมชาย",
"start": 1.84,
"end": 2.24
},
{
"text": "ยอด",
"start": 2.4,
"end": 2.56
},
{
"text": "ชำระ",
"start": 2.56,
"end": 2.839
},
{
"text": "ของ",
"start": 2.84,
"end": 2.96
},
{
"text": "ท่าน",
"start": 2.96,
"end": 3.14
},
{
"text": "จำนวน",
"start": 3.16,
"end": 3.44
},
{
"text": "พันสองร้อยห้าสิบ",
"start": 3.52,
"end": 4.56
},
{
"text": "บาท",
"start": 4.68,
"end": 4.84
},
{
"text": "ครบ",
"start": 5,
"end": 5.2
},
{
"text": "กำหนด",
"start": 5.24,
"end": 5.48
},
{
"text": "วัน",
"start": 5.52,
"end": 5.639
},
{
"text": "ที่",
"start": 5.64,
"end": 5.719
},
{
"text": "สิบห้า",
"start": 5.84,
"end": 6.08
},
{
"text": "สิงหาคม",
"start": 6.68,
"end": 7.16
},
{
"text": "นี้",
"start": 7.2,
"end": 7.34
},
{
"text": "ชำระ",
"start": 7.8,
"end": 8.08
},
{
"text": "ผ่าน",
"start": 8.12,
"end": 8.28
},
{
"text": "แอป",
"start": 8.28,
"end": 8.44
},
{
"text": "ได้",
"start": 8.48,
"end": 8.62
},
{
"text": "ตลอด",
"start": 8.64,
"end": 8.88
},
{
"text": "ยี่สิบสี่",
"start": 9.04,
"end": 9.439
},
{
"text": "ชั่วโมง",
"start": 9.56,
"end": 9.88
},
{
"text": "ค่ะ",
"start": 9.92,
"end": 10.06
}
],
"usage": {
"seconds": 10.25,
"credits": 1.43
}
}Cutting spans into cues#
Walk the spans in order and collect them into a cue until one of three limits is reached. The cue then ends at the last span's end, and the next cue starts at the next span's start.
- Length. A Thai line reads comfortably at around thirty-five characters, and a cue holds at most two lines. Count the characters of the collected words and cut before the word that would push past the limit.
- Duration. A cue on screen for longer than about six seconds reads as stuck. Cut when the next word would push the cue past the limit, even when the line has room.
- Pause. A gap between one span's end and the next span's start longer than about half a second is a breath or a full stop. Cut there, and the cue ends when the speaker does.
A cue that ends at a pause reads better than one that ends at a count. A small overrun of the length limit to reach a pause is worth allowing. Where a cue has two lines, break the line at the widest gap between spans inside the cue, which is usually a phrase boundary.
words
วันนี้ 0.00 0.38
เรา 0.38 0.55
จะ 0.55 0.70
คุย 0.70 0.98
เรื่อง 0.98 1.30
งบประมาณ 1.30 1.92
ปีหน้า 1.92 2.40
กัน 2.40 2.61
นะครับ 2.61 3.05
cues
1 00:00:00,000 --> 00:00:01,300
วันนี้เราจะคุยเรื่อง
2 00:00:01,300 --> 00:00:03,050
งบประมาณปีหน้ากันนะครับThe convention to ask for#
Subtitles want the spoken convention, which is the default. The viewer hears สองพันสามร้อยห้าสิบบาท and reads the same words. A digit on screen while the speaker says five syllables reads as a mismatch, and a cue built from the written form also loses the span boundaries inside a number, since the digits arrive as one token.
Keep the verbatim style for the same reason. A viewer who hears a false start and reads a cleaned sentence notices. The clean style is for a transcript read on its own, away from the audio.
Writing the file#
SRT numbers each cue, writes the start and end as hours, minutes, seconds, and milliseconds with a comma before the milliseconds, and follows with the text. WebVTT is the same with a period before the milliseconds and a header line. Both are plain text, and both carry Thai as UTF-8 with no further setting. Write the seconds from the spans with three decimals and the file is done.
For a translated track, send the cues' text to the translation API one cue per segment with the ids echoed back. The timings stay with the ids. A Thai video gets an English track with the same cut points, and the two tracks stay aligned.
Next steps#
- Read the transcription guide for the word timestamps beside the model's own spans on one recording.
- Read the timestamps page for the span shape shared with speech synthesis, where the same rules build read-along captions.
- Read the numbers post for why a subtitle keeps the spoken form.
- Read the captions use case for a caption pipeline end to end and what a season of video costs.