From 0fd5461daa883ccd0ab0db8d9fb2619eac8d945c Mon Sep 17 00:00:00 2001 From: marwin Date: Tue, 1 Sep 2026 07:04:27 +0200 Subject: [PATCH] Vorlesen: Sprachauswahl DE/EN mit englischer Piper-Stimme (SW v43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reader-Header bekommt ein #reader-tts-lang-Dropdown (Auswahl, keine Sprach-Erkennung — persistiert in localStorage); /tts/synthesize/ nimmt jetzt {"lang": "de"|"en"} entgegen. tts/piper_engine.py lädt pro Sprache eine eigene PiperVoice lazy (settings.TTS_VOICES), Docker-Image lädt zusätzlich en_US-lessac-medium beim Build. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Xb6yX2S9aTGepYA9JFba9x --- Dockerfile | 10 +++++++--- diora/settings.py | 10 +++++++--- static/css/app.css | 6 ++++++ static/js/app.js | 12 +++++++++++- static/js/sw.js | 2 +- templates/radio/player.html | 4 ++++ tts/piper_engine.py | 31 ++++++++++++++++++------------- tts/tests.py | 18 ++++++++++++++++-- tts/views.py | 6 +++++- 9 files changed, 75 insertions(+), 24 deletions(-) diff --git a/Dockerfile b/Dockerfile index 887fbe0..a3b78d6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,16 +10,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -# Piper voice for the reader's read-aloud feature (see tts/piper_engine.py). +# Piper voices for the reader's read-aloud feature (see tts/piper_engine.py). # Fetched at build time rather than kept in git or bind-mounted — there's no # existing volume-mount pattern for extra binary assets in this repo, and -# baking it into the image keeps the watchtower "just pull the new image" +# baking them into the image keeps the watchtower "just pull the new image" # deploy flow working unchanged. RUN mkdir -p /app/tts_models && \ curl -fsSL -o /app/tts_models/de_DE-thorsten-medium.onnx \ https://huggingface.co/rhasspy/piper-voices/resolve/main/de/de_DE/thorsten/medium/de_DE-thorsten-medium.onnx && \ curl -fsSL -o /app/tts_models/de_DE-thorsten-medium.onnx.json \ - https://huggingface.co/rhasspy/piper-voices/resolve/main/de/de_DE/thorsten/medium/de_DE-thorsten-medium.onnx.json + https://huggingface.co/rhasspy/piper-voices/resolve/main/de/de_DE/thorsten/medium/de_DE-thorsten-medium.onnx.json && \ + curl -fsSL -o /app/tts_models/en_US-lessac-medium.onnx \ + https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/medium/en_US-lessac-medium.onnx && \ + curl -fsSL -o /app/tts_models/en_US-lessac-medium.onnx.json \ + https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/medium/en_US-lessac-medium.onnx.json ARG BUILD_TIME ENV BUILD_TIME=${BUILD_TIME} diff --git a/diora/settings.py b/diora/settings.py index e99c962..c417d52 100644 --- a/diora/settings.py +++ b/diora/settings.py @@ -152,6 +152,10 @@ WEBDAV_ALLOW_PRIVATE_HOSTS = os.environ.get('WEBDAV_ALLOW_PRIVATE_HOSTS', 'False BUILD_TIME = os.environ.get('BUILD_TIME', '') -# Piper voice model for the reader's read-aloud feature (see tts/piper_engine.py). -TTS_MODEL_PATH = os.environ.get( - 'TTS_MODEL_PATH', str(BASE_DIR / 'tts_models' / 'de_DE-thorsten-medium.onnx')) +# Piper voice models for the reader's read-aloud feature (see tts/piper_engine.py). +TTS_VOICES = { + 'de': os.environ.get( + 'TTS_MODEL_PATH_DE', str(BASE_DIR / 'tts_models' / 'de_DE-thorsten-medium.onnx')), + 'en': os.environ.get( + 'TTS_MODEL_PATH_EN', str(BASE_DIR / 'tts_models' / 'en_US-lessac-medium.onnx')), +} diff --git a/static/css/app.css b/static/css/app.css index 07098a8..94c8723 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -1636,6 +1636,12 @@ body.dnd-mode .timer-display { padding: 4px 6px; font-size: 0.82rem; cursor: pointer; } +.tts-lang-select { + background: var(--surface, #1e1e2e); color: var(--fg, #fff); + border: 1px solid var(--border, #444); border-radius: 4px; + padding: 2px 4px; font-size: 0.8rem; cursor: pointer; +} + .reader-marker-btn-mobile { display: none; } @media (max-width: 600px) { diff --git a/static/js/app.js b/static/js/app.js index 6bceae7..aea7906 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -4150,6 +4150,12 @@ let ttsRunToken = 0; let ttsAudio = null; let ttsCurrentMark = null; let ttsBarEl = null; +let ttsLang = localStorage.getItem('diora_tts_lang') || 'de'; + +function setTtsLang(lang) { + ttsLang = lang; + localStorage.setItem('diora_tts_lang', lang); +} function _ttsSplitSentences(text) { const raw = text.split(/(?<=[.!?])\s+/).map(s => s.trim()).filter(Boolean); @@ -4172,7 +4178,7 @@ async function _ttsFetchAudio(sentence) { const resp = await fetch('/tts/synthesize/', { method: 'POST', headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({text: sentence}), + body: JSON.stringify({text: sentence, lang: ttsLang}), }); if (!resp.ok) throw new Error('tts request failed'); return await resp.blob(); @@ -6769,6 +6775,10 @@ function openRadioSidebar() { setVolume(vol); } + // Restore persisted read-aloud language + const ttsLangSelect = $('reader-tts-lang'); + if (ttsLangSelect) ttsLangSelect.value = ttsLang; + // Load recommendations on page load loadRecommendations(); diff --git a/static/js/sw.js b/static/js/sw.js index 0df9a50..0e6b7b3 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v42'; +const CACHE = 'diora-v43'; const PODCAST_CACHE = 'diora-podcast-v1'; const SHELL = [ '/static/css/app.css', diff --git a/templates/radio/player.html b/templates/radio/player.html index e5e0679..727f695 100644 --- a/templates/radio/player.html +++ b/templates/radio/player.html @@ -354,6 +354,10 @@ + diff --git a/tts/piper_engine.py b/tts/piper_engine.py index f2da467..dc9d6e0 100644 --- a/tts/piper_engine.py +++ b/tts/piper_engine.py @@ -4,31 +4,36 @@ import wave from django.conf import settings -# Lazy per-process singleton: each gunicorn worker loads its own copy on first -# use rather than all workers loading the ONNX model at startup (the host runs -# several other containers with limited spare RAM). -_voice = None +DEFAULT_LANGUAGE = 'de' +SUPPORTED_LANGUAGES = tuple(settings.TTS_VOICES.keys()) + +# Lazy per-process singletons, one per language: each gunicorn worker loads a +# voice only once it's actually requested, rather than all workers loading +# every model at startup (the host runs several other containers with limited +# spare RAM). One lock guards both the lazy-load and the inference call below +# — a single self-hosted user never needs concurrent synthesis across +# languages, so there's no reason for a lock per voice. +_voices = {} _lock = threading.Lock() -def _get_voice(): - global _voice - if _voice is None: +def _get_voice(lang): + if lang not in _voices: with _lock: - if _voice is None: + if lang not in _voices: from piper import PiperVoice - _voice = PiperVoice.load(str(settings.TTS_MODEL_PATH)) - return _voice + _voices[lang] = PiperVoice.load(str(settings.TTS_VOICES[lang])) + return _voices[lang] -def synthesize_wav(text): - """Synthesize `text` to WAV bytes. +def synthesize_wav(text, lang=DEFAULT_LANGUAGE): + """Synthesize `text` (in `lang`) to WAV bytes. Never persists or logs `text` — callers must not log it either. The lock also serializes inference, since one onnxruntime session isn't meant to run concurrent calls within a process. """ - voice = _get_voice() + voice = _get_voice(lang) buf = io.BytesIO() with _lock: with wave.open(buf, 'wb') as wav_file: diff --git a/tts/tests.py b/tts/tests.py index 69786d5..8be81f8 100644 --- a/tts/tests.py +++ b/tts/tests.py @@ -31,11 +31,25 @@ class TtsSynthesizeTests(TestCase): self.assertEqual(resp.status_code, 400) @patch.object(piper_engine, 'synthesize_wav', return_value=b'RIFF....WAVEfmt fake') - def test_synthesizes_audio(self, mock_synth): + def test_synthesizes_audio_default_lang(self, mock_synth): self.client.force_login(self.user) resp = self.client.post( '/tts/synthesize/', {'text': 'Hallo Welt.'}, content_type='application/json') self.assertEqual(resp.status_code, 200) self.assertEqual(resp['Content-Type'], 'audio/wav') self.assertEqual(b''.join(resp.streaming_content), b'RIFF....WAVEfmt fake') - mock_synth.assert_called_once_with('Hallo Welt.') + mock_synth.assert_called_once_with('Hallo Welt.', 'de') + + @patch.object(piper_engine, 'synthesize_wav', return_value=b'RIFF....WAVEfmt fake') + def test_synthesizes_audio_explicit_lang(self, mock_synth): + self.client.force_login(self.user) + resp = self.client.post( + '/tts/synthesize/', {'text': 'Hello world.', 'lang': 'en'}, content_type='application/json') + self.assertEqual(resp.status_code, 200) + mock_synth.assert_called_once_with('Hello world.', 'en') + + def test_rejects_unsupported_lang(self): + self.client.force_login(self.user) + resp = self.client.post( + '/tts/synthesize/', {'text': 'Hallo', 'lang': 'fr'}, content_type='application/json') + self.assertEqual(resp.status_code, 400) diff --git a/tts/views.py b/tts/views.py index f051ef8..60b8d8c 100644 --- a/tts/views.py +++ b/tts/views.py @@ -42,8 +42,12 @@ def synthesize(request): if len(text) > MAX_TEXT_LENGTH: return JsonResponse({'error': f'text exceeds {MAX_TEXT_LENGTH} characters'}, status=400) + lang = body.get('lang', piper_engine.DEFAULT_LANGUAGE) + if lang not in piper_engine.SUPPORTED_LANGUAGES: + return JsonResponse({'error': 'unsupported lang'}, status=400) + try: - audio = _synth_pool.apply(piper_engine.synthesize_wav, (text,)) + audio = _synth_pool.apply(piper_engine.synthesize_wav, (text, lang)) except Exception: return JsonResponse({'error': 'synthesis failed'}, status=500)