Streaming text to speech
curl --request POST \
--url https://api.kova.ai/v1/tts/stream \
--header 'Content-Type: application/json' \
--header 'x-api-key: <api-key>' \
--data '
{
"text": "<string>",
"voice": "<string>",
"normalize_text": false,
"response_format": {
"bitrate": "<string>",
"encoding": "mp3",
"sample_rate": 123
},
"temperature": 123,
"timestamps": false
}
'import requests
url = "https://api.kova.ai/v1/tts/stream"
payload = {
"text": "<string>",
"voice": "<string>",
"normalize_text": False,
"response_format": {
"bitrate": "<string>",
"encoding": "mp3",
"sample_rate": 123
},
"temperature": 123,
"timestamps": False
}
headers = {
"x-api-key": "<api-key>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {'x-api-key': '<api-key>', 'Content-Type': 'application/json'},
body: JSON.stringify({
text: '<string>',
voice: '<string>',
normalize_text: false,
response_format: {bitrate: '<string>', encoding: 'mp3', sample_rate: 123},
temperature: 123,
timestamps: false
})
};
fetch('https://api.kova.ai/v1/tts/stream', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.kova.ai/v1/tts/stream",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'text' => '<string>',
'voice' => '<string>',
'normalize_text' => false,
'response_format' => [
'bitrate' => '<string>',
'encoding' => 'mp3',
'sample_rate' => 123
],
'temperature' => 123,
'timestamps' => false
]),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"x-api-key: <api-key>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://api.kova.ai/v1/tts/stream"
payload := strings.NewReader("{\n \"text\": \"<string>\",\n \"voice\": \"<string>\",\n \"normalize_text\": false,\n \"response_format\": {\n \"bitrate\": \"<string>\",\n \"encoding\": \"mp3\",\n \"sample_rate\": 123\n },\n \"temperature\": 123,\n \"timestamps\": false\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("x-api-key", "<api-key>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://api.kova.ai/v1/tts/stream")
.header("x-api-key", "<api-key>")
.header("Content-Type", "application/json")
.body("{\n \"text\": \"<string>\",\n \"voice\": \"<string>\",\n \"normalize_text\": false,\n \"response_format\": {\n \"bitrate\": \"<string>\",\n \"encoding\": \"mp3\",\n \"sample_rate\": 123\n },\n \"temperature\": 123,\n \"timestamps\": false\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.kova.ai/v1/tts/stream")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["x-api-key"] = '<api-key>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"text\": \"<string>\",\n \"voice\": \"<string>\",\n \"normalize_text\": false,\n \"response_format\": {\n \"bitrate\": \"<string>\",\n \"encoding\": \"mp3\",\n \"sample_rate\": 123\n },\n \"temperature\": 123,\n \"timestamps\": false\n}"
response = http.request(request)
puts response.read_body{
"detail": [
{
"loc": [
"<string>"
],
"msg": "<string>",
"type": "<string>",
"ctx": {},
"input": "<unknown>"
}
]
}HTTP API
Streaming TTS
Start playback before generation finishes. SSE-style data records.
POST
/
v1
/
tts
/
stream
Streaming text to speech
curl --request POST \
--url https://api.kova.ai/v1/tts/stream \
--header 'Content-Type: application/json' \
--header 'x-api-key: <api-key>' \
--data '
{
"text": "<string>",
"voice": "<string>",
"normalize_text": false,
"response_format": {
"bitrate": "<string>",
"encoding": "mp3",
"sample_rate": 123
},
"temperature": 123,
"timestamps": false
}
'import requests
url = "https://api.kova.ai/v1/tts/stream"
payload = {
"text": "<string>",
"voice": "<string>",
"normalize_text": False,
"response_format": {
"bitrate": "<string>",
"encoding": "mp3",
"sample_rate": 123
},
"temperature": 123,
"timestamps": False
}
headers = {
"x-api-key": "<api-key>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {'x-api-key': '<api-key>', 'Content-Type': 'application/json'},
body: JSON.stringify({
text: '<string>',
voice: '<string>',
normalize_text: false,
response_format: {bitrate: '<string>', encoding: 'mp3', sample_rate: 123},
temperature: 123,
timestamps: false
})
};
fetch('https://api.kova.ai/v1/tts/stream', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.kova.ai/v1/tts/stream",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'text' => '<string>',
'voice' => '<string>',
'normalize_text' => false,
'response_format' => [
'bitrate' => '<string>',
'encoding' => 'mp3',
'sample_rate' => 123
],
'temperature' => 123,
'timestamps' => false
]),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"x-api-key: <api-key>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://api.kova.ai/v1/tts/stream"
payload := strings.NewReader("{\n \"text\": \"<string>\",\n \"voice\": \"<string>\",\n \"normalize_text\": false,\n \"response_format\": {\n \"bitrate\": \"<string>\",\n \"encoding\": \"mp3\",\n \"sample_rate\": 123\n },\n \"temperature\": 123,\n \"timestamps\": false\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("x-api-key", "<api-key>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://api.kova.ai/v1/tts/stream")
.header("x-api-key", "<api-key>")
.header("Content-Type", "application/json")
.body("{\n \"text\": \"<string>\",\n \"voice\": \"<string>\",\n \"normalize_text\": false,\n \"response_format\": {\n \"bitrate\": \"<string>\",\n \"encoding\": \"mp3\",\n \"sample_rate\": 123\n },\n \"temperature\": 123,\n \"timestamps\": false\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.kova.ai/v1/tts/stream")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["x-api-key"] = '<api-key>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"text\": \"<string>\",\n \"voice\": \"<string>\",\n \"normalize_text\": false,\n \"response_format\": {\n \"bitrate\": \"<string>\",\n \"encoding\": \"mp3\",\n \"sample_rate\": 123\n },\n \"temperature\": 123,\n \"timestamps\": false\n}"
response = http.request(request)
puts response.read_body{
"detail": [
{
"loc": [
"<string>"
],
"msg": "<string>",
"type": "<string>",
"ctx": {},
"input": "<unknown>"
}
]
}Building with an AI agent? Point it at
docs.kova.ai/for-agents.md — the complete API contract as one plain-text page.POST /v1/tts/stream returns a text/plain body of SSE-style records — one JSON object per data: line, separated by blank lines. The request body is identical to Text to speech.
Use streaming whenever you want playback to begin before generation completes, and for any text over roughly 1,000 characters — the synchronous endpoint would otherwise make you wait tens of seconds for the first byte.
The default response encoding is mp3, not raw PCM. Each
audio_chunk is base64-encoded audio in your chosen response_format; concatenate the decoded chunks to reconstruct the file.Response headers
Content-Type: text/plain; charset=utf-8
Transfer-Encoding: chunked
x-request-id: 3ad110d706bd...
Event stream format
The body is a sequence of records. Each one starts withdata: (note the trailing space), contains one JSON object, and ends with \n\n:
data: {"type":"audio","audio_chunk":"<base64>"}
data: {"type":"audio","audio_chunk":"<base64>"}
data: {"type":"timestamps","words":["hello"],"start_seconds":[0.0],"end_seconds":[0.3]}
data: {"type":"audio","audio_chunk":"<base64>"}
There is no
data: [DONE] sentinel and no terminating event. The stream is over when the HTTP body ends. Because the final record is also followed by \n\n, splitting the whole body on \n\n leaves a trailing empty string — skip empty segments rather than trying to parse them.Event types
audio
type AudioEvent = {
type: "audio";
audio_chunk: string; // base64-encoded audio bytes in your response_format
};
timestamps
Emitted only when the request settimestamps: true.
type TimestampsEvent = {
type: "timestamps";
words: string[];
start_seconds: number[];
end_seconds: number[];
};
words[i] spans start_seconds[i] to end_seconds[i].
timestamps events arrive repeatedly and incrementally — not once at the end. Each event carries only the words finalized since the previous one, typically one to three at a time. Append them in arrival order to rebuild the full word list; never overwrite.t=0.73s words=['The', 'quick']
t=0.96s words=['brown']
t=1.29s words=['fox', 'jumps']
t=1.44s words=['over', 'the']
t=1.60s words=['lazy']
t=1.76s words=['dog', 'near', 'the']
t=1.95s words=['river', 'bank', 'today.']
start_seconds are always absolute offsets from the beginning of the utterance, so no rebasing is needed as you append.
Audio and timestamps events interleave with no fixed ratio or ordering guarantee. Handle them in whatever order they arrive.
Reconstructing the audio
Base64-decode eachaudio_chunk and concatenate in arrival order.
Streaming
mp3 has no ID3 header. The synchronous endpoint returns MP3 beginning with ID3, while streamed MP3 is raw MPEG frames starting with the 0xFF 0xFB frame sync. Both are valid and play fine — but if your code sniffs for ID3 to detect MP3, it will fail on the streaming endpoint.linear16 re-emits a WAV header on every chunk. Concatenating those chunks produces a file with dozens of embedded headers. For streaming, use encoding: "pcm" and write a single header yourself at the end.Latency
Time to first audio chunk is under 200 ms on an already-open connection, plus your network round-trip. The floor is stable at roughly 185 ms measured from a client ~40 ms away, and does not vary with text length or voice.Reuse your HTTP connection. Opening a new TLS connection costs about 90 ms (roughly 6 ms TCP + 82 ms TLS 1.3 handshake) — close to half your latency budget again. A client that opens a fresh connection per request sees ~275 ms instead of ~185 ms.This is easy to get wrong with streaming clients: if you stop reading the response body early and abandon the stream, most HTTP libraries close the connection rather than returning it to the pool, so every request silently pays full setup. Read each response to completion, and keep one long-lived client rather than creating one per call.
# Good: one client, reused across requests.
client = httpx.Client(timeout=180)
# Bad: a new client (and new TLS handshake) every call.
def synth(text):
with httpx.Client() as c: # ~90 ms wasted per call
...
Examples
import asyncio, os
from kova_tts import KovaTTSClient, AudioResponseFormat
async def main():
client = KovaTTSClient(api_key=os.environ["KOVA_API_KEY"])
audio_bytes = bytearray()
words = []
async for event in client.stream_tts(
text="This is streaming TTS from Kova.",
voice="cal",
response_format=AudioResponseFormat(encoding="mp3"),
timestamps=True,
):
if event.type == "audio":
audio_bytes.extend(event.audio)
elif event.type == "timestamps":
words.extend(event.words) # append, never replace
with open("stream.mp3", "wb") as f:
f.write(audio_bytes)
print(words)
asyncio.run(main())
import { KovaTTSClient } from "@kova-ai/tts";
import { writeFile } from "node:fs/promises";
const client = new KovaTTSClient({ apiKey: process.env.KOVA_API_KEY! });
const chunks: Uint8Array[] = [];
const words: string[] = [];
for await (const event of client.streamTTS({
text: "This is streaming TTS from Kova.",
voice: "cal",
response_format: { encoding: "mp3" },
timestamps: true,
})) {
switch (event.type) {
case "audio":
chunks.push(event.audio);
break;
case "timestamps":
words.push(...event.words); // append, never replace
break;
}
}
const total = chunks.reduce((n, c) => n + c.byteLength, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const c of chunks) { out.set(c, offset); offset += c.byteLength; }
await writeFile("stream.mp3", out);
console.log(words);
curl -N https://api.kova.ai/v1/tts/stream \
-H "x-api-key: $KOVA_API_KEY" \
-H "content-type: application/json" \
-d '{
"text": "This is streaming TTS from Kova.",
"voice": "cal",
"response_format": {"encoding": "mp3"},
"timestamps": true
}'
# Each record is `data: {json}` followed by a blank line.
# Parse each JSON, base64-decode `audio_chunk`, concatenate to reconstruct the file.
Parsing the stream without an SDK
The framing is simple enough to handle directly. Buffer bytes, split on\n\n, strip the data: prefix:
import base64, json, httpx
audio = bytearray()
words = []
with httpx.Client(timeout=180) as c:
with c.stream(
"POST", "https://api.kova.ai/v1/tts/stream",
json={"text": "Hello from Kova.", "voice": "cal",
"response_format": {"encoding": "mp3"}, "timestamps": True},
headers={"x-api-key": KEY},
) as r:
r.raise_for_status()
buf = b""
for chunk in r.iter_bytes():
buf += chunk
while b"\n\n" in buf:
record, buf = buf.split(b"\n\n", 1)
if not record.strip(): # trailing empty segment
continue
ev = json.loads(record[len(b"data: "):])
if ev["type"] == "audio":
audio.extend(base64.b64decode(ev["audio_chunk"]))
elif ev["type"] == "timestamps":
words.extend(ev["words"])
open("out.mp3", "wb").write(audio)
\n\n rather than parsing each iter_bytes chunk directly.
See also
- WebSocket API — for multi-utterance, interactive use cases.
- Text to speech — same request body, single audio response.
- For AI agents — the whole contract on one page.