TTS API Python: Prepare Text for Speech with the SDK
This API does not return audio. It is a text model you call from Python with the OpenAI SDK, and it fits in front of a speech engine: it writes the script, cleans it up and splits it into sentences your voice stage can read aloud.
Updated

What this API does and does not do
If you searched for a tts api python tutorial, one point needs to be clear before any code: this service does not synthesize speech. It exposes POST /v1/chat/completions and GET /v1/models for one open-weight text model, with the model ID uncensored. There is no audio endpoint, no voice list and no audio file in any response. It also has no image, video or embedding output.
So where does it fit in a speech project? In the text half of the pipeline. Before a voice engine can read a script, somebody has to write it, expand numbers and abbreviations into spoken words, strip markup, and cut it into pieces short enough to synthesize without long pauses. A language model does that work well, and you can call it from Python with the same SDK you may already use for other OpenAI-compatible services.
The model is tuned to answer without content refusals for lawful adult use, which matters for fiction, character dialogue and narration where a stricter model might stop mid-scene. One hard rule applies: no sexual content involving minors.
The rest of this article builds a small Python module in four steps: a first call, a streaming sentence splitter, a text normalizer with JSON output, and a retry wrapper that respects the rate limits.
Install the SDK and make a first call
You need Python 3.8 or newer and the official openai package. Create a key on the API key page first. New accounts can claim a $0.50 trial credit there with one click, and no card is needed for it.
pip install openai
Keep the key out of your source. Export it as an environment variable and read it in code:
import os
from openai import OpenAI
client = OpenAI(
base_url="https://api.xtts.cc/v1",
api_key=os.environ["XTTS_API_KEY"],
)
resp = client.chat.completions.create(
model="uncensored",
messages=[
{"role": "system", "content": "You write short narration scripts. Plain sentences only."},
{"role": "user", "content": "Write a 60-word intro for a podcast about night trains."},
],
temperature=0.8,
)
script = resp.choices[0].message.content
print(script)
print(resp.usage.prompt_tokens, resp.usage.completion_tokens)
That is the whole integration. The SDK sends Authorization: Bearer <key> for you. Every response also carries an X-Balance-USD header, so you can read your remaining credit from the raw response if you want to log it. The docs page lists the other headers and parameters.
Parameters such as temperature, top_p, stop, seed and the penalties are passed through. For narration, a temperature between 0.6 and 0.9 is a sensible starting range, and a fixed seed helps when you want to reproduce a take while tuning prompts.
Stream the script and cut it into sentences
Speech engines usually work sentence by sentence. If you wait for a 900-word script to finish, the listener waits too. With stream: true the API sends tokens over server-sent events, and you can hand each finished sentence to the voice stage while the rest is still being written.
import os
import re
from openai import OpenAI
client = OpenAI(base_url="https://api.xtts.cc/v1", api_key=os.environ["XTTS_API_KEY"])
SENTENCE_END = re.compile(r"(?<=[.?])\s+")
def stream_sentences(topic):
stream = client.chat.completions.create(
model="uncensored",
messages=[
{"role": "system", "content": "Write spoken narration. One idea per sentence."},
{"role": "user", "content": topic},
],
stream=True,
)
buffer = ""
for chunk in stream:
if not chunk.choices:
continue # the final chunk can carry usage only
buffer += chunk.choices[0].delta.content or ""
parts = SENTENCE_END.split(buffer)
for sentence in parts[:-1]:
yield sentence.strip()
buffer = parts[-1]
if buffer.strip():
yield buffer.strip()
for line in stream_sentences("Explain how a tide table is read."):
print(line) # replace with: your_voice_engine.say(line)
Two details are easy to miss. First, the usage numbers arrive at the end of the stream, in a chunk that may have an empty choices list, which is why the loop skips chunks without choices. Second, a streamed request holds its concurrency slot until the stream ends. If your voice engine is slow and you read the stream lazily, the slot stays busy. Pull the text out quickly, put sentences in a queue, and let the audio side consume the queue at its own pace.
The regular expression above splits on a period or question mark followed by whitespace. It will wrongly split after abbreviations such as "Dr." or "No.", which is one more reason to normalize the text first, as the next section shows.
Normalize text for the ear
Written text and spoken text differ. "Dr. Lee bought 3 copies on 3/4 for the 2nd edition" is easy to read and awkward to say. Many speech engines handle some of this, but results vary by engine, so cleaning the script before it reaches the voice stage removes a class of surprises. Ask the model to do it with an explicit instruction:
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.xtts.cc/v1", api_key=os.environ["XTTS_API_KEY"])
NORMALIZE = (
"Rewrite the user's text so it can be read aloud. "
"Spell out numbers, currency, dates and abbreviations as words. "
"Remove markdown, URLs and emoji. Keep the meaning and the order. "
"Return only the rewritten text."
)
def normalize(text):
r = client.chat.completions.create(
model="uncensored",
messages=[
{"role": "system", "content": NORMALIZE},
{"role": "user", "content": text},
],
temperature=0.2,
)
return r.choices[0].message.content.strip()
print(normalize("Dr. Lee bought 3 copies on 3/4 for the 2nd edition."))
Use a low temperature for this step. You want the same input to give the same output, and creativity only gets in the way. Review a sample of results before you run a whole book through it, and decide how you want ambiguous items handled. The date "3/4" is the standard example: read it as March fourth or April third? Put your choice in the system prompt, because the model cannot guess your locale.
Cost of this step
Normalization is cheap. A 600-token paragraph in and about 650 tokens out costs 600 x $0.15 / 1,000,000 = $0.00009 for input, plus 650 x $0.50 / 1,000,000 = $0.000325 for output, a total of $0.000415. That is about $0.42 for a thousand paragraphs. The pricing page has the full table and top-up options.
Return structured segments with JSON mode
Some voice engines accept per-segment settings: a pause length, a speaking rate, a different voice for a different character. You can have the model produce those instructions as JSON and read them in Python. Set response_format to {"type": "json_object"} and also describe the shape in the prompt, since JSON mode guarantees valid JSON, not your schema.
import json
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.xtts.cc/v1", api_key=os.environ["XTTS_API_KEY"])
prompt = (
"Split this scene into segments. Return JSON like "
'{"segments": [{"speaker": "narrator", "text": "...", "pause_ms": 300}]}. '
"Speakers are narrator, mara, or tom."
)
r = client.chat.completions.create(
model="uncensored",
response_format={"type": "json_object"},
messages=[
{"role": "system", "content": prompt},
{"role": "user", "content": "Mara opens the door. Tom says he is late again. She sighs and lets him in."},
],
temperature=0.3,
)
data = json.loads(r.choices[0].message.content)
for seg in data["segments"]:
print(seg["speaker"], seg["pause_ms"], seg["text"])
Validate what comes back. Check that segments exists, that every item has a known speaker, and that pause_ms is a number in a range you accept. If validation fails, retry once with a lower temperature before giving up. Treat model output as untrusted input, the same as you would any user text.
Pitfalls that cost time
A few mistakes show up again and again when people wire a language model in front of a speech engine.
- Letting the model add commentary. Models like to say "Sure, here is your script." That sentence would be read aloud. End your system prompt with "Return only the script text" and strip a leading line if it ends with a colon.
- Trusting sentence length. Some engines struggle with very long sentences. Add a rule such as "no sentence longer than 25 words" to the prompt, and also enforce it in Python by splitting on commas when a sentence is still too long.
- Forgetting the context window. Prompt and completion share 100,000 tokens. For a long book chapter, send it in sections with a short summary of the previous section instead of the full text each time. That also keeps the input bill down.
- Reusing one global client across threads without a limit. The client is safe to share, but the service is not infinite. Cap your pool at your key's concurrency.
- Hard-coding the key. Use an environment variable and rotate the key if it ever lands in a repository.
When something looks wrong, log the X-Request-Id header with the prompt that caused it. If you need to ask for help, that ID is the fastest way to point at one specific call, and the docs explain where to find it.
Handle rate limits and retries
A full key allows 8 concurrent requests and 300 requests per minute. A trial key allows 2 concurrent and 60 per minute. Past either limit you get HTTP 429, with the error code rate_limited for requests per minute or concurrency for too many at once. On temporary overload the API also answers 429 and sends Retry-After: 5.
The SDK retries by itself, but an explicit wrapper gives you control and makes the behavior visible in logs. Combine it with a thread pool sized to your key, and you will rarely see a 429 at all:
import os
import time
from concurrent.futures import ThreadPoolExecutor
from openai import OpenAI, RateLimitError
client = OpenAI(
base_url="https://api.xtts.cc/v1",
api_key=os.environ["XTTS_API_KEY"],
max_retries=0,
)
def ask(prompt, tries=5):
for attempt in range(tries):
try:
r = client.chat.completions.create(
model="uncensored",
messages=[{"role": "user", "content": prompt}],
)
return r.choices[0].message.content
except RateLimitError as err:
wait = int(err.response.headers.get("Retry-After", 5))
time.sleep(wait * (attempt + 1))
raise RuntimeError("still rate limited after retries")
prompts = ["Narrate a sunrise in two sentences."] * 20
with ThreadPoolExecutor(max_workers=8) as pool: # use 2 on a trial key
results = list(pool.map(ask, prompts))
print(len(results))
Failed or refused requests are not charged, so retrying after an error does not cost you for the failed attempt. A request that completes is billed normally, so avoid blind retries on slow but successful calls.
Hand the text to your speech engine
The last step lives outside this API. Python has several ways to produce audio from text: open-source speech models you can run yourself, desktop speech services built into an operating system, and hosted speech APIs from other companies. Each has its own license, quality and price, so check their current documentation and terms before you choose. This site does not provide that part.
A clean boundary keeps your code easy to change. Write one function with the signature speak(text: str) -> None and keep every engine-specific detail inside it. The text pipeline above calls that function for each sentence or segment. When you swap engines, nothing else changes.
If you are still deciding between a trial and a paid setup, the numbers are simple: you pay $0.15 per million input tokens and $0.50 per million output tokens, from prepaid credit that never expires, with no subscription. Top-ups run from $20 to $500 and can be paid by crypto (USDT on TRC20, USDC on Base).
More guides
Questions and answers
Does this API return audio files?
No. It returns text only. Use it to write, clean and structure scripts, then pass the text to a separate speech engine.
Which Python package should I use?
The official openai package. Set base_url to https://api.xtts.cc/v1, pass your key, and use the model ID uncensored.
Can I stream responses in Python?
Yes. Pass stream=True and iterate over the chunks. Usage is reported at the end of the stream, and the request holds its concurrency slot until the stream finishes.
What happens when I hit the rate limit?
You get HTTP 429 with the code rate_limited or concurrency. Wait for the Retry-After value if present and retry, and keep your worker count at or below 8 on a full key or 2 on a trial key.
Your key is one form away
Create an account, copy the key, change the base URL. That is the whole setup.