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 URLhttps://api.ttswave.stream
ProtocolHTTPS only. JSON request bodies (Content-Type: application/json), except speech-to-text uploads (multipart/form-data). All text is UTF-8.
ResponsesJSON, audio bytes (/v1/audio/speech) or NDJSON streams (/v1/tts/stream).
Audio24 000 Hz, mono, 16-bit. Files as WAV, MP3, FLAC or raw PCM.
VersioningAll endpoints live under /v1, except the Gemini-compatible endpoint, which keeps Gemini's /v1beta/models/... path. Changes within v1 are additive: ignore JSON fields and stream frame types you do not recognise.
Call TTSWave from your backend only.

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).

A key is shown only once.

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:

HTTP headers
Authorization: Bearer tts_your_key_here
or
X-API-Key: tts_your_key_here

Authorization: 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.

Never put the key in the URL.

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):

HTTP header
X-TTS-Session: listener-8f3a2c
X-TTS-Order: 3

Parallel 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.

Shell
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

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

MethodPathUse it for
GET/v1/voicesThe voices your key may use, and your default
POST/v1/audio/speechA whole audio file (OpenAI-compatible)
POST/v1/tts/streamStreaming audio with sentence timing marks (NDJSON)
POST/v1/tts/prefetchPre-generate text you will play soon
POST/v1/tts/statusCheck whether pre-generated text is ready
POST/v1/text:synthesizeGoogle Cloud Text-to-Speech compatible synthesis
POST/v1beta/models/{model}:generateContentGemini API text-to-speech compatible (also :streamGenerateContent)
POST/v1/audio/transcriptionsSpeech-to-text (OpenAI-compatible, optional)
GET/v1/infoService information
GET/healthzPublic 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

GET/v1/voices

Returns 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

FieldTypeDescription
defaultstring or nullThe 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).
voicesarrayVoice objects (below), in catalog order.
rawKokoroVoicesarray of stringLow-level engine voice ids, for keys allowed to use them. Normally empty; use the preset ids in voices.

Voice object:

FieldTypeDescription
idstringStable id to send as voice, e.g. us-female-heart.
labelstringDisplay name, e.g. Heart (warm, default).
accentstringe.g. American, British, Indian.
genderstringfemale or male.
realtimebooleantrue: stream it live. false: a pre-generated voice; prepare the text first (see Voices).
samplestringA short sentence in the voice's language, used for previews. Previewing it is instant: the server pre-generates it.
availablebooleantrue if the server can synthesize it right now.
engine, voice, langstringInformational: the synthesis engine, its internal voice and language.
grade, notesstringOptional (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"
Response (shortened)
{
  "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)

POST/v1/audio/speech

Synthesizes 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)

FieldTypeRequiredDefaultNotes
inputstringyesThe text. At most 8000 characters, and it must contain something speakable.
voicestringnoyour defaultA voice id from GET /v1/voices, at most 100 characters. OpenAI names such as alloy are not recognised (400 voice_not_found).
speednumberno1.00.5 to 2.0. Must be 1.0 for pre-generated voices.
response_formatstringno"wav"wav, mp3, flac or pcm (case-insensitive). Note: OpenAI defaults to mp3, TTSWave to wav.
modelstringno"kokoro"Accepted for compatibility and ignored.

Unknown extra fields (such as OpenAI's instructions) are ignored.

Response 200: the audio bytes

response_formatContent-TypeContents
wavaudio/wav16-bit PCM WAV, 24 kHz mono
mp3audio/mpegMP3, 24 kHz mono
flacaudio/flac16-bit FLAC, 24 kHz mono
pcmaudio/L16;rate=24000;channels=1Raw 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

Errors

Stream speech (NDJSON)

POST/v1/tts/stream

Streams 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)

FieldTypeRequiredDefaultNotes
textstringyesAt most 8000 characters; must contain something speakable.
voicestringnoyour defaultA voice id from GET /v1/voices, at most 100 characters.
speednumberno1.00.5 to 2.0. Must be 1.0 for pre-generated voices.
waitbooleannofalsePre-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:

Example stream
{"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}
FrameFieldsMeaning
startprovider (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.
markindex (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.
audioformat ("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).
doneprovider, 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":"done","browser":true} to tell your frontend to fall back to on-device speech.
keepalivenoneSent 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.)
errorcode (internal_error or engine_loading), messageReplaces 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."}'

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.

Browser JavaScript
// 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

Pre-generate text (prefetch)

POST/v1/tts/prefetch

Queues 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)

FieldTypeRequiredDefaultNotes
textsarray of stringyes1 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.
voicestringnoyour defaultThe voice you will play with (at most 100 characters).
speednumberno1.0The speed you will play with (0.5 to 2.0; 1.0 for pre-generated voices).

Response 202 Accepted

FieldTypeDescription
queuedintegerSentences queued by this call (texts are split exactly as the stream splits them; sentences already queued are skipped).
pendingintegerSentences waiting in this voice's queue, across all callers.
realtimebooleanThe 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)

POST/v1/tts/status

Reports 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

FieldTypeDescription
voicestringThe resolved voice id.
totalintegerSentences in all texts.
readyintegerSentences already cached (they play instantly).
queuedintegerSentences not cached yet but waiting in a queue.
failedintegerSentences whose pre-generation failed and are no longer queued. Prefetch again to retry.
completebooleanready == total: streaming this text now plays without waiting.
realtimebooleanThe 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)

Errors (prefetch and status)

Google Cloud Text-to-Speech compatible

POST/v1/text:synthesize

Accepts 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)

FieldTypeRequiredDefaultNotes
input.textstringone ofPlain text, at most 8000 characters.
input.ssmlstringone ofSSML (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.languageCodestringno""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.ssmlGenderstringnoMALE or FEMALE narrows the choice; NEUTRAL or anything else is ignored.
voice.namestringnoAt 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.audioEncodingstringnoLINEAR16LINEAR16 (WAV with header), MP3, MULAW, ALAW (WAV container), OGG_OPUS (only if the server supports it). Case-insensitive.
audioConfig.sampleRateHertzintegerno24000 (8000 for MULAW/ALAW)8000, 16000, 22050, 24000, 44100 or 48000; for OGG_OPUS: 8000, 12000, 16000, 24000 or 48000.
audioConfig.speakingRatenumberno1.0Clamped to 0.5 to 2.0; 0 means normal speed. Always 1.0 for pre-generated voices (no error).
audioConfig.pitch, volumeGainDb, effectsProfileIdnoAccepted 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

JSON
{
  "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

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).

Gemini-compatible endpoint

POST/v1beta/models/{model}:generateContent
POST/v1beta/models/{model}:streamGenerateContent?alt=sse

Accepts 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)

FieldTypeRequiredNotes
contentsarray, object or stringyesThe 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.responseModalitiesarraynoMust include "AUDIO" when sent (["TEXT"] alone is 400 unsupported_format).
generationConfig.speechConfig.voiceConfig.prebuiltVoiceConfig.voiceNamestringnoAt 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.speechConfig.multiSpeakerVoiceConfigobjectnoAccepted, 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.speechConfig.languageCode, other generationConfig fields, systemInstruction, safetySettings, toolsnoAccepted 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 voicesTTSWave voice
Koreus-female-kore
Aoedeus-female-aoede
Zephyr, Autonoe, Sulafatus-female-heart
Leda, Laomedeiaus-female-bella
Callirrhoe, Pulcherrimaus-female-sarah
Achernar, Vindemiatrixus-female-nicole
Despina, Erinomeuk-female-emma
Gacruxuk-female-isabella
Puck, Achird, Zubenelgenubi, Sadachbiaus-male-puck
Fenrir, Orus, Alnilamus-male-fenrir
Charon, Enceladus, Umbriel, Schedarus-male-michael
Iapetus, Rasalgethi, Sadaltageruk-male-george
Algiebauk-male-fable
Algenibuk-male-lewis

Response 200

JSON
{
  "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

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.

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"}}}}}'

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.

Speech-to-text

POST/v1/audio/transcriptions

Transcribes 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)

FieldTypeRequiredDefaultNotes
filefileyesAudio in a common format (wav, mp3, m4a, ogg/opus, webm, flac, ...). Up to 25 MB unless the server sets another limit.
response_formatstringnojsonjson, verbose_json, text, srt or vtt.
languagestringnoauto-detectISO-639-1 code such as en.
promptstringnoContext or vocabulary hint (names, product terms).
timestamp_granularities[]stringnoword adds per-word timings. Use with verbose_json.
modelstringnoAccepted for compatibility and ignored (the server's model is used).

Response 200

response_formatContent-TypeBody
jsonapplication/json{"text": "..."}
verbose_jsonapplication/jsontext, 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}.
texttext/plainThe transcript
srtapplication/x-subripSubRip subtitles, one cue per segment
vtttext/vttWebVTT 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"
Response (verbose_json with word timestamps)
{
  "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

Service info

GET/v1/info

Optional. 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
curl https://api.ttswave.stream/v1/info -H "Authorization: Bearer $TTSWAVE_API_KEY"
Response
{
  "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

GET/healthz

Public 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
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:

  1. As soon as the text is final, call POST /v1/tts/prefetch with every text, the voice, and speed 1.0.
  2. Poll POST /v1/tts/status with the same body every few seconds (or from a background job) until complete is true.
  3. Play it with /v1/tts/stream or /v1/text:synthesize using 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:

409 response
{"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 idLabelAccentGender
us-female-heartHeart (warm, default)Americanfemale
us-female-bellaBella (bright)Americanfemale
us-female-nicoleNicole (soft, close-mic)Americanfemale
us-female-sarahSarahAmericanfemale
us-female-aoedeAoedeAmericanfemale
us-female-koreKoreAmericanfemale
us-female-blendHeart x Bella (blend)Americanfemale
us-male-michaelMichael (calm)Americanmale
us-male-fenrirFenrir (deep)Americanmale
us-male-puckPuck (lively)Americanmale
us-male-blendMichael x Puck (blend)Americanmale
uk-female-emmaEmmaBritishfemale
uk-female-isabellaIsabellaBritishfemale
uk-male-georgeGeorgeBritishmale
uk-male-fableFableBritishmale
uk-male-lewisLewisBritishmale
us-female-novaNova (clear)Americanfemale
us-female-alloyAlloy (neutral)Americanfemale
us-female-skySky (airy)Americanfemale
us-male-orionOrion (deep narrator)Americanmale
us-male-atlasAtlas (energetic)Americanmale
us-female-lunaLuna (soft)Americanfemale
us-female-ariaAria (bright)Americanfemale
uk-male-oliverOliver (British narrator)Britishmale
uk-female-sophiaSophia (warm)Britishfemale
mid-female-claireClaire (mid-Atlantic)Mid-Atlanticfemale
mid-male-jamesJames (mid-Atlantic)Mid-Atlanticmale
mid-male-arthurArthur (mid-Atlantic, deep)Mid-Atlanticmale
in-female-alphaAlpha (Indian English), experimentalIndianfemale
in-male-omegaOmega (Indian English), experimentalIndianmale

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:

AvoidSend 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, tablesa short spoken description, or leave them out

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.

  1. Prefetch every part as soon as the text is final. One POST /v1/tts/prefetch call 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/status tells you when everything is cached (complete: true).
  2. 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.
  3. Keep the audio timeline continuous. Schedule every audio frame at max(playhead, ctx.currentTime) on one AudioContext and 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.
  4. 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

Pause, resume and skip

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:

When something fails

Browser JavaScript
// 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)

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)

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:

Responses carry headers describing the requests-per-minute budget (when a limit applies):

HeaderMeaning
X-RateLimit-LimitRequests allowed per minute
X-RateLimit-RemainingRequests left in the current window
X-RateLimit-ResetSeconds 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.

429 response
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:

JSON
{
  "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.

CodeHTTPMeaning and what to do
missing_api_key401No Authorization: Bearer or X-API-Key header (or X-Goog-Api-Key on /v1/text:synthesize and the Gemini-compatible endpoint). Send your key.
invalid_api_key401The key is not recognised: typo, revoked, or replaced by a regenerated key. Check it; ask the administrator.
api_key_in_query401/v1/text:synthesize or the Gemini-compatible endpoint called with ?key= in the URL. Send the key in a header.
voice_not_allowed403The 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.
forbidden403The endpoint needs a higher access level than your key has (administration endpoints).
empty_text400The text is empty or has nothing speakable (only punctuation, symbols or Markdown). Send words.
voice_not_found400No voice with that id (for example an OpenAI name like alloy). Use an id from GET /v1/voices.
voice_unavailable400The voice exists but is disabled or not installed on this server. Use a voice with available: true.
unsupported_format400A response_format or audioEncoding the endpoint cannot produce, or uploaded audio that cannot be decoded.
validation_error400speed other than 1.0 with a pre-generated voice, or an unsupported sampleRateHertz.
not_found404No such path. Check the URL and the /v1 prefix.
method_not_allowed405Wrong HTTP method for this path.
voice_not_ready409A 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_long413A 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_large413JSON body over 256 KB, upload over 25 MB, or more than 200 texts in one prefetch/status call.
unsupported_media_type415A JSON endpoint was called without Content-Type: application/json. Send that header.
validation_error422The body failed validation: invalid JSON, missing field, wrong type, speed out of range, empty texts, missing or empty upload. message names the field.
rate_limited429A rate limit was exceeded. Wait Retry-After seconds.
too_many_auth_failures429Too 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_error500Unexpected server failure. Retry once, then fall back.
engine_loading503The 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_disabled503Speech-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

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

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:

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).

Node.js (your backend)
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.