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 .
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}

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', '')
# 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')),
}

View file

@ -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) {

View file

@ -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();

View file

@ -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',

View file

@ -354,6 +354,10 @@
</span>
<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>
<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-bookmark-btn" onclick="addBookmark()" title="Bookmark"></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
# 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:

View file

@ -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)

View file

@ -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)