TTSWave API documentation
Everything you need to add natural speech to your app: authentication, every endpoint with examples, voices, limits and errors.
Introduction
TTSWave turns text into natural speech. You can download a finished audio file, or stream audio sentence by sentence with timing marks so playback starts almost immediately. It also offers optional speech-to-text. Compatible endpoints follow the OpenAI, Google Cloud Text-to-Speech and Gemini API text-to-speech request formats, so existing integrations move with small changes.
| Base URL | https |
| Protocol | HTTPS only. JSON request bodies (Content-Type), except speech-to-text uploads (multipart/). All text is UTF-8. |
| Responses | JSON, audio bytes (/v1/audio/speech) or NDJSON streams (/v1/tts/stream). |
| Audio | 24 000 Hz, mono, 16-bit. Files as WAV, MP3, FLAC or raw PCM. |
| Versioning | All endpoints live under /v1, except the Gemini-compatible endpoint, which keeps Gemini's / path. Changes within v1 are additive: ignore JSON fields and stream frame types you do not recognise. |
Your API key must never be shipped in a web page, a mobile app or any other code that runs on a user's device. Your frontend calls your backend (which checks your own user's session and limits), and your backend calls TTSWave. The API is built for server-to-server calls and does not send the CORS headers a browser would need to call it directly.
Getting access
TTSWave is in private beta. There is no self-signup yet: API keys are issued by the TTSWave administrator. Ask the administrator for a key and tell them which voices you need; they also set your key's rate limits.
Keys look like tts_ followed by 43 characters. Store yours in your backend's secret store or environment (the examples on this page read it from TTSWAVE_API_KEY).
Copy it when it is issued. If a key is lost or leaks, the administrator (or the key's owner, in their dashboard) can regenerate it: that issues a new key and immediately disables the old one, so deploy the new key right away.
If you have a dashboard account, sign in at https://app.ttswave.stream/login to manage your keys and see their usage.
Authentication
Every /v1 request needs your key in a request header. Use either:
Authorization: Bearer tts_your_key_hereX-API-Key: tts_your_key_hereAuthorization: Bearer is what the OpenAI SDKs send, so they work unchanged. The Google-compatible endpoints (POST /v1/text:synthesize and the Gemini-compatible endpoint) additionally accept X-Goog-Api-Key: <key>, the header Google's REST APIs use. That header is not accepted anywhere else.
URLs end up in proxy, server and browser logs, so query-string keys are never read. On the Google-compatible /v1/text:synthesize and the Gemini-compatible /v1beta/models/{model}:generateContent and :streamGenerateContent, a Google-style ?key=... parameter is actively refused with 401 api_key_in_query (before the key is even checked); send it in the X-Goog-Api-Key header instead. On every other endpoint a key in the URL is simply ignored, so the request fails with 401 missing_api_key.
Authentication errors: 401 missing_api_key (no key header), 401 invalid_api_key (unknown, revoked or regenerated key; the response carries WWW-Authenticate: Bearer), 401 api_key_in_query (see above). Many invalid keys from the same IP address in a short time block that address temporarily: every request from it then answers 429 too_many_auth_failures with a Retry-After header, even with a valid key.
Session header (optional)
Speech is scheduled fairly and in order. Requests are grouped per caller (your key), optionally split by an X-TTS-Session header: different callers or sessions take turns (round robin), so one busy caller cannot hold up another, and within one session requests are finished in the order they arrived, sentence by sentence. If one key serves many end users, send a session id per listener or playback (any opaque id, at most 64 characters are used):
X-TTS-Session: listener-8f3a2c
X-TTS-Order: 3Parallel requests often arrive shuffled (each opens its own connection). Send X-TTS-Order with each part's play position (an integer, 0 to 1000000000) and the parts of a session are finished by that number, part 1 first, whatever order they arrive in. Without it they are finished in arrival order.
It works on every speech endpoint: POST /v1/tts/stream, POST /v1/audio/speech, POST /v1/text:synthesize and the Gemini-compatible endpoint (:generateContent and :streamGenerateContent). See Low latency tips.
Quickstart
Set your key, then run one of these. Each one saves an MP3 of a short sentence spoken with your key's default voice.
export TTSWAVE_API_KEY="tts_your_key_here"curl https://api.ttswave.stream/v1/audio/speech \
-H "Authorization: Bearer $TTSWAVE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"input": "Hello! This is my first sentence from TTSWave.", "response_format": "mp3"}' \
-o hello.mp3
// Node 18+ (built-in fetch). Save as quickstart.mjs, run: node quickstart.mjs
import { writeFile } from "node:fs/promises";
const res = await fetch("https://api.ttswave.stream/v1/audio/speech", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TTSWAVE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
input: "Hello! This is my first sentence from TTSWave.",
response_format: "mp3",
}),
});
if (!res.ok) {
const { error } = await res.json();
throw new Error(`${error.status} ${error.code}: ${error.message}`);
}
await writeFile("hello.mp3", Buffer.from(await res.arrayBuffer()));
console.log("Saved hello.mp3");
# pip install requests
import os
import requests
r = requests.post(
"https://api.ttswave.stream/v1/audio/speech",
headers={"Authorization": f"Bearer {os.environ['TTSWAVE_API_KEY']}"},
json={"input": "Hello! This is my first sentence from TTSWave.", "response_format": "mp3"},
timeout=60,
)
if not r.ok:
err = r.json()["error"]
raise RuntimeError(f"{err['status']} {err['code']}: {err['message']}")
with open("hello.mp3", "wb") as f:
f.write(r.content)
print("Saved hello.mp3")
<?php
// PHP 8+ with the curl extension. Save as quickstart.php, run: php quickstart.php
$ch = curl_init("https://api.ttswave.stream/v1/audio/speech");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("TTSWAVE_API_KEY"),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode([
"input" => "Hello! This is my first sentence from TTSWave.",
"response_format" => "mp3",
]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
]);
$body = curl_exec($ch);
if ($body === false) {
throw new RuntimeException(curl_error($ch));
}
if (curl_getinfo($ch, CURLINFO_RESPONSE_CODE) !== 200) {
$error = json_decode($body, true)["error"];
throw new RuntimeException("{$error['status']} {$error['code']}: {$error['message']}");
}
file_put_contents("hello.mp3", $body);
echo "Saved hello.mp3\n";
// Go 1.21+, standard library only. Save as main.go, run: go run main.go
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
)
func main() {
body, _ := json.Marshal(map[string]string{
"input": "Hello! This is my first sentence from TTSWave.",
"response_format": "mp3",
})
req, _ := http.NewRequest("POST", "https://api.ttswave.stream/v1/audio/speech", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("TTSWAVE_API_KEY"))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 60 * time.Second}
res, err := client.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
data, err := io.ReadAll(res.Body)
if err != nil {
log.Fatal(err)
}
if res.StatusCode != http.StatusOK {
var e struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
Status int `json:"status"`
} `json:"error"`
}
json.Unmarshal(data, &e)
log.Fatalf("%d %s: %s", e.Error.Status, e.Error.Code, e.Error.Message)
}
if err := os.WriteFile("hello.mp3", data, 0o644); err != nil {
log.Fatal(err)
}
fmt.Println("Saved hello.mp3")
}
// Java 11+, no dependencies. Save as Quickstart.java, run: java Quickstart.java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
public class Quickstart {
public static void main(String[] args) throws Exception {
String json = "{\"input\": \"Hello! This is my first sentence from TTSWave.\", \"response_format\": \"mp3\"}";
HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.ttswave.stream/v1/audio/speech"))
.header("Authorization", "Bearer " + System.getenv("TTSWAVE_API_KEY"))
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(60))
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<byte[]> res = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofByteArray());
if (res.statusCode() != 200) {
// The body is the JSON error envelope: {"error": {"code": ..., "message": ..., "status": ...}}
throw new RuntimeException(res.statusCode() + " " + new String(res.body()));
}
Files.write(Path.of("hello.mp3"), res.body());
System.out.println("Saved hello.mp3");
}
}
// .NET 8: dotnet new console, replace Program.cs with this, run: dotnet run
using System.Net.Http.Json;
using System.Text.Json;
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(60) };
http.DefaultRequestHeaders.Add("Authorization", "Bearer " + Environment.GetEnvironmentVariable("TTSWAVE_API_KEY"));
using var res = await http.PostAsJsonAsync("https://api.ttswave.stream/v1/audio/speech", new
{
input = "Hello! This is my first sentence from TTSWave.",
response_format = "mp3",
});
if (!res.IsSuccessStatusCode)
{
var error = (await res.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("error");
throw new Exception($"{error.GetProperty("status")} {error.GetProperty("code")}: {error.GetProperty("message")}");
}
await File.WriteAllBytesAsync("hello.mp3", await res.Content.ReadAsByteArrayAsync());
Console.WriteLine("Saved hello.mp3");
If hello.mp3 from curl will not play, open it as text: it contains a JSON error explaining what went wrong. Next steps: pick a voice with GET /v1/voices and pass it as "voice", or switch to streaming for audio that starts playing while the rest is generated.
Endpoints
| Method | Path | Use it for |
|---|---|---|
| GET | /v1/ | The voices your key may use, and your default |
| POST | /v1/ | A whole audio file (OpenAI-compatible) |
| POST | /v1/ | Streaming audio with sentence timing marks (NDJSON) |
| POST | /v1/ | Pre-generate text you will play soon |
| POST | /v1/ | Check whether pre-generated text is ready |
| POST | /v1/ | Google Cloud Text-to-Speech compatible synthesis |
| POST | /v1beta/ | Gemini API text-to-speech compatible (also :streamGenerateContent) |
| POST | /v1/ | Speech-to-text (OpenAI-compatible, optional) |
| GET | /v1/ | Service information |
| GET | /healthz | Public liveness check (no key) |
All /v1 endpoints require a key. Responses carry Cache-Control: no-store. Every error, on every endpoint, uses the same JSON envelope. The errors every authenticated endpoint can return (401, 429 rate_limited, 429 too_many_auth_failures, and, on the speech endpoints, 503 engine_loading while the server starts) are not repeated under each endpoint.
List voices
/v1/voicesReturns exactly the voices your key may use (the administrator can limit keys to certain voices), plus the voice you get when a request names none. Build voice pickers from this list and show only entries with available: true. No request body.
Response 200
| Field | Type | Description |
|---|---|---|
default | string or null | The voice used when you omit voice: your key's own default if it has one, else the server default, else your first available voice. null if none of your voices can be used right now (requests without voice then fail with 403 voice_not_allowed). |
voices | array | Voice objects (below), in catalog order. |
rawKokoroVoices | array of string | Low-level engine voice ids, for keys allowed to use them. Normally empty; use the preset ids in voices. |
Voice object:
| Field | Type | Description |
|---|---|---|
id | string | Stable id to send as voice, e.g. us-female-heart. |
label | string | Display name, e.g. Heart (warm, default). |
accent | string | e.g. American, British, Indian. |
gender | string | female or male. |
realtime | boolean | true: stream it live. false: a pre-generated voice; prepare the text first (see Voices). |
sample | string | A short sentence in the voice's language, used for previews. Previewing it is instant: the server pre-generates it. |
available | boolean | true if the server can synthesize it right now. |
engine, voice, lang | string | Informational: the synthesis engine, its internal voice and language. |
grade, notes | string | Optional (omitted when empty): a published quality grade (A best) and notes such as blend or experimental: .... |
curl https://api.ttswave.stream/v1/voices \
-H "Authorization: Bearer $TTSWAVE_API_KEY"
const res = await fetch("https://api.ttswave.stream/v1/voices", {
headers: { Authorization: `Bearer ${process.env.TTSWAVE_API_KEY}` },
});
const { default: defaultVoice, voices } = await res.json();
const usable = voices.filter((v) => v.available);
console.log(defaultVoice, usable.map((v) => `${v.id} (${v.accent}, ${v.gender})`));
import os
import requests
r = requests.get(
"https://api.ttswave.stream/v1/voices",
headers={"Authorization": f"Bearer {os.environ['TTSWAVE_API_KEY']}"},
timeout=10,
)
r.raise_for_status()
data = r.json()
print(data["default"], [v["id"] for v in data["voices"] if v["available"]])
<?php
$ch = curl_init("https://api.ttswave.stream/v1/voices");
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("TTSWAVE_API_KEY")],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$data = json_decode(curl_exec($ch), true);
if (curl_getinfo($ch, CURLINFO_RESPONSE_CODE) !== 200) {
throw new RuntimeException($data["error"]["code"] ?? curl_error($ch));
}
echo "default: {$data['default']}\n";
foreach ($data["voices"] as $v) {
if ($v["available"]) {
echo "{$v['id']} ({$v['accent']}, {$v['gender']})\n";
}
}
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"time"
)
type Voice struct {
ID string `json:"id"`
Accent string `json:"accent"`
Gender string `json:"gender"`
Realtime bool `json:"realtime"`
Available bool `json:"available"`
}
func main() {
req, _ := http.NewRequest("GET", "https://api.ttswave.stream/v1/voices", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("TTSWAVE_API_KEY"))
res, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
log.Fatalf("GET /v1/voices: %s", res.Status)
}
var data struct {
Default string `json:"default"` // "" when null
Voices []Voice `json:"voices"`
}
if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
log.Fatal(err)
}
fmt.Println("default:", data.Default)
for _, v := range data.Voices {
if v.Available {
fmt.Printf("%s (%s, %s)\n", v.ID, v.Accent, v.Gender)
}
}
}
// Java 11+. Save as Voices.java, run: java Voices.java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class Voices {
public static void main(String[] args) throws Exception {
HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.ttswave.stream/v1/voices"))
.header("Authorization", "Bearer " + System.getenv("TTSWAVE_API_KEY"))
.build();
HttpResponse<String> res = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) throw new RuntimeException(res.statusCode() + " " + res.body());
// Dependency-free parsing for this flat structure; in a real project use Jackson or Gson.
System.out.println("default: " + field(res.body(), "default"));
Matcher voice = Pattern.compile("\\{[^{}]*\\}").matcher(res.body()); // each voice object
while (voice.find()) {
String v = voice.group();
if (v.matches("(?s).*\"available\"\\s*:\\s*true.*")) {
System.out.println(field(v, "id") + " (" + field(v, "accent") + ", " + field(v, "gender") + ")");
}
}
}
static String field(String json, String name) {
Matcher m = Pattern.compile("\"" + name + "\"\\s*:\\s*\"([^\"]*)\"").matcher(json);
return m.find() ? m.group(1) : null;
}
}
using System.Text.Json;
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
http.DefaultRequestHeaders.Add("Authorization", "Bearer " + Environment.GetEnvironmentVariable("TTSWAVE_API_KEY"));
// GetStringAsync throws HttpRequestException on a non-2xx status.
using var doc = JsonDocument.Parse(await http.GetStringAsync("https://api.ttswave.stream/v1/voices"));
Console.WriteLine($"default: {doc.RootElement.GetProperty("default")}");
foreach (var v in doc.RootElement.GetProperty("voices").EnumerateArray())
{
if (v.GetProperty("available").GetBoolean())
Console.WriteLine($"{v.GetProperty("id")} ({v.GetProperty("accent")}, {v.GetProperty("gender")})");
}
{
"default": "us-female-heart",
"voices": [
{"id": "us-female-heart", "label": "Heart (warm, default)", "engine": "kokoro", "voice": "af_heart",
"lang": "en-us", "accent": "American", "gender": "female", "grade": "A",
"realtime": true, "available": true},
{"id": "uk-male-george", "label": "George", "engine": "kokoro", "voice": "bm_george",
"lang": "en-gb", "accent": "British", "gender": "male", "grade": "C",
"realtime": true, "available": true}
],
"rawKokoroVoices": []
}Create speech (audio file)
/v1/audio/speechSynthesizes the whole text and returns one audio file. The request and response match OpenAI's POST /v1/audio/speech, so the OpenAI SDKs work against it. Use it for downloads, voice notes, caching audio in your own storage and batch jobs. For live playback, /v1/tts/stream starts sooner.
Request body (JSON)
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
input | string | yes | The text. At most 8000 characters, and it must contain something speakable. | |
voice | string | no | your default | A voice id from GET /v1/voices, at most 100 characters. OpenAI names such as alloy are not recognised (400 voice_not_found). |
speed | number | no | 1.0 | 0.5 to 2.0. Must be 1.0 for pre-generated voices. |
response_format | string | no | "wav" | wav, mp3, flac or pcm (case-insensitive). Note: OpenAI defaults to mp3, TTSWave to wav. |
model | string | no | "kokoro" | Accepted for compatibility and ignored. |
Unknown extra fields (such as OpenAI's instructions) are ignored.
Response 200: the audio bytes
response_format | Content-Type | Contents |
|---|---|---|
wav | audio/wav | 16-bit PCM WAV, 24 kHz mono |
mp3 | audio/mpeg | MP3, 24 kHz mono |
flac | audio/flac | 16-bit FLAC, 24 kHz mono |
pcm | audio/ | Raw headerless 16-bit signed little-endian PCM, 24 kHz mono |
The X-Synthesis-Ms response header reports the server time spent producing the file. The response starts only once the whole file is ready, so allow a timeout of at least the length of the audio. With a pre-generated voice this endpoint does not answer 409: it waits until every sentence that is not cached yet has been generated, which can take minutes. Prefetch that text first, or use a realtime voice.
curl https://api.ttswave.stream/v1/audio/speech \
-H "Authorization: Bearer $TTSWAVE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"input": "Your order has shipped.", "voice": "us-male-michael", "response_format": "mp3"}' \
-o shipped.mp3
import { writeFile } from "node:fs/promises";
const res = await fetch("https://api.ttswave.stream/v1/audio/speech", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TTSWAVE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ input: "Your order has shipped.", voice: "us-male-michael", response_format: "mp3" }),
});
if (!res.ok) {
const { error } = await res.json();
throw new Error(`${error.code}: ${error.message}`);
}
await writeFile("shipped.mp3", Buffer.from(await res.arrayBuffer()));
import os
import requests
r = requests.post(
"https://api.ttswave.stream/v1/audio/speech",
headers={"Authorization": f"Bearer {os.environ['TTSWAVE_API_KEY']}"},
json={"input": "Your order has shipped.", "voice": "us-male-michael", "response_format": "mp3"},
timeout=120,
)
r.raise_for_status()
with open("shipped.mp3", "wb") as f:
f.write(r.content)
<?php
$ch = curl_init("https://api.ttswave.stream/v1/audio/speech");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("TTSWAVE_API_KEY"),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode([
"input" => "Your order has shipped.",
"voice" => "us-male-michael",
"response_format" => "mp3",
]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 120,
]);
$body = curl_exec($ch);
if ($body === false) {
throw new RuntimeException(curl_error($ch));
}
if (curl_getinfo($ch, CURLINFO_RESPONSE_CODE) !== 200) {
$error = json_decode($body, true)["error"];
throw new RuntimeException("{$error['code']}: {$error['message']}");
}
file_put_contents("shipped.mp3", $body);
package main
import (
"bytes"
"encoding/json"
"io"
"log"
"net/http"
"os"
"time"
)
func main() {
body, _ := json.Marshal(map[string]any{
"input": "Your order has shipped.",
"voice": "us-male-michael",
"response_format": "mp3",
})
req, _ := http.NewRequest("POST", "https://api.ttswave.stream/v1/audio/speech", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("TTSWAVE_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := (&http.Client{Timeout: 120 * time.Second}).Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
data, err := io.ReadAll(res.Body)
if err != nil {
log.Fatal(err)
}
if res.StatusCode != http.StatusOK {
log.Fatalf("%s: %s", res.Status, data) // data is the JSON error envelope
}
if err := os.WriteFile("shipped.mp3", data, 0o644); err != nil {
log.Fatal(err)
}
}
// Java 11+. Save as Speech.java, run: java Speech.java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
public class Speech {
public static void main(String[] args) throws Exception {
String json = "{\"input\": \"Your order has shipped.\", \"voice\": \"us-male-michael\", \"response_format\": \"mp3\"}";
HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.ttswave.stream/v1/audio/speech"))
.header("Authorization", "Bearer " + System.getenv("TTSWAVE_API_KEY"))
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(120))
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<byte[]> res = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofByteArray());
if (res.statusCode() != 200) throw new RuntimeException(res.statusCode() + " " + new String(res.body()));
Files.write(Path.of("shipped.mp3"), res.body());
}
}
using System.Net.Http.Json;
using System.Text.Json;
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(120) };
http.DefaultRequestHeaders.Add("Authorization", "Bearer " + Environment.GetEnvironmentVariable("TTSWAVE_API_KEY"));
using var res = await http.PostAsJsonAsync("https://api.ttswave.stream/v1/audio/speech", new
{
input = "Your order has shipped.",
voice = "us-male-michael",
response_format = "mp3",
});
if (!res.IsSuccessStatusCode)
{
var error = (await res.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("error");
throw new Exception($"{error.GetProperty("code")}: {error.GetProperty("message")}");
}
await File.WriteAllBytesAsync("shipped.mp3", await res.Content.ReadAsByteArrayAsync());
Errors
400 unsupported_format:response_formatis notwav,mp3,flacorpcm(OpenAI'sopusandaacare not supported).400 empty_text:inputis empty or has nothing speakable.400 voice_not_found,400 voice_unavailable,403 voice_not_allowed: see Errors.400 validation_error:speedother than1.0with a pre-generated voice.422 validation_error: invalid JSON, missinginput, orspeedoutside 0.5 to 2.0.413 text_too_long: more than 8000 characters.503 engine_loading: a pre-generated voice's engine is unavailable and part of the text is not cached (Retry-After: 30).
Stream speech (NDJSON)
/v1/tts/streamStreams speech as it is synthesized, one sentence at a time, as NDJSON: one JSON object ("frame") per line. The first audio arrives after the first sentence is ready, not after the whole text. Each sentence's audio is preceded by a mark frame with its timing, so captions, highlighting or other visuals can follow the voice. Use it for live narration and voice replies.
Request body (JSON)
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
text | string | yes | At most 8000 characters; must contain something speakable. | |
voice | string | no | your default | A voice id from GET /v1/voices, at most 100 characters. |
speed | number | no | 1.0 | 0.5 to 2.0. Must be 1.0 for pre-generated voices. |
wait | boolean | no | false | Pre-generated voices only: generate missing sentences live instead of answering 409. Very slow (the first audio can take minutes); for testing only. |
Response 200
Content-Type: application/x-ndjson. Frames, in this order:
{"type":"start","provider":"local-kokoro","voice":"us-female-heart","sampleRate":24000,"sentences":[{"index":0,"text":"Hello, and welcome to the demo."},{"index":1,"text":"This is a short test of the speech engine."}]}
{"type":"mark","index":0,"text":"Hello, and welcome to the demo.","startMs":0,"durationMs":2140,"cached":false}
{"type":"audio","format":"pcm_s16le","data":"AAABAAIA...","sampleRate":24000,"channels":1}
{"type":"audio","format":"pcm_s16le","data":"...","sampleRate":24000,"channels":1}
{"type":"mark","index":1,"text":"This is a short test of the speech engine.","startMs":2140,"durationMs":2650,"cached":false}
{"type":"audio","format":"pcm_s16le","data":"...","sampleRate":24000,"channels":1}
{"type":"done","provider":"local-kokoro","durationMs":4790,"browser":false}| Frame | Fields | Meaning |
|---|---|---|
start | provider (string), voice (resolved voice id), sampleRate (24000), sentences (array of {index, text}) | Always first, exactly once. Lists every piece that will be spoken, after Markdown stripping and sentence splitting. |
mark | index (into sentences), text, startMs, durationMs, cached (boolean) | Sent just before each piece's audio. startMs is where the piece begins on the audio timeline (milliseconds from the first sample, not wall-clock time); durationMs includes its trailing pause. cached: true means the audio came from the cache. |
audio | format ("pcm_s16le"), data (base64), sampleRate (24000), channels (1) | A slice of about 500 ms of 16-bit signed little-endian mono PCM at 24 000 Hz. Decoding every data in order and concatenating the bytes gives the complete, gapless recording (pauses included). |
done | provider, durationMs (total audio length), browser (always false from the server) | Last frame of a successful stream. browser is reserved for your own backend: you can send {"type" to tell your frontend to fall back to on-device speech. |
keepalive | none | Sent when no audio has gone out for about 10 seconds, for example while a part waits its turn behind earlier parts. Ignore it; it keeps clients with an idle timeout connected. (On :streamGenerateContent?alt=sse an SSE comment line plays this role.) |
error | code (internal_error or engine_loading), message | Replaces done if synthesis fails after the stream started. Treat a stream that ends without done as failed. |
Ignore frame types you do not recognise. Errors found before streaming starts (bad key, bad voice, text too long, ...) arrive as normal HTTP error responses with the JSON envelope, never as frames. Closing the connection stops the live synthesis; the rest of the text stays queued in the background (prepare ahead), so a retry finds it ready.
Timing and sync. Because startMs is measured on the audio timeline, the moment piece n becomes audible is (time your player started the first sample) + startMs. Show the matching caption or UI step at that moment. If you only need audio, ignore start and mark.
Examples
curl prints the raw frames; the Node.js tab is a backend route that relays the stream to your own frontend (the key stays on your server); the other tabs save the stream as a WAV file.
# -N disables buffering so frames print as they arrive
curl -N https://api.ttswave.stream/v1/tts/stream \
-H "Authorization: Bearer $TTSWAVE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"text": "Hello, and welcome to the demo. This is a short test of the speech engine."}'
// Your backend: Node 18+ with Express. The browser calls POST /api/speech;
// only this server knows the TTSWave key.
import express from "express";
const app = express();
app.use(express.json());
app.post("/api/speech", async (req, res) => {
// Check your own user's session and limits here.
const text = String(req.body?.text ?? "").slice(0, 8000);
const abort = new AbortController();
res.on("close", () => abort.abort()); // user went away: stop the upstream stream
try {
const upstream = await fetch("https://api.ttswave.stream/v1/tts/stream", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TTSWAVE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ text }), // add voice: "..." to pick a voice
signal: abort.signal,
});
if (!upstream.ok) {
// Errors arrive as JSON before any frame. Pass on the code, never the key.
const { error } = await upstream.json();
return res.status(upstream.status).json({ error: error.code });
}
res.setHeader("Content-Type", "application/x-ndjson");
res.setHeader("Cache-Control", "no-store");
res.setHeader("X-Accel-Buffering", "no"); // if nginx sits in front of your app
for await (const chunk of upstream.body) res.write(chunk); // forward frames unchanged
res.end();
} catch (err) {
if (!res.headersSent) res.status(502).json({ error: "tts_unreachable" });
else res.end();
}
});
app.listen(3000);
# Save a stream as a WAV file. pip install requests
import base64
import json
import os
import wave
import requests
with requests.post(
"https://api.ttswave.stream/v1/tts/stream",
headers={"Authorization": f"Bearer {os.environ['TTSWAVE_API_KEY']}"},
json={"text": "Hello, and welcome to the demo. This is a short test of the speech engine."},
stream=True,
timeout=(5, 60), # connect, per-read
) as r:
if r.status_code != 200:
err = r.json()["error"]
raise RuntimeError(f"{err['code']}: {err['message']}")
with wave.open("speech.wav", "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2) # 16-bit
wav.setframerate(24000)
for line in r.iter_lines():
if not line:
continue
frame = json.loads(line)
if frame["type"] == "mark":
print(f"{frame['startMs']:>6} ms {frame['text']}")
elif frame["type"] == "audio":
wav.writeframes(base64.b64decode(frame["data"]))
elif frame["type"] == "error":
raise RuntimeError(f"{frame['code']}: {frame['message']}")
<?php
// Save a stream as a WAV file. PHP 8+ with the curl extension.
$pcm = ""; // decoded audio so far
$buffer = ""; // bytes received but not yet split into lines
$failure = null; // error frame, if synthesis fails mid-stream
$handleFrame = function (array $frame) use (&$pcm, &$failure) {
if ($frame["type"] === "mark") {
printf("%6d ms %s\n", $frame["startMs"], $frame["text"]);
} elseif ($frame["type"] === "audio") {
$pcm .= base64_decode($frame["data"]);
} elseif ($frame["type"] === "error") {
$failure = "{$frame['code']}: {$frame['message']}";
}
};
$ch = curl_init("https://api.ttswave.stream/v1/tts/stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("TTSWAVE_API_KEY"),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode([
"text" => "Hello, and welcome to the demo. This is a short test of the speech engine.",
]),
CURLOPT_TIMEOUT => 300,
// Called for every chunk as it arrives: split it into NDJSON lines.
CURLOPT_WRITEFUNCTION => function ($ch, string $chunk) use (&$buffer, $handleFrame) {
$buffer .= $chunk;
if (curl_getinfo($ch, CURLINFO_RESPONSE_CODE) !== 200) {
return strlen($chunk); // an error envelope, handled below
}
while (($nl = strpos($buffer, "\n")) !== false) {
$line = trim(substr($buffer, 0, $nl));
$buffer = substr($buffer, $nl + 1);
if ($line !== "") {
$handleFrame(json_decode($line, true));
}
}
return strlen($chunk);
},
]);
if (curl_exec($ch) === false) {
throw new RuntimeException(curl_error($ch));
}
if (curl_getinfo($ch, CURLINFO_RESPONSE_CODE) !== 200) {
$error = json_decode($buffer, true)["error"];
throw new RuntimeException("{$error['code']}: {$error['message']}");
}
if ($failure !== null) {
throw new RuntimeException($failure);
}
// 44-byte WAV header: PCM, mono, 24 000 Hz, 16-bit.
$header = pack("A4VA4A4VvvVVvvA4V", "RIFF", 36 + strlen($pcm), "WAVE", "fmt ",
16, 1, 1, 24000, 48000, 2, 16, "data", strlen($pcm));
file_put_contents("speech.wav", $header . $pcm);
// Save a stream as a WAV file. Go 1.21+, standard library only.
package main
import (
"bytes"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
)
type Frame struct {
Type string `json:"type"`
Text string `json:"text"`
StartMs int `json:"startMs"`
Data []byte `json:"data"` // base64 in the JSON, decoded automatically
Code string `json:"code"`
Message string `json:"message"`
}
func main() {
body := strings.NewReader(`{"text": "Hello, and welcome to the demo. This is a short test of the speech engine."}`)
req, _ := http.NewRequest("POST", "https://api.ttswave.stream/v1/tts/stream", body)
req.Header.Set("Authorization", "Bearer "+os.Getenv("TTSWAVE_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(res.Body)
log.Fatalf("%s: %s", res.Status, msg)
}
var pcm bytes.Buffer
dec := json.NewDecoder(res.Body) // reads one NDJSON frame at a time, as it arrives
for {
var f Frame
if err := dec.Decode(&f); err == io.EOF {
break
} else if err != nil {
log.Fatal(err)
}
switch f.Type {
case "mark":
fmt.Printf("%6d ms %s\n", f.StartMs, f.Text)
case "audio":
pcm.Write(f.Data)
case "error":
log.Fatalf("%s: %s", f.Code, f.Message)
}
}
if err := os.WriteFile("speech.wav", append(wavHeader(pcm.Len()), pcm.Bytes()...), 0o644); err != nil {
log.Fatal(err)
}
}
// wavHeader returns a 44-byte WAV header for n bytes of 24 kHz mono 16-bit PCM.
func wavHeader(n int) []byte {
h := make([]byte, 44)
copy(h[0:], "RIFF")
binary.LittleEndian.PutUint32(h[4:], uint32(36+n))
copy(h[8:], "WAVEfmt ")
binary.LittleEndian.PutUint32(h[16:], 16)
binary.LittleEndian.PutUint16(h[20:], 1) // PCM
binary.LittleEndian.PutUint16(h[22:], 1) // mono
binary.LittleEndian.PutUint32(h[24:], 24000) // sample rate
binary.LittleEndian.PutUint32(h[28:], 48000) // bytes per second
binary.LittleEndian.PutUint16(h[32:], 2) // bytes per sample
binary.LittleEndian.PutUint16(h[34:], 16) // bits per sample
copy(h[36:], "data")
binary.LittleEndian.PutUint32(h[40:], uint32(n))
return h
}
// Save a stream as a WAV file. Java 11+. Save as StreamToWav.java, run: java StreamToWav.java
import java.io.ByteArrayOutputStream;
import java.io.FileOutputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Base64;
import java.util.Iterator;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class StreamToWav {
public static void main(String[] args) throws Exception {
String json = "{\"text\": \"Hello, and welcome to the demo. This is a short test of the speech engine.\"}";
HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.ttswave.stream/v1/tts/stream"))
.header("Authorization", "Bearer " + System.getenv("TTSWAVE_API_KEY"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
// ofLines() hands over each NDJSON frame as soon as its line arrives.
HttpResponse<Stream<String>> res =
HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofLines());
if (res.statusCode() != 200) {
throw new RuntimeException(res.statusCode() + " " + res.body().collect(Collectors.joining()));
}
ByteArrayOutputStream pcm = new ByteArrayOutputStream();
for (Iterator<String> lines = res.body().iterator(); lines.hasNext(); ) {
String frame = lines.next();
String type = field(frame, "type");
if ("mark".equals(type)) {
System.out.printf("%6s ms %s%n", number(frame, "startMs"), field(frame, "text"));
} else if ("audio".equals(type)) {
pcm.write(Base64.getDecoder().decode(field(frame, "data")));
} else if ("error".equals(type)) {
throw new RuntimeException(field(frame, "code") + ": " + field(frame, "message"));
}
}
// 44-byte WAV header: PCM, mono, 24 000 Hz, 16-bit.
ByteBuffer h = ByteBuffer.allocate(44).order(ByteOrder.LITTLE_ENDIAN);
h.put("RIFF".getBytes()).putInt(36 + pcm.size()).put("WAVEfmt ".getBytes())
.putInt(16).putShort((short) 1).putShort((short) 1).putInt(24000).putInt(48000)
.putShort((short) 2).putShort((short) 16).put("data".getBytes()).putInt(pcm.size());
try (FileOutputStream out = new FileOutputStream("speech.wav")) {
out.write(h.array());
pcm.writeTo(out);
}
}
// Minimal field extraction to stay dependency-free; use Jackson or Gson in real code.
static String field(String json, String name) {
Matcher m = Pattern.compile("\"" + name + "\"\\s*:\\s*\"([^\"\\\\]*(?:\\\\.[^\"\\\\]*)*)\"").matcher(json);
return m.find() ? m.group(1) : null;
}
static String number(String json, String name) {
Matcher m = Pattern.compile("\"" + name + "\"\\s*:\\s*(-?[0-9.]+)").matcher(json);
return m.find() ? m.group(1) : null;
}
}
// Save a stream as a WAV file. .NET 8 console app (Program.cs).
using System.Net.Http.Json;
using System.Text.Json;
using var http = new HttpClient { Timeout = Timeout.InfiniteTimeSpan }; // a stream can outlast the default 100 s
http.DefaultRequestHeaders.Add("Authorization", "Bearer " + Environment.GetEnvironmentVariable("TTSWAVE_API_KEY"));
using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.ttswave.stream/v1/tts/stream")
{
Content = JsonContent.Create(new { text = "Hello, and welcome to the demo. This is a short test of the speech engine." }),
};
// ResponseHeadersRead: start reading frames as they arrive instead of buffering the whole body.
using var res = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
if (!res.IsSuccessStatusCode)
throw new Exception($"{(int)res.StatusCode}: {await res.Content.ReadAsStringAsync()}");
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
using var pcm = new MemoryStream();
while (await reader.ReadLineAsync() is { } line)
{
if (line.Length == 0) continue;
using var doc = JsonDocument.Parse(line);
var frame = doc.RootElement;
switch (frame.GetProperty("type").GetString())
{
case "mark":
Console.WriteLine($"{frame.GetProperty("startMs"),6} ms {frame.GetProperty("text")}");
break;
case "audio":
pcm.Write(frame.GetProperty("data").GetBytesFromBase64());
break;
case "error":
throw new Exception($"{frame.GetProperty("code")}: {frame.GetProperty("message")}");
}
}
// 44-byte WAV header: PCM, mono, 24 000 Hz, 16-bit.
using var wav = new BinaryWriter(File.Create("speech.wav"));
wav.Write("RIFF"u8); wav.Write(36 + (int)pcm.Length); wav.Write("WAVEfmt "u8);
wav.Write(16); wav.Write((short)1); wav.Write((short)1); wav.Write(24000); wav.Write(48000);
wav.Write((short)2); wav.Write((short)16); wav.Write("data"u8); wav.Write((int)pcm.Length);
pcm.WriteTo(wav.BaseStream);
Playing it in the browser. The page calls your backend route (above), reads the frames as they arrive and schedules each audio slice back to back with the Web Audio API. Call speak() from a click or tap so the browser allows audio.
// Plays the NDJSON relayed by YOUR backend (POST /api/speech above).
async function speak(text, { onMark } = {}) {
const ctx = new AudioContext();
await ctx.resume();
const res = await fetch("/api/speech", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
if (!res.ok) throw new Error("speech failed: " + res.status);
let playhead = 0; // AudioContext time where the next slice starts
let pendingMark = null; // mark waiting for its first audio slice
const handle = (frame) => {
if (frame.type === "mark") {
pendingMark = frame;
} else if (frame.type === "audio") {
const bin = atob(frame.data);
const view = new DataView(Uint8Array.from(bin, (c) => c.charCodeAt(0)).buffer);
const samples = bin.length >> 1;
const buffer = ctx.createBuffer(1, samples, frame.sampleRate); // resampled by the browser
const channel = buffer.getChannelData(0);
for (let i = 0; i < samples; i++) channel[i] = view.getInt16(i * 2, true) / 32768;
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(ctx.destination);
const when = Math.max(playhead, ctx.currentTime + 0.1); // small jitter buffer
source.start(when);
playhead = when + buffer.duration;
if (pendingMark) { // fire the caption exactly when its sentence becomes audible
const mark = pendingMark;
pendingMark = null;
setTimeout(() => onMark?.(mark), Math.max(0, (when - ctx.currentTime) * 1000));
}
} else if (frame.type === "done" && frame.browser) {
speechSynthesis.speak(new SpeechSynthesisUtterance(text)); // your backend chose the fallback
} else if (frame.type === "error") {
console.error(frame.code, frame.message);
}
};
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let nl;
while ((nl = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (line) handle(JSON.parse(line));
}
}
}
document.querySelector("#play").addEventListener("click", () =>
speak("Hello, and welcome. This sentence is streamed.", {
onMark: (m) => console.log("now saying:", m.text),
})
);Errors
400 empty_text,413 text_too_long: empty, unspeakable or longer than 8000 characters.400 voice_not_found,400 voice_unavailable,403 voice_not_allowed: see Errors.400 validation_error(speed other than1.0with a pre-generated voice),422 validation_error(invalid body,speedout of range).409 voice_not_ready: a pre-generated voice whose text is not fully generated yet. The missing sentences have been queued; see Voices.503 engine_loading: the engine for this voice is unavailable (Retry-After).
Pre-generate text (prefetch)
/v1/tts/prefetchQueues text you will play soon for background synthesis into the server's cache, and returns immediately. When you later stream (or request a file for) the same text with the same voice and speed, it is served from the cache and starts almost instantly. Call it as soon as content is final, for example when an article is published or a script is approved. For pre-generated voices this step is required.
Request body (JSON)
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
texts | array of string | yes | 1 to 200 texts, each at most 8000 characters, in playback order; the whole JSON body can be at most 256 KB (split larger batches into several calls). Empty strings are skipped. Use exactly the strings you will stream later. | |
voice | string | no | your default | The voice you will play with (at most 100 characters). |
speed | number | no | 1.0 | The speed you will play with (0.5 to 2.0; 1.0 for pre-generated voices). |
Response 202 Accepted
| Field | Type | Description |
|---|---|---|
queued | integer | Sentences queued by this call (texts are split exactly as the stream splits them; sentences already queued are skipped). |
pending | integer | Sentences waiting in this voice's queue, across all callers. |
realtime | boolean | The voice's realtime flag. |
Prefetch is best effort and runs at low priority (it stops whenever a live stream needs the CPU; see Playing multi-part content without gaps). The characters of all texts count toward your character limit, even if some are already cached. Prefetching again retries sentences that failed.
Check readiness (status)
/v1/tts/statusReports how much of a set of texts is already cached for a voice and speed. It starts no synthesis and costs one request but no characters, so polling it every few seconds is fine. The request body is the same as for prefetch (texts, voice, speed).
Response 200
| Field | Type | Description |
|---|---|---|
voice | string | The resolved voice id. |
total | integer | Sentences in all texts. |
ready | integer | Sentences already cached (they play instantly). |
queued | integer | Sentences not cached yet but waiting in a queue. |
failed | integer | Sentences whose pre-generation failed and are no longer queued. Prefetch again to retry. |
complete | boolean | ready == total: streaming this text now plays without waiting. |
realtime | boolean | The voice's realtime flag. |
Sentences that are neither ready, queued nor failed (for example after a server restart cleared the queue) need another prefetch call; it skips what is already cached or queued.
BODY='{"texts": ["Welcome back to the weekly summary.", "Here are the three things that changed."], "voice": "uk-female-emma"}'
curl https://api.ttswave.stream/v1/tts/prefetch \
-H "Authorization: Bearer $TTSWAVE_API_KEY" -H "Content-Type: application/json" -d "$BODY"
# {"queued":2,"pending":2,"realtime":true}
curl https://api.ttswave.stream/v1/tts/status \
-H "Authorization: Bearer $TTSWAVE_API_KEY" -H "Content-Type: application/json" -d "$BODY"
# {"voice":"uk-female-emma","total":2,"ready":0,"queued":2,"failed":0,"complete":false,"realtime":true}
# repeat every few seconds until "complete":true (here "ready":2)
const API = "https://api.ttswave.stream";
const headers = {
Authorization: `Bearer ${process.env.TTSWAVE_API_KEY}`,
"Content-Type": "application/json",
};
const body = JSON.stringify({
texts: ["Welcome back to the weekly summary.", "Here are the three things that changed."],
voice: "uk-female-emma",
});
const post = (path) => fetch(API + path, { method: "POST", headers, body });
// 1. Queue the text (202, returns immediately).
await post("/v1/tts/prefetch");
// 2. Poll until every sentence is cached (costs no characters).
const deadline = Date.now() + 30 * 60_000;
for (;;) {
const st = await (await post("/v1/tts/status")).json();
console.log(`ready ${st.ready}/${st.total}`);
if (st.complete) break;
if (Date.now() > deadline) throw new Error("audio still not ready");
await new Promise((r) => setTimeout(r, 3000));
}
// 3. Stream the same texts with the same voice and speed: playback starts instantly.
import os
import time
import requests
API = "https://api.ttswave.stream"
H = {"Authorization": f"Bearer {os.environ['TTSWAVE_API_KEY']}"}
body = {
"texts": ["Welcome back to the weekly summary.", "Here are the three things that changed."],
"voice": "uk-female-emma",
}
requests.post(f"{API}/v1/tts/prefetch", headers=H, json=body, timeout=30).raise_for_status()
deadline = time.time() + 30 * 60
while True:
st = requests.post(f"{API}/v1/tts/status", headers=H, json=body, timeout=30).json()
print(f"ready {st['ready']}/{st['total']}")
if st["complete"]:
break
if time.time() > deadline:
raise TimeoutError("audio still not ready")
time.sleep(3)
<?php
$body = json_encode([
"texts" => ["Welcome back to the weekly summary.", "Here are the three things that changed."],
"voice" => "uk-female-emma",
]);
function post(string $path, string $body): array
{
$ch = curl_init("https://api.ttswave.stream" . $path);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("TTSWAVE_API_KEY"),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$data = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400 || $status === 0) {
throw new RuntimeException($data["error"]["code"] ?? curl_error($ch));
}
return $data;
}
// 1. Queue the text (202, returns immediately).
post("/v1/tts/prefetch", $body);
// 2. Poll until every sentence is cached (costs no characters).
$deadline = time() + 30 * 60;
while (true) {
$st = post("/v1/tts/status", $body);
echo "ready {$st['ready']}/{$st['total']}\n";
if ($st["complete"]) {
break;
}
if (time() > $deadline) {
throw new RuntimeException("audio still not ready");
}
sleep(3);
}
// 3. Stream the same texts with the same voice and speed: playback starts instantly.
package main
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"time"
)
var body, _ = json.Marshal(map[string]any{
"texts": []string{"Welcome back to the weekly summary.", "Here are the three things that changed."},
"voice": "uk-female-emma",
})
func post(path string, out any) {
req, _ := http.NewRequest("POST", "https://api.ttswave.stream"+path, bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("TTSWAVE_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode >= 400 {
log.Fatalf("%s: %s", path, res.Status)
}
if err := json.NewDecoder(res.Body).Decode(out); err != nil {
log.Fatal(err)
}
}
func main() {
// 1. Queue the text (202, returns immediately).
var queued struct{ Queued, Pending int }
post("/v1/tts/prefetch", &queued)
// 2. Poll until every sentence is cached (costs no characters).
deadline := time.Now().Add(30 * time.Minute)
for {
var st struct {
Ready, Total int
Complete bool
}
post("/v1/tts/status", &st)
fmt.Printf("ready %d/%d\n", st.Ready, st.Total)
if st.Complete {
break
}
if time.Now().After(deadline) {
log.Fatal("audio still not ready")
}
time.Sleep(3 * time.Second)
}
// 3. Stream the same texts with the same voice and speed: playback starts instantly.
}
// Java 11+. Save as Prefetch.java, run: java Prefetch.java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class Prefetch {
static final HttpClient HTTP = HttpClient.newHttpClient();
static final String BODY = "{\"texts\": [\"Welcome back to the weekly summary.\", "
+ "\"Here are the three things that changed.\"], \"voice\": \"uk-female-emma\"}";
static String post(String path) throws Exception {
HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.ttswave.stream" + path))
.header("Authorization", "Bearer " + System.getenv("TTSWAVE_API_KEY"))
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(30))
.POST(HttpRequest.BodyPublishers.ofString(BODY))
.build();
HttpResponse<String> res = HTTP.send(request, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() >= 400) throw new RuntimeException(res.statusCode() + " " + res.body());
return res.body();
}
public static void main(String[] args) throws Exception {
// 1. Queue the text (202, returns immediately).
post("/v1/tts/prefetch");
// 2. Poll until every sentence is cached (costs no characters).
long deadline = System.currentTimeMillis() + 30 * 60_000;
while (true) {
String st = post("/v1/tts/status"); // {"voice":"...","total":2,"ready":2,...,"complete":true,...}
System.out.println("ready " + st.replaceAll(".*\"ready\":(\\d+).*", "$1")
+ "/" + st.replaceAll(".*\"total\":(\\d+).*", "$1"));
if (st.contains("\"complete\":true")) break;
if (System.currentTimeMillis() > deadline) throw new RuntimeException("audio still not ready");
Thread.sleep(3000);
}
// 3. Stream the same texts with the same voice and speed: playback starts instantly.
}
}
using System.Net.Http.Json;
using System.Text.Json;
using var http = new HttpClient { BaseAddress = new Uri("https://api.ttswave.stream") };
http.DefaultRequestHeaders.Add("Authorization", "Bearer " + Environment.GetEnvironmentVariable("TTSWAVE_API_KEY"));
var body = new
{
texts = new[] { "Welcome back to the weekly summary.", "Here are the three things that changed." },
voice = "uk-female-emma",
};
// 1. Queue the text (202, returns immediately).
(await http.PostAsJsonAsync("/v1/tts/prefetch", body)).EnsureSuccessStatusCode();
// 2. Poll until every sentence is cached (costs no characters).
var deadline = DateTime.UtcNow.AddMinutes(30);
while (true)
{
using var res = await http.PostAsJsonAsync("/v1/tts/status", body);
res.EnsureSuccessStatusCode();
var st = await res.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine($"ready {st.GetProperty("ready")}/{st.GetProperty("total")}");
if (st.GetProperty("complete").GetBoolean()) break;
if (DateTime.UtcNow > deadline) throw new TimeoutException("audio still not ready");
await Task.Delay(3000);
}
// 3. Stream the same texts with the same voice and speed: playback starts instantly.
Errors (prefetch and status)
400 empty_text: every text is empty.413 payload_too_large: more than 200 texts (or a request body over the size limit).413 text_too_long: a text longer than 8000 characters.422 validation_error: missing or emptytextslist, wrong types,speedout of range.400 validation_error: speed other than1.0with a pre-generated voice.400 voice_not_found,400 voice_unavailable,403 voice_not_allowed.
Google Cloud Text-to-Speech compatible
/v1/text:synthesizeAccepts the Google Cloud Text-to-Speech v1 REST request (text:synthesize) and returns the same audioContent response: the whole file, base64-encoded in JSON. Use it to move an existing REST integration by changing the URL and the key (see Migrating).
Authentication: X-Goog-Api-Key: <key>, Authorization: Bearer <key> or X-API-Key: <key>. A ?key= query parameter is refused with 401 api_key_in_query.
Request body (JSON)
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
input.text | string | one of | Plain text, at most 8000 characters. | |
input.ssml | string | one of | SSML (used instead of text if both are sent). Tags are stripped to plain text; <break> becomes a short pause and <p>/<s> become sentence breaks. No other SSML feature is applied. Up to 16 000 characters with tags, 8000 after stripping. | |
voice. | string | no | "" | Picks the accent: en-US American, en-GB British, en-IN Indian, en-NG Nigerian; also fr-*, es-*, pt-*, yo, ig, ha. Empty or unrecognised: your default voice. |
voice.ssmlGender | string | no | MALE or FEMALE narrows the choice; NEUTRAL or anything else is ignored. | |
voice.name | string | no | At most 100 characters. A TTSWave voice id (e.g. uk-female-emma) picks that voice exactly. Google voice names such as en-US-Neural2-F are not recognised and fall back to language matching. | |
audioConfig. | string | no | LINEAR16 | LINEAR16 (WAV with header), MP3, MULAW, ALAW (WAV container), OGG_OPUS (only if the server supports it). Case-insensitive. |
audioConfig. | integer | no | 24000 (8000 for MULAW/ALAW) | 8000, 16000, 22050, 24000, 44100 or 48000; for OGG_OPUS: 8000, 12000, 16000, 24000 or 48000. |
audioConfig. | number | no | 1.0 | Clamped to 0.5 to 2.0; 0 means normal speed. Always 1.0 for pre-generated voices (no error). |
audioConfig.pitch, volumeGainDb, effectsProfileId | no | Accepted and ignored. |
Voice selection when voice.name is not a TTSWave id: among the available voices your key may use with the accent from languageCode (filtered by ssmlGender when that leaves any), your key's default voice wins if it matches, otherwise the first match in catalog order. With no match, your default voice is used. snake_case field names (language_code, audio_encoding, ...) are accepted too, and unknown fields are ignored.
Response 200
{
"audioContent": "SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4...",
"audioConfig": {"audioEncoding": "MP3", "sampleRateHertz": 24000, "speakingRate": 1.0}
}audioContent is the base64-encoded file; audioConfig echoes the encoding, sample rate and speaking rate actually used.
curl -sS https://api.ttswave.stream/v1/text:synthesize \
-H "X-Goog-Api-Key: $TTSWAVE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"input": {"text": "Hello from TTSWave."},
"voice": {"languageCode": "en-GB", "ssmlGender": "FEMALE"},
"audioConfig": {"audioEncoding": "MP3"}}' \
| jq -r .audioContent | base64 -d > hello.mp3
import { writeFile } from "node:fs/promises";
const res = await fetch("https://api.ttswave.stream/v1/text:synthesize", {
method: "POST",
headers: { "X-Goog-Api-Key": process.env.TTSWAVE_API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({
input: { text: "Hello from TTSWave." },
voice: { languageCode: "en-GB", ssmlGender: "FEMALE" },
audioConfig: { audioEncoding: "MP3" },
}),
});
if (!res.ok) {
const { error } = await res.json();
throw new Error(`${error.code}: ${error.message}`);
}
const { audioContent } = await res.json();
await writeFile("hello.mp3", Buffer.from(audioContent, "base64"));
import base64
import os
import requests
r = requests.post(
"https://api.ttswave.stream/v1/text:synthesize",
headers={"X-Goog-Api-Key": os.environ["TTSWAVE_API_KEY"]},
json={
"input": {"text": "Hello from TTSWave."},
"voice": {"languageCode": "en-GB", "ssmlGender": "FEMALE"},
"audioConfig": {"audioEncoding": "MP3"},
},
timeout=60,
)
r.raise_for_status()
with open("hello.mp3", "wb") as f:
f.write(base64.b64decode(r.json()["audioContent"]))
<?php
$ch = curl_init("https://api.ttswave.stream/v1/text:synthesize");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"X-Goog-Api-Key: " . getenv("TTSWAVE_API_KEY"),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode([
"input" => ["text" => "Hello from TTSWave."],
"voice" => ["languageCode" => "en-GB", "ssmlGender" => "FEMALE"],
"audioConfig" => ["audioEncoding" => "MP3"],
]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
]);
$data = json_decode(curl_exec($ch), true);
if (curl_getinfo($ch, CURLINFO_RESPONSE_CODE) !== 200) {
throw new RuntimeException($data["error"]["code"] ?? curl_error($ch));
}
file_put_contents("hello.mp3", base64_decode($data["audioContent"]));
package main
import (
"bytes"
"encoding/json"
"io"
"log"
"net/http"
"os"
"time"
)
func main() {
body, _ := json.Marshal(map[string]any{
"input": map[string]string{"text": "Hello from TTSWave."},
"voice": map[string]string{"languageCode": "en-GB", "ssmlGender": "FEMALE"},
"audioConfig": map[string]string{"audioEncoding": "MP3"},
})
req, _ := http.NewRequest("POST", "https://api.ttswave.stream/v1/text:synthesize", bytes.NewReader(body))
req.Header.Set("X-Goog-Api-Key", os.Getenv("TTSWAVE_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := (&http.Client{Timeout: 60 * time.Second}).Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(res.Body)
log.Fatalf("%s: %s", res.Status, msg)
}
var out struct {
AudioContent []byte `json:"audioContent"` // base64, decoded automatically
}
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
log.Fatal(err)
}
if err := os.WriteFile("hello.mp3", out.AudioContent, 0o644); err != nil {
log.Fatal(err)
}
}
// Java 11+. Save as Synthesize.java, run: java Synthesize.java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Base64;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class Synthesize {
public static void main(String[] args) throws Exception {
String json = "{\"input\": {\"text\": \"Hello from TTSWave.\"},"
+ " \"voice\": {\"languageCode\": \"en-GB\", \"ssmlGender\": \"FEMALE\"},"
+ " \"audioConfig\": {\"audioEncoding\": \"MP3\"}}";
HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.ttswave.stream/v1/text:synthesize"))
.header("X-Goog-Api-Key", System.getenv("TTSWAVE_API_KEY"))
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(60))
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> res = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) throw new RuntimeException(res.statusCode() + " " + res.body());
Matcher audio = Pattern.compile("\"audioContent\"\\s*:\\s*\"([^\"]+)\"").matcher(res.body());
if (!audio.find()) throw new IllegalStateException("no audioContent in response");
Files.write(Path.of("hello.mp3"), Base64.getDecoder().decode(audio.group(1)));
}
}
using System.Net.Http.Json;
using System.Text.Json;
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(60) };
http.DefaultRequestHeaders.Add("X-Goog-Api-Key", Environment.GetEnvironmentVariable("TTSWAVE_API_KEY"));
using var res = await http.PostAsJsonAsync("https://api.ttswave.stream/v1/text:synthesize", new
{
input = new { text = "Hello from TTSWave." },
voice = new { languageCode = "en-GB", ssmlGender = "FEMALE" },
audioConfig = new { audioEncoding = "MP3" },
});
var json = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!res.IsSuccessStatusCode)
throw new Exception($"{json.GetProperty("error").GetProperty("code")}: {json.GetProperty("error").GetProperty("message")}");
await File.WriteAllBytesAsync("hello.mp3", json.GetProperty("audioContent").GetBytesFromBase64());
Errors
Errors use the TTSWave envelope, not Google's (error.code is a string such as "voice_not_allowed", error.status the HTTP status number).
401 api_key_in_query: the key was sent as?key=.400 unsupported_format: unknownaudioEncoding, orOGG_OPUSon a server without Opus support.400 validation_error: unsupportedsampleRateHertz.422 validation_error: malformed body (for example noinputobject).400 empty_text: neitherinput.textnorinput.ssml, or nothing speakable.413 text_too_long: over the limits above.400 voice_not_found,400 voice_unavailable,403 voice_not_allowed.409 voice_not_ready: a pre-generated voice (for example one picked byen-NG) whose text is not generated yet.
Gemini-compatible endpoint
/v1beta/models/{model}:generateContent/v1beta/models/{model}:streamGenerateContent?alt=sseAccepts the Gemini API text-to-speech request (models.generateContent with responseModalities: ["AUDIO"]) and returns the same response: one candidate whose part is inlineData holding base64 audio. The path is exactly Gemini's, so an app (or the Google Gen AI SDK) that already calls Gemini text-to-speech switches by changing only the base URL and the key (see Migrating from Gemini TTS).
Authentication: X-Goog-Api-Key: <key> (what the Gemini SDKs send), Authorization: Bearer <key> or X-API-Key: <key>. A ?key= query parameter is refused with 401 api_key_in_query.
{model} can be any name (for example gemini-2.5-flash-preview-tts); it does not change the audio and is echoed back as modelVersion.
Request body (JSON)
| Field | Type | Required | Notes |
|---|---|---|---|
contents | array, object or string | yes | The text of the last user content (role "user" or no role) is spoken: its text parts joined with line breaks, at most 8000 characters. Earlier turns, model turns and non-text parts are ignored. A plain string is accepted too. |
generationConfig. | array | no | Must include "AUDIO" when sent (["TEXT"] alone is 400 unsupported_format). |
generationConfig. | string | no | At most 100 characters. A Gemini voice name (see the table below, case-insensitive) or a TTSWave voice id such as uk-female-emma. Anything else, or no voice: your default voice. |
generationConfig. | object | no | Accepted, with one voice: the whole text is read by the first speaker's voice, and Name: labels of the configured speakers at the start of lines are removed so they are not read aloud. |
generationConfig., other generationConfig fields, systemInstruction, safetySettings, tools | no | Accepted and ignored. Style instructions inside the text (such as "Say cheerfully:") are read as text. |
Field names may be camelCase (as in the REST API) or snake_case (generation_config, voice_name, ...). Unknown fields are ignored.
Voices
Each Gemini voice maps to a TTSWave voice of the same gender and a similar character. If your key may not use the mapped voice (or it is disabled), your key's default voice is used when it has the same gender, otherwise the first voice of that gender your key may use. A TTSWave voice id is used exactly, with the usual access rules (403 voice_not_allowed, 400 voice_unavailable).
| Gemini voices | TTSWave voice |
|---|---|
| Kore | us-female-kore |
| Aoede | us-female-aoede |
| Zephyr, Autonoe, Sulafat | us-female-heart |
| Leda, Laomedeia | us-female-bella |
| Callirrhoe, Pulcherrima | us-female-sarah |
| Achernar, Vindemiatrix | us-female-nicole |
| Despina, Erinome | uk-female-emma |
| Gacrux | uk-female-isabella |
| Puck, Achird, Zubenelgenubi, Sadachbia | us-male-puck |
| Fenrir, Orus, Alnilam | us-male-fenrir |
| Charon, Enceladus, Umbriel, Schedar | us-male-michael |
| Iapetus, Rasalgethi, Sadaltager | uk-male-george |
| Algieba | uk-male-fable |
| Algenib | uk-male-lewis |
Response 200
{
"candidates": [{
"content": {
"role": "model",
"parts": [{"inlineData": {"mimeType": "audio/L16;codec=pcm;rate=24000", "data": "AAAEAAgA..."}}]
},
"finishReason": "STOP",
"index": 0
}],
"usageMetadata": {"promptTokenCount": 9, "candidatesTokenCount": 60, "totalTokenCount": 69,
"promptTokensDetails": [{"modality": "TEXT", "tokenCount": 9}],
"candidatesTokensDetails": [{"modality": "AUDIO", "tokenCount": 60}]},
"modelVersion": "gemini-2.5-flash-preview-tts",
"responseId": "5d0c1f7e9a2b4c6d8e0f1a"
}data is raw PCM (no WAV header): 16-bit signed little-endian, mono, 24 000 Hz, exactly like Gemini's output, so existing code that wraps it in a WAV header or plays it as PCM keeps working. Token counts are estimates (about 4 characters per text token, 25 audio tokens per second); your usage is counted in characters, like every other endpoint.
curl -sS "https://api.ttswave.stream/v1beta/models/gemini-2.5-flash-preview-tts:generateContent" \
-H "X-Goog-Api-Key: $TTSWAVE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"contents": [{"parts": [{"text": "Have a wonderful day!"}]}],
"generationConfig": {"responseModalities": ["AUDIO"],
"speechConfig": {"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": "Kore"}}}}}' \
| jq -r '.candidates[0].content.parts[0].inlineData.data' | base64 -d > out.pcm
ffmpeg -f s16le -ar 24000 -ac 1 -i out.pcm out.wav
import { writeFile } from "node:fs/promises";
const res = await fetch(
"https://api.ttswave.stream/v1beta/models/gemini-2.5-flash-preview-tts:generateContent",
{
method: "POST",
headers: { "X-Goog-Api-Key": process.env.TTSWAVE_API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{ parts: [{ text: "Have a wonderful day!" }] }],
generationConfig: {
responseModalities: ["AUDIO"],
speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: "Kore" } } },
},
}),
}
);
if (!res.ok) {
const { error } = await res.json();
throw new Error(`${error.code}: ${error.message}`);
}
const data = await res.json();
const pcm = Buffer.from(data.candidates[0].content.parts[0].inlineData.data, "base64");
// Wrap the raw PCM in a 44-byte WAV header (24 kHz, mono, 16-bit).
const header = Buffer.alloc(44);
header.write("RIFF", 0); header.writeUInt32LE(36 + pcm.length, 4); header.write("WAVE", 8);
header.write("fmt ", 12); header.writeUInt32LE(16, 16); header.writeUInt16LE(1, 20);
header.writeUInt16LE(1, 22); header.writeUInt32LE(24000, 24); header.writeUInt32LE(48000, 28);
header.writeUInt16LE(2, 32); header.writeUInt16LE(16, 34);
header.write("data", 36); header.writeUInt32LE(pcm.length, 40);
await writeFile("out.wav", Buffer.concat([header, pcm]));
import base64
import os
import wave
import requests
r = requests.post(
"https://api.ttswave.stream/v1beta/models/gemini-2.5-flash-preview-tts:generateContent",
headers={"X-Goog-Api-Key": os.environ["TTSWAVE_API_KEY"]},
json={
"contents": [{"parts": [{"text": "Have a wonderful day!"}]}],
"generationConfig": {
"responseModalities": ["AUDIO"],
"speechConfig": {"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": "Kore"}}},
},
},
timeout=60,
)
r.raise_for_status()
pcm = base64.b64decode(r.json()["candidates"][0]["content"]["parts"][0]["inlineData"]["data"])
with wave.open("out.wav", "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(24000)
wf.writeframes(pcm)
<?php
$ch = curl_init("https://api.ttswave.stream/v1beta/models/gemini-2.5-flash-preview-tts:generateContent");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"X-Goog-Api-Key: " . getenv("TTSWAVE_API_KEY"),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode([
"contents" => [["parts" => [["text" => "Have a wonderful day!"]]]],
"generationConfig" => [
"responseModalities" => ["AUDIO"],
"speechConfig" => ["voiceConfig" => ["prebuiltVoiceConfig" => ["voiceName" => "Kore"]]],
],
]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
]);
$data = json_decode(curl_exec($ch), true);
if (curl_getinfo($ch, CURLINFO_RESPONSE_CODE) !== 200) {
throw new RuntimeException($data["error"]["code"] ?? curl_error($ch));
}
$pcm = base64_decode($data["candidates"][0]["content"]["parts"][0]["inlineData"]["data"]);
// Wrap the raw PCM in a 44-byte WAV header (24 kHz, mono, 16-bit).
$header = pack("A4VA4A4VvvVVvvA4V", "RIFF", 36 + strlen($pcm), "WAVE", "fmt ",
16, 1, 1, 24000, 48000, 2, 16, "data", strlen($pcm));
file_put_contents("out.wav", $header . $pcm);
package main
import (
"encoding/binary"
"encoding/json"
"io"
"log"
"net/http"
"os"
"strings"
"time"
)
func main() {
body := strings.NewReader(`{
"contents": [{"parts": [{"text": "Have a wonderful day!"}]}],
"generationConfig": {"responseModalities": ["AUDIO"],
"speechConfig": {"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": "Kore"}}}}}`)
req, _ := http.NewRequest("POST",
"https://api.ttswave.stream/v1beta/models/gemini-2.5-flash-preview-tts:generateContent", body)
req.Header.Set("X-Goog-Api-Key", os.Getenv("TTSWAVE_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := (&http.Client{Timeout: 60 * time.Second}).Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(res.Body)
log.Fatalf("%s: %s", res.Status, msg)
}
var out struct {
Candidates []struct {
Content struct {
Parts []struct {
InlineData struct {
Data []byte `json:"data"` // base64 PCM, decoded automatically
} `json:"inlineData"`
} `json:"parts"`
} `json:"content"`
} `json:"candidates"`
}
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
log.Fatal(err)
}
pcm := out.Candidates[0].Content.Parts[0].InlineData.Data
// Wrap the raw PCM in a 44-byte WAV header (24 kHz, mono, 16-bit).
h := make([]byte, 44)
copy(h[0:], "RIFF")
binary.LittleEndian.PutUint32(h[4:], uint32(36+len(pcm)))
copy(h[8:], "WAVEfmt ")
binary.LittleEndian.PutUint32(h[16:], 16)
binary.LittleEndian.PutUint16(h[20:], 1)
binary.LittleEndian.PutUint16(h[22:], 1)
binary.LittleEndian.PutUint32(h[24:], 24000)
binary.LittleEndian.PutUint32(h[28:], 48000)
binary.LittleEndian.PutUint16(h[32:], 2)
binary.LittleEndian.PutUint16(h[34:], 16)
copy(h[36:], "data")
binary.LittleEndian.PutUint32(h[40:], uint32(len(pcm)))
if err := os.WriteFile("out.wav", append(h, pcm...), 0o644); err != nil {
log.Fatal(err)
}
}
// Java 11+. Save as Gemini.java, run: java Gemini.java
import java.io.FileOutputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.time.Duration;
import java.util.Base64;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class Gemini {
public static void main(String[] args) throws Exception {
String json = "{\"contents\": [{\"parts\": [{\"text\": \"Have a wonderful day!\"}]}],"
+ " \"generationConfig\": {\"responseModalities\": [\"AUDIO\"],"
+ " \"speechConfig\": {\"voiceConfig\": {\"prebuiltVoiceConfig\": {\"voiceName\": \"Kore\"}}}}}";
HttpRequest request = HttpRequest.newBuilder(URI.create(
"https://api.ttswave.stream/v1beta/models/gemini-2.5-flash-preview-tts:generateContent"))
.header("X-Goog-Api-Key", System.getenv("TTSWAVE_API_KEY"))
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(60))
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> res = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) throw new RuntimeException(res.statusCode() + " " + res.body());
// candidates[0].content.parts[0].inlineData.data: the only "data" field in the response
Matcher m = Pattern.compile("\"data\"\\s*:\\s*\"([^\"]+)\"").matcher(res.body());
if (!m.find()) throw new IllegalStateException("no audio in response");
byte[] pcm = Base64.getDecoder().decode(m.group(1));
// Wrap the raw PCM in a 44-byte WAV header (24 kHz, mono, 16-bit).
ByteBuffer h = ByteBuffer.allocate(44).order(ByteOrder.LITTLE_ENDIAN);
h.put("RIFF".getBytes()).putInt(36 + pcm.length).put("WAVEfmt ".getBytes())
.putInt(16).putShort((short) 1).putShort((short) 1).putInt(24000).putInt(48000)
.putShort((short) 2).putShort((short) 16).put("data".getBytes()).putInt(pcm.length);
try (FileOutputStream out = new FileOutputStream("out.wav")) {
out.write(h.array());
out.write(pcm);
}
}
}
using System.Net.Http.Json;
using System.Text.Json;
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(60) };
http.DefaultRequestHeaders.Add("X-Goog-Api-Key", Environment.GetEnvironmentVariable("TTSWAVE_API_KEY"));
using var res = await http.PostAsJsonAsync(
"https://api.ttswave.stream/v1beta/models/gemini-2.5-flash-preview-tts:generateContent",
new
{
contents = new[] { new { parts = new[] { new { text = "Have a wonderful day!" } } } },
generationConfig = new
{
responseModalities = new[] { "AUDIO" },
speechConfig = new { voiceConfig = new { prebuiltVoiceConfig = new { voiceName = "Kore" } } },
},
});
var json = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!res.IsSuccessStatusCode)
throw new Exception($"{json.GetProperty("error").GetProperty("code")}: {json.GetProperty("error").GetProperty("message")}");
byte[] pcm = json.GetProperty("candidates")[0].GetProperty("content").GetProperty("parts")[0]
.GetProperty("inlineData").GetProperty("data").GetBytesFromBase64();
// Wrap the raw PCM in a 44-byte WAV header (24 kHz, mono, 16-bit).
using var wav = new BinaryWriter(File.Create("out.wav"));
wav.Write("RIFF"u8); wav.Write(36 + pcm.Length); wav.Write("WAVEfmt "u8);
wav.Write(16); wav.Write((short)1); wav.Write((short)1); wav.Write(24000); wav.Write(48000);
wav.Write((short)2); wav.Write((short)16); wav.Write("data"u8); wav.Write(pcm.Length);
wav.Write(pcm);
Streaming
:streamGenerateContent takes the same body and returns one response object per sentence, as soon as that sentence is synthesized, so playback can start before the whole text is done. Each chunk carries the next piece of PCM in inlineData; only the last chunk has "finishReason": "STOP", and usageMetadata in each chunk counts the audio so far. Concatenate the data of every chunk to get the same audio as :generateContent.
?alt=sse(what the SDKs use): Server-Sent Events,Content-Type: text/event-stream, onedata: {json}event per chunk.- No
alt(oralt=json): one JSON array, written element by element as sentences are ready. Any otheraltis400 unsupported_format.
If synthesis fails after streaming started, the last event (or array element) is {"error": {"code", "message", "status"}}. Closing the connection stops the live synthesis; the rest of the text stays queued in the background (prepare ahead), so a retry finds it ready.
curl -sSN "https://api.ttswave.stream/v1beta/models/gemini-2.5-flash-preview-tts:streamGenerateContent?alt=sse" \
-H "X-Goog-Api-Key: $TTSWAVE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"contents": [{"parts": [{"text": "This is the first sentence of the streamed reply. And here is a second sentence that follows it."}]}],
"generationConfig": {"responseModalities": ["AUDIO"],
"speechConfig": {"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": "Puck"}}}}}'
const res = await fetch(
"https://api.ttswave.stream/v1beta/models/gemini-2.5-flash-preview-tts:streamGenerateContent?alt=sse",
{
method: "POST",
headers: { "X-Goog-Api-Key": process.env.TTSWAVE_API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{ parts: [{ text: "This is the first sentence of the streamed reply. And here is a second sentence that follows it." }] }],
generationConfig: {
responseModalities: ["AUDIO"],
speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: "Puck" } } },
},
}),
}
);
if (!res.ok) throw new Error((await res.json()).error.code);
const decoder = new TextDecoder();
let buffer = "";
for await (const bytes of res.body) {
buffer += decoder.decode(bytes, { stream: true }).replace(/\r/g, "");
let end;
while ((end = buffer.indexOf("\n\n")) !== -1) { // events end with a blank line
const event = buffer.slice(0, end);
buffer = buffer.slice(end + 2);
if (!event.startsWith("data: ")) continue;
const chunk = JSON.parse(event.slice(6));
if (chunk.error) throw new Error(chunk.error.code);
for (const part of chunk.candidates[0].content.parts) {
const pcm = Buffer.from(part.inlineData.data, "base64"); // play or forward this piece
}
}
}
import base64
import json
import os
import requests
with requests.post(
"https://api.ttswave.stream/v1beta/models/gemini-2.5-flash-preview-tts:streamGenerateContent",
params={"alt": "sse"},
headers={"X-Goog-Api-Key": os.environ["TTSWAVE_API_KEY"]},
json={
"contents": [{"parts": [{"text": "This is the first sentence of the streamed reply. And here is a second sentence that follows it."}]}],
"generationConfig": {
"responseModalities": ["AUDIO"],
"speechConfig": {"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": "Puck"}}},
},
},
stream=True,
timeout=60,
) as r:
r.raise_for_status()
for line in r.iter_lines(decode_unicode=True):
if not line or not line.startswith("data: "):
continue
chunk = json.loads(line[6:])
if "error" in chunk:
raise RuntimeError(chunk["error"]["code"])
for part in chunk["candidates"][0]["content"]["parts"]:
pcm = base64.b64decode(part["inlineData"]["data"]) # play or forward this piece
<?php
$buffer = ""; // bytes received but not yet split into lines
$failure = null; // error event, if synthesis fails mid-stream
$ch = curl_init("https://api.ttswave.stream/v1beta/models/gemini-2.5-flash-preview-tts:streamGenerateContent?alt=sse");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"X-Goog-Api-Key: " . getenv("TTSWAVE_API_KEY"),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode([
"contents" => [["parts" => [["text" => "This is the first sentence of the streamed reply. And here is a second sentence that follows it."]]]],
"generationConfig" => [
"responseModalities" => ["AUDIO"],
"speechConfig" => ["voiceConfig" => ["prebuiltVoiceConfig" => ["voiceName" => "Puck"]]],
],
]),
CURLOPT_TIMEOUT => 60,
// Called for every chunk as it arrives: handle each complete "data: {json}" line.
CURLOPT_WRITEFUNCTION => function ($ch, string $bytes) use (&$buffer, &$failure) {
$buffer .= $bytes;
if (curl_getinfo($ch, CURLINFO_RESPONSE_CODE) !== 200) {
return strlen($bytes); // an error envelope, handled below
}
while (($nl = strpos($buffer, "\n")) !== false) {
$line = rtrim(substr($buffer, 0, $nl), "\r");
$buffer = substr($buffer, $nl + 1);
if (!str_starts_with($line, "data: ")) {
continue;
}
$chunk = json_decode(substr($line, 6), true);
if (isset($chunk["error"])) {
$failure = $chunk["error"]["code"];
return 0; // stop the transfer
}
foreach ($chunk["candidates"][0]["content"]["parts"] as $part) {
$pcm = base64_decode($part["inlineData"]["data"]); // play or forward this piece
echo strlen($pcm), " bytes of audio\n";
}
}
return strlen($bytes);
},
]);
$ok = curl_exec($ch);
if ($failure !== null) {
throw new RuntimeException($failure);
}
if ($ok === false) {
throw new RuntimeException(curl_error($ch));
}
if (curl_getinfo($ch, CURLINFO_RESPONSE_CODE) !== 200) {
throw new RuntimeException(json_decode($buffer, true)["error"]["code"]);
}
package main
import (
"bufio"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
)
type Chunk struct {
Candidates []struct {
Content struct {
Parts []struct {
InlineData struct {
Data []byte `json:"data"` // base64 PCM, decoded automatically
} `json:"inlineData"`
} `json:"parts"`
} `json:"content"`
} `json:"candidates"`
Error *struct {
Code string `json:"code"`
} `json:"error"`
}
func main() {
body := strings.NewReader(`{
"contents": [{"parts": [{"text": "This is the first sentence of the streamed reply. And here is a second sentence that follows it."}]}],
"generationConfig": {"responseModalities": ["AUDIO"],
"speechConfig": {"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": "Puck"}}}}}`)
req, _ := http.NewRequest("POST",
"https://api.ttswave.stream/v1beta/models/gemini-2.5-flash-preview-tts:streamGenerateContent?alt=sse", body)
req.Header.Set("X-Goog-Api-Key", os.Getenv("TTSWAVE_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(res.Body)
log.Fatalf("%s: %s", res.Status, msg)
}
reader := bufio.NewReader(res.Body) // no line-length limit, unlike bufio.Scanner
for {
line, err := reader.ReadString('\n')
if data, ok := strings.CutPrefix(strings.TrimRight(line, "\r\n"), "data: "); ok {
var chunk Chunk
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
log.Fatal(err)
}
if chunk.Error != nil {
log.Fatal(chunk.Error.Code)
}
for _, part := range chunk.Candidates[0].Content.Parts {
pcm := part.InlineData.Data // play or forward this piece
fmt.Println(len(pcm), "bytes of audio")
}
}
if err == io.EOF {
break
} else if err != nil {
log.Fatal(err)
}
}
}
// Java 11+. Save as GeminiStream.java, run: java GeminiStream.java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Base64;
import java.util.Iterator;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class GeminiStream {
public static void main(String[] args) throws Exception {
String json = "{\"contents\": [{\"parts\": [{\"text\": \"This is the first sentence of the streamed reply. And here is a second sentence that follows it.\"}]}],"
+ " \"generationConfig\": {\"responseModalities\": [\"AUDIO\"],"
+ " \"speechConfig\": {\"voiceConfig\": {\"prebuiltVoiceConfig\": {\"voiceName\": \"Puck\"}}}}}";
HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.ttswave.stream/v1beta/models/"
+ "gemini-2.5-flash-preview-tts:streamGenerateContent?alt=sse"))
.header("X-Goog-Api-Key", System.getenv("TTSWAVE_API_KEY"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<Stream<String>> res =
HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofLines());
if (res.statusCode() != 200) {
throw new RuntimeException(res.statusCode() + " " + res.body().collect(Collectors.joining()));
}
Pattern data = Pattern.compile("\"data\"\\s*:\\s*\"([^\"]+)\"");
for (Iterator<String> lines = res.body().iterator(); lines.hasNext(); ) {
String line = lines.next();
if (!line.startsWith("data: ")) continue; // one "data: {json}" line per chunk
if (line.startsWith("data: {\"error\"")) throw new RuntimeException(line.substring(6));
Matcher m = data.matcher(line);
while (m.find()) {
byte[] pcm = Base64.getDecoder().decode(m.group(1)); // play or forward this piece
System.out.println(pcm.length + " bytes of audio");
}
}
}
}
using System.Net.Http.Json;
using System.Text.Json;
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("X-Goog-Api-Key", Environment.GetEnvironmentVariable("TTSWAVE_API_KEY"));
using var request = new HttpRequestMessage(HttpMethod.Post,
"https://api.ttswave.stream/v1beta/models/gemini-2.5-flash-preview-tts:streamGenerateContent?alt=sse")
{
Content = JsonContent.Create(new
{
contents = new[] { new { parts = new[] { new { text = "This is the first sentence of the streamed reply. And here is a second sentence that follows it." } } } },
generationConfig = new
{
responseModalities = new[] { "AUDIO" },
speechConfig = new { voiceConfig = new { prebuiltVoiceConfig = new { voiceName = "Puck" } } },
},
}),
};
using var res = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
if (!res.IsSuccessStatusCode)
throw new Exception($"{(int)res.StatusCode}: {await res.Content.ReadAsStringAsync()}");
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
while (await reader.ReadLineAsync() is { } line)
{
if (!line.StartsWith("data: ")) continue; // one "data: {json}" line per chunk
using var doc = JsonDocument.Parse(line[6..]);
var chunk = doc.RootElement;
if (chunk.TryGetProperty("error", out var error))
throw new Exception(error.GetProperty("code").GetString());
foreach (var part in chunk.GetProperty("candidates")[0].GetProperty("content").GetProperty("parts").EnumerateArray())
{
byte[] pcm = part.GetProperty("inlineData").GetProperty("data").GetBytesFromBase64(); // play or forward this piece
Console.WriteLine($"{pcm.Length} bytes of audio");
}
}
Errors
Errors use the TTSWave envelope, not Google's: error.code is a string such as "voice_not_allowed" and error.status the HTTP status number (Google puts a number in code and a word such as "INVALID_ARGUMENT" in status). The SDKs still raise their usual API error for any non-2xx response.
401 api_key_in_query: the key was sent as?key=.415 unsupported_media_type: noContent-Type: application/json.400 unsupported_format:responseModalitieswithout"AUDIO", or an unsupportedalt.400 empty_text: no text in the last user content, or nothing speakable.413 text_too_long: over 8000 characters.422 validation_error: malformed body (for example nocontents).400 voice_unavailable,403 voice_not_allowed: a TTSWave voice id your key may not use.409 voice_not_ready: a pre-generated voice id whose text is not generated yet (see Voices).
Speech-to-text
/v1/audio/transcriptionsTranscribes an audio file. The request and response follow OpenAI's POST /v1/audio/transcriptions, so the OpenAI SDKs work. Speech-to-text is optional: if it is not enabled on the server, this endpoint answers 503 stt_disabled. You can check stt.enabled in GET /v1/info.
Request body (multipart/form-data)
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
file | file | yes | Audio in a common format (wav, mp3, m4a, ogg/opus, webm, flac, ...). Up to 25 MB unless the server sets another limit. | |
response_format | string | no | json | json, verbose_json, text, srt or vtt. |
language | string | no | auto-detect | ISO-639-1 code such as en. |
prompt | string | no | Context or vocabulary hint (names, product terms). | |
timestamp_granularities[] | string | no | word adds per-word timings. Use with verbose_json. | |
model | string | no | Accepted for compatibility and ignored (the server's model is used). |
Response 200
response_format | Content-Type | Body |
|---|---|---|
json | application/json | {"text": "..."} |
verbose_json | application/json | text, language, duration (seconds), processingMs, segments ({start, end, text} in seconds). With timestamp_granularities[]=word: each segment gets words, and a top-level words array lists every {start, end, word, probability}. |
text | text/plain | The transcript |
srt | application/ | SubRip subtitles, one cue per segment |
vtt | text/vtt | WebVTT subtitles, one cue per segment |
curl https://api.ttswave.stream/v1/audio/transcriptions \
-H "Authorization: Bearer $TTSWAVE_API_KEY" \
-F file=@question.webm \
-F response_format=verbose_json \
-F "timestamp_granularities[]=word"
import { readFile } from "node:fs/promises";
const form = new FormData();
form.append("file", new Blob([await readFile("question.webm")]), "question.webm");
form.append("response_format", "json");
const res = await fetch("https://api.ttswave.stream/v1/audio/transcriptions", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.TTSWAVE_API_KEY}` }, // fetch sets the multipart Content-Type
body: form,
});
if (!res.ok) {
const { error } = await res.json();
throw new Error(`${error.code}: ${error.message}`);
}
console.log((await res.json()).text);
import os
import requests
with open("question.webm", "rb") as f:
r = requests.post(
"https://api.ttswave.stream/v1/audio/transcriptions",
headers={"Authorization": f"Bearer {os.environ['TTSWAVE_API_KEY']}"},
files={"file": ("question.webm", f)},
data={"response_format": "verbose_json", "timestamp_granularities[]": "word"},
timeout=120,
)
r.raise_for_status()
result = r.json()
print(result["text"])
for w in result.get("words", []):
print(f"{w['start']:6.2f}-{w['end']:6.2f} {w['word']}")
<?php
$ch = curl_init("https://api.ttswave.stream/v1/audio/transcriptions");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("TTSWAVE_API_KEY")],
// An array body makes curl send multipart/form-data.
CURLOPT_POSTFIELDS => [
"file" => new CURLFile("question.webm"),
"response_format" => "verbose_json",
"timestamp_granularities[]" => "word",
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 120,
]);
$result = json_decode(curl_exec($ch), true);
if (curl_getinfo($ch, CURLINFO_RESPONSE_CODE) !== 200) {
throw new RuntimeException($result["error"]["code"] ?? curl_error($ch));
}
echo $result["text"], "\n";
foreach ($result["words"] ?? [] as $w) {
printf("%6.2f-%6.2f %s\n", $w["start"], $w["end"], $w["word"]);
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"mime/multipart"
"net/http"
"os"
"time"
)
func main() {
// Build the multipart/form-data body.
var body bytes.Buffer
form := multipart.NewWriter(&body)
audio, err := os.Open("question.webm")
if err != nil {
log.Fatal(err)
}
part, _ := form.CreateFormFile("file", "question.webm")
io.Copy(part, audio)
audio.Close()
form.WriteField("response_format", "verbose_json")
form.WriteField("timestamp_granularities[]", "word")
form.Close()
req, _ := http.NewRequest("POST", "https://api.ttswave.stream/v1/audio/transcriptions", &body)
req.Header.Set("Authorization", "Bearer "+os.Getenv("TTSWAVE_API_KEY"))
req.Header.Set("Content-Type", form.FormDataContentType())
res, err := (&http.Client{Timeout: 120 * time.Second}).Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(res.Body)
log.Fatalf("%s: %s", res.Status, msg)
}
var result struct {
Text string `json:"text"`
Words []struct {
Start float64 `json:"start"`
End float64 `json:"end"`
Word string `json:"word"`
} `json:"words"`
}
if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
log.Fatal(err)
}
fmt.Println(result.Text)
for _, w := range result.Words {
fmt.Printf("%6.2f-%6.2f %s\n", w.Start, w.End, w.Word)
}
}
// Java 11+. Save as Transcribe.java, run: java Transcribe.java
import java.io.ByteArrayOutputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.UUID;
public class Transcribe {
public static void main(String[] args) throws Exception {
// Build the multipart/form-data body by hand (HttpClient has no form helper).
String boundary = "----ttswave" + UUID.randomUUID();
ByteArrayOutputStream body = new ByteArrayOutputStream();
body.write(("--" + boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"response_format\"\r\n\r\n"
+ "text\r\n" // plain-text transcript; use verbose_json for timings
+ "--" + boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"file\"; filename=\"question.webm\"\r\n"
+ "Content-Type: application/octet-stream\r\n\r\n").getBytes(StandardCharsets.UTF_8));
body.write(Files.readAllBytes(Path.of("question.webm")));
body.write(("\r\n--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8));
HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.ttswave.stream/v1/audio/transcriptions"))
.header("Authorization", "Bearer " + System.getenv("TTSWAVE_API_KEY"))
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
.timeout(Duration.ofSeconds(120))
.POST(HttpRequest.BodyPublishers.ofByteArray(body.toByteArray()))
.build();
HttpResponse<String> res = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) throw new RuntimeException(res.statusCode() + " " + res.body());
System.out.println(res.body().trim());
}
}
using System.Text.Json;
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(120) };
http.DefaultRequestHeaders.Add("Authorization", "Bearer " + Environment.GetEnvironmentVariable("TTSWAVE_API_KEY"));
using var form = new MultipartFormDataContent
{
{ new ByteArrayContent(await File.ReadAllBytesAsync("question.webm")), "file", "question.webm" },
{ new StringContent("verbose_json"), "response_format" },
{ new StringContent("word"), "timestamp_granularities[]" },
};
using var res = await http.PostAsync("https://api.ttswave.stream/v1/audio/transcriptions", form);
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
if (!res.IsSuccessStatusCode)
throw new Exception($"{doc.RootElement.GetProperty("error").GetProperty("code")}");
Console.WriteLine(doc.RootElement.GetProperty("text"));
if (doc.RootElement.TryGetProperty("words", out var words))
{
foreach (var w in words.EnumerateArray())
Console.WriteLine($"{w.GetProperty("start").GetDouble(),6:F2}-{w.GetProperty("end").GetDouble(),6:F2} {w.GetProperty("word")}");
}
{
"text": "What time does the store open tomorrow?",
"language": "en",
"duration": 2.61,
"segments": [
{"start": 0.0, "end": 2.4, "text": "What time does the store open tomorrow?",
"words": [{"start": 0.0, "end": 0.18, "word": "What", "probability": 0.98}]}
],
"processingMs": 540,
"words": [{"start": 0.0, "end": 0.18, "word": "What", "probability": 0.98}]
}Errors
503 stt_disabled: speech-to-text is not enabled on this server.400 unsupported_format: unknownresponse_format, or audio that cannot be decoded.413 payload_too_large: the upload is over the size limit.422 validation_error: missing or emptyfile.429 rate_limited: transcriptions have their own per-minute limit in addition to the request limit.503 engine_loading: the speech-to-text model is not available yet.
Service info
/v1/infoOptional. Service and model details; a cheap way to check that your key works. Fields: service, version, provider, model, sampleRate (24000), defaultVoice (the server-wide default; your key's own default is default in GET /v1/voices), maxConcurrent, stt (enabled, model, loaded, device, compute_type) and engines (availability of each synthesis engine; null while the server is starting).
curl https://api.ttswave.stream/v1/info -H "Authorization: Bearer $TTSWAVE_API_KEY"{
"service": "TTSWave",
"version": "1.4.0",
"provider": "local-kokoro",
"model": "kokoro-v1.0.onnx",
"sampleRate": 24000,
"defaultVoice": "us-female-heart",
"maxConcurrent": 1,
"stt": {"enabled": true, "model": "base.en", "device": "cpu", "compute_type": "int8", "loaded": true},
"engines": {
"kokoro": {"available": true, "realtime": true},
"yarngpt": {"available": false, "configured": false, "queue": 0, "realtime": false, "status": "disabled"}
}
}Health check
/healthzPublic liveness check: no key needed, no rate limit, returns only {"status": "ok"}. Use it for uptime monitors and to decide quickly whether to use a fallback.
curl https://api.ttswave.stream/healthz
# {"status":"ok"}Voices
Send a voice id as voice. Which voices exist and are available depends on the server, and the administrator may limit your key to some of them: GET /v1/voices is the authoritative list for your key. Prefer voice ids over hard-coding labels.
Realtime voices
realtime: true. Synthesized live and streamed as it is generated, any speed from 0.5 to 2.0. On the shared CPU server, first audio for new text takes a few seconds and generation can run at or below real-time speed; cached or prefetched text plays instantly, so prefetch text you will speak.
Pre-generated voices
realtime: false. Far slower than real time, so the text must be generated in advance. Speed is fixed at 1.0.
Pre-generated voices
If a server offers pre-generated voices (for example regional accents or other languages), use this flow:
- As soon as the text is final, call
POST /v1/tts/prefetchwith every text, the voice, andspeed1.0. - Poll
POST /v1/tts/statuswith the same body every few seconds (or from a background job) untilcompleteistrue. - Play it with
/v1/tts/streamor/v1/text:synthesizeusing exactly the same text. It starts instantly from the cache.
If you stream a pre-generated voice before its text is ready, /v1/tts/stream and /v1/text:synthesize answer 409 voice_not_ready with a Retry-After: 30 header. The missing sentences are queued automatically by that request, and the error object reports progress:
{"error": {"code": "voice_not_ready", "status": 409, "ready": 3, "total": 12,
"message": "Voice ... is not realtime and 9 of 12 sentences are not generated yet; they have been queued. Poll POST /v1/tts/status and retry when complete"}}A failed count is added when some sentences could not be generated. Meanwhile, play the text with a realtime voice and switch once status reports complete. (/v1/audio/speech never answers 409; it waits for generation instead.)
Realtime English voices
These realtime English voices are defined on TTSWave. Your key may see a subset, and a server may also offer voices in other languages.
| Voice id | Label | Accent | Gender |
|---|---|---|---|
us-female-heart | Heart (warm, default) | American | female |
us-female-bella | Bella (bright) | American | female |
us-female-nicole | Nicole (soft, close-mic) | American | female |
us-female-sarah | Sarah | American | female |
us-female-aoede | Aoede | American | female |
us-female-kore | Kore | American | female |
us-female-blend | Heart x Bella (blend) | American | female |
us-male-michael | Michael (calm) | American | male |
us-male-fenrir | Fenrir (deep) | American | male |
us-male-puck | Puck (lively) | American | male |
us-male-blend | Michael x Puck (blend) | American | male |
uk-female-emma | Emma | British | female |
uk-female-isabella | Isabella | British | female |
uk-male-george | George | British | male |
uk-male-fable | Fable | British | male |
uk-male-lewis | Lewis | British | male |
us-female-nova | Nova (clear) | American | female |
us-female-alloy | Alloy (neutral) | American | female |
us-female-sky | Sky (airy) | American | female |
us-male-orion | Orion (deep narrator) | American | male |
us-male-atlas | Atlas (energetic) | American | male |
us-female-luna | Luna (soft) | American | female |
us-female-aria | Aria (bright) | American | female |
uk-male-oliver | Oliver (British narrator) | British | male |
uk-female-sophia | Sophia (warm) | British | female |
mid-female-claire | Claire (mid-Atlantic) | Mid-Atlantic | female |
mid-male-james | James (mid-Atlantic) | Mid-Atlantic | male |
mid-male-arthur | Arthur (mid-Atlantic, deep) | Mid-Atlantic | male |
in-female-alpha | Alpha (Indian English), experimental | Indian | female |
in-male-omega | Omega (Indian English), experimental | Indian | male |
The Indian English voices are experimental; listen before offering them to users. If you omit voice, your key's default is used (the default field of GET /v1/voices).
Writing text for speech
The engine reads exactly what you send, so the best results come from text written to be heard:
| Avoid | Send instead |
|---|---|
$a^2 + b^2 = c^2$ (LaTeX) | a squared plus b squared equals c squared |
3/4, \frac{3}{4} | three quarters |
x ≥ 5, → | x is at least five, gives |
e.g., i.e., etc. | for example, that is, and so on |
| URLs, code, emoji, tables | a short spoken description, or leave them out |
- Spell out numbers, maths and symbols the way a person would say them. Never send LaTeX.
- Short sentences sound better and start faster: text is split into sentences (at
.!?and line breaks), and each sentence is synthesized and streamed in turn. Very short fragments are merged, very long sentences are cut at commas. Thestartframe of a stream lists the final pieces. - Markdown is stripped: heading markers, block quotes, list bullets, bold/italic markers and backticks are removed. Links, tables and images are not converted, so remove them yourself. Keep display text and spoken text as separate fields in your data.
- Length: at most 8000 characters per request (per text for prefetch and status), else
413 text_too_long. Split long content into paragraphs; this also improves cache reuse. - Reuse identical text. Audio is cached per sentence, voice and speed: the same sentence played again is served from the cache in milliseconds.
Playing multi-part content without gaps
Lessons, articles, playlists and scripts are usually played as a series of parts (paragraphs, slides, segments), one stream per part. Text that is already cached streams its first audio in milliseconds. Text that is not cached has to be synthesized first: on a small CPU server this takes about as long as the audio itself, so an uncached part takes a few seconds to start, and a long sentence can run past the audio already buffered, which the listener hears as a pause between sentences. To avoid both, make sure each part is cached before it is played.
- Prefetch every part as soon as the text is final. One
POST /v1/tts/prefetchcall with all the texts in playback order, with the voice and speed you will play with. The queue is processed in order, so the first parts are ready first.POST /v1/tts/statustells you when everything is cached (complete: true). - At the very least, prefetch part N+1 while part N plays (N+2 as well if parts are short). Prefetch runs whenever no live stream is being synthesized, so it uses the time in which part N plays from what is already buffered.
- Keep the audio timeline continuous. Schedule every audio frame at
max(playhead, ctx.currentTime)on oneAudioContextand keep the playhead across parts. For a seamless join, request part N+1 while part N is still playing (as soon as part N's stream has been received) and append its frames to the same playhead, instead of waiting for part N to end. - Prefetch with the exact strings you stream. Audio is cached per sentence, voice and speed; a changed word means a new synthesis.
Prefetch never slows down a listener: as soon as a stream needs the CPU, background work stops (and resumes afterwards), and a stream that needs a sentence the prefetch is already generating simply receives it when it is done.
Low latency tips
- Prefetch early. As soon as you know the text of the upcoming parts (when the lesson or article is planned, not when playback starts), send them all to
POST /v1/tts/prefetchin play order, with the voice and speed you will play with. They are made in the background, in order; later requests for them are instant cache hits or join the work already in progress. Prefetch always yields to live requests. - Play as it streams. Use
POST /v1/tts/stream(or:streamGenerateContent) and start playback on the firstaudioframe instead of waiting for the whole part. First audio typically arrives in about 1-3 s for new text, almost instantly for cached text. - Request parts in play order with the same
X-TTS-Sessionand their position inX-TTS-Order. Sending them in parallel is fine: they are finished by position, part 1 first. - Retries are cheap. Every request is prepared ahead: its later sentences are queued in the background the moment it arrives, so if a request times out or the listener pauses, asking again plays from the cache.
- Use an idle timeout, not a short total timeout. Give up when no bytes have arrived for several seconds, not after a fixed total time. A 60 s total timeout cuts off a long part, or a part queued behind earlier ones, while its audio is still arriving.
- Plan capacity. On a small shared server one uncached listener is served at about real time. More simultaneous listeners rely on prefetch and cached text.
Pause, resume and skip
- Keep reading the stream while the user pauses playback. Pause the audio with
ctx.suspend()and resume withctx.resume(), but keep reading frames and scheduling them; they are buffered on the suspended timeline and play on resume. Nothing is lost and nothing is synthesized twice. - Or close the stream and request it again. Abort the request (for example with an
AbortController) and, on resume, stream the part again. Sentences that were already sent are cached and replay instantly; the server stops the live work on the closed stream at once and keeps preparing the rest in the background while it is idle. Skipping works the same way: abort the current stream and start the next part. - Do not leave a stream open and unread for minutes: proxies can time it out. For long pauses, close it and re-request.
Minimized or background tabs
Audio already scheduled on an AudioContext keeps playing when the tab is minimized or in the background, and fetch streams keep delivering frames. Timers do not: browsers throttle setTimeout and setInterval in background tabs (to once a second or less often) and pause requestAnimationFrame. So:
- Drive audio scheduling from the stream reader and
ctx.currentTime, never from timers. Schedule each frame as soon as it arrives. - Start the next part from the reader too (when the buffered audio,
playhead - ctx.currentTime, drops below a couple of seconds), not from a timer set to fire when the current part ends. - Captions and highlights driven by timers may lag in the background; when the tab becomes visible again (
visibilitychange), work out the current sentence fromctx.currentTimeand eachmark's scheduled time. - Some browsers suspend an
AudioContextthey did not start from a user gesture; create or resume it in the click that starts playback and checkctx.state.
When something fails
429 rate_limited: wait forRetry-After. Prefetched characters count toward the per-minute character limit, so a large prefetch can make live streams in the same minute hit429; spread large prefetches out or use a separate key for them.409 voice_not_ready(pre-generated voices): the missing sentences were queued; play the part with a realtime voice now and switch when status reportscomplete.503 engine_loading: retry afterRetry-After; meanwhile fall back (another voice, or the browser's own speech).- An
errorframe, or no frame for several seconds, after audio has started: stop the stream and fall back for the rest of the part. The lastmarkyou received tells you which sentence was playing.
// Plays parts back to back on one AudioContext timeline, with no timers.
// Your backend relays POST /v1/tts/stream (/api/speech) and POST /v1/tts/prefetch (/api/prefetch).
const post = (url, body, signal) => fetch(url, { method: "POST", signal,
headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
async function playParts(parts, { voice } = {}) {
const ctx = new AudioContext(); // create it in the click handler
await ctx.resume();
post("/api/prefetch", { texts: parts, voice }); // best: everything, once, in order
let playhead = 0; // AudioContext time where the next frame starts
// Streams one part, schedules every frame on the shared playhead,
// and returns a promise that resolves when that part has finished playing.
async function streamPart(text) {
const res = await post("/api/speech", { text, voice });
if (!res.ok) throw new Error("speech " + res.status); // 409 / 429 / 503: fall back
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "", last = null;
for (;;) {
const { value, done } = await reader.read(); // keeps running in background tabs
if (done) break;
buf += decoder.decode(value, { stream: true });
let nl;
while ((nl = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line) continue;
const frame = JSON.parse(line);
if (frame.type === "error") throw new Error(frame.code);
if (frame.type !== "audio") continue;
const bin = atob(frame.data);
const view = new DataView(Uint8Array.from(bin, (c) => c.charCodeAt(0)).buffer);
const buffer = ctx.createBuffer(1, bin.length >> 1, frame.sampleRate);
const ch = buffer.getChannelData(0);
for (let i = 0; i < ch.length; i++) ch[i] = view.getInt16(i * 2, true) / 32768;
last = ctx.createBufferSource();
last.buffer = buffer;
last.connect(ctx.destination);
const when = Math.max(playhead, ctx.currentTime + 0.05);
last.start(when);
playhead = when + buffer.duration;
}
}
return last ? new Promise((r) => last.addEventListener("ended", r)) : Promise.resolve();
}
// Part n+1 is fetched while part n plays; never more than two parts are buffered.
let olderEnded = Promise.resolve(), prevEnded = Promise.resolve();
for (const text of parts) {
await olderEnded; // the part before the one playing now is over
const ended = await streamPart(text);
[olderEnded, prevEnded] = [prevEnded, ended];
}
await prevEnded;
}Everything is driven by the stream reader and audio events, so it keeps playing gap-free in a minimized tab. Pausing is ctx.suspend() / ctx.resume(); the reader keeps buffering meanwhile. To skip, abort the current request (pass an AbortController signal to post) and move on to the next part.
Using existing SDKs
OpenAI SDK
/v1/audio/speech and /v1/audio/transcriptions accept the requests the official OpenAI SDKs send. Point the SDK at https://api.ttswave.stream/v1 with your TTSWave key. model is ignored, voice must be a TTSWave voice id, and response_format should be set explicitly (TTSWave defaults to wav). Only the audio endpoints exist; chat, embeddings and other OpenAI APIs do not.
# pip install openai
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.ttswave.stream/v1", api_key=os.environ["TTSWAVE_API_KEY"])
# Text to speech
speech = client.audio.speech.create(
model="tts-1", # ignored
voice="us-female-heart", # a TTSWave voice id
input="Hello from TTSWave.",
response_format="mp3",
)
speech.write_to_file("hello.mp3")
# Speech to text (if enabled on the server)
with open("question.webm", "rb") as f:
result = client.audio.transcriptions.create(
model="whisper-1", # ignored
file=f,
response_format="verbose_json",
timestamp_granularities=["word"],
)
print(result.text)
// npm install openai
import fs from "node:fs";
import OpenAI from "openai";
const client = new OpenAI({ baseURL: "https://api.ttswave.stream/v1", apiKey: process.env.TTSWAVE_API_KEY });
// Text to speech
const speech = await client.audio.speech.create({
model: "tts-1", // ignored
voice: "us-female-heart", // TypeScript: add `as any`, the typings list OpenAI voice names only
input: "Hello from TTSWave.",
response_format: "mp3",
});
fs.writeFileSync("hello.mp3", Buffer.from(await speech.arrayBuffer()));
// Speech to text (if enabled on the server)
const transcript = await client.audio.transcriptions.create({
model: "whisper-1", // ignored
file: fs.createReadStream("question.webm"),
});
console.log(transcript.text);
The OpenAI SDKs retry 429 and 5xx responses automatically. Errors arrive as the SDK's API error with the TTSWave envelope as the body.
Google Cloud client libraries
Use plain REST (fetch, requests, curl) against /v1/text:synthesize. The official Google client libraries (@google-cloud/text-to-speech, google-cloud-texttospeech) are not supported: they talk gRPC and authenticate with Google credentials.
Google Gen AI SDK (Gemini)
The Google Gen AI SDKs (google-genai for Python, @google/genai for JavaScript) work against the Gemini-compatible endpoint: set the base URL to https://api.ttswave.stream and use your TTSWave key as the API key. The SDK keeps its default v1beta API version and sends the key as X-Goog-Api-Key. generate_content / generateContent and the streaming variants are supported; other SDK features (chat, files, caching, live) are not.
import os
import wave
from google import genai
from google.genai import types
client = genai.Client(
api_key=os.environ["TTSWAVE_API_KEY"],
http_options=types.HttpOptions(base_url="https://api.ttswave.stream"),
)
response = client.models.generate_content(
model="gemini-2.5-flash-preview-tts",
contents="Have a wonderful day!",
config=types.GenerateContentConfig(
response_modalities=["AUDIO"],
speech_config=types.SpeechConfig(
voice_config=types.VoiceConfig(
prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name="Kore"),
)
),
),
)
pcm = response.candidates[0].content.parts[0].inline_data.data # bytes: 24 kHz mono 16-bit PCM
with wave.open("out.wav", "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(24000)
wf.writeframes(pcm)
import { GoogleGenAI } from "@google/genai";
const ai = new GoogleGenAI({
apiKey: process.env.TTSWAVE_API_KEY,
httpOptions: { baseUrl: "https://api.ttswave.stream" },
});
const response = await ai.models.generateContent({
model: "gemini-2.5-flash-preview-tts",
contents: [{ parts: [{ text: "Have a wonderful day!" }] }],
config: {
responseModalities: ["AUDIO"],
speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: "Kore" } } },
},
});
const data = response.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data; // base64 PCM
const pcm = Buffer.from(data, "base64");
A dictionary works as well: http_options={"base_url": "https://api.ttswave.stream"}. Errors reach the SDK as its usual API error (for example google.genai.errors.ClientError), with the TTSWave envelope as the body.
Rate limits
Each key has limits, set by the TTSWave administrator, over a sliding 60-second window:
- Requests per minute: every authenticated request counts once.
- Characters per minute: the text you send to
/v1/audio/speech,/v1/tts/stream,/v1/text:synthesize, the Gemini-compatible endpoint and/v1/tts/prefetch./v1/tts/statuscosts no characters. - Transcriptions per minute:
/v1/audio/transcriptions, in addition to the request limit.
Responses carry headers describing the requests-per-minute budget (when a limit applies):
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed per minute |
X-RateLimit-Remaining | Requests left in the current window |
X-RateLimit-Reset | Seconds until capacity frees up |
When any limit is exceeded the API answers 429 rate_limited with a Retry-After header (seconds); the message says which limit was hit, and the X-RateLimit-* headers on that response describe that limit. Wait at least Retry-After seconds before retrying. A single request with more characters than your whole per-minute character budget can never fit, so it is answered 413 text_too_long (not 429, so SDKs do not retry it): send shorter texts. You can see your current usage by signing in at https://app.ttswave.stream/login. If you need higher limits, ask the administrator.
HTTP/1.1 429 Too Many Requests
Retry-After: 12
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 12
{"error": {"code": "rate_limited", "message": "Rate limit exceeded: 120 requests per minute for this key. Retry in 12 s", "status": 429}}Errors
Every error uses one JSON envelope, with the HTTP status repeated in status:
{
"error": {
"code": "voice_not_found",
"message": "Unknown voice(s): us-female-nobody",
"status": 400
}
}Branch on code, which is stable; message is for humans and may change. Some errors add fields inside error (voice_not_ready adds ready, total and sometimes failed). 401 responses also carry WWW-Authenticate: Bearer. Note that validation_error can come with status 400 or 422.
| Code | HTTP | Meaning and what to do |
|---|---|---|
missing_api_key | 401 | No Authorization or X-API-Key header (or X-Goog-Api-Key on / and the Gemini-compatible endpoint). Send your key. |
invalid_api_key | 401 | The key is not recognised: typo, revoked, or replaced by a regenerated key. Check it; ask the administrator. |
api_key_in_query | 401 | / or the Gemini-compatible endpoint called with ?key= in the URL. Send the key in a header. |
voice_not_allowed | 403 | The voice exists but your key may not use it, or no voice was given and none of your voices can be used. Pick one from your GET /v1/voices. |
forbidden | 403 | The endpoint needs a higher access level than your key has (administration endpoints). |
empty_text | 400 | The text is empty or has nothing speakable (only punctuation, symbols or Markdown). Send words. |
voice_not_found | 400 | No voice with that id (for example an OpenAI name like alloy). Use an id from GET /v1/voices. |
voice_unavailable | 400 | The voice exists but is disabled or not installed on this server. Use a voice with available: true. |
unsupported_format | 400 | A response_format or audioEncoding the endpoint cannot produce, or uploaded audio that cannot be decoded. |
validation_error | 400 | speed other than 1.0 with a pre-generated voice, or an unsupported sampleRateHertz. |
not_found | 404 | No such path. Check the URL and the /v1 prefix. |
method_not_allowed | 405 | Wrong HTTP method for this path. |
voice_not_ready | 409 | A pre-generated voice's text is not generated yet; the missing sentences were queued. Has ready/total and Retry-After: 30. Poll /v1/tts/status, then retry. |
text_too_long | 413 | A text is longer than 8000 characters (SSML: 16 000), or one request needs more characters than your whole per-minute budget. Split it. |
payload_too_large | 413 | JSON body over 256 KB, upload over 25 MB, or more than 200 texts in one prefetch/status call. |
unsupported_media_type | 415 | A JSON endpoint was called without Content-Type. Send that header. |
validation_error | 422 | The body failed validation: invalid JSON, missing field, wrong type, speed out of range, empty texts, missing or empty upload. message names the field. |
rate_limited | 429 | A rate limit was exceeded. Wait Retry-After seconds. |
too_many_auth_failures | 429 | Too many unknown keys from your IP address: further invalid keys from it are refused for a while (revoked keys are not counted). Valid keys from the same address keep working. Fix the key and wait Retry-After seconds. |
internal_error | 500 | Unexpected server failure. Retry once, then fall back. |
engine_loading | 503 | The server is starting, or the engine for this voice (or speech-to-text) is not available yet. Retry after Retry-After seconds when present. |
stt_disabled | 503 | Speech-to-text is not enabled on this server. |
If a stream fails after it has started, you receive an error frame instead of an HTTP error (see stream frames).
Migrating from Google Text-to-Speech
If you call Google Cloud Text-to-Speech over REST, the move is three changes: the host, the key header and (optionally) the voice. Your JSON body and the audioContent handling stay the same.
const res = await fetch(
`https://texttospeech.googleapis.com/v1/text:synthesize?key=${process.env.GOOGLE_API_KEY}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
input: { text },
voice: { languageCode: "en-US", name: "en-US-Neural2-F" },
audioConfig: { audioEncoding: "MP3" },
}),
}
);
const { audioContent } = await res.json(); // base64 MP3
const res = await fetch("https://api.ttswave.stream/v1/text:synthesize", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Goog-Api-Key": process.env.TTSWAVE_API_KEY, // header, never ?key=
},
body: JSON.stringify({
input: { text },
voice: { languageCode: "en-US", ssmlGender: "FEMALE" }, // or name: "us-female-heart"
audioConfig: { audioEncoding: "MP3" },
}),
});
const { audioContent } = await res.json(); // base64 MP3, same shape
What differs: Google voice names are not recognised (the language and gender pick a voice, or put a TTSWave id in voice.name); SSML is reduced to plain text with pauses; pitch, volumeGainDb and effectsProfileId are ignored; speakingRate is clamped to 0.5 to 2.0; timepoints, long-audio and streaming synthesis are not available (use /v1/tts/stream for sentence marks); errors use the TTSWave envelope; and the gRPC client libraries are not supported.
Migrating from Gemini TTS
If your app calls Gemini text-to-speech (REST or the Google Gen AI SDK), change two things: the base URL (https://generativelanguage.googleapis.com becomes https://api.ttswave.stream) and the key. The path, the request body, the inlineData response and your PCM handling stay the same.
const BASE = "https://generativelanguage.googleapis.com";
const res = await fetch(`${BASE}/v1beta/models/gemini-2.5-flash-preview-tts:generateContent`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-goog-api-key": process.env.GEMINI_API_KEY },
body: JSON.stringify({
contents: [{ parts: [{ text }] }],
generationConfig: {
responseModalities: ["AUDIO"],
speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: "Kore" } } },
},
}),
});
const data = (await res.json()).candidates[0].content.parts[0].inlineData.data; // base64 PCM
const BASE = "https://api.ttswave.stream"; // changed
const res = await fetch(`${BASE}/v1beta/models/gemini-2.5-flash-preview-tts:generateContent`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-goog-api-key": process.env.TTSWAVE_API_KEY }, // changed
body: JSON.stringify({
contents: [{ parts: [{ text }] }],
generationConfig: {
responseModalities: ["AUDIO"],
speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: "Kore" } } }, // or "us-female-heart"
},
}),
});
const data = (await res.json()).candidates[0].content.parts[0].inlineData.data; // base64 PCM, same shape
With the SDK, pass the base URL once: genai.Client(api_key=..., http_options={"base_url": "https://api.ttswave.stream"}) in Python, new GoogleGenAI({ apiKey, httpOptions: { baseUrl: "https://api.ttswave.stream" } }) in JavaScript (see Google Gen AI SDK).
What differs:
- Voices: Gemini voice names map to similar TTSWave voices (table); for full control put a TTSWave voice id in
voiceName. - Style prompts: the text is read as written. Instructions such as "Say cheerfully:" are spoken, not followed; remove them from the text you send.
- Multi-speaker: one voice (the first speaker's) reads the whole dialogue; the speaker labels are removed.
- Language:
languageCodeis ignored; the voice decides the language and accent. The mapped voices speak English. - Keys: send the key in the
x-goog-api-keyheader;?key=in the URL is refused. - Limits and errors: at most 8000 characters per request, TTSWave rate limits, and errors in the TTSWave envelope.
usageMetadatatoken counts are estimates. - Only speech: text generation, other modalities and the other Gemini API methods are not available.
Fallback pattern
Keep your previous provider as a safety net. Give TTSWave a short timeout; if it fails (network error, timeout, 429 or 5xx), use your previous provider, and if that is not available, tell your frontend to use the device's built-in speech (speechSynthesis in browsers).
async function ttswave(text) {
const res = await fetch("https://api.ttswave.stream/v1/text:synthesize", {
method: "POST",
headers: { "Content-Type": "application/json", "X-Goog-Api-Key": process.env.TTSWAVE_API_KEY },
body: JSON.stringify({ input: { text }, voice: { languageCode: "en-US" }, audioConfig: { audioEncoding: "MP3" } }),
signal: AbortSignal.timeout(15_000), // allow about the length of the audio
});
if (!res.ok) {
const { error } = await res.json().catch(() => ({ error: {} }));
throw new Error(`TTSWave ${res.status} ${error.code ?? ""}`);
}
return (await res.json()).audioContent;
}
// Returns { provider, audioContent } or { provider: "browser" } for on-device speech.
export async function synthesize(text) {
try {
return { provider: "ttswave", audioContent: await ttswave(text) };
} catch (err) {
console.warn("TTSWave failed, falling back:", err.message);
}
try {
return { provider: "previous", audioContent: await previousProvider(text) }; // your old Google call
} catch (err) {
console.warn("Previous provider failed:", err.message);
}
return { provider: "browser" }; // frontend: speechSynthesis.speak(new SpeechSynthesisUtterance(text))
}Log which provider served each request: a rising fallback rate tells you something needs attention. Do not retry 4xx errors other than 429 against TTSWave; they will fail again until the request changes.