9 Lessons · Python 3.10+ · Updated 2026

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.

hello_tts.py
import pyttsx3

engine = pyttsx3.init()
engine.say("Hello! Python can speak.")
engine.runAndWait()
🔊 (audio plays through your speakers)
🔊 "Hello! Python can speak."

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

How to read this course: Every lesson has a short concept explanation, a code block, and a line-by-line breakdown that explains every statement. Don't skip the breakdowns — that's where the real learning happens.
BeginnerLesson 1 of 9·~7 min read

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

Key idea: A TTS system takes a string, analyses it (pronunciation, intonation, pauses), synthesises a waveform, and outputs audio — either through speakers or saved as a file. Python gives you several ways to do this, from 5-line scripts to production cloud services.

🧰 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?

Use offline (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.

python
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 with pip install pyttsx3. On Linux you also need sudo 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 a Voice object 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, using enumerate to get both the index i and 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.
Run Locally Only — 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.
BeginnerLesson 2 of 9·~9 min read

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

python
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 multiple say() 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–9
    The second say + runAndWait pair. Same pattern — queue then execute. You could also queue both say calls and then call runAndWait once 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 both runAndWait() calls have returned, meaning both sentences have finished playing. Without a block like runAndWait, the script would exit immediately and you'd hear nothing (or audio would cut off mid-sentence).
💡 Why 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:

python
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 because runAndWait() 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–8
    Queue two sentences with say(). They'll play one after the other.
  • 9
    engine.runAndWait() — Starts speaking. Blocks this thread until both sentences finish — or until engine.stop() is called from another thread.
  • 11
    thread = threading.Thread(target=speak) — Creates a thread object that will run the speak function 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. Since engine.stop() caused runAndWait to 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.
⚠️ On some systems, calling 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.
BeginnerLesson 3 of 9·~10 min read
BeginnerLesson 3 of 9·~10 min read

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 from engine.getProperty("voices").

You set these with engine.setProperty(name, value) and read them with engine.getProperty(name).

🧪 Runnable Example — Slow, Loud, Custom Voice

python
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()
  • 1
    Import pyttsx3.
  • 3
    engine = pyttsx3.init() — Initialise.
  • 5–6
    current_rate = engine.getProperty("rate") and current_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–8
    Print the current defaults. On most systems you'll see something like Default rate: 200 wpm and Default 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 pass voices[1].id, not voices[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 when say() is called — so you can change properties between say calls if you want different utterances to have different settings.
  • 18
    engine.runAndWait() — Plays it.
💡 Discovering what voices exist: The voice list differs wildly between platforms. Windows usually has "David" and "Zira" (male/female English). macOS has "Alex", "Samantha", "Victoria", etc. Linux with espeak has a dozen variants (en, en-us, en-gb, etc.). Print the voices to see what's available before guessing.

🗣️ Listing Voices with Names and Languages

For a more useful voice picker, inspect each voice's attributes:

python
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–2
    Import and initialise.
  • 3
    Get 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.languages is a list like ["en-US"]. Join into a comma-separated string. If the list is empty or missing, show "unknown". The ternary prevents an AttributeError if a voice doesn't expose languages.
  • 7
    gender = getattr(v, "gender", "unknown") or "unknown" — Two layers of safety: getattr returns "unknown" if the attribute doesn't exist; the or "unknown" handles the case where the attribute exists but is None. Some voices report gender, some don't.
  • 8–11
    Print the four attributes. Two blank lines (\n\n) between voices make the output readable.
✨ Voice IDs are the only reliable selector. Voice names can be duplicated or changed between OS updates; voice IDs are stable. Always store and use the ID, even if you display the name to users.
IntermediateLesson 4 of 9·~10 min read

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

python
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}")
  • 1
    Import pyttsx3.
  • 3
    Initialise 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 time save_to_file runs 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 .wav extension, not .mp3. pyttsx3 uses 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. Like say(), 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.
Platform-specific gotcha: 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:

python
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 with pip install pydub. You also need ffmpeg installed on your system (brew install ffmpeg on macOS, sudo apt install ffmpeg on Linux, or download from ffmpeg.org on Windows).
  • 3
    AudioSegment.from_wav("speech.wav") — Loads the WAV file into memory as an AudioSegment object. This object exposes dozens of audio operations (trimming, volume, fade, speed).
  • 4
    audio.export("speech.mp3", format="mp3") — Encodes and writes an MP3 file. The format="mp3" tells pydub which codec to use. Behind the scenes, pydub calls ffmpeg.
  • 6
    Confirmation print.
⚠️ If 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.
IntermediateLesson 5 of 9·~11 min read

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

python
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 with pip 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 a gTTS object. 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 — When True, gTTS speaks more slowly — useful for language learners. When False, natural pace.
  • 6
    tts.save("google_tts.mp3") — Writes the audio to an MP3 file. Unlike pyttsx3, this saves the file immediately — no runAndWait needed. gTTS handles the network call and MP3 encoding synchronously inside .save().
  • 8
    Confirmation print.
💡 Speed of gTTS: Each request usually takes 1–3 seconds because it's making an HTTP call to Google. For bulk generation of many files, consider batching or using a local TTS for speed. For infrequent, high-quality generation, gTTS is excellent.

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

python
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.")
  • 1
    Import gTTS.
  • 2
    from playsound import playsound — Import the play function. Install with pip install playsound. Note: newer versions of playsound have compatibility issues on Python 3.10+; if you hit problems, use pip install playsound==1.2.2 to get the stable version.
  • 4–5
    Generate 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 playsound library.
  • 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.
  • 9
    Confirmation print — reached only after playback finishes.
✨ Alternative: 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

python
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. lang gets the first element of each tuple, text gets the second.
  • 10
    tts = gTTS(text=text, lang=lang) — Creates a new gTTS object 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 are hello_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.
  • 13
    Print confirmation for each file.
AdvancedLesson 6 of 9·~12 min read

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:

  1. Create a free AWS account at aws.amazon.com.
  2. Go to IAM → Users → Create user.
  3. Name the user (e.g. polly-user), choose "Programmatic access".
  4. Attach the AmazonPollyFullAccess policy.
  5. Save the Access Key ID and Secret Access Key.
  6. Install the SDK: pip install boto3.
  7. Set credentials either via aws configure (recommended) or environment variables.
Never hardcode AWS credentials in source files. If you commit a script with 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

python
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. boto3 is the current version (boto2 is legacy). Install with pip 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-1 has 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 a NoCredentialsError.
  • 5
    The text to speak. Polly handles up to 3,000 characters per call (for long text, use the async start_speech_synthesis_task API).
  • 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 at docs.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 a StreamingBody object (a file-like object holding the audio data). .read() loads all its bytes into memory. Then f.write(...) writes those bytes to disk. If the audio is very long, this could be memory-heavy — for those cases, use shutil.copyfileobj(response["AudioStream"], f) to stream instead.
  • 17
    Confirmation.

📊 Polly Pricing (as of 2026)

Free tier: 5 million characters per month for 12 months (both neural and standard combined).

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.
IntermediateLesson 7 of 9·~10 min read

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

python
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.")
  • 1
    Import pyttsx3.
  • 2
    from pathlib import Path — The modern, cross-platform way to work with file paths. Always prefer pathlib over string concatenation or os.path — it's cleaner and handles Windows vs Unix differences automatically.
  • 4–5
    Initialise the engine and set a comfortable rate of 170 wpm.
  • 7
    file_path = Path("article.txt") — Creates a Path object pointing to the file. If you need an absolute path, use Path.cwd() / "article.txt" or Path.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 with FileNotFoundError.
  • 10
    Error 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.
  • 18
    Confirmation, printed after the audio finishes.
Chunking long text: Some engines truncate audio at ~32,000 characters or speak in a strained voice for very long text. Split long text at sentence boundaries and speak each chunk in sequence — see the pattern below.

✂️ Splitting Long Text into Speakable Chunks

python
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–2
    Import pyttsx3 and re (for regex-based sentence splitting).
  • 4
    Initialise 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 = [] and current = "" — Initialize the accumulator list and the current in-progress chunk.
  • 9
    for s in sentences: — Process one sentence at a time.
  • 10–11
    If adding this sentence would keep us under the limit, add it. The +1 accounts for the space we'll add between sentences.
  • 12–14
    Otherwise, this sentence would overflow the current chunk. Save the current chunk (if non-empty) and start a new one with this sentence.
  • 15–16
    After the loop, don't forget the final chunk — the loop exits before appending it.
  • 17
    Return the list of chunks.
  • 19–22
    A 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 pass max_chars=80 to force chunking in this small example — the actual output will produce 2–3 chunks.
  • 25
    Print the chunk number and content for debugging.
  • 26–27
    engine.say(chunk) then engine.runAndWait() — Speak each chunk one at a time. Calling runAndWait per 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.

python
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–3
    Imports: requests for the HTTP fetch, BeautifulSoup for HTML parsing, gTTS for 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 inside join is 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.
💡 Use the API when available. Wikipedia offers a REST API (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.
AdvancedLesson 8 of 9·~12 min read

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

Command-line interface: Use 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

python
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
    argparse for CLI parsing; Path for 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 --help output.
  • 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. Default None means "play aloud, don't save".
  • 12–13
    -e/--engine — Restricted to pyttsx3 or gtts by choices. Default gtts because 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=int means argparse converts the string automatically and errors on non-numbers.
  • 18–19
    --volume — type=float for decimal values.
  • 20
    Return 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

python
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–2
    Import sys (for stdin) and Path (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. So python tts.py "hello world" speaks "hello world" without needing a file.

⚙️ Step 3 — The Engine Functions

python
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. output is 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–5
    Initialise 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 to None would crash the engine.
  • 9–10
    If output is provided, queue a save operation. str(output) converts a Path object to string — pyttsx3 doesn't accept Path directly.
  • 11–12
    Otherwise, queue say() for immediate playback.
  • 13
    engine.runAndWait() — Executes whichever operation we queued.
  • 15
    def speak_gtts(text, output=None, voice="en"): — gTTS uses voice as 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–19
    If saving to a file, call tts.save() directly — the simplest case.
  • 20–22
    Otherwise, use tempfile.NamedTemporaryFile to create a safe temporary path with a unique name. delete=False means Python won't auto-delete — we handle cleanup ourselves in finally.
  • 23–25
    try/finally pattern: save the audio, play it, and always remove the temp file — even if playback errors out. Without finally, a playback crash would leave the MP3 on disk.

🚀 Step 4 — Wiring It All Together

python
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–3
    Build the parser and parse sys.argv. argparse handles --help, invalid choices, and type conversion automatically.
  • 5–6
    Read the input. This may raise SystemExit from read_input when stdin is empty.
  • 7–8
    Catch SystemExit and convert it to parser.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–20
    Dispatch to the correct engine. pyttsx3 gets all five parameters; the CLI defaults flow through.
  • 21–22
    gTTS only uses voice (as language). args.voice or "en" handles the case where the user didn't pass -v.
  • 24–25
    Confirmation print — but only when actually saving, so a play-only invocation stays quiet (Unix-friendly).
  • 27–28
    Standard entry-point guard.

📋 Full Usage Examples

bash
# 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
AdvancedLesson 9 of 9·~14 min read

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

Input: A plain-text file with chapters marked by lines like ## 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

python
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–3
    Imports: re for pattern matching, Path for later file work, dataclass for 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–9
    Four fields: number (int), title (str), body (str), and slug (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 like slug.
  • 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–26
    Extract the number and title from the regex capture groups.
  • 27–28
    start = m.end() (end of the header line) and end = next chapter's start, or end of file. This defines the chapter body boundaries. The conditional handles the last chapter specially.
  • 29
    Slice the body and construct the Chapter object.
  • 30
    Return the list.

🎙️ Step 2 — Synthesize Each Chapter with Resume Support

python
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) and Path.
  • 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): — force allows regenerating existing files; otherwise resume-skip.
  • 16
    out_path = out_dir / f"{chapter.slug}.mp3" — Build the full output path using Path / str.
  • 17–19
    Resume logic: if the file exists and force is 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–25
    Fast path: if there's only one chunk, synthesize directly to the output path — no temp files needed.
  • 26–38
    Multi-chunk path: synthesize each chunk to a temp file, load it as a pydub AudioSegment, and accumulate into combined via +=. The += operator on AudioSegment concatenates audio in-place.
  • 33–37
    try/finally ensures 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).
💡 Why concatenate chunks instead of one big gTTS call? gTTS silently splits long text internally, but it doesn't give you control over where those splits happen — sometimes mid-word or mid-sentence, causing awkward pauses. Explicit sentence-boundary chunking sounds noticeably better and also keeps individual requests short enough to retry cheaply on network errors.

📦 Step 3 — Orchestration and Manifest

python
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–3
    Coerce both paths to Path objects, then create the output directory. parents=True creates intermediate dirs (book/chapters/); exist_ok=True avoids errors if it already exists.
  • 5–6
    Fail fast if the input doesn't exist. sys.exit() with a message is the cleanest way to signal a fatal CLI error.
  • 8–10
    Read 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] Title is essential UX for anything longer than a few seconds.
  • 15–21
    Call synthesize_chapter, then append a metadata dict to generated. The chars field gives the user a rough sense of chapter length.
  • 22–24
    Error 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–31
    Build the manifest dict. It records source, language, chapter list, and total. This becomes the metadata that downstream tools (players, catalogs) can rely on.
  • 32–35
    Write manifest.json with indent=2 (human-readable) and ensure_ascii=False (preserves Unicode titles — important for non-English books).
  • 36–37
    Final summary, including the manifest path so the user knows where to look.

🏁 Step 4 — The Entry Point

python
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 to audiobook.
  • 7
    -l/--lang — Language code, defaults to English.
  • 8
    --force — action="store_true" means it's a flag: --force sets it to True, omitting it leaves it False. No value argument needed.
  • 9
    args = parser.parse_args() — Parses sys.argv and returns a Namespace. Handles --help and errors automatically.
  • 11
    generate_audiobook(...) — Kick off the whole pipeline.

🧪 Running the Full Project

bash
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
Expected output: A folder 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

1. Fail loudly, fail early. Check for missing files, empty input, and missing dependencies before doing any real work. 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 mutagen to 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

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

python
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

python
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

python
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

python
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

python
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

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

🏁 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, and soundfile open up mixing, normalisation, and sound design.

📚 Related Lessons on Poetic Bytes

Poetic Bytes · 2026 · Written and maintained by Glenn Junsay Pansensoy