Thai interactive fiction and visual novels
Voice branching Thai stories: chat fiction, visual novels, and choice-driven games, either pre-rendered across the whole tree or spoken at runtime down the path a reader picks.
Ideas to try
Every card plays a real clip generated from its exact script with Nom Yen. Open one in the playground to make it say anything.
How a branching chapter is voiced
Branching stories are the one kind of fiction whose next line is unknown until a reader acts. Joylada publishes Thai fiction as a chat you tap through, and visual novels built on engines like Ren'Py write dialogue one line per speaker with choices between scenes. Both shapes leave a studio with the same decision. Pre-render the tree and every branch is a file, ready before anyone opens the app, and paid for whether or not a reader ever walks it. Speak at runtime and a reader pays for exactly the path they take, at the cost of a connection open while they read. Which one is cheaper depends on how much of the tree a typical reader sees, and the table below puts real numbers under that.
Sources:Joylada(opens in a new tab)Ren'Py(opens in a new tab)
- Flatten the tree to spansEvery node becomes a span of speech with a speaker and a voice id. A span up to 5,000 characters is one request, and a longer one splits at a sentence boundary.
- Pick pre-render or runtime per sceneA prologue every reader sees is worth rendering once; a rare branch is worth speaking on demand. The choice is per scene and can change after launch without touching the script.
- Cache what a reader already heardA line spoken at runtime is an ordinary MP3. Keeping it turns a replayed route into a cache hit, and a re-read of the same path costs nothing the second time.
Pre-render the tree, or speak the path
| Delivery | Characters | Credits | Cost |
|---|---|---|---|
| One reader's path, 120 lines | 10,800 | 162 | $0.16 |
| 10,000 readers, spoken at runtime | 108,000,000 | 1,620,000 | $1,620 |
| The whole tree, voiced once for every reader you will ever have | about $2.16 | ||
Computed at 15 credits per 1,000 characters over 90-character lines. Pre-rendering is paid once for the whole audience; runtime is paid per reader, so the crossover sits at roughly 13 readers.
One chapter, four endings
scene_07a ณิชา nomyen 96 chars scene_07a ธาวิน massaman 82 chars choice_1 -> scene_07b choice_2 -> scene_07c scene_07b ณิชา nomyen 88 chars [pre-rendered] scene_07c ณิชา nomyen 94 chars [pre-rendered] scene_07c คุณยาย tako 90 chars [pre-rendered] # 4 branches x ~400 lines = the tree above # a reader hears ~120 of them
The tree is the unit a studio budgets; the path is the unit a reader hears. Casting is a voice id per speaker in both cases.
From launch to steady state
One open live connection holds one of the plan's concurrent speech slots, so a runtime deployment is sized by simultaneous readers rather than by lines per day.
For developers
The TypeScript sample opens the live socket a runtime session uses, and the Python quickstart posts one span the way a pre-render batch does. The options request shows the voice, model, and format a scene pins.
// 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.
const socket = new WebSocket("wss://api.paxalabs.com/v1/tts/live", {
headers: { Authorization: `Bearer ${process.env.PAXA_API_KEY}` },
});
socket.binaryType = "arraybuffer";
socket.onopen = () => {
socket.send(JSON.stringify({
type: "start",
model: "paxa-tts-flash-v1",
voice: "khanomkrok",
timestamps: "word",
}));
// Send text whenever it becomes available, token by token if you like.
socket.send(JSON.stringify({ type: "text", text: "สวัสดีครับ ยินดีต้อนรับสู่ Paxa Labs" }));
socket.send(JSON.stringify({ type: "flush" }));
socket.send(JSON.stringify({ type: "end" }));
};
socket.onmessage = (event) => {
if (typeof event.data === "string") {
const message = JSON.parse(event.data);
if (message.type === "done") socket.close();
return;
}
player.feed(new Uint8Array(event.data)); // binary frames are audio
};import os
import requests
response = requests.post(
"https://api.paxalabs.com/v1/tts",
headers={"Authorization": f"Bearer {os.environ['PAXA_API_KEY']}"},
json={"text": "สวัสดีครับ ยินดีต้อนรับสู่ Paxa Labs", "voice": "khanomkrok", "model": "paxa-tts-flash-v1"},
)
response.raise_for_status()
with open("speech.mp3", "wb") as f:
f.write(response.content)curl -X POST https://api.paxalabs.com/v1/tts \
-H "Authorization: Bearer $PAXA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"text": "สวัสดีครับ ยินดีต้อนรับสู่ Paxa Labs",
"voice": "khanomkrok",
"model": "paxa-tts-flash-v1",
"format": "mp3",
"stream": false
}' \
--output speech.mp3Hear the voices
Luk ChupFemale0:04
Light, high, youthful voice for characters and playful spots.
สวัสดีค่า วันนี้หนูมีเรื่องน่ารัก ๆ มาเล่าให้ฟังด้วยนะคะ
Khanom KrokMale0:03
Calm, unhurried elder voice and the roster's male lead: documentary, heritage storytelling, and narration.
สมัยผมยังหนุ่ม แถวนี้ยังเป็นทุ่งนา เดินไปตลาดทีหนึ่งก็ค่อนวันแล้วครับ
Nom YenFemale0:06
Bright, energetic voice and the roster's female lead: promos, social clips, and everyday product speech.
ยินดีต้อนรับสู่ Paxa Labs ค่ะ เสียงภาษาไทยที่สลับเป็น English ได้กลางประโยค อย่างเป็นธรรมชาติ
TakoFemale0:05
Friendly young narrator for audiobooks, recaps, and explainers.
ยามเช้าในกรุงเทพฯ แสงแดดอ่อน ๆ ทาบลงบนถนนที่เริ่มคึกคัก
A few of the 26 production voices, generated from the API with the published voice ids. The full roster plays on the Text-to-Speech page.
Short lines are their own problem
Branching fiction is written in fragments: a line of chat, a single retort, a choice label. Thai runs without spaces between words, so a four-word fragment gives a reader very little context to segment from. Paxa TTS Flash reads tone from spelling rather than from a dictionary lookup, which is what keeps a short isolated line intelligible instead of guessed at.
Common questions
- Should I pre-render the tree or speak at runtime?
- Pre-rendering costs about $2.16 once for the whole audience. Runtime costs about $0.16 per reader. Below roughly 13 readers, runtime is cheaper, and above it pre-rendering is.
- Can each character have a different voice?
- Yes, up to the catalog's 26. A voice id per speaker is held in the script, and the same id returns the same voice every run.
- What happens when a reader taps past a line?
- Send a cancel frame on the live socket. The line stops, the queued remainder is discarded without charge, and the same connection carries the next line.
- How much of the plan's capacity does a reader use?
- One open live connection holds one concurrent speech slot for as long as it stays open, so a runtime deployment is sized by simultaneous readers rather than by lines per day.
- Can we ship the audio inside a game?
- Output made on free credits is for personal use and evaluation. Once you buy a plan or credits, generated output becomes yours for any use, including shipping it inside a commercial title, and that covers output generated before the purchase.
Start with free credits
Sign in with Google, GitHub, or Hugging Face and spend 100 one-time free credits on your own text.