perf: bound persisted line audio cache

This commit is contained in:
mARTin-B78 2026-09-04 08:09:36 +02:00
parent d21a6f28e8
commit 116e234c20
4 changed files with 58 additions and 3 deletions

View File

@ -49,6 +49,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
filenames before deriving extensions; voice ZIP imports also reject absolute, traversal and malformed archive paths.
- **Generation Audit History.** Prompt and image generation now record bounded metadata in the protected history API;
prompt/source content and sensitive result fields are excluded from the audit record.
- **Synthesized Audio Cache Eviction.** Persisted line-audio clips now use configurable global file-count and byte
limits with oldest-first eviction, and oversized individual clips are rejected.
- **Security Regression Coverage.** Added a Python test gate for character metadata validation, private-download URL
rejection and oversized JSON request rejection.
- **Navigation Reliability.** Rehearser phase requests now survive the feature-module initialization boundary.

View File

@ -55,8 +55,8 @@ the first block before investing in architecture or polish.
repeated requests with `Idempotency-Key`; extend the same bounded contract to voice and casting jobs.
- [ ] **M — Improve large-table performance.** Character productions and the flat voice library now paginate at 50
visible rows; updating only affected rows and disabling preloading on low-memory devices remain.
- [ ] **M — Add explicit cache invalidation and eviction.** Fish.audio search responses now have bounded eviction;
add equivalent LRU cleanup for audio/image entries and object URLs.
- [ ] **M — Add explicit cache invalidation and eviction.** Fish.audio search responses and persisted synthesized
line-audio clips now have bounded eviction; image entries and browser object URLs still need equivalent cleanup.
- [ ] **M — Add constrained-device validation.** Test with Chrome CPU/memory throttling,
a 4 GB VM/container, and measure browser, Docker and temporary-disk usage.
- [ ] **L — Add background jobs and bounded queues.** Give voice design, transcription,
@ -109,7 +109,8 @@ the first block before investing in architecture or polish.
- [ ] Cache `/api/voices` with explicit invalidation instead of repeated full scans.
- [x] Lazy-load profile images and concept art.
- [ ] Update only affected table rows instead of rebuilding entire tables.
- [ ] Add an LRU cache for audio and images and remove old entries automatically.
- [ ] Add an LRU cache for audio and images and remove old entries automatically. Persisted line-audio is now bounded;
image-cache eviction remains.
- [x] Revoke discarded object URLs with `URL.revokeObjectURL(objectUrl)`; completed synthesized voice previews now release their Blob URLs when playback ends.
- [x] Add low-memory mode using `navigator.deviceMemory` where available; shared worker pools cap at two tasks on devices reporting approximately 4 GB RAM or less.
- [ ] Disable image preloading and reduce table/cache sizes on devices with approximately 4 GB RAM.

View File

@ -1260,6 +1260,34 @@ async def encode_mp3(request: Request):
# audio.
_LINE_AUDIO_DIR = CONFIG_DIR / "line_audio_cache"
_LINE_AUDIO_KEY_RE = re.compile(r"^[a-f0-9]{16,64}$")
_LINE_AUDIO_MAX_FILES = max(1, int(os.environ.get("LINE_AUDIO_CACHE_MAX_FILES", "2000")))
_LINE_AUDIO_MAX_BYTES = max(1, int(os.environ.get("LINE_AUDIO_CACHE_MAX_MB", "1024"))) * 1024 * 1024
def _evict_line_audio_cache() -> int:
"""Evict least-recently-written line clips until the global cache is bounded."""
if not _LINE_AUDIO_DIR.is_dir():
return 0
files = []
total = 0
for path in _LINE_AUDIO_DIR.glob("*/[a-f0-9]*.wav"):
try:
stat = path.stat()
except OSError:
continue
files.append((stat.st_mtime, path, stat.st_size))
total += stat.st_size
removed = 0
for _, path, size in sorted(files):
if len(files) - removed <= _LINE_AUDIO_MAX_FILES and total <= _LINE_AUDIO_MAX_BYTES:
break
try:
path.unlink()
total -= size
removed += 1
except OSError:
pass
return removed
def _line_audio_book_dir(book: str) -> Path:
@ -1374,8 +1402,11 @@ async def put_line_audio(book: str, key: str, request: Request):
wav_bytes = await request.body()
if not wav_bytes:
raise HTTPException(400, "Empty request body")
if len(wav_bytes) > _LINE_AUDIO_MAX_BYTES:
raise HTTPException(413, "Cached audio clip is too large")
path = _line_audio_book_dir(book) / f"{key}.wav"
path.write_bytes(wav_bytes)
_evict_line_audio_cache()
return {"ok": True}

View File

@ -119,6 +119,27 @@ class SecurityValidationTests(unittest.TestCase):
self.assertEqual(rows[0]["prompt"], "")
self.assertNotIn("must-not-be-added", rows[0]["result"])
def test_line_audio_cache_eviction_is_bounded(self):
from pathlib import Path
from tempfile import TemporaryDirectory
import routes.tts as tts
with TemporaryDirectory() as directory:
old_dir, old_count, old_bytes = tts._LINE_AUDIO_DIR, tts._LINE_AUDIO_MAX_FILES, tts._LINE_AUDIO_MAX_BYTES
try:
tts._LINE_AUDIO_DIR = Path(directory)
tts._LINE_AUDIO_MAX_FILES = 2
tts._LINE_AUDIO_MAX_BYTES = 10
book = Path(directory) / "book"
book.mkdir()
for index in range(4):
(book / f"{index:032x}.wav").write_bytes(b"1234")
tts._evict_line_audio_cache()
files = list(book.glob("*.wav"))
self.assertLessEqual(len(files), 2)
self.assertLessEqual(sum(path.stat().st_size for path in files), 10)
finally:
tts._LINE_AUDIO_DIR, tts._LINE_AUDIO_MAX_FILES, tts._LINE_AUDIO_MAX_BYTES = old_dir, old_count, old_bytes
if __name__ == "__main__":
unittest.main()