diff --git a/.env.example b/.env.example index d23276eb8..2d1be3373 100644 --- a/.env.example +++ b/.env.example @@ -189,6 +189,7 @@ SEARXNG_INSTANCE=http://localhost:8080 # ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=26214400 # email compose attachment (25 MB) # ODYSSEUS_STT_MAX_AUDIO_BYTES=26214400 # speech-to-text audio (25 MB) # ODYSSEUS_ICS_MAX_BYTES=10485760 # calendar .ics import (10 MB) +# ODYSSEUS_TTS_CACHE_MAX_BYTES=524288000 # TTS cache (500 MB) # ============================================================ # Host Docker access (explicit opt-in) diff --git a/docker-compose.gpu-amd.yml b/docker-compose.gpu-amd.yml index 91e223e05..9699fc038 100644 --- a/docker-compose.gpu-amd.yml +++ b/docker-compose.gpu-amd.yml @@ -67,6 +67,7 @@ services: - ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400} - ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400} - ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760} + - ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES} - DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-} - GOOGLE_API_KEY=${GOOGLE_API_KEY:-} - GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-} diff --git a/docker-compose.gpu-nvidia.yml b/docker-compose.gpu-nvidia.yml index e8c2fd032..804a0a14e 100644 --- a/docker-compose.gpu-nvidia.yml +++ b/docker-compose.gpu-nvidia.yml @@ -66,6 +66,7 @@ services: - ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400} - ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400} - ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760} + - ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES} - DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-} - GOOGLE_API_KEY=${GOOGLE_API_KEY:-} - GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-} diff --git a/docker-compose.yml b/docker-compose.yml index b1f2c37ee..b0efb4439 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,6 +55,7 @@ services: - ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400} - ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400} - ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760} + - ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES} - DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-} - GOOGLE_API_KEY=${GOOGLE_API_KEY:-} - GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-} diff --git a/services/tts/tts_service.py b/services/tts/tts_service.py index 2120d7720..dd37865a7 100644 --- a/services/tts/tts_service.py +++ b/services/tts/tts_service.py @@ -2,6 +2,7 @@ """Multi-provider TTS service — dispatches to local Kokoro, OpenAI-compatible API, or browser.""" import io +import os import wave import logging import hashlib @@ -41,6 +42,11 @@ class TTSService: self.cache_dir = Path(cache_dir) self.cache_dir.mkdir(parents=True, exist_ok=True) self._kokoro = None # lazy-init + + try: + self.max_cache_bytes = int(os.getenv("ODYSSEUS_TTS_CACHE_MAX_BYTES", 500 * 1024 * 1024)) + except ValueError: + self.max_cache_bytes = 500 * 1024 * 1024 # ── Settings ── @@ -89,6 +95,53 @@ class TTSService: ext = ".mp3" if (len(data) >= 3 and (data[:3] == b'ID3' or (data[0] == 0xff and (data[1] & 0xe0) == 0xe0))) else ".wav" (self.cache_dir / f"{key}{ext}").write_bytes(data) + self._enforce_cache_limit() + + def _enforce_cache_limit(self): + """Evicts oldest files if the cache exceeds the configured byte limit.""" + if self.max_cache_bytes <= 0: + return + + try: + files = [] + total_size = 0 + + # Safely scan files and sum sizes, ignoring files deleted mid-scan + for f in self.cache_dir.iterdir(): + try: + if f.is_file() and f.suffix.lower() in (".mp3", ".wav"): + files.append(f) + total_size += f.stat().st_size + except OSError: + continue + + if total_size > self.max_cache_bytes: + logger.info( + f"TTS cache ({total_size} bytes) exceeded limit ({self.max_cache_bytes} bytes). Evicting oldest files." + ) + + # Sort files by modification time (oldest first) + try: + files.sort(key=lambda f: f.stat().st_mtime) + except OSError as e: + logger.warning(f"Failed to sort cache files by mtime: {e}") + + # Trim down to 80% of max capacity + target_size = self.max_cache_bytes * 0.8 + + while files and total_size > target_size: + f = files.pop(0) + try: + size = f.stat().st_size + f.unlink() + total_size -= size + except OSError as e: + logger.warning(f"Failed to evict cache file {f}: {e}") + continue + + except Exception as e: + logger.warning(f"Error enforcing TTS cache limit: {e}", exc_info=True) + def clear_cache(self): count = 0 for f in self.cache_dir.glob("*.*"): diff --git a/tests/test_tts_service_enforce_cache_limit.py b/tests/test_tts_service_enforce_cache_limit.py new file mode 100644 index 000000000..1da9d16c0 --- /dev/null +++ b/tests/test_tts_service_enforce_cache_limit.py @@ -0,0 +1,97 @@ +import os +import time +from pathlib import Path +import pytest + +# Adjust the import path if your file is directly in ./services instead of ./services/tts +from services.tts.tts_service import TTSService + +def test_cache_under_limit(tmp_path, monkeypatch): + """Test that writing a file under the size limit does not trigger eviction.""" + # Set a tiny limit: 100 bytes + monkeypatch.setenv("ODYSSEUS_TTS_CACHE_MAX_BYTES", "100") + + # Initialize service with pytest's temporary directory + service = TTSService(cache_dir=str(tmp_path)) + + # Write a 40-byte file (under the 100-byte limit) + service._put_cache("test_key", b"x" * 40) + + # Verify the file was written and nothing was deleted + files = list(tmp_path.glob("*.*")) + assert len(files) == 1 + assert sum(f.stat().st_size for f in files) == 40 + +def test_cache_exceeds_limit_triggers_eviction(tmp_path, monkeypatch): + """Test that exceeding the limit evicts the oldest files down to 80% capacity.""" + # Set limit to 100 bytes. 80% target capacity will be 80 bytes. + monkeypatch.setenv("ODYSSEUS_TTS_CACHE_MAX_BYTES", "100") + service = TTSService(cache_dir=str(tmp_path)) + + # 1. Setup: Manually create two older files (40 bytes each) + file1 = tmp_path / "oldest.wav" + file2 = tmp_path / "middle.wav" + + file1.write_bytes(b"a" * 40) + file2.write_bytes(b"b" * 40) + + # Spoof timestamps so file1 is explicitly older than file2 + now = time.time() + os.utime(file1, (now - 100, now - 100)) # 100 seconds ago + os.utime(file2, (now - 50, now - 50)) # 50 seconds ago + + # 2. Action: Write a 3rd file using the service method (40 bytes) + # Total cache is now 120 bytes, which exceeds 100. + # It should delete oldest (file1) to drop to 80 bytes (which matches the 80% target). + service._put_cache("newest", b"c" * 40) + + # 3. Assertions + # The newest file should exist (saved as .wav because it lacks MP3 magic bytes) + newest_file = tmp_path / "newest.wav" + + assert not file1.exists(), "The oldest file should have been evicted." + assert file2.exists(), "The middle file should still exist." + assert newest_file.exists(), "The newest file should have been saved." + + # Verify the final directory size is <= 80 bytes + total_size = sum(f.stat().st_size for f in tmp_path.glob("*.*")) + assert total_size <= 80 + +def test_cache_limit_disabled(tmp_path, monkeypatch): + """Test that setting max bytes to 0 disables eviction.""" + monkeypatch.setenv("ODYSSEUS_TTS_CACHE_MAX_BYTES", "0") + service = TTSService(cache_dir=str(tmp_path)) + + # Write 3 large files that would normally trigger eviction + service._put_cache("file1", b"x" * 1000) + service._put_cache("file2", b"x" * 1000) + service._put_cache("file3", b"x" * 1000) + + # Ensure nothing was deleted + files = list(tmp_path.glob("*.*")) + assert len(files) == 3 + assert sum(f.stat().st_size for f in files) == 3000 + +def test_cache_eviction_handles_unlink_error_gracefully(tmp_path, monkeypatch): + """Test that if unlinking a file fails, _put_cache still succeeds without raising.""" + service = TTSService(cache_dir=str(tmp_path)) + service.max_cache_bytes = 50 + + # Create a file to evict + old_file = tmp_path / "old.wav" + old_file.write_bytes(b"x" * 40) + + # Monkeypatch unlink on Path objects to simulate a PermissionError / file-lock failure + def mock_unlink(self_path): + raise OSError("Permission denied / file locked") + + monkeypatch.setattr(Path, "unlink", mock_unlink) + + # Writing a new file triggers eviction which encounters the mocked unlink error + try: + service._put_cache("new_key", b"y" * 40) + except Exception as e: + pytest.fail(f"_put_cache raised an exception during failed eviction: {e}") + + # The new file should still be written successfully + assert (tmp_path / "new_key.wav").exists() \ No newline at end of file