Turn an Article Into a Narrated MP3

Turn markdown articles into narrated MP3 files with OpenRouter text to speech.

Introduction

30 Second Summary

Written articles can be hard to fit into a busy day. A narration lets your words travel with you.

In this project, you will turn your own Markdown article into a narrated MP3 through OpenRouter's text to speech endpoint. Your script will process a full document into audio that plays from beginning to end.

What You'll Build

Your finished MP3 will narrate your own article from the first sentence to the last.

By the end of this project, you'll have:

  • A voice comparison that lets you hear the same sentence in two different voices.
  • A long-document narration where audio chunks appear one by one as your article is processed.
  • A finished MP3 that plays your article from start to finish. Your script will surface readable API errors before they become broken audio files.
  • Secret Mission: An optional challenge to push your skills further.

Are there any prerequisites?

You'll need basic familiarity with terminal commands plus either Python or Node installed on your computer. You'll also need an OpenRouter account with credit because the speech endpoint is pay per use.

Before We Start

A reliable narration workflow needs the right tools before any text reaches a speech service. Python 3 processes your article. FFmpeg joins the generated audio parts into one file.

You also need an account with OpenRouter, which provides an OpenAI-compatible text-to-speech endpoint. This step prepares those pieces without sending an API request.

In this step, get ready to:
  • Confirm that Python 3 is available in your terminal.
  • Confirm that FFmpeg is installed for joining audio files.
  • Set up your provider account and prepare article.md.
Verify Python and FFmpeg

The terminal gives you a direct way to confirm that both tools are ready. Each check prints version information when the command is available.

  • Press Cmd+Space on macOS or the Windows key on Windows to open system search.
  • Type terminal into the search field.
  • Select the terminal application shown in the results.

Python uses a different recommended command on each operating system. Run the command for your computer.

macOS

  • Check your Python version by running this command:
python3 --version

What does this command do?

The command asks the Python 3 executable to print its installed version. A result beginning with Python 3 confirms that the interpreter is available.

Windows

  • Check your Python version by running this command:
py --version

What does this command do?

The command asks the Python launcher to print the active interpreter version. A result beginning with Python 3 confirms that the interpreter is available.

Choose the tab that matches the result in your terminal.

✔️ I see Python 3

Your Python interpreter is ready. You can use the version shown in your terminal for this project.

ⓧ I do not see Python 3

Python needs to be installed when the command is missing. An older major version also needs a current Python 3 installation.

  • Visit the official Python download page.
  • Choose the current Python 3 installer for your operating system.
  • Run the downloaded installer using its default options.
  • Return to the terminal from earlier after the installation finishes.

macOS

  • Confirm the new installation by running this command:
python3 --version
What should I see?

You should see output beginning with Python 3. This confirms that the terminal can find the installed interpreter.

Windows

  • Confirm the new installation by running this command:
py --version
What should I see?

You should see output beginning with Python 3. This confirms that the Python launcher can find the installed interpreter.

FFmpeg performs the final audio join later in the project. Its version command is the same on macOS and Windows.

  • Check that FFmpeg is available by running this command:
ffmpeg -version

What does this command do?

The -version option prints the installed FFmpeg version plus its build details. Any FFmpeg version output confirms that your terminal can find the tool.

Choose the tab that matches what you see.

✔️ I see FFmpeg version details

Both local tools are now available. Your machine can process the article text and join the generated audio parts.

ⓧ Command not found

FFmpeg publishes its source code and links to compiled builds for common operating systems. Use the download option that matches your computer.

  • Visit the official FFmpeg download page.
  • Select the build option for your operating system.
  • Follow the linked build provider's installation instructions.
  • Return to the terminal from earlier after the installation finishes.
  • Confirm the installation by running this command:
ffmpeg -version

What should I see?

You should see FFmpeg version information followed by configuration details. This output confirms that the terminal can find the installed tool.

Still missing a local tool?

Close your terminal after the installer finishes. Start a fresh terminal session so it can load updated command paths.

If the command still fails, help me diagnose why Python or FFmpeg is unavailable.

Prepare your provider account

The speech endpoint runs remotely, so your script needs a provider account with available credit. OpenRouter bills text-to-speech models by input character. The exact rate depends on the model.

Adding credit touches billing, which deserves a careful pause. You choose how much to add before any request is sent.

  • Visit OpenRouter in your browser.
  • Create an account using the provider's sign-up flow.
  • Review the current text-to-speech model pricing before adding credit.
  • Add credit using the provider's billing flow.
  • Record the API base URL as https://openrouter.ai/api/v1.

Why record the base URL?

An API base URL tells your future requests which service receives them. OpenRouter uses the same base URL with OpenAI-compatible clients.

You are preparing access here without creating a key or sending a request. The first audio test comes next.

Create your source article

A Markdown file keeps your article readable while preserving headings and links for later cleanup. The narration script uses article.md as its source.

  • Use system search to open a plain-text editor on your computer.
  • Create a new document in the editor.
  • Add the source text below to the document:
Learner-authored markdown article that will be converted into narrated audio.

What does this file contain?

This file holds the writing that the later script reads and converts into speech. Keeping the source in article.md gives every processing step one predictable input file.

  • Save the document to your Desktop as article.md.
  • Check the editor's file name to confirm that it ends with .md.
  • Compare the visible sentence with the source text above.

File saved with the wrong extension?

Some text editors add .txt automatically. Rename the file so its complete name is article.md.

If the extension stays hidden, help me save a plain-text file with a .md extension.

Use these tabs to compare your finished source file.

✔️ Awesome, I've got everything!

Great work. Make sure article.md is saved before the final readiness check.

ⓧ I'd like to double check the full code

Learner-authored markdown article that will be converted into narrated audio.

How should I use this reference?

Compare this complete file with your saved article.md. The wording should match exactly.

Before you run the final check, do you expect both commands to return version information?

macOS

  • Run the final local-tool check with these commands:
python3 --version
ffmpeg -version

What does this check prove?

The first command confirms that Python 3 is available. The second confirms that FFmpeg can run from the same terminal.

Windows

  • Run the final local-tool check with these commands:
py --version
ffmpeg -version

What does this check prove?

The first command confirms that Python 3 is available. The second confirms that FFmpeg can run from the same terminal.

You should see Python 3 version output followed by FFmpeg version details. Your editor should show the saved article.md file with the source sentence intact.

Your local tools, provider account, API base URL, and source article are ready. Next, you will create an API key and turn your first sentence into an MP3.

Generate Your First MP3

Your article is ready, but the speech service has not received a request yet. The quickest proof that the connection works is a short sample you can hear.

In this step, you will create an API key for OpenRouter. A single curl request will turn one sentence into an MP3 you can play locally.

In this step, get ready to:
  • Create an API key without putting it in your project files.
  • Load API_KEY and API_BASE_URL into your terminal session.
  • Generate and play first-sample.mp3.
Create and load your API key

An API key authorizes your request. An environment variable makes the key available to curl without storing it in article.md.

Your new key is a live credential that should stay out of screenshots. Keep the terminal line containing it outside anything you share.

  • Return to the OpenRouter API keys page in your browser.
  • Create an API key for this narration project.
  • Copy the new key when OpenRouter displays it.
  • Replace your-api-key-here in the commands below with your copied key.
  • Load the key and base URL into your current terminal session by running these commands:
export API_KEY="your-api-key-here"
export API_BASE_URL="https://openrouter.ai/api/v1"
test -n "$API_KEY" && test -n "$API_BASE_URL" && echo "API settings loaded."

What Do These Settings Do?

  • API_KEY holds the credential used to authorize the request.
  • API_BASE_URL holds the shared beginning of the speech endpoint URL.
  • test confirms that both variables contain values without printing your key.

You will see API settings loaded. when both variables contain values. Your terminal session is now ready to authenticate a request.

No Confirmation Message?

  • Run the commands in the same terminal that you plan to use for the speech request.
  • Check that both variable names use capital letters and underscores.
  • Replace the placeholder with the copied key before running the commands.
  • Help me check why my API environment variables are not loading.
Send one sentence to the speech endpoint

The speech endpoint receives a JSON request containing the text and voice settings. The response contains audio bytes that curl writes to first-sample.mp3.

This call uses provider credit. It sends one sentence to keep your first test contained.

Before you run this, will the request leave readable text in the terminal or create an audio file on disk?

  • Send the sample sentence to the speech endpoint by running this request:
curl --silent \
  --show-error \
  --fail-with-body \
  --request POST \
  --url "$API_BASE_URL/audio/speech" \
  --header "Authorization: Bearer $API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "model": "mistralai/voxtral-mini-tts-2603",
    "input": "OpenRouter turns this text into speech through one API endpoint.",
    "voice": "en_paul_neutral",
    "response_format": "mp3"
  }' \
  --output first-sample.mp3 && ls -lh first-sample.mp3

What Does This Request Do?

  • --fail-with-body prevents the final file check from running when the endpoint reports an HTTP failure.
  • Authorization sends your API key as a bearer token.
  • model selects mistralai/voxtral-mini-tts-2603 for speech generation.
  • input contains the sentence that the model speaks.
  • voice selects en_paul_neutral for this sample.
  • response_format requests MP3 audio.
  • --output saves the returned bytes as first-sample.mp3.

On success, you will see an ls row for first-sample.mp3 with a file size greater than zero. You now have your first generated audio file on disk.

Request Did Not Create the File?

  • Confirm that you are using the same terminal session where you loaded API_KEY and API_BASE_URL.
  • Check that your account has enough provider credit to send the request.
  • Confirm that the model remains paired with the en_paul_neutral voice.
  • Remove any failed first-sample.mp3 file through your file browser before retrying.
  • Help me diagnose my failed text-to-speech curl request.
Play your first MP3

Playback confirms that first-sample.mp3 contains decodable audio. FFmpeg includes the ffplay player for this end-to-end check.

Before you press play, what sentence do you expect to hear?

  • Play first-sample.mp3 with ffplay by running this command:
ffplay -nodisp -autoexit first-sample.mp3

How Does Playback Work?

  • -nodisp disables the graphical display for this audio-only file.
  • -autoexit closes the player when the sample finishes.
  • first-sample.mp3 is the audio file returned by your speech request.

You will hear “OpenRouter turns this text into speech through one API endpoint.” in the selected voice. The player closes when the sentence finishes.

That is the first sound generated by your project. Your credentials, endpoint, request body, output file, and local playback path now work together.

Cannot Hear the Sample?

  • Check your system volume before replaying the file.
  • Select the correct audio output device in your system settings.
  • Play first-sample.mp3 through your file browser if ffplay produces no sound.
  • Help me work out why first-sample.mp3 does not play.

Your first sample speaks clearly from a file on your computer. Next, you will send the same sentence to two voices and compare how each one sounds.

Compare Two Voices

Your first sample proved that the text-to-speech endpoint can return playable audio. Now your narration needs a voice that suits your article.

Keeping the model fixed isolates the voice choice. Two MP3 files turn that choice into a side-by-side listening test.

In this step, get ready to:
  • Create voice-one.mp3 with the eve voice.
  • Create voice-two.mp3 with the ara voice.
  • Compare both MP3 files with your local audio player.
Generate the first voice sample

Voice identifiers are model-specific. This comparison uses the eve voice plus the ara voice from x-ai/grok-voice-tts-1.0.

These requests can use account credit. Each request contains one short sentence so the test stays limited to one generation.

  • Save the eve rendition as voice-one.mp3 by running this request:
curl --silent \
  --show-error \
  --fail-with-body \
  --request POST \
  --url "$API_BASE_URL/audio/speech" \
  --header "Authorization: Bearer $API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "model": "x-ai/grok-voice-tts-1.0",
    "input": "OpenRouter turns this text into speech through one API endpoint.",
    "voice": "eve",
    "response_format": "mp3"
  }' \
  --output voice-one.mp3

What Does This Request Do?

  • The model field keeps the speech engine fixed for both samples.
  • The input field provides the comparison sentence.
  • The voice field selects eve for the first sample.
  • The response_format field requests MP3 audio.
  • The --output option writes the returned audio to voice-one.mp3.
  • Play voice-one.mp3 with the same local playback method from earlier.

You will hear the comparison sentence in the eve voice. Your first half of the listening test now works.

First Voice Not Playing?

  • Retry the request from the terminal session used earlier so the command can read API_BASE_URL.
  • Confirm your account has enough credit for one generation.
  • Check that eve is paired with x-ai/grok-voice-tts-1.0 exactly.

Help me diagnose why the first voice request did not produce playable audio.

Generate the second voice sample

A fair comparison changes one request value. This request keeps the model plus sentence fixed while selecting the second voice.

  • Save the ara rendition as voice-two.mp3 by running this request:
curl --silent \
  --show-error \
  --fail-with-body \
  --request POST \
  --url "$API_BASE_URL/audio/speech" \
  --header "Authorization: Bearer $API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "model": "x-ai/grok-voice-tts-1.0",
    "input": "OpenRouter turns this text into speech through one API endpoint.",
    "voice": "ara",
    "response_format": "mp3"
  }' \
  --output voice-two.mp3

What Changed in This Request?

  • The model field matches the first request.
  • The input field matches the first request.
  • The voice field switches to ara.
  • The --output option preserves this result as voice-two.mp3.
  • Play voice-two.mp3 with the same local playback method from earlier.

You now have the matching ara version. Both samples contain identical words.

Second Voice Not Playing?

  • Check that the second request uses ara exactly.
  • Check that the second output filename is voice-two.mp3.
  • Retry the request after confirming your account can generate another sample.

Help me diagnose why the second voice request did not produce playable audio.

Compare the two voices
  • Confirm both MP3 files exist by running this command:
ls -lh voice-one.mp3 voice-two.mp3

What Does This Check Show?

  • The -l option displays details for each file.
  • The -h option presents each file size in readable units.
  • A non-zero size confirms that the request wrote bytes to disk.

You will see voice-one.mp3 plus voice-two.mp3 with non-zero sizes. Playback confirms whether those bytes form usable audio.

Missing One of the Files?

  • Rerun the request for the missing filename.
  • Check the output filename in that request for a spelling difference.

Help me find why one of my two MP3 files is missing.

Before you play them back to back, make a quick prediction about which differences you expect in pace, tone, or emphasis.

  • Play voice-one.mp3 with your local audio player.
  • Play voice-two.mp3 with your local audio player.
  • Compare how each voice handles pace.
  • Compare how each voice handles tone.
  • Compare how each voice emphasizes words.

You will hear the same sentence delivered by two distinct voices. The fixed model plus fixed input make the voice selection the focus of your comparison.

Voices Sound Too Similar?

  • Check the filename shown by your player during each playback.
  • Replay a short section through headphones to make the contrast easier to hear.
  • Confirm that the two requests use different voice values.

Help me compare the audible differences between these two voice samples.

Your voice comparison is complete. Next, you will prepare article.md for narration by turning its Markdown into clean spoken text.

Prepare the Article Text

Your two voice samples proved that the speech endpoint can turn one sentence into audio. A full Markdown article also contains formatting that would sound awkward aloud.

This step uses Python to turn that formatting into plain narration text. The cleaned character count becomes your cost estimate.

In this step, get ready to:
  • Record your provider's character price.
  • Build a script that cleans the article text.
  • Run the script to see the character count and estimated cost.
Record the character price

Speech models can have different character prices. Your script needs the price for the model you plan to use.

  • Open your provider's pricing page to find the character price for your chosen speech model.
  • Convert the listed amount to a per-million-character price if its unit differs.
  • Record only the numeric amount here: your numeric provider price per million characters.
Build the text preparation script

Markdown headings use leading hash marks. Markdown links wrap visible words with a destination that the narrator does not need to read.

  • Use your text editor to create narrate_article.py in the same folder as article.md.
  • Add the preparation logic by copying this code into narrate_article.py:
import re
from pathlib import Path

# Point to the markdown source saved beside this script.
ARTICLE_PATH = Path("article.md")
# Store the provider's price for one million input characters.
PRICE_PER_MILLION_CHARACTERS = [[PRICE_PER_MILLION_CHARACTERS="your numeric provider price per million characters"]]

def strip_markdown(markdown_text):
    # Keep heading words while removing their leading hash markers.
    text_without_headings = re.sub(r"(?m)^\s{0,3}#{1,6}\s+", "", markdown_text)
    # Keep each link label while removing its destination.
    return re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text_without_headings)

# Read the article before converting its formatting to narration text.
article_markdown = ARTICLE_PATH.read_text(encoding="utf-8")
narration_text = strip_markdown(article_markdown)
character_count = len(narration_text)
estimated_cost = character_count / 1_000_000 * PRICE_PER_MILLION_CHARACTERS

# Report the size and estimated price before generating audio.
print(f"Cleaned character count: {character_count:,}")
print(f"Estimated cost: ${estimated_cost:.4f}")

What does this code do?

  • The ARTICLE_PATH value points to the markdown source beside the script.
  • The strip_markdown() function uses regular expressions to preserve spoken words while removing heading markers and link destinations.
  • The character_count value measures the cleaned narration text.
  • The cost formula scales your provider's per-million-character price to the size of your article.
  • Save narrate_article.py.

Before you run it, predict whether removing markdown formatting should make the cleaned count larger or smaller.

  • Test the cleaned text and cost calculation by running this command:
python3 narrate_article.py

The first output line shows a cleaned character count above zero. The second shows the estimated cost based on your saved price.

You have turned a formatted article into measurable narration text.

Script not producing both lines?

  • Place narrate_article.py beside article.md if Python cannot find the source article.
  • Enter the provider price using digits with an optional decimal point if the cost calculation fails.

Help me diagnose my article preparation script.

✔️ Awesome, I've got everything!

Your saved script now cleans the article and estimates its narration cost.

ⓧ I'd like to double check the full code

import re
from pathlib import Path

# Point to the markdown source saved beside this script.
ARTICLE_PATH = Path("article.md")
# Store the provider's price for one million input characters.
PRICE_PER_MILLION_CHARACTERS = [[PRICE_PER_MILLION_CHARACTERS="your numeric provider price per million characters"]]

def strip_markdown(markdown_text):
    # Keep heading words while removing their leading hash markers.
    text_without_headings = re.sub(r"(?m)^\s{0,3}#{1,6}\s+", "", markdown_text)
    # Keep each link label while removing its destination.
    return re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text_without_headings)

# Read the article before converting its formatting to narration text.
article_markdown = ARTICLE_PATH.read_text(encoding="utf-8")
narration_text = strip_markdown(article_markdown)
character_count = len(narration_text)
estimated_cost = character_count / 1_000_000 * PRICE_PER_MILLION_CHARACTERS

# Report the size and estimated price before generating audio.
print(f"Cleaned character count: {character_count:,}")
print(f"Estimated cost: ${estimated_cost:.4f}")

Your article is clean and costed. Next, you will split it into request-sized chunks that preserve the narration flow.

Generate Audio Chunks

Your script already turns article.md into clean narration text. It also estimates the cost before you generate speech.

One long request can exceed a text-to-speech endpoint's character limit. In this step, you will turn the article into ordered audio chunks that stay below that ceiling.

In this step, get ready to:
  • Split the cleaned article into ordered chunks below the endpoint limit.
  • Configure the narration request settings.
  • Generate one numbered MP3 file for each successful request.
Split the cleaned narration

Sentence-aware chunking keeps natural pauses near sentence endings. A hard fallback also protects the request limit when one sentence is unusually long.

How should the chunker behave?

  • The selected chunk ceiling sits below your provider's documented per-request character limit.
  • Complete sentences fill the current chunk while the total stays below the ceiling.
  • The current chunk closes before the next sentence would cross the ceiling.
  • Any oversized sentence splits at word boundaries into smaller pieces.
  • The preview shows each chunk number plus its character count.
  • Implement the chunking behavior above in narrate_article.py.
  • Save narrate_article.py.

Before you run the updated script, how many chunks do you expect from your article's cleaned character count?

  • Run narrate_article.py using the same Python command from the previous step.

You should see an ordered preview of the chunks. Every displayed character count should stay below your selected ceiling.

Seeing a chunk over the limit?

  • Lower the selected ceiling if it matches the provider's maximum exactly.
  • Check that the oversized-sentence fallback splits at word boundaries.
  • Confirm that spaces added between sentences count toward the chunk total.

Ask for help reviewing the chunking logic without sharing your API key: Why is my text chunk exceeding its configured character limit?

Generate each audio part

The generation loop sends chunks in sequence. Sequential requests make the filenames line up with the article order.

What should the generation loop do?

  • The TTS_MODEL setting holds the exact supported model from your successful sample request.
  • The TTS_VOICE setting holds the supported voice you preferred in the comparison.
  • The loop creates audio_chunks before the first request.
  • Each request reuses the existing API_BASE_URL plus API_KEY values.
  • Each successful response is written as a numbered MP3 file.
  • Three-digit numbering keeps the files in article order.
  • Add the generation-loop behavior above to narrate_article.py.
  • Save narrate_article.py.

Check the estimate first

This run sends one paid request for each chunk. Your earlier character estimate shows the scale before any provider credit is used.

  • Review the estimated cost from the previous step before generating the audio.

Before you run the script, do you expect the generated file count to match the previewed chunk count?

  • Run narrate_article.py using the same Python command from the previous step.

You should see the script process the chunks in order. Each successful request should add the next numbered file inside audio_chunks.

Files stop appearing partway through?

  • Confirm the current terminal still has access to API_KEY.
  • Confirm the current terminal still has access to API_BASE_URL.
  • Check that TTS_MODEL matches the model from the successful sample request.
  • Check that TTS_VOICE is supported by the selected model.

Get help diagnosing the stopped request without sharing credentials: Why did my audio chunk loop stop before generating every file?

Verify the ordered files

Sequential filenames give the next step a reliable playback order. The folder should contain one file for every previewed chunk.

Before you inspect the folder, do you expect its file count to match the chunk preview exactly?

  • Expand audio_chunks in your editor's file sidebar.
  • Count the numbered files shown in the folder.
  • Compare the file count with the previewed chunk count.
  • Confirm the sequence starts with chunk_001.mp3.
  • Confirm each later filename increases by one without gaps.

You should see chunk_001.mp3 through the final numbered chunk. The total should match the preview from the chunking pass.

That is the long-document engine working. Your article now becomes manageable audio parts without losing its original order.

Your article now exists as ordered audio parts. Next, you will protect those files by confirming that every response contains playable audio.

Handle API Errors Safely

Your script now turns a long article into ordered audio chunks. A failed API request can still return a response body.

That body may contain JSON error details instead of sound. This step adds a Content-Type gate so only audio reaches MP3 files.

In this step, get ready to:
  • Check each response's Content-Type before writing a chunk.
  • Print the provider's JSON response when the content is not audio.
  • Prove the guard with a deliberately invalid model request.
Add the response gate

The Content-Type response header describes the format returned by the endpoint. The file write belongs behind that check.

  • Switch back to narrate_article.py from the previous step.
  • Find the response-handling code inside the existing chunk-generation loop.

How the response gate works

  • The script reads Content-Type before it chooses an output path.
  • An audio response follows the existing numbered MP3 write path.
  • A non-audio response is parsed as JSON.
  • The error branch prints the parsed response without writing a chunk file.
  • Update the response-handling code so it follows all four safeguards above.
  • Save narrate_article.py.

Before you test it, do you expect the supported model to take the audio path or the error path?

  • Run narrate_article.py using the same method from the previous step.

You should see numbered MP3 files written as each request succeeds. The terminal should not print provider error JSON.

Seeing errors with the supported model?

  • Check that TTS_MODEL still contains your supported model name.
  • Confirm that API_BASE_URL remains available in the terminal.
  • Confirm that API_KEY remains available without printing its secret value.

Help me debug the supported request.

Trigger a controlled failure

A safety check earns trust when a known failure passes through it. An empty output folder makes an accidental MP3 easy to spot.

Deleting generated chunks can feel risky. Your article.md file stays untouched because the script rebuilds audio from that source.

  • Record your current TTS_MODEL value here: your supported TTS model.

This value is your return point after the controlled failure.

  • Navigate to audio_chunks with your system file browser.
  • Delete only the generated .mp3 files inside that folder.

The audio_chunks folder should now be empty.

  • Temporarily replace the TTS_MODEL value in its existing location with invalid-model-for-error-test.
  • Save narrate_article.py if that value lives in the script.

Before you run the script, do you think the failed response reaches the MP3 write operation?

  • Run narrate_article.py using the same method as before.

You should see readable JSON error details from the provider. The audio_chunks folder should remain empty because the gate rejects the response before the write.

Why the file stays safe

The invalid model produces a non-audio response. Your guard sends that response through the JSON error path.

The loop never writes those bytes with an MP3 filename. The provider's explanation stays readable in your terminal.

Did the failed request create an MP3?

  • Confirm that the file-writing operation sits entirely inside the audio success branch.
  • Check that the error branch moves to the next chunk before any output path is written.

Help me find the unsafe write path.

Restore the supported model

The forced error proved the rejection path. Restoring the supported model proves the same gate still allows valid audio through.

  • Restore TTS_MODEL to your supported TTS model in the same location.
  • Save narrate_article.py if you restored the value there.

Before the final run, which branch do you expect the restored request to take?

  • Run narrate_article.py using the same method as before.

You should see sequential files such as chunk_001.mp3 appear inside audio_chunks. The terminal should show no provider error JSON.

  • Open chunk_001.mp3 from audio_chunks in your default audio player.

You should hear the first narrated chunk play normally. That sound confirms the accepted response contains valid audio.

Still seeing JSON after restoring the model?

  • Confirm that the restored value exactly matches the supported model you recorded.
  • Check the active TTS_MODEL location for the temporary test value.

Help me restore the successful audio path.

You have turned a fragile request loop into a safer audio pipeline. Next, you will join the validated chunks into one finished narration.

Join the Finished Narration

Your narration pipeline now produces a numbered MP3 for every valid text chunk. It also keeps JSON errors out of the audio folder.

A folder of separate clips breaks the listening flow whenever one file ends. In this step, you use FFmpeg's concat demuxer to preserve their order in one finished narration.

In this step, get ready to:
  • Create an ordered concat list from the valid chunk files.
  • Join the listed clips into finished_narration.mp3.
  • Play the completed narration from beginning to end.
Create the ordered chunk list

The concat demuxer reads one file path per line. Your zero-padded names preserve the narration sequence.

  • Generate audio_chunks.txt from the valid clips by running these commands:
printf "ffconcat version 1.0\n" > audio_chunks.txt
for file in audio_chunks/chunk_*.mp3; do
  printf "file '%s'\n" "$file" >> audio_chunks.txt
done
cat audio_chunks.txt

How Does the List Preserve Order?

  • The ffconcat version 1.0 header tells FFmpeg how to interpret the file.
  • The loop selects files matching chunk_*.mp3 in filename order.
  • Each file directive gives the concat demuxer one clip to read.
  • Review the printed paths from top to bottom.

You should see chunk_001.mp3 first. Each following line should increase by one until the final chunk.

Chunk List Looks Wrong?

  • Confirm audio_chunks/ still contains your generated MP3 files.
  • Check that every filename follows the zero-padded chunk_001.mp3 pattern.

Help me diagnose my generated concat list.

Join the chunk files

Every chunk came from the same model settings. Their shared audio format lets stream copy assemble one file without rendering the speech again.

  • Create finished_narration.mp3 by running this command:
ffmpeg -f concat -i audio_chunks.txt -c copy finished_narration.mp3 && rm audio_chunks.txt

What Does This Command Do?

  • The -f concat option selects the concat demuxer.
  • The -i audio_chunks.txt option supplies the ordered list of clips.
  • The -c copy option copies the existing audio streams into the finished file.
  • The final cleanup removes audio_chunks.txt only after FFmpeg succeeds.
  • Confirm the finished file exists by running this command:
ls -lh finished_narration.mp3

What Should I See?

You should see finished_narration.mp3 with a nonzero file size. This confirms that FFmpeg wrote the joined narration to disk.

Finished File Missing?

  • Confirm every path in audio_chunks.txt points to an existing chunk file.
  • Remove an older finished_narration.mp3 before rerunning if FFmpeg asks about replacing it.

Help me fix my FFmpeg concat command.

Play the complete narration

Before you press play, where do you expect the joins between chunks to be easiest to notice?

  • Play finished_narration.mp3 by running this command:
ffplay finished_narration.mp3

What Should I Hear?

The ffplay command plays the joined file through the FFmpeg toolset.

You should hear every section from article.md in order. The final sentence should play before the command exits.

  • Let playback continue through the final sentence.

That is the full pipeline working. Your article now plays as one continuous narration.

Playback Not Working?

  • Play finished_narration.mp3 with your computer's existing audio player if ffplay is unavailable.
  • Replay the individual chunk nearest any broken join.

Help me diagnose my finished narration playback.

Secret mission

Run a Loudness Quality Check

A finished narration should remain comfortable across different speakers. Create a temporary normalized version, compare it with your original, and practice an audio quality check used before publishing.

Clean Up Your Resources

Clean Up Your Resources

Your local MP3 files and Python script create no ongoing charges because only new speech requests consume provider credit. Choose one of the options below for your setup.

Resources you used:

  • Provider API key created for the speech endpoint.
  • Current terminal environment values: API_KEY and API_BASE_URL.
  • Local project input and code: article.md and narrate_article.py.
  • Generated audio: audio_chunks/, finished_narration.mp3, first-sample.mp3, voice-one.mp3, and voice-two.mp3.

Your script estimated the narration cost from the cleaned character count. Your provider's usage record or credit balance gives you the final amount charged.

  • Return to your provider's usage or credit balance page.
  • Find the recorded spend for the narration requests from this project.
  • Compare the recorded spend with the estimate printed by narrate_article.py.

You'll see the provider's recorded charge or updated credit balance for the run. This gives you the final cost record for your narration.

Keep everything running

No action needed. Choose this if you want to narrate more articles or generate the audio again with a different model or voice.

  • Keep the listed local artifacts together in the folder that already contains narrate_article.py.
  • Keep the provider key private if you plan to make more speech requests.
  • Play finished_narration.mp3 whenever you want to review or share the completed narration.

Pause - I'll come back to this later

No speech process needs to stay running once the files are generated. Choose this if you want to return later without deleting your work.

  • Close the current terminal session to clear its environment values.
  • Leave the listed local artifacts in their current folder.
  • Keep the provider account unchanged so you can configure a new terminal session when you return.

Delete - I don't want to use this again

Remove the project credentials and local artifacts if you do not plan to generate more narration. Your provider account and installed tools can stay in place for other work.

Save what you want to keep

Revoking the key prevents future requests from using it. Emptying your computer's trash permanently removes the local copies.

  • Copy article.md to a safe folder if it is your only copy of the original writing.
  • Copy finished_narration.mp3 to a safe folder if you want to keep the finished audio.
  • Return to the provider's API key management page that you used earlier.
  • Find the key you created for this project.
  • Use the provider's revoke or delete control for that key.
  • Confirm the key is no longer shown as active.

That closes the paid-request path for this project. Your provider account and remaining credit stay available.

  • Close the current terminal session to clear API_KEY and API_BASE_URL from that session.
  • In Finder on macOS or File Explorer on Windows, return to the folder that contains narrate_article.py.
  • Select every local artifact shown under Resources you used.
  • Move the selected artifacts to your operating system's trash.
  • Empty your operating system's trash.

You should no longer see any of the listed local artifacts in their original folder. Your revoked key can no longer authorize speech requests.

Nice Work!

Nice Work!

You made it! Your text-to-speech workflow now turns article.md into a complete finished_narration.mp3 while keeping error responses out of your audio files.

What you can now do:

  • Your narration preparation removes Markdown formatting from article.md. The Python script reports the cleaned character count. It also estimates the narration cost.
  • Your voice workflow compares two supported voices using the same sentence. The full article becomes ordered MP3 files inside audio_chunks/.
  • Your response safety check keeps JSON errors out of MP3 files. FFmpeg joins the valid chunks into finished_narration.mp3.
  • Secret Mission: Extended your narration workflow through an optional challenge that pushed your skills further.

Ready to quiz yourself?