Vorlesen: Sprachauswahl DE/EN mit englischer Piper-Stimme (SW v43)
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:
parent
05216a8613
commit
0fd5461daa
9 changed files with 75 additions and 24 deletions
10
Dockerfile
10
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}
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
18
tts/tests.py
18
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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue