Text to Speech with Amazon Polly and SSML
Amazon Polly is a managed neural text to speech service. Feed it SSML and it returns natural narration audio, with no local model and no GPU. Because a single SynthesizeSpeech call is capped at a few thousand characters, the SSML is split on paragraph boundaries, each chunk is synthesized to its own MP3, and the parts are concatenated with ffmpeg. Polly is a paid service and bills per character.
Prerequisites
Section titled “Prerequisites”- An AWS account.
- The AWS CLI v2 with a named profile that has Polly access.
- ffmpeg, to concatenate the parts.
- Python 3. The build script uses the standard library only, with no third-party dependencies.
AWS CLI v2 on Arch:
sudo pacman -S aws-cli-v2AWS CLI v2 on Ubuntu uses the official bundle:
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o awscliv2.zipunzip awscliv2.zipsudo ./aws/installffmpeg on Arch:
sudo pacman -S ffmpegffmpeg on Ubuntu:
sudo apt install ffmpegConfigure the AWS profile
Section titled “Configure the AWS profile”Create a named profile for the pipeline. When prompted, set the region to a Polly region such as us-east-1.
aws configure --profile tts-generatorThe profile’s user needs only one Polly permission:
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": "polly:SynthesizeSpeech", "Resource": "*" } ]}Audition voices
Section titled “Audition voices”Synthesize one short sample per candidate neural voice and listen before committing. The original of this loop was a Windows .bat; this is the bash form.
sample.ssml<speak> <p>Today I will summarize a research paper in clear and neutral language.</p></speak>for V in Matthew Brian Kevin Ruth Justin Russell; do aws polly synthesize-speech \ --profile tts-generator \ --region us-east-1 \ --engine neural \ --voice-id "$V" \ --text-type ssml \ --text file://sample.ssml \ --output-format mp3 \ "sample_$V.mp3"doneWrite the SSML
Section titled “Write the SSML”Author the script as SSML. Wrap paragraphs in <p>, insert <break> tags for pacing, and spell acronyms with spaces (G A R C H) so Polly reads them as letters rather than a word.
episode.ssml<speak> <p>Extreme events in financial markets are rare by definition,<break time='200ms'/> but their consequences are anything but marginal.<break time='300ms'/> A single catastrophic month can wipe out years of accumulated returns.</p>
<break time='700ms'/>
<p>Estimating volatility is straightforward. A G A R C H model tracks how it evolves month to month.<break time='300ms'/> Tail risk does not have that luxury.</p></speak>Build the narration script
Section titled “Build the narration script”Extract the <p> blocks, pack them into chunks under the per-call character limit, synthesize each chunk through Polly, then concatenate the parts into one MP3 re-encoded to a uniform 24 kHz mono.
build_episode.pyimport reimport subprocessfrom pathlib import Path
PROFILE = "tts-generator"REGION = "us-east-1"VOICE = "Brian"ENGINE = "neural"SSML_FILE = "episode.ssml"WORK_DIR = Path("parts")FINAL_MP3 = "episode.mp3"MAX_CHARS = 2500
WORK_DIR.mkdir(exist_ok=True)
ssml_text = Path(SSML_FILE).read_text(encoding="utf-8").strip()paras = re.findall(r"<p>.*?</p>", ssml_text, flags=re.DOTALL)
def chunk_paragraphs(paras, max_chars=MAX_CHARS): chunks, cur, cur_len = [], [], 0 for p in paras: if cur_len + len(p) <= max_chars: cur.append(p) cur_len += len(p) else: chunks.append(cur) cur = [p] cur_len = len(p) if cur: chunks.append(cur) return chunks
chunks = chunk_paragraphs(paras)print("SSML chunks:", len(chunks))
mp3_parts = []for i, chunk in enumerate(chunks): ssml_chunk = "<speak>\n" + "\n".join(chunk) + "\n</speak>" ssml_path = WORK_DIR / f"part_{i:03d}.ssml" mp3_path = WORK_DIR / f"part_{i:03d}.mp3" ssml_path.write_text(ssml_chunk, encoding="utf-8")
subprocess.check_call([ "aws", "polly", "synthesize-speech", "--profile", PROFILE, "--region", REGION, "--engine", ENGINE, "--voice-id", VOICE, "--text-type", "ssml", "--text", f"file://{ssml_path}", "--output-format", "mp3", str(mp3_path), ]) mp3_parts.append(mp3_path) print("Wrote:", mp3_path)
concat_txt = WORK_DIR / "concat.txt"with open(concat_txt, "w", encoding="utf-8") as f: for p in mp3_parts: f.write(f"file '{p.resolve().as_posix()}'\n")
subprocess.check_call([ "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_txt), "-ar", "24000", "-ac", "1", "-b:a", "128k", FINAL_MP3,])
print("FINAL EPISODE READY:", FINAL_MP3)python build_episode.pyExpected output, with ffmpeg’s own log omitted:
SSML chunks: 3Wrote: parts/part_000.mp3Wrote: parts/part_001.mp3Wrote: parts/part_002.mp3FINAL EPISODE READY: episode.mp3- Polly bills per character. SynthesizeSpeech caps each request at 6000 total and 3000 billed characters, which is why paragraphs are packed under 2500.
--engine neuralgives the most natural voices, but only a subset of voices support it, so audition within the neural set.<break>controls pacing, and acronyms spelled with spaces (G A R C H) are read as letters. This is all standard SSML.- The concat step re-encodes every part to 24 kHz mono 128k so the joined MP3 is uniform. For the reverse direction, see Convert M4B Audiobook to MP3.