Text-to-Speech skill for Claude Code
Natural voice output without seams or misreads. Claude builds TTS pipelines that sound right at scale. It picks voices through blind listening tests on your own scripts, normalizes numbers, dates, and acronyms before synthesis, uses SSML and lexicons where the provider supports them, chunks long text on sentence boundaries with a bundled script, streams sentence by sentence for agents, caches by content hash, and checks output with round-trip ASR.
- name
text-to-speech - size177 lines · ≈2,050 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds text-to-speech pipelines with hosted TTS APIs and open models - voice selection with blind listening tests, text normalization for numbers, dates, and acronyms, SSML and pronunciation lexicons, sentence-bounded chunking for long-form narration, low-latency streaming for voice agents, audio caching, loudness normalization, round-trip ASR checks, cost per character, and voice-cloning consent. Use when adding voice output to an app or agent, narrating articles or audiobooks, generating IVR prompts, when TTS mispronounces names, sounds inconsistent across chunks, starts too slowly, or costs too much. Try asking
- Add spoken answers to our voice agent with under half a second to first audio
- Turn our blog posts into narrated audio without audible seams between chunks
- The TTS keeps saying 'S-Q-L' and reading prices wrong. Fix pronunciation everywhere
Use it when
- Adding voice output to an app, assistant, or phone system
- Narrating articles, courses, or audiobooks from text
- TTS mispronounces names, numbers, or jargon
- Speech starts too slowly or costs too much at volume
Skip it when
- You need to transcribe audio into text — use speech-to-text
- You are writing the words for UI prompts, not producing audio — use ux-microcopy
How to check the result
Transcribe the generated audio and compute WER against the normalized input, check loudness and duration per chunk, and listen to every chunk seam in a sample.
The common mistake it prevents
Sending raw LLM markdown or unnormalized text to the TTS engine, so it reads asterisks, URLs, and '$1.5M' aloud literally.
TTS output is ready when a listener cannot spot chunk seams, names and numbers are pronounced correctly, first audio arrives fast enough for the use case (under ~500 ms for conversational agents), loudness is consistent, and the cost per finished minute is known. Generating one nice sample in a vendor playground proves none of that.
Ground rules
- Normalize text before synthesis. Most “the voice is bad” complaints are “$1.5M”, “Dr.”, “2026-03-04”, or “SQL” being read wrong.
- Choose voices by blind listening on your own scripts, not on vendor demos.
- Cache audio by a hash of (normalized text, voice, model, settings). Re-synthesizing unchanged text is the biggest avoidable cost.
- Voice cloning requires documented consent from the voice owner, and many products must disclose that a voice is synthetic. Never clone a real person’s voice without it.
- Keep the source text and synthesis settings with every audio file so it can be regenerated when the voice or model changes.
Choose the engine
| Need | Choice |
|---|---|
| Voice agent, sub-second response | Streaming TTS API with WebSocket or chunked HTTP output; low-latency model tier |
| Narration, audiobooks, marketing voiceover | Highest-quality expressive model; offline batch generation |
| IVR, notifications, precise pronunciation control | Provider with full SSML and lexicon support (the major cloud speech services) |
| Offline, on-device, privacy, or zero marginal cost | Open models (e.g. Piper or Kokoro class) running locally; verify license and language coverage |
| Many languages | Test each language separately; quality varies widely per voice and language |
Check each candidate for: languages and voices, SSML subset, custom lexicons, streaming, output formats, rate limits, data retention, commercial-use terms, and price per million characters.
Workflow
- [ ] 1. Collect test scripts that reflect production text
- [ ] 2. Shortlist engines and voices; run a blind listening test
- [ ] 3. Build text normalization and pronunciation rules
- [ ] 4. Chunk long text on sentence boundaries
- [ ] 5. Synthesize (batch or streaming) with caching
- [ ] 6. Post-process audio (join, loudness, format)
- [ ] 7. Verify with round-trip ASR and listening QA
- [ ] 8. Track cost and latency
1-2. Test scripts and voice selection
- 20-40 short scripts from real content: names, product terms, numbers, addresses, questions, long sentences, lists, other-language words.
- Generate each script with 3-6 candidate voices. Strip identifying metadata, shuffle, and have 3+ listeners rate naturalness, clarity, and fit (1-5). Pick by mean score and by the worst-script score, not just the average.
- Pick one primary voice per persona and lock its settings (speed, stability or style parameters) in config.
3. Normalize text and pronunciation
| Input | Normalize to |
|---|---|
$1.5M | “one point five million dollars” |
2026-03-04 | Locale-correct spoken date: “March fourth, twenty twenty-six” |
10:30 | “ten thirty” |
Dr. Smith, St. James St. | Expand by context: “Doctor”, “Saint”, “Street” |
SQL, nginx, Kubernetes | Lexicon entry or spelled alias (“sequel”, “engine x”) |
| URLs, emails | Speak a short form or drop; never read full query strings |
| Emojis, markdown, code | Strip or replace with words |
| Parenthetical asides | Commas or separate sentence for natural prosody |
Where the provider supports SSML, use it for what text cannot express:
<speak>
Your code is <say-as interpret-as="characters">A7Q</say-as>.
<break time="400ms"/>
Call us at <say-as interpret-as="telephone">+1-415-555-0100</say-as>.
Welcome to <phoneme alphabet="ipa" ph="ˈnaɪki">Nike</phoneme>.
</speak>
SSML support differs by provider and even by voice; some expressive models ignore or reject tags. Test each tag you rely on. Keep pronunciations in one lexicon file (provider lexicon feature or your own replacement table) so fixes apply everywhere.
4. Chunk long text
APIs cap characters per request, and quality drifts on very long inputs. Split on sentence boundaries, keep paragraphs together when they fit, and never cut mid-sentence:
python3 scripts/chunk_text.py article.txt --max-chars 2500 > chunks.jsonl
Each output line has index, text, chars, and paragraph_end. The script splits
an oversized sentence at commas or spaces only as a last resort, reports chunk count and
size range, and warns when SSML tags look unbalanced inside a chunk. Use
paragraph_end to insert a longer pause when joining.
Consistency across chunks: same voice and settings for every chunk; where the API accepts previous or next text as context, pass neighboring sentences so intonation carries across seams.
5. Synthesize
Batch narration: process chunks with bounded concurrency, write each to
cache/<sha256>.wav, retry transient errors with backoff, and log the request ID.
Streaming for agents:
- Start synthesis on the first complete sentence of the LLM response, not after the full response. Stream LLM tokens, cut at sentence ends, and send each sentence to TTS.
- Use raw PCM or Opus for streaming; MP3 frames add latency and seams.
- Measure time to first audio byte separately from LLM time to first token.
- Support barge-in: when the user starts speaking, stop playback and cancel pending synthesis.
6. Post-process
# join chunks (same sample rate and format), then normalize loudness
ffmpeg -f concat -safe 0 -i list.txt -c copy joined.wav
ffmpeg -i joined.wav -af loudnorm=I=-16:TP=-1.5:LRA=11 -ar 44100 out.mp3
- Spoken-word targets: about -16 LUFS integrated for stereo podcasts and web, -19 for mono; true peak at or below -1 dBTP.
- Insert silence between paragraphs (400-800 ms) rather than relying on the model.
- Trim leading and trailing silence per chunk before joining to avoid uneven gaps.
7. Verify
- Round-trip ASR: transcribe the output with a speech-to-text model and compute WER
against the normalized input text (see
speech-to-textand itswer.py). High WER flags skipped words, mispronunciations, and garbled chunks automatically. - Duration sanity: speaking rate should be roughly 13-17 characters per second for English narration; outliers mean truncation, repeated audio, or long silences.
- Listening QA on a sample of every batch, focused on names, numbers, and chunk seams.
- Check for clipping and silence-only files with ffmpeg’s
volumedetectandsilencedetectfilters.
8. Cost and latency
- Cost is usually per character; estimate with normalized text length, not raw text (normalization expands numbers and abbreviations).
- Cache hit rate, characters synthesized per day, cost per finished audio minute, and p95 time to first audio are the metrics to track.
- Use a cheaper or faster voice tier for system prompts and notifications; reserve premium voices for long-form content where quality is the product.
Gotchas
- Sending markdown straight from an LLM to TTS makes the voice read asterisks, hashes, and URLs. Generate speech-specific text or strip formatting first.
- Changing a voice or model version mid-project makes old and new chunks sound different. Pin versions and regenerate the whole piece when you change.
- Per-request character limits differ between plain text and SSML (tags count).
- Short isolated words (“Yes.”) often sound unnatural; give the model a full sentence.
- Concatenating MP3s without re-encoding can leave gaps from encoder padding; join WAV or PCM, then encode once.
- Some voice licenses forbid specific uses (political ads, impersonation); check terms.
Output contract
**Engine/voice:** <provider/model/voice>, settings <...>, chosen by blind test (<n> listeners)
**Normalization:** <rules file>, lexicon <n entries>
**Chunking:** max <n> chars, sentence-bounded; context passing <yes/no>
**Audio:** <format, sample rate>, loudness <LUFS>, paragraph pause <ms>
**QA:** round-trip WER <x>%, listening sample <n> files
**Cost/latency:** <$ per audio minute>, cache hit <x>%, time to first audio p95 <ms> Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/chunk_text.py
#!/usr/bin/env python3
"""Split long text into sentence-bounded chunks for TTS requests.
Usage:
python3 chunk_text.py input.txt [--max-chars 2500] [--min-chars 0] > chunks.jsonl
cat input.txt | python3 chunk_text.py - --max-chars 1000
Rules:
- Paragraphs (blank-line separated) are kept together when they fit.
- Otherwise chunks are filled sentence by sentence; a sentence is never split unless it
alone exceeds --max-chars, in which case it is split at ; : , then at spaces.
- Common abbreviations (Dr., e.g., U.S., etc.) and decimals do not end sentences.
- Chunks shorter than --min-chars are merged into the previous chunk when possible.
Output: one JSON object per line on stdout:
{"index": 0, "text": "...", "chars": 1234, "paragraph_end": true}
Summary and warnings go to stderr. Warns when SSML-like tags look unbalanced in a chunk.
Exits 1 if any chunk still exceeds --max-chars, 2 on input errors.
"""
import argparse
import json
import re
import sys
ABBREV = {"mr", "mrs", "ms", "dr", "prof", "sr", "jr", "st", "vs", "etc", "e.g", "i.e",
"inc", "ltd", "co", "corp", "no", "fig", "approx", "dept", "est", "mt", "u.s",
"u.k", "a.m", "p.m", "jan", "feb", "mar", "apr", "jun", "jul", "aug", "sep",
"sept", "oct", "nov", "dec"}
END_RE = re.compile(r"([.!?]+[\"')\]]*)(\s+)")
TAG_RE = re.compile(r"<(/?)([a-zA-Z][\w:-]*)([^>]*?)(/?)>")
def split_sentences(par):
sentences, start = [], 0
for m in END_RE.finditer(par):
end = m.end(1)
before = par[start:end]
word = re.findall(r"([\w.]+)[.!?]+[\"')\]]*$", before)
token = word[0].rstrip(".").lower() if word else ""
nxt = par[m.end():m.end() + 1]
if m.group(1).startswith(".") and (token in ABBREV or (len(token) == 1 and token.isalpha())):
continue
if nxt and nxt.islower():
continue
sentences.append(par[start:end].strip())
start = m.end()
tail = par[start:].strip()
if tail:
sentences.append(tail)
return sentences
def hard_split(sentence, limit):
parts, rest = [], sentence
while len(rest) > limit:
window = rest[:limit]
cut = max(window.rfind("; "), window.rfind(": "), window.rfind(", "))
if cut < limit // 3:
cut = window.rfind(" ")
if cut <= 0:
cut = limit - 1
parts.append(rest[:cut + 1].strip())
rest = rest[cut + 1:].strip()
if rest:
parts.append(rest)
return parts
def unbalanced_tags(text):
stack = []
for m in TAG_RE.finditer(text):
closing, name, _, selfclose = m.group(1), m.group(2), m.group(3), m.group(4)
if selfclose:
continue
if closing:
if stack and stack[-1] == name:
stack.pop()
else:
return True
else:
stack.append(name)
return bool(stack)
def chunk(text, max_chars, min_chars):
paragraphs = [" ".join(p.split()) for p in re.split(r"\n\s*\n", text) if p.strip()]
chunks = [] # list of [text, paragraph_end]
for par in paragraphs:
if len(par) <= max_chars:
pieces = [par]
else:
pieces = []
cur = ""
for s in split_sentences(par):
for piece in (hard_split(s, max_chars) if len(s) > max_chars else [s]):
if cur and len(cur) + 1 + len(piece) > max_chars:
pieces.append(cur)
cur = piece
else:
cur = f"{cur} {piece}".strip()
if cur:
pieces.append(cur)
for i, p in enumerate(pieces):
chunks.append([p, i == len(pieces) - 1])
if min_chars:
merged = []
for text_, pend in chunks:
if merged and len(text_) < min_chars and len(merged[-1][0]) + 1 + len(text_) <= max_chars:
merged[-1][0] += " " + text_
merged[-1][1] = pend
else:
merged.append([text_, pend])
chunks = merged
return chunks
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("input", help="text file, or - for stdin")
ap.add_argument("--max-chars", type=int, default=2500)
ap.add_argument("--min-chars", type=int, default=0)
args = ap.parse_args()
if args.max_chars < 50:
print("error: --max-chars must be at least 50", file=sys.stderr)
sys.exit(2)
try:
text = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read()
except OSError as exc:
print(f"error: {exc}", file=sys.stderr)
sys.exit(2)
if not text.strip():
print("error: input is empty", file=sys.stderr)
sys.exit(2)
chunks = chunk(text, args.max_chars, args.min_chars)
over = 0
for i, (t, pend) in enumerate(chunks):
print(json.dumps({"index": i, "text": t, "chars": len(t), "paragraph_end": pend}, ensure_ascii=False))
if len(t) > args.max_chars:
over += 1
if unbalanced_tags(t):
print(f"warning: chunk {i} has unbalanced SSML/XML tags; a split may have cut through markup",
file=sys.stderr)
sizes = [len(t) for t, _ in chunks]
print(f"chunks: {len(chunks)} chars: total={sum(sizes)} min={min(sizes)} max={max(sizes)} "
f"limit={args.max_chars}", file=sys.stderr)
if over:
print(f"FAIL: {over} chunk(s) exceed --max-chars", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.