diff --git a/.gitignore b/.gitignore index 319f27a..1f720e9 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ env/ media/ staticfiles/ .env +tts_models/ # IDE .idea/ diff --git a/Dockerfile b/Dockerfile index a19240d..887fbe0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,11 +4,23 @@ WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ gcc \ + curl \ && rm -rf /var/lib/apt/lists/* 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). +# 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" +# 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 + ARG BUILD_TIME ENV BUILD_TIME=${BUILD_TIME} diff --git a/diora/settings.py b/diora/settings.py index 9a4e865..e99c962 100644 --- a/diora/settings.py +++ b/diora/settings.py @@ -30,6 +30,7 @@ INSTALLED_APPS = [ 'podcasts', 'books', 'gpodder', + 'tts', ] EBOOK_MAX_BYTES = 50 * 1024 * 1024 # 50 MB @@ -150,3 +151,7 @@ AMAZON_AFFILIATE_ENABLED = os.environ.get('AMAZON_AFFILIATE_ENABLED', 'True') == WEBDAV_ALLOW_PRIVATE_HOSTS = os.environ.get('WEBDAV_ALLOW_PRIVATE_HOSTS', 'False') == 'True' 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')) diff --git a/diora/urls.py b/diora/urls.py index 0664906..283cf2c 100644 --- a/diora/urls.py +++ b/diora/urls.py @@ -11,6 +11,7 @@ urlpatterns = [ path('accounts/', include('accounts.urls')), path('podcasts/', include('podcasts.urls')), path('books/', include('books.urls')), + path('tts/', include('tts.urls')), path('api/2/', include('gpodder.urls')), path('api/sync/', sync_snapshot, name='api_sync'), # Served at the root (not /static/js/sw.js) so its default scope covers diff --git a/requirements.txt b/requirements.txt index 457e64d..40bc49d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,3 +16,4 @@ gevent==26.8.0 # dependencies of its own, so packaging has to be requested explicitly. gunicorn==26.2.0 packaging==26.3 +piper-tts==1.7.0 diff --git a/static/css/app.css b/static/css/app.css index b451796..07098a8 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -2197,6 +2197,12 @@ mark.reader-search-match { background:rgba(241,196,15,.6); color:inherit; border mark.reader-search-match.active { background:rgba(230,57,70,.7); } #rs-search-count { font-size:12px; min-width:50px; } +/* Read-aloud (TTS) */ +mark.tts-current { background:rgba(230,57,70,.55); color:inherit; border-radius:2px; } +.tts-bar { position:fixed; bottom:calc(var(--bar-h) + 16px); left:50%; transform:translateX(-50%); display:flex; align-items:center; gap:14px; background:var(--bg-card,#1a1a1a); border:1px solid var(--border); border-radius:var(--radius); padding:8px 16px; box-shadow:0 4px 16px rgba(0,0,0,.5); z-index:600; } +.tts-bar button { background:none; border:none; color:inherit; font-size:16px; cursor:pointer; padding:2px 4px; line-height:1; } +.tts-bar button:hover { opacity:0.7; } + /* Bookmarks sidebar */ .bookmark-entry { display:flex; width:100%; padding:6px 0; font-size:13px; justify-content:space-between; border-bottom:1px solid var(--border); } diff --git a/static/js/app.js b/static/js/app.js index 263f25f..6bceae7 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -4136,6 +4136,233 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) { return {title: pdfTitle, author: pdfAuthor, toc, numPages: pdf.numPages}; } +// --------------------------------------------------------------------------- +// Read-aloud (TTS) — server-side Piper synthesis, one sentence per request. +// EPUB only (no block model for PDFs). Highlights the sentence currently +// playing and scrolls it into view; playback advances sentence-by-sentence, +// then block-by-block, until stopped or the book ends. +// --------------------------------------------------------------------------- +const TTS_MAX_CHARS = 480; // stay under the server's 500-char cap with margin + +let ttsActive = false; +let ttsPaused = false; +let ttsRunToken = 0; +let ttsAudio = null; +let ttsCurrentMark = null; +let ttsBarEl = null; + +function _ttsSplitSentences(text) { + const raw = text.split(/(?<=[.!?])\s+/).map(s => s.trim()).filter(Boolean); + const pieces = raw.length ? raw : [text]; + const out = []; + for (const piece of pieces) { + let rest = piece; + while (rest.length > TTS_MAX_CHARS) { + let cut = rest.lastIndexOf(' ', TTS_MAX_CHARS); + if (cut <= 0) cut = TTS_MAX_CHARS; + out.push(rest.slice(0, cut).trim()); + rest = rest.slice(cut).trim(); + } + if (rest) out.push(rest); + } + return out; +} + +async function _ttsFetchAudio(sentence) { + const resp = await fetch('/tts/synthesize/', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({text: sentence}), + }); + if (!resp.ok) throw new Error('tts request failed'); + return await resp.blob(); +} + +function _ttsClearHighlight() { + if (ttsCurrentMark && ttsCurrentMark.parentNode) { + const parent = ttsCurrentMark.parentNode; + while (ttsCurrentMark.firstChild) parent.insertBefore(ttsCurrentMark.firstChild, ttsCurrentMark); + parent.removeChild(ttsCurrentMark); + parent.normalize(); + } + ttsCurrentMark = null; +} + +// Finds `sentence` as a substring of block.textContent starting at fromOffset +// (so repeated sentence text earlier in the block isn't matched again), turns +// it into a DOM Range via the same char-offset addressing highlights use, and +// wraps it in a . Returns the offset to resume searching from. +function _ttsHighlightSentence(block, sentence, fromOffset) { + _ttsClearHighlight(); + const full = block.textContent; + const idx = full.indexOf(sentence, fromOffset); + if (idx === -1) return fromOffset; + const start = _nodeAtCharOffset(block, idx); + const end = _nodeAtCharOffset(block, idx + sentence.length); + if (!start || !end) return idx + sentence.length; + try { + const range = document.createRange(); + range.setStart(start.node, start.offset); + range.setEnd(end.node, end.offset); + const mark = document.createElement('mark'); + mark.className = 'tts-current'; + range.surroundContents(mark); + ttsCurrentMark = mark; + _ttsScrollIntoView(mark); + } catch (e) {} + return idx + sentence.length; +} + +function _ttsScrollIntoView(el) { + const contentEl = $('reader-content'); + if (!contentEl) return; + const top = el.getBoundingClientRect().top - contentEl.getBoundingClientRect().top; + if (top < 40 || top > contentEl.clientHeight - 80) { + _suppressScrollJumpDetect(); + contentEl.scrollBy({top: top - contentEl.clientHeight * 0.3, behavior: 'smooth'}); + } +} + +function _ttsPlayBlob(blob) { + return new Promise((resolve) => { + const url = URL.createObjectURL(blob); + const audio = new Audio(url); + ttsAudio = audio; + const done = () => { URL.revokeObjectURL(url); resolve(); }; + audio.addEventListener('ended', done); + audio.addEventListener('error', done); + if (!ttsPaused) audio.play().catch(done); + }); +} + +// Walks blocks/sentences from startBlockIndex onward, fetching one sentence +// ahead while the current one plays so there's no gap between them. +async function _ttsPlayLoop(blocks, startBlockIndex, runToken) { + function* sentenceStream() { + for (let bi = startBlockIndex; bi < blocks.length; bi++) { + const block = blocks[bi]; + const text = (block.textContent || '').trim(); + if (!text) continue; + for (const sentence of _ttsSplitSentences(text)) yield {block, sentence}; + } + } + + const iter = sentenceStream(); + let cur = iter.next(); + if (cur.done) { stopReadAloud(); return; } + let curFetch = _ttsFetchAudio(cur.value.sentence); + let lastBlock = null; + let blockSearchOffset = 0; + + while (!cur.done) { + if (runToken !== ttsRunToken) return; + const {block, sentence} = cur.value; + const next = iter.next(); + const nextFetch = next.done ? null : _ttsFetchAudio(next.value.sentence).catch(() => null); + + let audioBlob; + try { + audioBlob = await curFetch; + } catch (e) { + stopReadAloud(); + return; + } + if (runToken !== ttsRunToken) return; + + if (block !== lastBlock) { lastBlock = block; blockSearchOffset = 0; } + blockSearchOffset = _ttsHighlightSentence(block, sentence, blockSearchOffset); + + await _ttsPlayBlob(audioBlob); + if (runToken !== ttsRunToken) return; + + cur = next; + curFetch = nextFetch; + } + if (runToken === ttsRunToken) stopReadAloud(); +} + +function _ttsTogglePause() { + ttsPaused = !ttsPaused; + if (ttsAudio) { + if (ttsPaused) ttsAudio.pause(); + else ttsAudio.play().catch(() => {}); + } + _ttsUpdateBar(); +} + +function _ttsUpdateBar() { + if (!ttsBarEl) return; + const btn = ttsBarEl.querySelector('.tts-pause-btn'); + if (btn) btn.textContent = ttsPaused ? '▶' : '⏸'; +} + +function _ttsShowBar() { + _ttsRemoveBar(); + const bar = document.createElement('div'); + bar.className = 'tts-bar'; + bar.innerHTML = ` + + + `; + document.body.appendChild(bar); + ttsBarEl = bar; + bar.querySelector('.tts-pause-btn').addEventListener('click', _ttsTogglePause); + bar.querySelector('.tts-stop-btn').addEventListener('click', stopReadAloud); +} + +function _ttsRemoveBar() { + if (ttsBarEl) { ttsBarEl.remove(); ttsBarEl = null; } +} + +function toggleReadAloud() { + if (ttsActive) stopReadAloud(); + else startReadAloud(); +} + +function startReadAloud() { + if (ttsActive) return; + if (currentPdfDoc) { + const toast = document.createElement('div'); + toast.className = 'reader-toast'; + toast.textContent = 'Vorlesen ist aktuell nur für EPUB-Bücher verfügbar.'; + document.body.appendChild(toast); + setTimeout(() => toast.remove(), 2200); + return; + } + const contentEl = $('reader-content'); + if (!contentEl) return; + const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR)); + if (!blocks.length) return; + + const [anchorBlock] = _anchorParts(getPositionAnchor(contentEl)); + const startIndex = (anchorBlock >= 0 && anchorBlock < blocks.length) ? anchorBlock : 0; + + ttsActive = true; + ttsPaused = false; + ttsRunToken++; + const runToken = ttsRunToken; + + const btn = $('reader-tts-btn'); + if (btn) { btn.classList.add('active'); btn.title = 'Vorlesen beenden'; } + _ttsShowBar(); + + _ttsPlayLoop(blocks, startIndex, runToken); +} + +function stopReadAloud() { + ttsRunToken++; + ttsActive = false; + ttsPaused = false; + if (ttsAudio) { + try { ttsAudio.pause(); ttsAudio.src = ''; } catch (e) {} + ttsAudio = null; + } + _ttsClearHighlight(); + _ttsRemoveBar(); + const btn = $('reader-tts-btn'); + if (btn) { btn.classList.remove('active'); btn.title = 'Vorlesen'; } +} + // --------------------------------------------------------------------------- // Immersive reader mode — tap centre of screen to toggle bars // --------------------------------------------------------------------------- @@ -4143,7 +4370,7 @@ let _immBarsVisible = true; function _immHandleTap(e) { // Ignore taps on interactive elements (buttons, links, inputs, settings panel, footnote popover) - if (e.target.closest('button, a, input, select, label, #reader-settings-panel, .reader-header, .footnote-popover, #reader-margin, #highlight-popover, .note-bottom-sheet')) return; + if (e.target.closest('button, a, input, select, label, #reader-settings-panel, .reader-header, .footnote-popover, #reader-margin, #highlight-popover, .note-bottom-sheet, .tts-bar')) return; // In marker mode, taps have a dedicated meaning (highlight/create a note) — // don't also toggle the immersive bars underneath. if (markerModeActive) return; @@ -4542,6 +4769,7 @@ async function saveReaderProgress(force = false) { function closeReader() { exitReaderImmersiveMode(); + stopReadAloud(); // Save progress BEFORE hiding — scrollHeight/clientHeight return 0 once display:none saveReaderProgress(); if (bookmarksDirty) saveBookmarks(); diff --git a/static/js/sw.js b/static/js/sw.js index fa5dff0..0df9a50 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-v41'; +const CACHE = 'diora-v42'; const PODCAST_CACHE = 'diora-podcast-v1'; const SHELL = [ '/static/css/app.css', @@ -53,6 +53,7 @@ self.addEventListener('fetch', function (event) { if (url.pathname.startsWith('/radio/sse/') || url.pathname.startsWith('/radio/record/') || url.pathname.startsWith('/radio/affiliate/') || + url.pathname.startsWith('/tts/') || url.pathname.startsWith('/admin/') || url.pathname.startsWith('/podcasts/progress/') || url.pathname.startsWith('/podcasts/queue/') || diff --git a/templates/radio/player.html b/templates/radio/player.html index 5e0248e..e5e0679 100644 --- a/templates/radio/player.html +++ b/templates/radio/player.html @@ -354,6 +354,7 @@ + diff --git a/tts/__init__.py b/tts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tts/apps.py b/tts/apps.py new file mode 100644 index 0000000..f3a37a2 --- /dev/null +++ b/tts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TtsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'tts' diff --git a/tts/piper_engine.py b/tts/piper_engine.py new file mode 100644 index 0000000..f2da467 --- /dev/null +++ b/tts/piper_engine.py @@ -0,0 +1,36 @@ +import io +import threading +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 +_lock = threading.Lock() + + +def _get_voice(): + global _voice + if _voice is None: + with _lock: + if _voice is None: + from piper import PiperVoice + _voice = PiperVoice.load(str(settings.TTS_MODEL_PATH)) + return _voice + + +def synthesize_wav(text): + """Synthesize `text` 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() + buf = io.BytesIO() + with _lock: + with wave.open(buf, 'wb') as wav_file: + voice.synthesize_wav(text, wav_file) + return buf.getvalue() diff --git a/tts/tests.py b/tts/tests.py new file mode 100644 index 0000000..69786d5 --- /dev/null +++ b/tts/tests.py @@ -0,0 +1,41 @@ +from unittest.mock import patch + +from django.contrib.auth.models import User +from django.test import TestCase + +from . import piper_engine + + +class TtsSynthesizeTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username='alice', password='pw12345678') + + def test_requires_auth(self): + resp = self.client.post('/tts/synthesize/', {'text': 'Hallo'}, content_type='application/json') + self.assertEqual(resp.status_code, 401) + + def test_rejects_empty_text(self): + self.client.force_login(self.user) + resp = self.client.post('/tts/synthesize/', {'text': ' '}, content_type='application/json') + self.assertEqual(resp.status_code, 400) + + def test_rejects_text_over_limit(self): + self.client.force_login(self.user) + resp = self.client.post( + '/tts/synthesize/', {'text': 'a' * 501}, content_type='application/json') + self.assertEqual(resp.status_code, 400) + + def test_rejects_invalid_json(self): + self.client.force_login(self.user) + resp = self.client.post('/tts/synthesize/', 'not json', content_type='application/json') + 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): + 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.') diff --git a/tts/urls.py b/tts/urls.py new file mode 100644 index 0000000..0d663e2 --- /dev/null +++ b/tts/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path('synthesize/', views.synthesize, name='tts_synthesize'), +] diff --git a/tts/views.py b/tts/views.py new file mode 100644 index 0000000..f051ef8 --- /dev/null +++ b/tts/views.py @@ -0,0 +1,53 @@ +import json + +from django.http import JsonResponse, StreamingHttpResponse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods +from gevent.threadpool import ThreadPool + +from . import piper_engine + +# One sentence per request, hard-capped — this is the load-bearing part of the +# "server never holds more than a small, transient snippet of book text" +# agreement (see CLAUDE.md), not just a client-side convention. +MAX_TEXT_LENGTH = 500 + +# Offloads the CPU-bound Piper inference off the gevent hub's event loop, so a +# synthesis call doesn't stall other concurrent greenlets (radio SSE, other +# requests) in the same worker the way a plain in-greenlet call would. +_synth_pool = ThreadPool(1) + + +def _require_auth(request): + if not request.user.is_authenticated: + return JsonResponse({'error': 'authentication required'}, status=401) + return None + + +@csrf_exempt +@require_http_methods(['POST']) +def synthesize(request): + err = _require_auth(request) + if err: + return err + + try: + body = json.loads(request.body) + except (json.JSONDecodeError, ValueError): + return JsonResponse({'error': 'invalid JSON'}, status=400) + + text = body.get('text', '') + if not isinstance(text, str) or not text.strip(): + return JsonResponse({'error': 'text required'}, status=400) + if len(text) > MAX_TEXT_LENGTH: + return JsonResponse({'error': f'text exceeds {MAX_TEXT_LENGTH} characters'}, status=400) + + try: + audio = _synth_pool.apply(piper_engine.synthesize_wav, (text,)) + except Exception: + return JsonResponse({'error': 'synthesis failed'}, status=500) + + response = StreamingHttpResponse(iter([audio]), content_type='audio/wav') + response['Cache-Control'] = 'no-store' + response['X-Accel-Buffering'] = 'no' + return response