Aaryan

/Caching TTS Audio with Content Hashing

August 26, 2026

TTS

Voice Agents

Caching

Python

Text-to-speech is one of the slowest and most expensive hops in a voice pipeline. A synthesis call routinely costs 200–800 ms, and in a conversation agent that delay lands between the model finishing its reply and the caller hearing it. Meanwhile, real deployments say a lot of the same things over and over: greetings, confirmations, hold messages, error prompts, menu options.

The fix is boring and very effective: hash the request, store the audio, reuse it forever. This post walks through how to do it correctly — because the naive version has a handful of silent failure modes.

The key insight: TTS is (mostly) deterministic

For a fixed provider, model, voice, and parameter set, the same input text produces audio that is good enough to identical. That means TTS output is content-addressable: instead of keying a cache by session or request ID, key it by a hash of everything that determines the output.

A cache hit turns a network round-trip plus synthesis into a local file read. That is the difference between a prompt that starts playing instantly and one the caller waits on.

Step 1 — Normalize before you hash

The same "sentence" arrives in many byte-level shapes. If you hash raw text, "Hello." and " Hello. " become two cache entries and your hit rate quietly halves. Normalize first:

def normalize(text: str) -> str:
    return " ".join(text.split())  # trim + collapse all whitespace runs

Two rules of thumb:

  • Collapse whitespace, don't change meaning. Lowercasing is not safe — case changes pronunciation in some engines ("POLISH" vs "polish").
  • Pick one canonical form for punctuation and SSML. If some call sites send SSML and others send plain text, normalize to one of them before hashing, or the same words will cache under different keys.

Step 2 — Hash the full tuple, not just the text

The most common bug in TTS caching is hashing only the text. If you switch voices — or two customers share the cache with different voices — you'll serve the wrong audio. Everything that changes the waveform goes into the key:

import hashlib
import json
import pathlib
 
CACHE_ROOT = pathlib.Path("tts-cache")
SCHEMA_VERSION = 1  # bump to invalidate the whole cache at once
 
def cache_key(text: str, voice: str, *, model: str, fmt: str = "mp3", **opts) -> pathlib.Path:
    payload = {
        "v": SCHEMA_VERSION,
        "model": model,      # provider AND version — see pitfalls below
        "voice": voice,
        "format": fmt,
        "text": normalize(text),
        "opts": opts,        # speed, pitch, sample rate, language code...
    }
    digest = hashlib.sha256(
        json.dumps(payload, sort_keys=True, ensure_ascii=False).encode("utf-8")
    ).hexdigest()
    return CACHE_ROOT / voice / f"{digest}.{fmt}"

Details that matter:

  • sort_keys=True makes the JSON canonical — dict ordering can't split your cache.
  • ensure_ascii=False plus UTF-8 encoding keeps non-English text stable.
  • The schema version field is your escape hatch. When you change anything about how you build keys, bump it and every stale entry is instantly unreachable.

Step 3 — Store, and write atomically

Retrieval is a lookup; the only interesting part is the write. If two requests synthesize the same phrase concurrently, don't let them write the same file at the same time — a reader can pick up a half-written file:

def synth_or_reuse(text: str, voice: str, synthesize, **kw) -> bytes:
    path = cache_key(text, voice, **kw)
    if path.exists():
        return path.read_bytes()
 
    audio = synthesize(text, voice, **kw)          # the slow, expensive path
 
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(".tmp")
    tmp.write_bytes(audio)
    tmp.replace(path)                              # atomic on POSIX and Windows
    return audio

Write to a temp name, then replace() into place. The rename is atomic, so concurrent readers see either the old file or the complete new one — never a torn write.

The layout (cache/<voice>/<hash>.mp3) is cheap to reason about: voices never collide, and deleting a directory invalidates exactly one voice.

Step 4 — Let the hash do double duty on the way out

If you serve cached audio over HTTP, the digest is already a perfect ETag:

Content-Type: audio/mpeg
ETag: "b13c2e8f..."
Cache-Control: public, max-age=31536000, immutable

Hash-addressed content never changes in place, so it can be marked immutable and cached aggressively by browsers, CDNs, and anything else in between.

Pitfalls that will bite you

  • Silent model updates. Providers update voices without asking you. Your cache happily returns audio from the old model. Include the model version in the key when the provider exposes one, and keep the schema version ready for when they don't.
  • Forgetting a parameter. Add a new synthesis option (say, speed) and forget to route it into the key — you'll serve wrong-speed audio for every phrase already cached. Passing options through **opts into the payload, as above, makes this hard to do by accident.
  • Multi-tenant leakage. If different customers configure their own voices or lexicons, the voice (and tenant) belongs in the key. Sharing is fine; accidentally sharing is not.
  • Caching the uncachable. Anything with high entropy — names spliced into sentences, timestamps, one-of-a-kind responses — has a near-zero hit rate. It costs nothing but disk, but pre-warming and size caps are worth adding once the cache grows: an LRU on top of the directory is usually enough.
  • Not measuring. Log hits and misses. If your hit rate is low, the fix is usually better normalization — not more storage.

Wrapping up

The whole pattern is about twenty lines: normalize the text, hash the full request tuple, read or synthesize, write atomically. For a voice agent, the common paths — greetings, confirmations, error prompts — start playing with zero synthesis latency, and your TTS bill only pays for sentences you've never said before.