Generating Spoken Responses from Text
Beyond a Single Synthesis Call
Lesson 1 introduced the basic shape of a text-to-speech call: pass text in, get audio bytes back. This lesson goes deeper into the parameters that shape the quality and character of that output, the practical constraints around input length, streaming for responsiveness, and the design decisions involved in choosing how synthesized speech should sound for a given application.
Generating a single short sentence of speech is straightforward. Generating spoken responses as part of a real application — a voice assistant reply, a narrated article, an automated phone system prompt — involves decisions about voice selection, output format, latency, and how to handle text that does not translate cleanly into natural-sounding speech.
The Full Set of Synthesis Parameters
from pathlib import Path
from openai import OpenAI
client = OpenAI()
response = client.audio.speech.create(
model="gpt-5.6-terra",
voice="verse",
input="Thank you for calling. Your ticket number is 4 4 host and a representative will be with you shortly.",
response_format="mp3",
speed=1.0,
)
Path("ticket_confirmation.mp3").write_bytes(response.read())
Each parameter shapes the output in a distinct way:
voiceselects which pre-built synthetic voice speaks the text. Different voices carry different tonal qualities — some sound warmer and more conversational, others more neutral and formal. This is a real product decision: a meditation app and a fraud-alert notification system should probably not use the same voice.response_formatcontrols the audio container format of the output (common options include MP3, and other formats optimized for different playback or streaming needs). MP3 is broadly compatible and a reasonable default for most applications; formats optimized for low-latency streaming may be preferable for real-time playback scenarios.speedadjusts the playback rate of the generated speech, typically as a multiplier where1.0is normal speed. Values below1.0slow speech down (useful for accessibility or language-learning contexts), and values above1.0speed it up.
Note: The exact list of available voices, supported
response_formatvalues, and the valid range forspeedare details that can change as the API evolves. Confirm the current set of options against official documentation before hardcoding a specific voice name or speed value into a production system, since a deprecated voice name would cause requests to fail.
Handling Text That Does Not Read Naturally
Raw application text is often not written with speech in mind. Numbers, abbreviations, and symbols that read fine visually can sound awkward or ambiguous when spoken aloud, and a synthesis model can only work with the text it is given — it has no independent knowledge of what a given abbreviation is supposed to mean in your specific context.
import re
def prepare_text_for_speech(raw_text: str) -> str:
text = raw_text
# Expand common abbreviations that read poorly as speech.
replacements = {
r"\bDr\.": "Doctor",
r"\bMr\.": "Mister",
r"\bMrs\.": "Missus",
r"\be\.g\.": "for example",
r"\bi\.e\.": "that is",
}
for pattern, replacement in replacements.items():
text = re.sub(pattern, replacement, text)
# Collapse repeated whitespace left over from formatting.
text = re.sub(r"\s+", " ", text).strip()
return text
sample = "Dr. Alvarez will see you shortly, e.g. within 10 minutes."
print(prepare_text_for_speech(sample))
This function applies a small set of regular-expression substitutions to expand common abbreviations into their spoken-word form before the text reaches the synthesis call. Each pattern uses \b (a word boundary) to avoid accidentally matching the abbreviation as part of a longer word, and the replacement dictionary is intentionally small and explicit rather than attempting to handle every possible case — a general-purpose text normalizer is a much larger undertaking, and most applications only need to handle a known, limited set of abbreviations that actually appear in their content. The final whitespace-collapsing step cleans up any formatting artifacts (like double spaces from concatenated strings) that can otherwise cause subtly awkward pauses in the synthesized audio.
This kind of preprocessing is optional for many applications — the synthesis model already handles a great deal of natural text reasonably well — but it becomes valuable for content with a lot of domain-specific abbreviations, or for applications where mispronunciations are especially noticeable or embarrassing, such as a professional voice assistant or automated customer service line.
Handling Long Input Text
The synthesis endpoint has an input length limit, and a long article or document will exceed it in a single call. The correct approach is to split the text into chunks at natural boundaries (sentence or paragraph breaks) and synthesize each chunk separately, then combine the resulting audio if a single continuous file is needed.
def split_text_for_synthesis(text: str, max_chars: int = 3000) -> list[str]:
"""
Splits text into chunks no longer than max_chars, breaking at sentence
boundaries where possible to avoid cutting off mid-sentence.
"""
sentences = re.split(r"(?<=[.!?])\s+", text.strip())
chunks = []
current_chunk = ""
for sentence in sentences:
candidate = f"{current_chunk} {sentence}".strip() if current_chunk else sentence
if len(candidate) > max_chars and current_chunk:
chunks.append(current_chunk)
current_chunk = sentence
else:
current_chunk = candidate
if current_chunk:
chunks.append(current_chunk)
return chunks
re.split(r"(?<=[.!?])\s+", text.strip()) splits the input into sentences using a lookbehind assertion: it breaks on whitespace that immediately follows a sentence-ending punctuation mark (., !, or ?), which keeps the punctuation attached to the sentence it belongs to rather than stripping it out. The function then greedily accumulates sentences into current_chunk until adding the next sentence would exceed max_chars, at which point it closes out the current chunk and starts a new one. This is the same greedy-accumulation pattern used for paragraph grouping in Lesson 5, applied here to text splitting instead of segment grouping — recognizing this kind of reusable pattern across different problems is a useful skill as you write more of these pipelines yourself.
Note: The exact maximum input length accepted by the speech synthesis endpoint should be confirmed against current official documentation; the
max_charsvalue used here is illustrative and conservative, not a guaranteed API limit.
Synthesizing each chunk and concatenating the resulting audio requires either concatenating raw audio bytes (which works cleanly for some formats but can introduce artifacts for others) or using an audio-processing library to properly join clips. For most applications, a simpler and more robust approach is to synthesize and deliver each chunk separately — for example, playing them back-to-back in a client-side player — rather than attempting byte-level concatenation of compressed audio formats.
Streaming Synthesized Audio for Lower Perceived Latency
For interactive applications, waiting for an entire audio file to be generated before playback starts creates a noticeable delay, especially for longer responses. Streaming the response allows playback to begin as soon as the first chunk of audio data arrives, rather than waiting for the complete file.
from openai import OpenAI
client = OpenAI()
def synthesize_to_file_streaming(text: str, output_path: str, voice: str = "alloy") -> None:
with client.audio.speech.with_streaming_response.create(
model="gpt-5.6-terra",
voice=voice,
input=text,
) as response:
response.stream_to_file(output_path)
with_streaming_response requests a streaming-capable response object rather than one that buffers the entire result before returning. Using it inside a with block ensures the underlying network connection is properly closed once streaming completes, even if an error occurs partway through — this is the same resource-management pattern as using with open(...) for files, applied to a network response instead. response.stream_to_file(output_path) writes the incoming audio data to disk incrementally as it arrives, rather than accumulating it all in memory first. For a real-time playback scenario (rather than writing to a file), the streaming response object typically also supports iterating over the incoming bytes directly, which can be piped to an audio playback device as data arrives.
Note: The exact streaming interface (
with_streaming_response, its supported helper methods likestream_to_file, and how to access raw streamed bytes for live playback) is SDK-version-specific. Confirm the current recommended streaming pattern in the official SDK documentation before building latency-sensitive playback features around a specific method name.
When Streaming Matters and When It Does Not
Streaming is valuable when a human is waiting in real time for audio to start playing — a voice assistant response, a live phone system prompt. It is not necessary, and adds unneeded complexity, for batch use cases where the audio is generated ahead of time and stored for later playback, such as pre-generating narration for a video that will not be played until well after synthesis completes. Choosing streaming by default for every use case adds code complexity without a corresponding user-facing benefit in non-interactive contexts.
Common Mistakes
Sending raw, unedited application text directly to synthesis without considering how it will sound spoken aloud, which causes awkward or confusing audio output for text full of abbreviations, symbols, or formatting artifacts intended for visual reading. Light text preprocessing, as shown above, meaningfully improves output quality for such content.
Attempting to synthesize very long text in a single call, which fails once the input exceeds the endpoint's length limit. Split long text into sentence-bounded chunks proactively, rather than discovering the limit through a failed request in production.
Using a blocking, non-streaming call for a latency-sensitive interactive feature, which causes a noticeable and avoidable delay before any audio plays. Use the streaming response pattern for any scenario where a user is waiting in real time.
Best Practices
Pick a voice deliberately based on the application's context and audience, and keep it consistent across the application rather than switching voices between different features, which can feel jarring to users.
Chunk long text at sentence boundaries, not at arbitrary character counts. Splitting mid-sentence produces audio with an unnatural break, while sentence-aware splitting (as shown with the lookbehind-based regular expression) preserves natural speech rhythm across chunk boundaries.
Cache synthesized audio for text that does not change often, such as fixed prompts in an IVR system or standard notification messages. Since synthesis has a real cost and a small amount of latency, regenerating the same audio for identical text repeatedly is wasted work that caching easily eliminates.