Vorlesen: Sprachauswahl DE/EN mit englischer Piper-Stimme (SW v43)
All checks were successful
Build and push Docker image / build (push) Successful in 1m7s
Test / test (push) Successful in 1m32s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xb6yX2S9aTGepYA9JFba9x
This commit is contained in:
marwin 2026-09-01 07:04:27 +02:00
parent 05216a8613
commit 0fd5461daa
9 changed files with 75 additions and 24 deletions

View file

@ -10,16 +10,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r 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 # 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 # 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. # deploy flow working unchanged.
RUN mkdir -p /app/tts_models && \ RUN mkdir -p /app/tts_models && \
curl -fsSL -o /app/tts_models/de_DE-thorsten-medium.onnx \ 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 && \ 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 \ 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 ARG BUILD_TIME
ENV BUILD_TIME=${BUILD_TIME} ENV BUILD_TIME=${BUILD_TIME}

View file

@ -152,6 +152,10 @@ WEBDAV_ALLOW_PRIVATE_HOSTS = os.environ.get('WEBDAV_ALLOW_PRIVATE_HOSTS', 'False
BUILD_TIME = os.environ.get('BUILD_TIME', '') BUILD_TIME = os.environ.get('BUILD_TIME', '')
# Piper voice model for the reader's read-aloud feature (see tts/piper_engine.py). # Piper voice models for the reader's read-aloud feature (see tts/piper_engine.py).
TTS_MODEL_PATH = os.environ.get( TTS_VOICES = {
'TTS_MODEL_PATH', str(BASE_DIR / 'tts_models' / 'de_DE-thorsten-medium.onnx')) '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')),
}

View file

@ -1636,6 +1636,12 @@ body.dnd-mode .timer-display {
padding: 4px 6px; font-size: 0.82rem; cursor: pointer; 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; } .reader-marker-btn-mobile { display: none; }
@media (max-width: 600px) { @media (max-width: 600px) {

View file

@ -4150,6 +4150,12 @@ let ttsRunToken = 0;
let ttsAudio = null; let ttsAudio = null;
let ttsCurrentMark = null; let ttsCurrentMark = null;
let ttsBarEl = 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) { function _ttsSplitSentences(text) {
const raw = text.split(/(?<=[.!?])\s+/).map(s => s.trim()).filter(Boolean); 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/', { const resp = await fetch('/tts/synthesize/', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, 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'); if (!resp.ok) throw new Error('tts request failed');
return await resp.blob(); return await resp.blob();
@ -6769,6 +6775,10 @@ function openRadioSidebar() {
setVolume(vol); setVolume(vol);
} }
// Restore persisted read-aloud language
const ttsLangSelect = $('reader-tts-lang');
if (ttsLangSelect) ttsLangSelect.value = ttsLang;
// Load recommendations on page load // Load recommendations on page load
loadRecommendations(); loadRecommendations();

View file

@ -2,7 +2,7 @@
* diora service worker caches the app shell for offline use. * 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 PODCAST_CACHE = 'diora-podcast-v1';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',

View file

@ -354,6 +354,10 @@
</span> </span>
<button class="btn-icon" id="reader-search-btn" onclick="toggleReaderSearch()" title="Search"></button> <button class="btn-icon" id="reader-search-btn" onclick="toggleReaderSearch()" title="Search"></button>
<button class="btn-icon" id="reader-settings-btn" onclick="toggleSettingsPanel()" title="Font &amp; layout"></button> <button class="btn-icon" id="reader-settings-btn" onclick="toggleSettingsPanel()" title="Font &amp; layout"></button>
<select id="reader-tts-lang" class="tts-lang-select" title="Vorlese-Sprache" onchange="setTtsLang(this.value)">
<option value="de">DE</option>
<option value="en">EN</option>
</select>
<button class="btn-icon" id="reader-tts-btn" onclick="toggleReadAloud()" title="Vorlesen"></button> <button class="btn-icon" id="reader-tts-btn" onclick="toggleReadAloud()" title="Vorlesen"></button>
<button class="btn-icon" id="reader-bookmark-btn" onclick="addBookmark()" title="Bookmark"></button> <button class="btn-icon" id="reader-bookmark-btn" onclick="addBookmark()" title="Bookmark"></button>
<button class="btn-icon" id="reader-bm-list-btn" onclick="openBookmarksSidebar()" title="Bookmarks"></button> <button class="btn-icon" id="reader-bm-list-btn" onclick="openBookmarksSidebar()" title="Bookmarks"></button>

View file

@ -4,31 +4,36 @@ import wave
from django.conf import settings from django.conf import settings
# Lazy per-process singleton: each gunicorn worker loads its own copy on first DEFAULT_LANGUAGE = 'de'
# use rather than all workers loading the ONNX model at startup (the host runs SUPPORTED_LANGUAGES = tuple(settings.TTS_VOICES.keys())
# several other containers with limited spare RAM).
_voice = None # 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() _lock = threading.Lock()
def _get_voice(): def _get_voice(lang):
global _voice if lang not in _voices:
if _voice is None:
with _lock: with _lock:
if _voice is None: if lang not in _voices:
from piper import PiperVoice from piper import PiperVoice
_voice = PiperVoice.load(str(settings.TTS_MODEL_PATH)) _voices[lang] = PiperVoice.load(str(settings.TTS_VOICES[lang]))
return _voice return _voices[lang]
def synthesize_wav(text): def synthesize_wav(text, lang=DEFAULT_LANGUAGE):
"""Synthesize `text` to WAV bytes. """Synthesize `text` (in `lang`) to WAV bytes.
Never persists or logs `text` callers must not log it either. The lock Never persists or logs `text` callers must not log it either. The lock
also serializes inference, since one onnxruntime session isn't meant to also serializes inference, since one onnxruntime session isn't meant to
run concurrent calls within a process. run concurrent calls within a process.
""" """
voice = _get_voice() voice = _get_voice(lang)
buf = io.BytesIO() buf = io.BytesIO()
with _lock: with _lock:
with wave.open(buf, 'wb') as wav_file: with wave.open(buf, 'wb') as wav_file:

View file

@ -31,11 +31,25 @@ class TtsSynthesizeTests(TestCase):
self.assertEqual(resp.status_code, 400) self.assertEqual(resp.status_code, 400)
@patch.object(piper_engine, 'synthesize_wav', return_value=b'RIFF....WAVEfmt fake') @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) self.client.force_login(self.user)
resp = self.client.post( resp = self.client.post(
'/tts/synthesize/', {'text': 'Hallo Welt.'}, content_type='application/json') '/tts/synthesize/', {'text': 'Hallo Welt.'}, content_type='application/json')
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
self.assertEqual(resp['Content-Type'], 'audio/wav') self.assertEqual(resp['Content-Type'], 'audio/wav')
self.assertEqual(b''.join(resp.streaming_content), b'RIFF....WAVEfmt fake') 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)

View file

@ -42,8 +42,12 @@ def synthesize(request):
if len(text) > MAX_TEXT_LENGTH: if len(text) > MAX_TEXT_LENGTH:
return JsonResponse({'error': f'text exceeds {MAX_TEXT_LENGTH} characters'}, status=400) 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: try:
audio = _synth_pool.apply(piper_engine.synthesize_wav, (text,)) audio = _synth_pool.apply(piper_engine.synthesize_wav, (text, lang))
except Exception: except Exception:
return JsonResponse({'error': 'synthesis failed'}, status=500) return JsonResponse({'error': 'synthesis failed'}, status=500)