Implement in-memory LRU cache for audio synthesis requests#5
Conversation
Cache encoded audio bytes (WAV/PCM) keyed on the full parameter set (text, voice mode, profile path, speed, num_step, etc.) to serve identical requests instantly without re-running inference. - Add AudioCache with LRU eviction, configurable max memory cap (cache_max_mb, default 512MB), and TTL expiry (cache_ttl_s, default 1h) - Skip caching entries larger than the entire budget to prevent thrashing - Cache key uses SHA-256 of all output-affecting parameters; clone mode uses stable profile path (not temp file path) - Integrate into /v1/audio/speech (non-streaming only); streaming and one-shot /clone (temp file paths) are not cached - Add X-Cache: HIT/MISS response header for observability - Expose cache stats (entries, bytes, hit rate, evictions) on /metrics and cache config on /health - Add --cache-enabled, --cache-max-mb, --cache-ttl-s CLI flags and OMNIVOICE_CACHE_* env vars
Update README, architecture overview, and dataflow docs to cover: - AudioCache in layer diagram, component map, and service graph - Cache lookup/store in non-streaming request lifecycle - DELETE /v1/audio/cache endpoint - Cache stats in /health and /metrics responses - Configuration table with cache CLI flags and env vars - Audio Cache section under Advanced Features in README
maemreyo
left a comment
There was a problem hiding this comment.
Hi @hdduytran, great implementation on the LRU cache! 🎉
I'm curious about the 512MB default for cache_max_mb. With 24kHz WAV files (~48KB/s), this holds roughly ~1000 x 10s audio clips. For production workloads with many voice profiles, we might hit the limit quickly.
Thoughts on bumping default to 2048MB (2GB)? Modern servers typically have 16GB+ RAM, and 2GB would cache ~4000 entries without being too aggressive. Would love your take on this!
| description="Enable in-memory LRU cache for repeated synthesis requests", | ||
| ) | ||
| cache_max_mb: int = Field( | ||
| default=512, |
There was a problem hiding this comment.
Hi @hdduytran, great implementation on the LRU cache!
I'm curious about the 512MB default for cache_max_mb. With 24kHz WAV files (~48KB/s), this holds roughly ~1000 x 10s audio clips. For production workloads with many voice profiles, we might hit the limit quickly.
Thoughts on bumping default to 2048MB (2GB)? Modern servers typically have 16GB+ RAM, and 2GB would cache ~4000 entries without being too aggressive. Would love your take on this!
There was a problem hiding this comment.
The cache logic looks solid! To make future maintenance easier, would you be open to adding a tests/test_cache.py with a few unit tests? I'm thinking:
- LRU eviction when budget exceeded
- TTL expiry behavior
- Hit/miss counters
- Oversized entry skip
Happy to help write these if you're busy - just want to ensure the logic stays robust as we iterate.
| description="Max memory for audio cache in MB. LRU eviction when exceeded.", | ||
| ) | ||
| cache_ttl_s: int = Field( | ||
| default=3600, |
There was a problem hiding this comment.
With ttl_s=3600, the sweep runs every 30 min (max(3600/2, 10)). Stale entries could linger up to 30 min after expiry.
What do you think about making the divisor configurable or defaulting to 4 instead of 2? That would sweep every 15 min - more memory-efficient without much overhead. Or is the 30-min window intentional for performance reasons?
| return len(self.audio_bytes) | ||
|
|
||
|
|
||
| def _build_cache_key( |
There was a problem hiding this comment.
The cache key uses voice="clone:profile_id" which is clean! One edge case: if a user updates the reference audio for a profile (same ID, different voice), the cache would still return old audio until TTL/LRU eviction.
Options I'm considering:
- Document this in README (cache clear recommended after profile updates)
- Add ProfileService hook to auto-clear affected cache entries
- Include profile file hash in cache key (adds complexity)
What's your take? For v1, option 1 (documentation) seems reasonable - we could add a note in the "Audio Cache" section.
| self._store.clear() | ||
| self._current_bytes = 0 | ||
|
|
||
| def snapshot(self) -> dict: |
There was a problem hiding this comment.
Could we add a cache_skipped_oversized counter for entries that exceeded cache_max_mb? This would help operators tune the cache size when they see frequent skips in metrics.
This came to mind while reviewing the put() method - it's great that we skip huge entries, but having visibility into how often it happens would be valuable for tuning.
maemreyo
left a comment
There was a problem hiding this comment.
Hi @hdduytran Im adding some feedback when walking through your excellent work. Please feel free to reach out, we can discuss more bout those.
Once again, thanks for your dedicated work on this!!
feat: in-memory LRU cache for repeated synthesis requests
Problem
Some clients send the same request repeatedly — same clone profile, same text, same parameters. Each request runs full inference (~10s on CPU, ~0.5s on GPU), wasting compute on identical work.
Solution
An in-memory LRU cache stores the final audio bytes (WAV/PCM) keyed on all output-affecting parameters. Identical requests return instantly from cache with zero inference cost.
How it works
On each non-streaming
/v1/audio/speechrequest, the router hashes(voice, text, speed, num_step, guidance_scale, denoise, t_shift, position_temperature, class_temperature, duration, response_format)into a SHA-256 cache key. Thevoicefield is the raw client string (e.g."clone:voice11") — a stable semantic identifier that doesn't depend on filesystem paths.Cache hits return stored bytes immediately with
X-Cache: HIT. Misses run inference normally, store the result, and respond withX-Cache: MISS.Memory safety:
cache_max_mb(default 512MB)cache_ttl_s / 2, min 10s)get()as wellbytes, not GPU tensors — no VRAM impactNot cached: streaming requests (can't cache a chunked stream), one-shot
/v1/audio/speech/clone(temp file paths change every request).Changes
omnivoice_server/services/cache.py—AudioCachewith LRUOrderedDict, TTL sweep task,_build_cache_key()config.py—cache_enabled,cache_max_mb,cache_ttl_sfieldsapp.py— wireAudioCacheinto lifespan (start/stop)routers/speech.py— cache lookup before inference, cache store after,X-Cacheheader,DELETE /v1/audio/cacheendpointcli.py—--cache-enabled,--no-cache,--cache-max-mb,--cache-ttl-sflagshealth.py— expose cache config in/health, cache stats in/metricsdocs/architecture/overview.md,docs/design/dataflow.md— updated diagrams, request lifecycle, endpoint docsConfiguration
--cache-enabled/--no-cacheOMNIVOICE_CACHE_ENABLEDtrue--cache-max-mbOMNIVOICE_CACHE_MAX_MB512--cache-ttl-sOMNIVOICE_CACHE_TTL_S3600API
Testing
All 40 existing tests pass. Cache is created during app lifespan but tests mock
synthesize()directly so cache integration is transparent.