Python Text to Speech
Turn any text into spoken audio
A complete, beginner-friendly text-to-speech course. Nine lessons take you from "what is TTS?" to a working CLI tool that reads any text file aloud — with a line-by-line explanation of every code block so you understand exactly what each statement does.
📚 All 9 Lessons — Complete Index
Each lesson builds on the one before it. Start at Lesson 1 if you're new to text to speech.
Lesson 1 — What Is Text to Speech, and Why Use Python?
Text-to-speech (TTS) is the process of converting written text into spoken audio. It's the same technology behind screen readers, voice assistants, GPS navigation, audiobook generators, and accessibility features on every major platform. In Python, TTS is available through both offline libraries (which run on your machine) and cloud APIs (which produce more natural-sounding voices).
🧰 The Tools You'll Use in This Course
pyttsx3
Offline, free, cross-platform. Uses your OS's built-in TTS engine (SAPI5 on Windows, NSSpeechSynthesizer on macOS, espeak on Linux). Fast and private.
gTTS
Online, free, needs internet. Uses Google Translate's TTS endpoint. More natural voice, works for many languages, produces MP3 files.
Amazon Polly
Cloud API, pay-per-character. Neural voices that sound almost human. Production-grade, used in apps with millions of users.
pydub / playsound
Play or manipulate generated audio. Convert MP3 to WAV, change speed, combine segments.
⚖️ Offline vs Online TTS — Which to Use?
pyttsx3) when: you need it to work without internet, you care about privacy (text never leaves your machine), you need zero latency, or you're building a desktop app.Use online (
gTTS, Polly) when: audio quality matters (natural-sounding voices), you need many languages, you want to save high-quality MP3s, or you're building a cloud service.
🧪 Runnable Example — Verify Your TTS Setup
This first example verifies that pyttsx3 can find a voice on your system. It doesn't play audio yet — just lists the voices available. Run this locally.
import pyttsx3 engine = pyttsx3.init() voices = engine.getProperty("voices") print(f"Found {len(voices)} voice(s):") for i, voice in enumerate(voices): print(f" [{i}] {voice.name} ({voice.id})")
- 1
import pyttsx3— Loads the library. Install it first withpip install pyttsx3. On Linux you also needsudo apt install espeak espeak-data libespeak1 libespeak-dev. - 3
engine = pyttsx3.init()— Creates and initialises the TTS engine. Behind the scenes, this detects your OS and loads the appropriate speech backend: SAPI5 on Windows, NSSpeechSynthesizer on macOS, espeak on Linux. This is the object you use for all speech operations. - 4
voices = engine.getProperty("voices")— Asks the engine for a list of available voices. Each voice is aVoiceobject with attributes like.id(a unique string you'll use to select that voice later),.name(human-readable name),.languages(list of language codes), and.gender. - 6
print(f"Found {len(voices)} voice(s):")— Reports how many voices are installed. Most Windows/macOS systems come with at least 2–4 by default. Linux with just espeak ships with several variants. - 7
for i, voice in enumerate(voices):— Loops through each voice, usingenumerateto get both the indexiand the voice object. The index is useful for selecting a voice later by number. - 8
print(f" [{i}] {voice.name} ({voice.id})")— Prints the index, name, and ID of each voice. You'll use either the index or the ID to select a voice in Lesson 3. The ID often looks like"HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Speech\\Voices\\Tokens\\TTS_MS_EN-US_DAVID_11.0"on Windows — long but unique.
pyttsx3 requires access to your operating system's speech engine. It cannot run inside the browser-based Python runner. Install it locally with pip install pyttsx3 and run with python file.py.Lesson 2 — Meet pyttsx3: Your First Spoken Sentence
Now that you know pyttsx3 exists, let's make it speak. This lesson covers the three core methods — say(), runAndWait(), and stop() — and explains why you need both say and runAndWait.
🔊 Speak a Single Sentence
import pyttsx3 engine = pyttsx3.init() engine.say("Hello from Python. This is your first spoken sentence.") engine.runAndWait() engine.say("Notice how we call say first, then runAndWait.") engine.runAndWait() print("All speech finished.")
- 1
import pyttsx3— Same import as before. - 3
engine = pyttsx3.init()— Initialises the engine. Do this once per program — not once per sentence. Reusing the engine is faster and avoids resource leaks. - 5
engine.say("...")— Queues the text to be spoken. Important: this doesn't actually start speaking — it adds the text to an internal queue. Think of it like pressing "add to playlist" rather than "play". You can queue multiplesay()calls and they'll all play in order. - 6
engine.runAndWait()— Starts the engine and blocks until the queue is empty. This is what actually makes the audio come out of your speakers. If you forget this call, you'll queue up text but hear nothing — one of the most common beginner mistakes. - 8–9The second
say+runAndWaitpair. Same pattern — queue then execute. You could also queue both say calls and then callrunAndWaitonce at the end; they'd play in sequence. The one-at-a-time pattern is clearer for learning. - 11
print("All speech finished.")— Reaches this line only after bothrunAndWait()calls have returned, meaning both sentences have finished playing. Without a block likerunAndWait, the script would exit immediately and you'd hear nothing (or audio would cut off mid-sentence).
say then runAndWait? The split exists because pyttsx3 needs to support queuing multiple utterances and other operations. say() enqueues; runAndWait() starts the engine. Without the split, you couldn't queue several sentences and then have them play as one uninterrupted stream.⏹️ Stopping Speech Mid-Sentence
If you're reading a long text and want to interrupt it, call engine.stop(). Here's a simple pattern where the user can press Enter to stop the reading:
import pyttsx3 import threading engine = pyttsx3.init() def speak(): engine.say("This is a long speech. Press Enter in the terminal to stop it.") engine.say("You can also call stop at any time to interrupt.") engine.runAndWait() thread = threading.Thread(target=speak) thread.start() input("Press Enter to stop the speech...") engine.stop() thread.join() print("Speech stopped.")
- 1
import pyttsx3— The TTS library. - 2
import threading— Standard library. We need it becauserunAndWait()blocks the calling thread — we can't wait for user input on the same thread. - 4
engine = pyttsx3.init()— Initialise once. - 6
def speak():— A function that queues two sentences and starts speaking. Runs in a separate thread so the main thread can wait for input simultaneously. - 7–8Queue two sentences with
say(). They'll play one after the other. - 9
engine.runAndWait()— Starts speaking. Blocks this thread until both sentences finish — or untilengine.stop()is called from another thread. - 11
thread = threading.Thread(target=speak)— Creates a thread object that will run thespeakfunction when started. Nothing has run yet. - 12
thread.start()— Starts the thread.speak()begins running in parallel with the main thread. Speech starts immediately. - 14
input("Press Enter to stop...")— Blocks the main thread until the user presses Enter. Meanwhile, the speech thread is playing audio. - 15
engine.stop()— Called when the user presses Enter. Immediately halts the engine's current utterance and clears the queue. Speech stops. - 16
thread.join()— Waits for the speech thread to finish. Sinceengine.stop()causedrunAndWaitto return, the thread exits quickly — this ensures we don't print the final line before the thread is truly done. - 17
print("Speech stopped.")— Confirms it's done.
engine.stop() from a different thread can occasionally cause the engine to become unresponsive for the next utterance. If you plan to use the same engine after stopping, test carefully, or re-initialise with pyttsx3.init() after stopping.Lesson 3 — Controlling Rate, Volume & Voice
The default voice and speed are often too fast or too robotic. This lesson shows how to slow down, speed up, change volume, and pick a specific voice. These controls transform pyttsx3 from "speaks text" into "speaks text well".
⚙️ The Three Properties You Can Control
rate— Words per minute. Default is usually ~200. Lower = slower.volume— Float between 0.0 (silent) and 1.0 (full).voice— A specific voice object fromengine.getProperty("voices").
You set these with engine.setProperty(name, value) and read them with engine.getProperty(name).
🧪 Runnable Example — Slow, Loud, Custom Voice
import pyttsx3 engine = pyttsx3.init() current_rate = engine.getProperty("rate") current_volume = engine.getProperty("volume") print(f"Default rate: {current_rate} wpm") print(f"Default volume: {current_volume}") engine.setProperty("rate", 150) engine.setProperty("volume", 0.9) voices = engine.getProperty("voices") if len(voices) > 1: engine.setProperty("voice", voices[1].id) engine.say("Now I am speaking slower, and my volume is turned up.") engine.runAndWait()
- 1Import
pyttsx3. - 3
engine = pyttsx3.init()— Initialise. - 5–6
current_rate = engine.getProperty("rate")andcurrent_volume = engine.getProperty("volume")— Reads the current settings so you know what you're starting from. Useful for debugging — sometimes a system's default rate is wildly different from what you expect. - 7–8Print the current defaults. On most systems you'll see something like
Default rate: 200 wpmandDefault volume: 1.0. Rates vary by OS — Windows SAPI5 typically defaults to 200, espeak often defaults to 175. - 10
engine.setProperty("rate", 150)— Sets the speaking rate to 150 words per minute. Slower than the default 200. Try values like 100 (very slow), 150 (comfortable), 200 (normal), 300 (fast). Note: some engines interpret rate as a percentage rather than WPM — espeak, for example, uses a 80–450 range. Test empirically on your platform. - 11
engine.setProperty("volume", 0.9)— Sets volume to 90% of max. Range is 0.0 to 1.0. Values outside this range are usually clamped — but don't rely on that; keep values in [0.0, 1.0]. - 13
voices = engine.getProperty("voices")— Retrieves the list of available voices. - 14–15
if len(voices) > 1: engine.setProperty("voice", voices[1].id)— If at least two voices exist, select the second one. We passvoices[1].id, notvoices[1]— the engine expects the voice ID string, not the Voice object. This is a very common mistake for beginners. - 17
engine.say("Now I am speaking slower...")— Queue the text. It will be spoken with the rate, volume, and voice we just set. Note that properties are read when the engine actually plays the audio, not whensay()is called — so you can change properties betweensaycalls if you want different utterances to have different settings. - 18
engine.runAndWait()— Plays it.
🗣️ Listing Voices with Names and Languages
For a more useful voice picker, inspect each voice's attributes:
import pyttsx3 engine = pyttsx3.init() voices = engine.getProperty("voices") for i, v in enumerate(voices): langs = ", ".join(v.languages) if v.languages else "unknown" gender = getattr(v, "gender", "unknown") or "unknown" print(f"[{i}] {v.name}") print(f" ID: {v.id}") print(f" Languages: {langs}") print(f" Gender: {gender}\n")
- 1–2Import and initialise.
- 3Get the voices list.
- 5
for i, v in enumerate(voices):— Loop with index and voice object. - 6
langs = ", ".join(v.languages) if v.languages else "unknown"—v.languagesis a list like["en-US"]. Join into a comma-separated string. If the list is empty or missing, show "unknown". The ternary prevents anAttributeErrorif a voice doesn't exposelanguages. - 7
gender = getattr(v, "gender", "unknown") or "unknown"— Two layers of safety:getattrreturns "unknown" if the attribute doesn't exist; theor "unknown"handles the case where the attribute exists but isNone. Some voices report gender, some don't. - 8–11Print the four attributes. Two blank lines (
\n\n) between voices make the output readable.
Lesson 4 — Saving Speech to a File (pyttsx3)
Playing audio is great for demos, but real applications need to save speech to a file: to embed in a video, attach to an email, listen offline, or generate an audiobook. pyttsx3 supports this via save_to_file() — with important caveats.
💾 The save_to_file() Pattern
import pyttsx3 engine = pyttsx3.init() engine.setProperty("rate", 160) text = "This audio is being saved to a file for later listening." output_path = "speech.wav" engine.save_to_file(text, output_path) engine.runAndWait() print(f"Saved to {output_path}")
- 1Import
pyttsx3. - 3Initialise the engine.
- 4
engine.setProperty("rate", 160)— Set the speaking rate. This property applies to the saved audio too — whatever properties are set at the timesave_to_fileruns will be baked into the file. - 6
text = "..."— The text to convert to speech. - 7
output_path = "speech.wav"— The output file path. Use a.wavextension, not.mp3.pyttsx3uses each OS's native TTS engine, and those engines typically output WAV (SAPI5 outputs WAV, NSSpeechSynthesizer outputs AIFF on some macOS versions, espeak outputs WAV). Some platforms won't produce valid MP3s even if you name the file.mp3. - 9
engine.save_to_file(text, output_path)— Queues a "save this text to this file" operation. Likesay(), this just queues the operation — it doesn't actually write anything yet. - 10
engine.runAndWait()— Executes the queued operations. This is what actually synthesises the audio and writes the file. Without this call, the file is never created. - 12
print(f"Saved to {output_path}")— Confirmation. At this point the file should exist — but see the warning below.
save_to_file() works reliably on Windows (SAPI5) and most macOS setups (NSSpeechSynthesizer), but espeak on Linux frequently fails silently — it prints no error, but no file is created. If you're on Linux and the file doesn't appear, try running espeak -w output.wav "test" directly from the terminal to verify espeak itself works. If it does, you may need to switch to gTTS (Lesson 5) for file output.
📼 Converting WAV to MP3 (Optional)
If you need an MP3 (smaller files, wider compatibility), the pydub library can convert — but it requires ffmpeg installed on your system:
from pydub import AudioSegment audio = AudioSegment.from_wav("speech.wav") audio.export("speech.mp3", format="mp3") print("Converted to speech.mp3")
- 1
from pydub import AudioSegment— Import the audio-processing class. Install withpip install pydub. You also needffmpeginstalled on your system (brew install ffmpegon macOS,sudo apt install ffmpegon Linux, or download from ffmpeg.org on Windows). - 3
AudioSegment.from_wav("speech.wav")— Loads the WAV file into memory as anAudioSegmentobject. This object exposes dozens of audio operations (trimming, volume, fade, speed). - 4
audio.export("speech.mp3", format="mp3")— Encodes and writes an MP3 file. Theformat="mp3"tells pydub which codec to use. Behind the scenes, pydub calls ffmpeg. - 6Confirmation print.
pydub raises CouldntDecodeError or FileNotFoundError, it means ffmpeg isn't installed or isn't on your PATH. Check by running ffmpeg -version in your terminal.Lesson 5 — Google TTS with gTTS
pyttsx3 is fine for local use, but the voices are robotic and the file output is unreliable on some platforms. gTTS (Google Text-to-Speech) uses Google Translate's TTS endpoint. It's free, produces natural-sounding MP3 files, and supports many languages — but it requires an internet connection.
🌐 Generating Speech with gTTS
from gtts import gTTS text = "Hello, this is Google Text to Speech. I sound more natural than pyttsx3." tts = gTTS(text=text, lang="en", slow=False) tts.save("google_tts.mp3") print("Saved to google_tts.mp3")
- 1
from gtts import gTTS— Import the class. Install first withpip install gTTS. - 3
text = "..."— The text to speak. This gets sent to Google's servers — don't use it for anything confidential. - 5
tts = gTTS(text=text, lang="en", slow=False)— Creates agTTSobject. Three arguments: - —
text— The string to convert. Can be up to ~100 characters per request if you're calling repeatedly; for longer text, gTTS handles chunking internally, but rate limits apply. - —
lang— ISO 639-1 language code."en"for English,"es"for Spanish,"fr"for French,"ja"for Japanese, etc. Use"en-gb"for British accent,"en-au"for Australian,"en-in"for Indian English. - —
slow=False— WhenTrue, gTTS speaks more slowly — useful for language learners. WhenFalse, natural pace. - 6
tts.save("google_tts.mp3")— Writes the audio to an MP3 file. Unlikepyttsx3, this saves the file immediately — norunAndWaitneeded. gTTS handles the network call and MP3 encoding synchronously inside.save(). - 8Confirmation print.
🎧 Playing gTTS Audio Immediately
If you want to hear the audio right away (rather than just saving it), combine gTTS with a playback library. The simplest option is playsound:
from gtts import gTTS from playsound import playsound tts = gTTS(text="Playing audio directly from memory.", lang="en") tts.save("temp_speech.mp3") playsound("temp_speech.mp3") print("Playback complete.")
- 1Import
gTTS. - 2
from playsound import playsound— Import the play function. Install withpip install playsound. Note: newer versions ofplaysoundhave compatibility issues on Python 3.10+; if you hit problems, usepip install playsound==1.2.2to get the stable version. - 4–5Generate speech and save it to a temporary file. gTTS needs a file to write to — it can't play audio directly from memory with the
playsoundlibrary. - 7
playsound("temp_speech.mp3")— Plays the MP3 through your default audio device. Blocks until playback finishes — so the next line runs only after the audio completes. - 9Confirmation print — reached only after playback finishes.
pydub.playback. If playsound gives you trouble, install pydub and simpleaudio: pip install pydub simpleaudio (or pip install pydub pyaudio). Then: from pydub import AudioSegment; from pydub.playback import play; play(AudioSegment.from_mp3("temp_speech.mp3")). This is more reliable on Linux.🌍 Multilingual Example — Speak in Three Languages
from gtts import gTTS phrases = [ ("en", "Hello, how are you?"), ("es", "Hola, ¿cómo estás?"), ("fr", "Bonjour, comment allez-vous ?"), ] for lang, text in phrases: tts = gTTS(text=text, lang=lang) filename = f"hello_{lang}.mp3" tts.save(filename) print(f"Saved: {filename}")
- 3–7
phrases = [...]— A list of tuples. Each tuple is(language_code, text). This structure makes it easy to loop over many language/text pairs. - 9
for lang, text in phrases:— Tuple unpacking in the loop header.langgets the first element of each tuple,textgets the second. - 10
tts = gTTS(text=text, lang=lang)— Creates a newgTTSobject per iteration, with the current language and text. - 11
filename = f"hello_{lang}.mp3"— Builds a filename that includes the language code. So the outputs arehello_en.mp3,hello_es.mp3,hello_fr.mp3. Simple but effective — each language gets its own file. - 12
tts.save(filename)— Saves the audio. Note that each language uses the same voice infrastructure but with a different language model — so the Spanish version will sound like native Spanish, not English-with-Spanish-words. - 13Print confirmation for each file.
Lesson 6 — Amazon Polly: Production-Grade Neural TTS
Amazon Polly is a paid cloud service that produces neural voices — audio so natural it sounds like a professional voice actor. It's the same TTS that powers many commercial products. This lesson shows how to use it in Python via the AWS SDK (boto3), and covers the free tier (5 million characters per month, 12 months free).
🔐 Setting Up AWS Credentials
Before you can call Polly, you need AWS credentials. Here's the process:
- Create a free AWS account at
aws.amazon.com. - Go to IAM → Users → Create user.
- Name the user (e.g.
polly-user), choose "Programmatic access". - Attach the
AmazonPollyFullAccesspolicy. - Save the Access Key ID and Secret Access Key.
- Install the SDK:
pip install boto3. - Set credentials either via
aws configure(recommended) or environment variables.
aws_access_key_id="AKIA..." to GitHub, bots will find it within minutes and rack up charges on your account. Use aws configure or environment variables instead — always.
🧪 Generating Speech with Polly
import boto3 polly = boto3.client("polly", region_name="us-east-1") text = "Welcome to Amazon Polly. This voice is neural and sounds almost human." response = polly.synthesize_speech( Text=text, OutputFormat="mp3", VoiceId="Joanna", Engine="neural", ) with open("polly_output.mp3", "wb") as f: f.write(response["AudioStream"].read()) print("Saved polly_output.mp3")
- 1
import boto3— The AWS SDK for Python.boto3is the current version (boto2 is legacy). Install withpip install boto3. - 3
polly = boto3.client("polly", region_name="us-east-1")— Creates a client for the Polly service in the US East (N. Virginia) region. Region matters — voice availability varies by region.us-east-1has the widest selection, including all the neural voices. Other regions have fewer voices. If you don't have credentials set up yet, this line will fail with aNoCredentialsError. - 5The text to speak. Polly handles up to 3,000 characters per call (for long text, use the async
start_speech_synthesis_taskAPI). - 7
response = polly.synthesize_speech(...)— The API call. Returns a response dict with the audio stream. - 8
Text=text— The string to convert. Max 3,000 characters for the synchronous API. - 9
OutputFormat="mp3"— Audio format. Options:"mp3","ogg_vorbis","pcm". MP3 is the most universally playable. - 10
VoiceId="Joanna"— The voice name. Popular neural voices:Joanna(en-US female),Matthew(en-US male),Amy(en-GB female),Olivia(en-AU female),Lucia(es-ES female),Takumi(ja-JP male). Full list atdocs.aws.amazon.com/polly/latest/dg/voicelist.html. - 11
Engine="neural"— Choose the neural engine. This is what gives Polly its natural sound. Alternatives:"standard"(older, cheaper, more robotic) and"long-form"(for reading long text with consistent intonation). Neural costs more per character but sounds dramatically better. - 14
with open("polly_output.mp3", "wb") as f:— Opens a binary-write file. The"wb"mode is critical — audio is bytes, not text. Using"w"would corrupt the file with Unicode encoding errors. - 15
f.write(response["AudioStream"].read())—response["AudioStream"]is aStreamingBodyobject (a file-like object holding the audio data)..read()loads all its bytes into memory. Thenf.write(...)writes those bytes to disk. If the audio is very long, this could be memory-heavy — for those cases, useshutil.copyfileobj(response["AudioStream"], f)to stream instead. - 17Confirmation.
📊 Polly Pricing (as of 2026)
Standard engine: $4 per million characters.
Neural engine: $16 per million characters.
Long-form engine: $100 per million characters (used for audiobooks).
For perspective: the entire text of Pride and Prejudice (~700,000 characters) would cost about $11 with the neural engine. A 1,000-word blog post (~6,000 characters) costs about $0.10.
Lesson 7 — Reading from Files and URLs
Speaking a hardcoded string is fine for demos. But real applications speak content — a text file on disk, an article fetched from the web, or a paragraph extracted from a PDF. This lesson connects the TTS engines from Lessons 2–6 to text sources.
📄 Reading a Text File Aloud
import pyttsx3 from pathlib import Path engine = pyttsx3.init() engine.setProperty("rate", 170) file_path = Path("article.txt") if not file_path.exists(): print(f"File not found: {file_path}") else: text = file_path.read_text(encoding="utf-8") print(f"Read {len(text)} characters") engine.say(text) engine.runAndWait() print("Finished reading.")
- 1Import
pyttsx3. - 2
from pathlib import Path— The modern, cross-platform way to work with file paths. Always preferpathlibover string concatenation oros.path— it's cleaner and handles Windows vs Unix differences automatically. - 4–5Initialise the engine and set a comfortable rate of 170 wpm.
- 7
file_path = Path("article.txt")— Creates aPathobject pointing to the file. If you need an absolute path, usePath.cwd() / "article.txt"orPath.home() / "Documents" / "article.txt". - 9
if not file_path.exists():— Checks whether the file exists before trying to open it. This is defensive — if the file is missing, we print a helpful message rather than crashing withFileNotFoundError. - 10Error message with the path — this is much more useful than just "file not found", because it tells the user which file and where.
- 11
else:— The file exists, so we can safely read it. - 12
text = file_path.read_text(encoding="utf-8")— Reads the entire file as one string.encoding="utf-8"is explicit and important — without it, Python uses the system default, which mangles non-ASCII characters on many Windows systems.read_text()handles the file open, read, and close all in one call. - 13
print(f"Read {len(text)} characters")— Reports the file size. Useful for confirming you actually read the file you expected (a 3,000-char file that reports 15 chars probably means you read the wrong file). - 15
engine.say(text)— Queues the entire text for speech. Note: some TTS engines cut off at a certain length. If your text is very long, see the "chunking" pattern below. - 16
engine.runAndWait()— Plays it. - 18Confirmation, printed after the audio finishes.
✂️ Splitting Long Text into Speakable Chunks
import pyttsx3 import re engine = pyttsx3.init() def chunk_text(text, max_chars=500): sentences = re.split(r"(?<=[.!?])\s+", text) chunks = [] current = "" for s in sentences: if len(current) + len(s) + 1 <= max_chars: current = (current + " " + s).strip() else: if current: chunks.append(current) current = s if current: chunks.append(current) return chunks long_text = ( "This is the first sentence. Here is another one with more content. " "And a third. Finally, a fourth sentence to close." ) for i, chunk in enumerate(chunk_text(long_text, 80)): print(f"Chunk {i+1}: {chunk}") engine.say(chunk) engine.runAndWait()
- 1–2Import
pyttsx3andre(for regex-based sentence splitting). - 4Initialise the engine.
- 6
def chunk_text(text, max_chars=500):— Helper function. Default max chunk size is 500 characters — comfortable for most engines. - 7
sentences = re.split(r"(?<=[.!?])\s+", text)— Regex split. The(?<=[.!?])is a lookbehind: it matches a position preceded by.,!, or?, followed by whitespace. This splits the text at sentence boundaries while keeping the punctuation attached to the sentence. - 8
chunks = []andcurrent = ""— Initialize the accumulator list and the current in-progress chunk. - 9
for s in sentences:— Process one sentence at a time. - 10–11If adding this sentence would keep us under the limit, add it. The
+1accounts for the space we'll add between sentences. - 12–14Otherwise, this sentence would overflow the current chunk. Save the current chunk (if non-empty) and start a new one with this sentence.
- 15–16After the loop, don't forget the final chunk — the loop exits before appending it.
- 17Return the list of chunks.
- 19–22A multi-line string sample. Note the parentheses around the strings — Python allows adjacent strings to be concatenated when separated by whitespace, and parentheses make that work across multiple lines.
- 24
for i, chunk in enumerate(chunk_text(long_text, 80)):— Loop with an index over the returned chunks. We passmax_chars=80to force chunking in this small example — the actual output will produce 2–3 chunks. - 25Print the chunk number and content for debugging.
- 26–27
engine.say(chunk)thenengine.runAndWait()— Speak each chunk one at a time. CallingrunAndWaitper chunk ensures each finishes before the next starts, and gives a natural pause between chunks.
🌐 Reading an Article from a URL
Combine requests + BeautifulSoup + TTS to speak a web article aloud. This is the pattern used by podcast generators and accessibility tools.
import requests from bs4 import BeautifulSoup from gtts import gTTS url = "https://en.wikipedia.org/wiki/Text-to-speech" response = requests.get(url, timeout=10, headers={"User-Agent": "TTS-Reader/1.0"}) soup = BeautifulSoup(response.text, "html.parser") paragraphs = soup.select("#mw-content-text p")[:5] article_text = " ".join(p.get_text(strip=True) for p in paragraphs) print(f"Extracted {len(article_text)} characters") tts = gTTS(text=article_text, lang="en") tts.save("article_audio.mp3") print("Saved article_audio.mp3")
- 1–3Imports:
requestsfor the HTTP fetch,BeautifulSoupfor HTML parsing,gTTSfor speech. - 5
url = "..."— The article URL. - 6
response = requests.get(url, timeout=10, headers={"User-Agent": "TTS-Reader/1.0"})— Fetch with a timeout and a custom User-Agent. Wikipedia is generous with bots, but a descriptive UA is respectful. - 7
soup = BeautifulSoup(response.text, "html.parser")— Parse the HTML. - 10
paragraphs = soup.select("#mw-content-text p")[:5]— Select all<p>tags inside the article content container, then slice the first 5. Wikipedia's article body is inside<div id="mw-content-text">. This selects the article paragraphs while excluding nav, sidebars, and footer links. - 11
article_text = " ".join(p.get_text(strip=True) for p in paragraphs)— Extracts text from each paragraph tag and joins them with spaces. The generator expression insidejoinis more memory-efficient than a list comprehension — it doesn't hold all the strings in memory at once. - 13
Print the extracted character count — if it's suspiciously small (like 15 chars), the selector probably didn't match; if it's huge (like 100,000 chars), you're grabbing too much.- 15
tts = gTTS(text=article_text, lang="en")— Create the gTTS object. gTTS will chunk the long text internally.- 16
tts.save("article_audio.mp3")— Saves the whole article as one MP3. For a 5-paragraph Wikipedia intro, this is usually 2–3 minutes of audio and takes 5–10 seconds to generate.- 18
Confirmation.- 13
https://en.wikipedia.org/api/rest_v1/page/summary/{title}) that returns clean, pre-extracted article text — no HTML parsing needed. When a site offers an API, always prefer it over scraping the HTML.Lesson 8 — Building a CLI Text-to-Speech Tool
This lesson brings everything together into a single, reusable command-line tool. The goal: python tts.py input.txt -o output.mp3 -e gtts -v en — a small utility that reads any text file and speaks it, with options for engine, voice, rate, and output file.
🎯 The Design
argparse so the tool behaves like a proper Unix utility.Engine abstraction: One function per engine (
pyttsx3, gTTS) behind a common interface, so adding Polly later is a 20-line addition.Input flexibility: Accept a file path, a string, or stdin — so the tool works in pipelines (
cat article.txt | python tts.py -).Safe defaults: gTTS for output quality, pyttsx3 for instant playback, auto-detect from the output file extension.
🧱 Step 1 — The Argument Parser
import argparse from pathlib import Path def build_parser(): p = argparse.ArgumentParser( description="Convert text to speech from a file, string, or stdin." ) p.add_argument("source", nargs="?", default="-", help="File path, '-' for stdin, or a quoted string.") p.add_argument("-o", "--output", default=None, help="Save audio to this file (.mp3 or .wav). Omit to play.") p.add_argument("-e", "--engine", choices=["pyttsx3", "gtts"], default="gtts", help="Which TTS engine to use (default: gtts).") p.add_argument("-v", "--voice", default=None, help="Voice ID (pyttsx3) or language code (gTTS).") p.add_argument("-r", "--rate", type=int, default=170, help="Speech rate in words per minute (pyttsx3 only).") p.add_argument("--volume", type=float, default=1.0, help="Volume between 0.0 and 1.0 (pyttsx3 only).") return p if __name__ == "__main__": args = build_parser().parse_args() print(args)
- 1–2
argparsefor CLI parsing;Pathfor file handling. - 4
def build_parser():— Encapsulates parser construction in a function, which makes it importable and testable. - 5–7
ArgumentParser(description=...)— Creates the parser with a one-line description shown in--helpoutput. - 8–9
p.add_argument("source", nargs="?", default="-")— Positional argument.nargs="?"makes it optional.default="-"means "read from stdin if not provided". - 10–11
-o/--output— Destination file. DefaultNonemeans "play aloud, don't save". - 12–13
-e/--engine— Restricted topyttsx3orgttsbychoices. Defaultgttsbecause most users want to save an MP3 file. - 14–15
-v/--voice— Free-form string. Could be a voice ID or a language code depending on the engine. - 16–17
-r/--rate—type=intmeans argparse converts the string automatically and errors on non-numbers. - 18–19
--volume—type=floatfor decimal values. - 20Return the configured parser.
- 22–24
if __name__ == "__main__":— Only executes when the file is run directly, not when imported as a module. Parses args and prints the resulting namespace for inspection.
🔧 Step 2 — Reading Input from Any Source
import sys from pathlib import Path def read_input(source): if source == "-": if sys.stdin.isatty(): raise SystemExit("No input. Pass a file, a quoted string, or pipe via stdin.") return sys.stdin.read() p = Path(source) if p.is_file(): return p.read_text(encoding="utf-8") return source
- 1–2Import
sys(for stdin) andPath(for files). - 4
def read_input(source):— One function handles all three cases: stdin, file, or literal string. - 5
if source == "-":— The conventional Unix marker for stdin. - 6–7
sys.stdin.isatty()— True when the user is at a terminal and hasn't piped anything. In that case, block indefinitely waiting for input — so we exit with a helpful error instead. - 8
return sys.stdin.read()— Reads all piped input at once. - 10
p = Path(source)— Treat the argument as a path to check. - 11–12
if p.is_file(): return p.read_text(...)— If it's a real file, read it as UTF-8. - 14
return source— Fall back to treating the argument as literal text. Sopython tts.py "hello world"speaks "hello world" without needing a file.
⚙️ Step 3 — The Engine Functions
def speak_pyttsx3(text, output=None, voice=None, rate=170, volume=1.0): import pyttsx3 engine = pyttsx3.init() engine.setProperty("rate", rate) engine.setProperty("volume", volume) if voice: engine.setProperty("voice", voice) if output: engine.save_to_file(text, str(output)) else: engine.say(text) engine.runAndWait() def speak_gtts(text, output=None, voice="en"): from gtts import gTTS tts = gTTS(text=text, lang=voice or "en") if output: tts.save(str(output)) else: import tempfile, os from playsound import playsound with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f: tmp = f.name try: tts.save(tmp) playsound(tmp) finally: os.unlink(tmp)
- 1
def speak_pyttsx3(text, output=None, voice=None, rate=170, volume=1.0):— All pyttsx3-specific logic lives here.outputis optional — if omitted, we play aloud. - 2
import pyttsx3— Deliberately imported inside the function. This lets the tool run (with gTTS) on systems where pyttsx3 isn't installed. - 3–5Initialise and set properties. Properties are applied regardless of play-vs-save, since they affect the saved file too.
- 6–7
if voice: engine.setProperty("voice", voice)— Only set voice if the user passed one. Trying to set it toNonewould crash the engine. - 9–10If
outputis provided, queue a save operation.str(output)converts aPathobject to string — pyttsx3 doesn't acceptPathdirectly. - 11–12Otherwise, queue
say()for immediate playback. - 13
engine.runAndWait()— Executes whichever operation we queued. - 15
def speak_gtts(text, output=None, voice="en"):— gTTS usesvoiceas a language code, so the parameter is reused with a different meaning. - 16–17
gTTS(text=text, lang=voice or "en")— If voice is empty/None, default to English. - 18–19If saving to a file, call
tts.save()directly — the simplest case. - 20–22Otherwise, use
tempfile.NamedTemporaryFileto create a safe temporary path with a unique name.delete=Falsemeans Python won't auto-delete — we handle cleanup ourselves infinally. - 23–25
try/finallypattern: save the audio, play it, and always remove the temp file — even if playback errors out. Withoutfinally, a playback crash would leave the MP3 on disk.
🚀 Step 4 — Wiring It All Together
def main(): parser = build_parser() args = parser.parse_args() try: text = read_input(args.source) except SystemExit as e: parser.error(str(e)) if not text.strip(): parser.error("Input text is empty.") if args.engine == "pyttsx3": speak_pyttsx3( text, output=args.output, voice=args.voice, rate=args.rate, volume=args.volume, ) else: speak_gtts(text, output=args.output, voice=args.voice or "en") if args.output: print(f"Saved: {args.output}") if __name__ == "__main__": main()
- 1
def main():— Entry point. Keeps the module importable and testable. - 2–3Build the parser and parse
sys.argv. argparse handles--help, invalid choices, and type conversion automatically. - 5–6Read the input. This may raise
SystemExitfromread_inputwhen stdin is empty. - 7–8Catch
SystemExitand convert it toparser.error()— which prints a clean message and the usage line, exits with code 2, and behaves like any other argparse error. - 10–11
if not text.strip():— Refuse empty input. Without this check, pyttsx3 plays nothing and gTTS raises a confusing API error. - 13–20Dispatch to the correct engine.
pyttsx3gets all five parameters; the CLI defaults flow through. - 21–22gTTS only uses
voice(as language).args.voice or "en"handles the case where the user didn't pass-v. - 24–25Confirmation print — but only when actually saving, so a play-only invocation stays quiet (Unix-friendly).
- 27–28Standard entry-point guard.
📋 Full Usage Examples
# Play a quoted string aloud (default engine) python tts.py "Hello, world." # Read a file and play it with pyttsx3 python tts.py article.txt -e pyttsx3 # Save an MP3 with a British accent python tts.py article.txt -o speech.mp3 -v en-gb # Pipe from another program cat long_article.txt | python tts.py - -o article.mp3 # Slow, quiet pyttsx3 output to a WAV file python tts.py notes.txt -e pyttsx3 -r 120 --volume 0.6 -o notes.wav # Save in Spanish python tts.py "Hola, mundo" -o hola.mp3 -v es
Lesson 9 — Full Project & Best Practices
The
The final lesson builds a complete, production-style project: an audiobook generator that reads a long text file, splits it into chapters, generates one MP3 per chapter with progress tracking, and writes a manifest file. Along the way, we cover the best practices that separate a quick script from a tool you'd actually ship.
🎯 What We're Building
## Chapter 1: The Beginning.Output: A directory containing one MP3 per chapter (
01-the-beginning.mp3, etc.), plus a manifest.json describing what was generated.Features: Chapter detection, safe filename generation, chunked synthesis for long chapters, resume support (skip chapters already generated), progress reporting, and proper error handling.
📚 Step 1 — Parse the Chapters
import re from pathlib import Path from dataclasses import dataclass, field @dataclass class Chapter: number: int title: str body: str slug: str = "" def __post_init__(self): # Safe, filesystem-friendly slug from the title s = re.sub(r"[^a-z0-9]+", "-", self.title.lower()).strip("-") self.slug = f"{self.number:02d}-{s[:40]}" def parse_chapters(text): pattern = re.compile(r"^##\s*Chapter\s+(\d+)\s*[:\-–]\s*(.+)$", re.MULTILINE) matches = list(pattern.finditer(text)) if not matches: # No chapter markers: treat entire file as one chapter return [Chapter(1, "Full Text", text.strip())] chapters = [] for i, m in enumerate(matches): number = int(m.group(1)) title = m.group(2).strip() start = m.end() end = matches[i + 1].start() if i + 1 < len(matches) else len(text) chapters.append(Chapter(number, title, text[start:end].strip())) return chapters
- 1–3Imports:
refor pattern matching,Pathfor later file work,dataclassfor a clean data container. - 5
@dataclass— Auto-generates__init__,__repr__, and__eq__based on the annotated fields. Saves boilerplate for a simple record type. - 6–9Four fields:
number(int),title(str),body(str), andslug(str, default empty — computed post-init). - 11
__post_init__— A dataclass hook that runs automatically after the auto-generated__init__. Perfect place to compute derived fields likeslug. - 13
re.sub(r"[^a-z0-9]+", "-", self.title.lower())— Lowercases the title, then replaces every run of non-alphanumeric characters with a single dash. Produces URL- and filesystem-safe slugs. - 14
self.slug = f"{self.number:02d}-{s[:40]}"— Zero-padded chapter number (so files sort correctly:01-,02-, …,10-) plus the first 40 characters of the slug. Result:01-the-beginning. - 16
def parse_chapters(text):— The parsing logic. - 17
re.compile(r"^##\s*Chapter\s+(\d+)\s*[:\-–]\s*(.+)$", re.MULTILINE)— The chapter-header pattern:
•^##— a line starting with##
•\s*Chapter\s+— the word "Chapter" (with flexible whitespace)
•(\d+)— capture group 1: the chapter number
•\s*[:\-–]\s*— a colon, hyphen, or en-dash separator
•(.+)$— capture group 2: the title
•re.MULTILINE— makes^and$match at line boundaries, not just string boundaries. - 18
matches = list(pattern.finditer(text))— Finds every match. Converting to a list lets us index it (matches[i + 1]for the next chapter). - 19–21
if not matches:— Fallback: no chapter markers found. Treat the entire file as a single chapter titled "Full Text". Robust behavior for unstructured input. - 23
chapters = []— Accumulator. - 24
for i, m in enumerate(matches):— Loop with both index and match object. - 25–26Extract the number and title from the regex capture groups.
- 27–28
start = m.end()(end of the header line) andend = next chapter's start, or end of file. This defines the chapter body boundaries. The conditional handles the last chapter specially. - 29Slice the body and construct the
Chapterobject. - 30Return the list.
🎙️ Step 2 — Synthesize Each Chapter with Resume Support
import json, sys from pathlib import Path def chunk_text(text, max_chars=4500): sentences = re.split(r"(?<=[.!?])\s+", text) chunks, current = [], "" for s in sentences: if len(current) + len(s) + 1 <= max_chars: current = (current + " " + s).strip() else: if current: chunks.append(current) current = s if current: chunks.append(current) return chunks def synthesize_chapter(chapter, out_dir, lang="en", force=False): out_path = out_dir / f"{chapter.slug}.mp3" if out_path.exists() and not force: print(f" ↻ Skipping {out_path.name} (already exists)") return out_path from gtts import gTTS chunks = chunk_text(chapter.body) if len(chunks) == 1: gTTS(text=chunks[0], lang=lang).save(str(out_path)) else: # Multiple chunks: synthesize each, then concatenate with pydub import tempfile, os from pydub import AudioSegment combined = AudioSegment.empty() for i, chunk in enumerate(chunks): with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f: tmp = f.name try: gTTS(text=chunk, lang=lang).save(tmp) combined += AudioSegment.from_mp3(tmp) finally: os.unlink(tmp) combined.export(str(out_path), format="mp3") return out_path
- 1–2
json(for the manifest later) andPath. - 4
chunk_text(text, max_chars=4500)— Same function as Lesson 7, but with a higher limit (4,500) because Google TTS accepts longer chunks than most engines. - 15
def synthesize_chapter(chapter, out_dir, lang="en", force=False):—forceallows regenerating existing files; otherwise resume-skip. - 16
out_path = out_dir / f"{chapter.slug}.mp3"— Build the full output path usingPath / str. - 17–19Resume logic: if the file exists and
forceis False, print a skip message and return. This makes the tool re-runnable — a crash mid-batch means you resume where you left off instead of re-generating everything. - 21
from gtts import gTTS— Lazy import, keeping the function importable even if gTTS isn't installed. - 22
chunks = chunk_text(chapter.body)— Split into API-safe pieces. - 24–25Fast path: if there's only one chunk, synthesize directly to the output path — no temp files needed.
- 26–38Multi-chunk path: synthesize each chunk to a temp file, load it as a pydub
AudioSegment, and accumulate intocombinedvia+=. The+=operator onAudioSegmentconcatenates audio in-place. - 33–37
try/finallyensures the temp file is deleted even if MP3 loading fails.combined +=requires the temp MP3 to be fully readable, so the whole chunk must be present first. - 38
combined.export(str(out_path), format="mp3")— Write the concatenated audio as one MP3. Requires ffmpeg installed (see Lesson 4).
📦 Step 3 — Orchestration and Manifest
def generate_audiobook(input_path, out_dir, lang="en", force=False): input_path = Path(input_path) out_dir = Path(out_dir) out_dir.mkdir(parents=True, exist_ok=True) if not input_path.exists(): sys.exit(f"Input not found: {input_path}") text = input_path.read_text(encoding="utf-8") chapters = parse_chapters(text) print(f"Found {len(chapters)} chapter(s) in {input_path.name}") generated = [] for ch in chapters: print(f"[{ch.number}/{len(chapters)}] {ch.title}") try: path = synthesize_chapter(ch, out_dir, lang=lang, force=force) generated.append({ "number": ch.number, "title": ch.title, "file": path.name, "chars": len(ch.body), }) except Exception as e: print(f" ✗ Failed: {e}") continue manifest = { "source": input_path.name, "language": lang, "chapters": generated, "total_chapters": len(generated), } (out_dir / "manifest.json").write_text( json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8", ) print(f"\nDone. {len(generated)} file(s) in {out_dir}/") print(f"Manifest: {out_dir / 'manifest.json'}")
- 1–3Coerce both paths to
Pathobjects, then create the output directory.parents=Truecreates intermediate dirs (book/chapters/);exist_ok=Trueavoids errors if it already exists. - 5–6Fail fast if the input doesn't exist.
sys.exit()with a message is the cleanest way to signal a fatal CLI error. - 8–10Read the whole file, parse chapters, print a summary. Users need confirmation the tool saw the right file and the expected chapter count.
- 12–13
generated = []then loop over chapters. The progress line[1/10] Titleis essential UX for anything longer than a few seconds. - 15–21Call
synthesize_chapter, then append a metadata dict togenerated. Thecharsfield gives the user a rough sense of chapter length. - 22–24Error containment:
except Exception as e: print(...); continue— one failed chapter doesn't abort the whole book. This is the right call for long batch operations; users would rather have 9 of 10 chapters than 0. - 26–31Build the manifest dict. It records source, language, chapter list, and total. This becomes the metadata that downstream tools (players, catalogs) can rely on.
- 32–35Write
manifest.jsonwithindent=2(human-readable) andensure_ascii=False(preserves Unicode titles — important for non-English books). - 36–37Final summary, including the manifest path so the user knows where to look.
🏁 Step 4 — The Entry Point
import argparse if __name__ == "__main__": parser = argparse.ArgumentParser(description="Generate an audiobook from a text file.") parser.add_argument("input", help="Source text file with '## Chapter N: Title' headers.") parser.add_argument("-o", "--out", default="audiobook", help="Output directory.") parser.add_argument("-l", "--lang", default="en", help="Language code (default: en).") parser.add_argument("--force", action="store_true", help="Regenerate existing files.") args = parser.parse_args() generate_audiobook(args.input, args.out, lang=args.lang, force=args.force)
- 1
import argparse— Standard library, no install needed. - 3
if __name__ == "__main__":— Only runs when the file is invoked directly, not when imported. - 4
ArgumentParser(description=...)— Creates the parser with a one-line summary for--help. - 5
parser.add_argument("input", help=...)— Required positional argument. Users must pass a source file. - 6
-o/--out— Optional output directory, defaults toaudiobook. - 7
-l/--lang— Language code, defaults to English. - 8
--force—action="store_true"means it's a flag:--forcesets it toTrue, omitting it leaves itFalse. No value argument needed. - 9
args = parser.parse_args()— Parsessys.argvand returns a Namespace. Handles--helpand errors automatically. - 11
generate_audiobook(...)— Kick off the whole pipeline.
🧪 Running the Full Project
python audiobook.py my-book.txt -o output -l en # Force regeneration (overwrite existing files) python audiobook.py my-book.txt -o output --force # Non-English python audiobook.py libro.txt -o salida -l es
output/ containing 01-introduction.mp3, 02-the-journey-begins.mp3, … and manifest.json. A 10-chapter book at ~5,000 chars/chapter typically takes 60–90 seconds total on gTTS.
✅ Best Practices — What Separates a Script from a Tool
sys.exit(f"Input not found: {path}") beats a stack trace 30 seconds into processing.2. Idempotent by default. The tool should be safe to re-run. Resume-skip (
if out_path.exists() and not force) makes crashes cheap to recover from.3. Contain errors per unit. One bad chapter shouldn't kill the whole book. Wrap each iteration in
try/except and continue.4. Record what you did. The
manifest.json is the source of truth — it lets other tools index the output, and it lets you check what succeeded later.5. Never hardcode secrets. AWS keys, API tokens — use environment variables or
aws configure. Never commit secrets to git.6. Choose the right engine per task. Offline speech (pyttsx3) for privacy and speed; cloud (gTTS, Polly) for natural voice and language coverage.
7. Chunk at sentence boundaries. Never split mid-word. The regex lookbehind pattern
re.split(r"(?<=[.!?])\s+", text) is the workhorse.8. Clean up temp files. Use
tempfile.NamedTemporaryFile(delete=False) plus try/finally: os.unlink(...) so a crash never leaves garbage behind.9. Log progress.
print(f"[{i}/{n}] {title}") is one line of code and worth its weight in gold when processing takes minutes.10. Respect rate limits and terms. gTTS and Polly both have quotas. Add
time.sleep(1) between requests in batch jobs; use Polly's async API for very long jobs.
📈 What to Build Next
- Add Polly as a third engine — the abstraction makes this a 20-line addition:
speak_polly(text, output, voice, engine="neural"). - Add SRT subtitles — generate a subtitle track alongside the audio by timing each sentence.
- Web API — expose the same pipeline via FastAPI, so a web UI can generate books on demand.
- Multi-voice — detect speakers ("Alice said:", "Bob replied:") and assign a different voice to each.
- ID3 tagging — use
mutagento write title, author, chapter number, and cover art into each MP3. - M4B output — a single chapter-indexed audiobook file (like Audible) using
ffmpeg.
📋 Text-to-Speech Cheat Sheet
Every pattern from the 9 lessons, on one page
🔊 pyttsx3 — Offline TTS
import pyttsx3 engine = pyttsx3.init() engine.setProperty("rate", 170) # words per minute engine.setProperty("volume", 1.0) # 0.0 to 1.0 engine.setProperty("voice", voice_id) # voice ID string voices = engine.getProperty("voices") for v in voices: print(v.id, v.name) engine.say("Hello, world") engine.runAndWait() engine.save_to_file("Save me", "out.wav") engine.runAndWait() engine.stop()
🌐 gTTS — Google TTS (Online)
from gtts import gTTS tts = gTTS(text="Hello", lang="en", slow=False) tts.save("out.mp3") # Language codes: en, en-gb, en-au, en-in, es, fr, de, it, ja, ko, zh-CN, pt, ru gTTS(text="Hola", lang="es", slow=True).save("slow.mp3")
☁️ Amazon Polly — Neural Voices
import boto3 polly = boto3.client("polly", region_name="us-east-1") response = polly.synthesize_speech( Text="Hello from Polly", OutputFormat="mp3", VoiceId="Joanna", Engine="neural", ) with open("out.mp3", "wb") as f: f.write(response["AudioStream"].read()) # Popular neural voices: # en-US: Joanna, Matthew, Danielle, Gregory # en-GB: Amy, Emma, Brian, Arthur # en-AU: Olivia, Nicole, Russell # es-ES: Lucia, Mia # ja-JP: Takumi, Kazuha
🎧 Playing Audio
from playsound import playsound playsound("out.mp3") from pydub import AudioSegment from pydub.playback import play play(AudioSegment.from_mp3("out.mp3")) audio = AudioSegment.from_wav("in.wav") audio.export("out.mp3", format="mp3") audio.export("out.ogg", format="ogg")
📄 Reading Text Sources
from pathlib import Path text = Path("article.txt").read_text(encoding="utf-8") import requests from bs4 import BeautifulSoup r = requests.get(url, timeout=10) soup = BeautifulSoup(r.text, "html.parser") text = " ".join(p.get_text(strip=True) for p in soup.select("article p")) import sys if not sys.stdin.isatty(): text = sys.stdin.read()
✂️ Chunking Long Text
import re def chunk_text(text, max_chars=500): sentences = re.split(r"(?<=[.!?])\s+", text) chunks, current = [], "" for s in sentences: if len(current) + len(s) + 1 <= max_chars: current = (current + " " + s).strip() else: if current: chunks.append(current) current = s if current: chunks.append(current) return chunks
📦 Installation Summary
pip install pyttsx3 pip install gTTS pip install boto3 pip install playsound==1.2.2 pip install pydub simpleaudio # Linux also needs: sudo apt install espeak espeak-data libespeak1 libespeak-dev # pydub also needs ffmpeg: brew/apt install ffmpeg
Frequently Asked Questions
Common questions about Python text-to-speech
It depends on your constraints. Use pyttsx3 when you need offline operation or privacy (text never leaves your machine). Use gTTS when you want natural-sounding audio, free of charge, and don't mind sending text to Google. Use Amazon Polly when voice quality is critical and you can pay per character. For most prototypes and personal projects, gTTS hits the best balance of quality and simplicity.
The most common cause is forgetting to call engine.runAndWait() — say() only queues text, it doesn't speak. If you're calling runAndWait and still hearing nothing: (1) check your system volume, (2) on Linux, verify espeak works by running espeak "test" in a terminal, (3) some systems have all audio muted at the driver level, (4) try switching the voice — some voice IDs are installed but broken. Try engine.setProperty("voice", voices[0].id) to force the first available voice.
This is a known issue: pyttsx3's espeak backend (used on Linux) frequently fails save_to_file silently — no error, just no file. The fix: switch to gTTS for file output (gTTS(text).save("out.mp3") works reliably on all platforms). If you must stay offline, call espeak directly via subprocess.run(["espeak", "-w", "out.wav", text]) — that's the underlying command pyttsx3 would have run.
Yes, but with two important caveats. First, gTTS uses Google Translate's public TTS endpoint — it's not an officially supported Google API and could change or be rate-limited without notice. Second, Google applies IP-based rate limits — if you generate hundreds of files in quick succession, you may be temporarily blocked. For production or heavy use, switch to Amazon Polly, Azure Speech, or the official Google Cloud Text-to-Speech API (which is paid but stable and documented).
Yes, but only with proper consent. Amazon Polly supports custom voices via Brand Voice (enterprise). ElevenLabs and Azure Custom Neural Voice offer voice cloning from short samples. Ethically and legally: you must have explicit permission from the person whose voice you're cloning. Cloning someone's voice without consent — even for a "harmless" project — can violate laws like Tennessee's ELVIS Act (2024), the EU AI Act, and similar legislation emerging worldwide. Never clone a public figure's voice without their written consent.
With gTTS, use the lang argument: gTTS(text, lang="es") for Spanish, "fr" for French, "ja" for Japanese, etc. The full list is at gtts.lang.tts_langs() or the gTTS docs. With pyttsx3, you have to find a voice whose .languages attribute includes the target language — availability depends entirely on what's installed on your OS. With Amazon Polly, choose a VoiceId whose native language is your target (e.g. Lucia for Spanish, Takumi for Japanese).
Yes. With Amazon Polly, response["AudioStream"] is a streaming object you can pipe directly to an audio player. With the async Polly API (start_speech_synthesis_task), you get an S3 URL when audio is ready — useful for very long text. For pyttsx3, audio isn't directly streamable — it goes straight to OS speakers (which is effectively streaming to your ears). For web apps, gTTS + Flask/FastAPI can serve generated MP3s as streaming HTTP responses with Content-Type: audio/mpeg.
It depends. Converting your own text (that you wrote) into audio is fine for any purpose. Converting a book you bought into an audiobook is a gray area — some publishers allow it, most don't, and the legal status varies by jurisdiction. For commercial use (selling audiobooks), always check the source material's license: public domain works (Project Gutenberg, etc.) are safe; copyrighted works require permission. Amazon Polly's terms allow commercial use of generated audio, but that only covers the TTS service — it doesn't grant you rights to the underlying text.
📬 Python, weekly
Short lessons, real code, and ecosystem news — sent about once a week
🏁 You've Finished the Course
Nine lessons, dozens of code examples, and one complete audiobook project — you now have the practical skills to add text-to-speech to any Python project. From here, the path is built by building.
🚀 Your Next Steps
- Build an audiobook generator — one MP3 per chapter, with a chapter index, from any EPUB or text file.
- Add TTS to a chatbot — stream LLM responses through gTTS or Polly to hear ChatGPT-style conversations.
- Build a voice assistant — combine
speech_recognition(STT) with TTS for a full voice loop. - Explore neural TTS — try Azure Neural TTS or ElevenLabs for voices almost indistinguishable from humans.
- Learn audio processing —
pydub,librosa, andsoundfileopen up mixing, normalisation, and sound design.
📚 Related Lessons on Poetic Bytes
Web Scraping
Fetch text from any website — combine with TTS to speak articles.
Open lesson →File Handling
Read files, save audio, work with binary data.
Open lesson →Consuming APIs
Call Polly, ElevenLabs, and other cloud TTS services.
Open lesson →Async Python
Generate many TTS files concurrently for batch jobs.
Open lesson →Poetic Bytes · 2026 · Written and maintained by Glenn Junsay Pansensoy