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 .
|
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}
|
||||||
|
|
|
||||||
|
|
@ -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')),
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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 & layout">⚙</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-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>
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
18
tts/tests.py
18
tts/tests.py
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue