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)