diff --git a/CHANGELOG.md b/CHANGELOG.md index e4a0beb..bbc93fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/TODO.md b/TODO.md index b71ce46..bc06965 100644 --- a/TODO.md +++ b/TODO.md @@ -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. diff --git a/routes/tts.py b/routes/tts.py index 52e47d7..4cf3aca 100644 --- a/routes/tts.py +++ b/routes/tts.py @@ -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} diff --git a/tests/test_security.py b/tests/test_security.py index 4bb6ee4..d86d4c1 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -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()