Compare commits

...

9 commits

Author SHA1 Message Date
09055b9874 Reader: Lesefortschritt nach Textanteil statt Pixeln (SW v46)
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 1m33s
Der Fortschritt (Speichern/Sync, Prozentanzeige im Reader-Kopf, Sprung
über das Prozentfeld, "≈ x % im Buch" bei Markierungen) wird jetzt aus
dem Positions-Anker als Anteil des Buchtexts in Zeichen berechnet statt
aus scrollTop/scrollHeight. Das ist unabhängig vom Layout (Schriftgröße,
Breite, welche Kapitel gerendert sind) und damit auf allen Clients gleich
— die Android-App rendert im E-Reader-Modus nur ein Kapitel und kennt die
Pixelhöhe des Buchs nicht. Pixel zählten außerdem Ränder, Überschriften
und Bilder mit. Anker und Server-Logik bleiben unverändert; PDFs weiter
nach Seiten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 19:57:10 +02:00
marwin
9d097f5fba Reader: Notiz-Randspalte auf dem Handy nicht automatisch öffnen (SW v45)
All checks were successful
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 1m33s
Beim Öffnen eines Buchs mit Markierungen klappte die Randspalte auch auf
schmalen Bildschirmen auf und verdeckte den Text. Auf Mobil (_isMobileReader)
bleibt sie jetzt zu und ist weiter über ✎ erreichbar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 10:49:09 +00:00
marwin
149b64b67c Reader: Source Serif 4 etwas leichter (400 -> 380)
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 1m31s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mxq5zi49Qs9FyQEQAeR8xc
2026-09-12 15:11:50 +02:00
marwin
51fb6258b8 Serif-Schriftart im Reader auf Source Serif 4 umstellen
All checks were successful
Build and push Docker image / build (push) Successful in 18s
Test / test (push) Successful in 1m32s
Selbst gehostet (Latin + Latin Extended, normal/italic, variable
Weight 200-900) statt Google-Fonts-Request, damit der Reader offline
über den Service-Worker-App-Shell-Cache weiter funktioniert. SW-Cache
auf v44 gebumpt, damit bestehende Clients die neuen Font-Dateien
nachladen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mxq5zi49Qs9FyQEQAeR8xc
2026-09-12 15:05:08 +02:00
marwin
26f983bbaa Default-Lautstärke auf 40% senken
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 1m31s
Betrifft alle Sender, insbesondere creamfresh radio.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Drq8o4HfFmwkXz2AgvaQ4p
2026-09-04 12:30:58 +02:00
marwin
171560c71e Add thumbs up/down for creamfresh radio, hardcoded to that station
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 1m35s
Relays to the DJ's own /dj/feedback via the same server-side-auth
proxy pattern as the stream itself, so the browser never needs the
station's credentials. Buttons only show when the currently playing
station is creamfresh radio (matched by URL against a hardcoded
constant) -- no other station's backend does anything with a vote,
so no other station gets the buttons yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Drq8o4HfFmwkXz2AgvaQ4p
2026-09-04 11:38:34 +02:00
marwin
9228906db8 Add a transparent stream proxy for creamfresh radio
All checks were successful
Build and push Docker image / build (push) Successful in 19s
Test / test (push) Successful in 1m34s
A plain <audio src="https://user:pass@host/..."> doesn't get its
embedded Basic Auth credentials honoured consistently across
browsers (the user hit a native auth prompt on play) -- this instead
holds the credentials server-side and relays the request. Forwards
the client's own Icy-MetaData header and the upstream's icy-* response
headers unchanged, so the existing icy.py-based metadata SSE keeps
working when pointed at this URL instead of the direct one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Drq8o4HfFmwkXz2AgvaQ4p
2026-09-04 11:29:52 +02:00
marwin
0fd5461daa 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
2026-09-01 07:04:27 +02:00
marwin
05216a8613 Vorlesen-Funktion im Reader: Piper/Thorsten-TTS (SW v42)
All checks were successful
Build and push Docker image / build (push) Successful in 1m3s
Test / test (push) Successful in 1m32s
Serverseitige Sprachsynthese (deutsche Thorsten-Stimme, Piper) für den
EPUB-Reader: /tts/synthesize/ nimmt einen Satz (max. 500 Zeichen) entgegen,
synthetisiert und streamt WAV zurück, ohne zu persistieren, zu loggen oder zu
cachen — eine bewusste, eng begrenzte Ausnahme vom "Server sieht nie
Klartext"-Prinzip (siehe CLAUDE.md). Der neue ▶-Button im Reader-Header liest
ab der aktuellen Position satzweise vor, hebt den gerade gesprochenen Satz
hervor und scrollt mit; eine kleine Leiste bietet Pause/Stop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xb6yX2S9aTGepYA9JFba9x
2026-08-31 22:29:13 +02:00
21 changed files with 691 additions and 15 deletions

1
.gitignore vendored
View file

@ -19,6 +19,7 @@ env/
media/ media/
staticfiles/ staticfiles/
.env .env
tts_models/
# IDE # IDE
.idea/ .idea/

View file

@ -4,11 +4,27 @@ WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \ gcc \
curl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
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 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 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 && \
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}

View file

@ -30,6 +30,7 @@ INSTALLED_APPS = [
'podcasts', 'podcasts',
'books', 'books',
'gpodder', 'gpodder',
'tts',
] ]
EBOOK_MAX_BYTES = 50 * 1024 * 1024 # 50 MB EBOOK_MAX_BYTES = 50 * 1024 * 1024 # 50 MB
@ -122,7 +123,7 @@ BG_MAX_BYTES = 5 * 1024 * 1024 # 5 MB
HIGHLIGHTS_MAX_BYTES = 700 * 1024 # 700 KB HIGHLIGHTS_MAX_BYTES = 700 * 1024 # 700 KB
BOOKMARKS_MAX_BYTES = 100 * 1024 # 100 KB BOOKMARKS_MAX_BYTES = 100 * 1024 # 100 KB
VOLUME_DEFAULT = 204 # out of 255 VOLUME_DEFAULT = 102 # out of 255 (40%)
ITUNES_TIMEOUT = 6 # seconds ITUNES_TIMEOUT = 6 # seconds
BOOK_METADATA_TIMEOUT = 6 # seconds (DNB / Open Library shelf lookup) BOOK_METADATA_TIMEOUT = 6 # seconds (DNB / Open Library shelf lookup)
WEBDAV_TIMEOUT = 15 # seconds (cloud import browse/fetch) WEBDAV_TIMEOUT = 15 # seconds (cloud import browse/fetch)
@ -150,3 +151,11 @@ AMAZON_AFFILIATE_ENABLED = os.environ.get('AMAZON_AFFILIATE_ENABLED', 'True') ==
WEBDAV_ALLOW_PRIVATE_HOSTS = os.environ.get('WEBDAV_ALLOW_PRIVATE_HOSTS', 'False') == 'True' WEBDAV_ALLOW_PRIVATE_HOSTS = os.environ.get('WEBDAV_ALLOW_PRIVATE_HOSTS', 'False') == 'True'
BUILD_TIME = os.environ.get('BUILD_TIME', '') BUILD_TIME = os.environ.get('BUILD_TIME', '')
# 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

@ -11,6 +11,7 @@ urlpatterns = [
path('accounts/', include('accounts.urls')), path('accounts/', include('accounts.urls')),
path('podcasts/', include('podcasts.urls')), path('podcasts/', include('podcasts.urls')),
path('books/', include('books.urls')), path('books/', include('books.urls')),
path('tts/', include('tts.urls')),
path('api/2/', include('gpodder.urls')), path('api/2/', include('gpodder.urls')),
path('api/sync/', sync_snapshot, name='api_sync'), path('api/sync/', sync_snapshot, name='api_sync'),
# Served at the root (not /static/js/sw.js) so its default scope covers # Served at the root (not /static/js/sw.js) so its default scope covers

View file

@ -19,4 +19,6 @@ urlpatterns = [
path('radio/focus/record/', views.record_focus_session, name='record_focus_session'), path('radio/focus/record/', views.record_focus_session, name='record_focus_session'),
path('radio/focus/stats/', views.focus_stats, name='focus_stats'), path('radio/focus/stats/', views.focus_stats, name='focus_stats'),
path('radio/stream-player/', views.stream_player, name='stream_player'), path('radio/stream-player/', views.stream_player, name='stream_player'),
path('radio/creamfresh-stream/', views.creamfresh_stream, name='creamfresh_stream'),
path('radio/creamfresh-feedback/', views.creamfresh_feedback, name='creamfresh_feedback'),
] ]

View file

@ -581,6 +581,74 @@ def import_m3u(request):
# Minimal HTTP stream player (standalone tab for mixed-content streams) # Minimal HTTP stream player (standalone tab for mixed-content streams)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# creamfresh radio proxy
# ---------------------------------------------------------------------------
# Held server-side only -- never exposed to the browser this way. The
# upstream is protected by HTTP Basic Auth; a plain <audio src="user:pass@..">
# doesn't get credentials honoured consistently across browsers, so this
# transparently relays the request instead (including the client's own
# Icy-MetaData header, so the existing icy.py-based metadata SSE keeps
# working unmodified when pointed at this URL instead of the direct one).
CREAMFRESH_STREAM_URL = 'https://radio.creamfresh.xyz/stream.mp3'
CREAMFRESH_AUTH = ('player', 'MJMr58p83zAU5zZaDGU0_BIn')
def creamfresh_stream(request):
headers = {}
if request.META.get('HTTP_ICY_METADATA'):
headers['Icy-MetaData'] = request.META['HTTP_ICY_METADATA']
try:
upstream = requests.get(
CREAMFRESH_STREAM_URL,
auth=CREAMFRESH_AUTH,
headers=headers,
stream=True,
timeout=15,
)
except requests.RequestException:
return HttpResponse(status=502)
response = StreamingHttpResponse(
upstream.iter_content(chunk_size=4096),
content_type=upstream.headers.get('Content-Type', 'audio/mpeg'),
status=upstream.status_code,
)
for h in ('icy-metaint', 'icy-name', 'icy-genre', 'icy-br', 'icy-description', 'icy-url'):
if h in upstream.headers:
response[h] = upstream.headers[h]
response['Cache-Control'] = 'no-cache'
return response
@csrf_exempt
@require_http_methods(['POST'])
def creamfresh_feedback(request):
"""Relays a thumbs up/down to the creamfresh DJ's own /dj/feedback --
same server-side-credentials reasoning as creamfresh_stream above."""
try:
body = json.loads(request.body)
except (json.JSONDecodeError, ValueError):
return JsonResponse({'error': 'invalid JSON'}, status=400)
vote = body.get('vote')
if vote not in ('up', 'down'):
return JsonResponse({'error': "vote must be 'up' or 'down'"}, status=400)
try:
upstream = requests.post(
'https://radio.creamfresh.xyz/dj/feedback',
auth=CREAMFRESH_AUTH,
json={'vote': vote},
timeout=15,
)
except requests.RequestException:
return JsonResponse({'error': 'upstream unreachable'}, status=502)
return JsonResponse(upstream.json(), status=upstream.status_code, safe=False)
def stream_player(request): def stream_player(request):
url = request.GET.get('url', '').strip() url = request.GET.get('url', '').strip()
name = request.GET.get('name', '').strip() name = request.GET.get('name', '').strip()

View file

@ -16,3 +16,4 @@ gevent==26.8.0
# dependencies of its own, so packaging has to be requested explicitly. # dependencies of its own, so packaging has to be requested explicitly.
gunicorn==26.2.0 gunicorn==26.2.0
packaging==26.3 packaging==26.3
piper-tts==1.7.0

View file

@ -2,6 +2,42 @@
diora — dark radio player theme diora — dark radio player theme
========================================================= */ ========================================================= */
/* Self-hosted (no external request, works offline via the service worker's
app-shell cache) — variable font covering weight 200-900 in one file per
style/subset. Latin + Latin Extended only (covers German/English text). */
@font-face {
font-family: 'Source Serif 4';
font-style: normal;
font-weight: 200 900;
font-display: swap;
src: url('/static/fonts/SourceSerif4-normal-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Source Serif 4';
font-style: normal;
font-weight: 200 900;
font-display: swap;
src: url('/static/fonts/SourceSerif4-normal-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Source Serif 4';
font-style: italic;
font-weight: 200 900;
font-display: swap;
src: url('/static/fonts/SourceSerif4-italic-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Source Serif 4';
font-style: italic;
font-weight: 200 900;
font-display: swap;
src: url('/static/fonts/SourceSerif4-italic-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
*, *::before, *::after { *, *::before, *::after {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
@ -1636,6 +1672,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) {
@ -1936,9 +1978,9 @@ body.dnd-mode .timer-display {
position: relative; position: relative;
padding: 24px 16px; padding: 24px 16px;
line-height: 1.8; line-height: 1.8;
font-family: Georgia, 'Times New Roman', serif; font-family: 'Source Serif 4', Georgia, 'Times New Roman', serif;
font-size: 16px; font-size: 16px;
font-weight: 400; font-weight: 380;
} }
.reader-content > * { .reader-content > * {
max-width: 65ch; max-width: 65ch;
@ -2197,6 +2239,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); } mark.reader-search-match.active { background:rgba(230,57,70,.7); }
#rs-search-count { font-size:12px; min-width:50px; } #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 */ /* Bookmarks sidebar */
.bookmark-entry { display:flex; width:100%; padding:6px 0; font-size:13px; justify-content:space-between; border-bottom:1px solid var(--border); } .bookmark-entry { display:flex; width:100%; padding:6px 0; font-size:13px; justify-content:space-between; border-bottom:1px solid var(--border); }

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -9,6 +9,10 @@
// State // State
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Hardcoded for now -- only creamfresh radio gets the vote buttons, since
// only its backend (the DJ) actually does anything with them.
const CREAMFRESH_RADIO_URL = 'https://diora.creamfresh.xyz/radio/creamfresh-stream/';
let currentStation = null; // { url, name, id } | null let currentStation = null; // { url, name, id } | null
let currentTrack = ''; let currentTrack = '';
let sseSource = null; let sseSource = null;
@ -176,6 +180,12 @@ function playStation(url, name, stationId) {
$('play-stop-btn').classList.add('playing'); $('play-stop-btn').classList.add('playing');
$('save-station-btn').style.display = ''; $('save-station-btn').style.display = '';
const isCreamfresh = url === CREAMFRESH_RADIO_URL;
$('creamfresh-vote-up-btn').style.display = isCreamfresh ? '' : 'none';
$('creamfresh-vote-down-btn').style.display = isCreamfresh ? '' : 'none';
$('creamfresh-vote-up-btn').classList.remove('active');
$('creamfresh-vote-down-btn').classList.remove('active');
startMetadataSSE(url); startMetadataSSE(url);
startPlaySession(name, url); startPlaySession(name, url);
maybeShowDonationHint(url, name); maybeShowDonationHint(url, name);
@ -220,6 +230,8 @@ function stopPlayback(clearStation = true) {
$('play-stop-btn').textContent = '▶ Play'; $('play-stop-btn').textContent = '▶ Play';
$('play-stop-btn').classList.remove('playing'); $('play-stop-btn').classList.remove('playing');
$('save-station-btn').style.display = 'none'; $('save-station-btn').style.display = 'none';
$('creamfresh-vote-up-btn').style.display = 'none';
$('creamfresh-vote-down-btn').style.display = 'none';
$('affiliate-section').style.display = 'none'; $('affiliate-section').style.display = 'none';
stopPlaySession(); stopPlaySession();
@ -604,6 +616,36 @@ async function saveCurrentStation() {
await saveStation(data); await saveStation(data);
} }
// ---------------------------------------------------------------------------
// creamfresh radio: DJ feedback (hardcoded to this one station, see
// CREAMFRESH_RADIO_URL above)
// ---------------------------------------------------------------------------
async function creamfreshVote(direction) {
const upBtn = $('creamfresh-vote-up-btn');
const downBtn = $('creamfresh-vote-down-btn');
upBtn.disabled = true;
downBtn.disabled = true;
try {
const res = await fetch('/radio/creamfresh-feedback/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken(),
},
body: JSON.stringify({ vote: direction }),
});
if (!res.ok) throw new Error(`feedback returned ${res.status}`);
upBtn.classList.toggle('active', direction === 'up');
downBtn.classList.toggle('active', direction === 'down');
} catch (err) {
console.warn('creamfresh vote failed', err);
} finally {
upBtn.disabled = false;
downBtn.disabled = false;
}
}
async function saveStation(station) { async function saveStation(station) {
try { try {
const res = await fetch('/radio/save/', { const res = await fetch('/radio/save/', {
@ -2924,6 +2966,59 @@ function getPositionAnchor(contentEl) {
return `${bestIndex}:${innerFraction.toFixed(6)}`; return `${bestIndex}:${innerFraction.toFixed(6)}`;
} }
// Reading progress as a share of the book's text (characters), from a
// position anchor. Independent of layout — font size, screen width, which
// chapters are rendered (the Android app's e-reader mode lays out one chapter
// at a time) — so every client shows the same percentage for the same place.
// The pixel share (scrollTop / scrollHeight) used before differed between
// layouts and counted margins, headings and images as reading.
let _charIndex = null; // {content, starts[], lens[], total} for the open book
function _bookCharIndex(contentEl) {
if (_charIndex && _charIndex.content === contentEl) return _charIndex;
const offsets = new Map();
let pos = 0;
const walker = document.createTreeWalker(contentEl, NodeFilter.SHOW_TEXT);
let node;
while ((node = walker.nextNode())) {
offsets.set(node, pos);
pos += node.nodeValue.length;
}
const blocks = contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR);
const starts = new Array(blocks.length);
const lens = new Array(blocks.length);
for (let i = 0; i < blocks.length; i++) {
const first = document.createTreeWalker(blocks[i], NodeFilter.SHOW_TEXT).nextNode();
starts[i] = first ? offsets.get(first) : (i ? starts[i - 1] + lens[i - 1] : 0);
lens[i] = blocks[i].textContent.length;
}
_charIndex = {content: contentEl, starts, lens, total: pos || 1};
return _charIndex;
}
// Share of the text before the anchored place (0..1), or null.
function _fractionFromAnchor(contentEl, anchor) {
const [block, inner] = _anchorParts(anchor);
if (block < 0) return null;
const idx = _bookCharIndex(contentEl);
if (block >= idx.starts.length) return null;
return Math.max(0, Math.min(1, (idx.starts[block] + inner * idx.lens[block]) / idx.total));
}
// Anchor of the place where `fraction` of the text has been read.
function _anchorForFraction(contentEl, fraction) {
const idx = _bookCharIndex(contentEl);
if (!idx.starts.length) return '';
const target = Math.max(0, Math.min(1, fraction)) * idx.total;
let lo = 0, hi = idx.starts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (idx.starts[mid] <= target) lo = mid; else hi = mid - 1;
}
const inner = idx.lens[lo] ? Math.max(0, Math.min(1, (target - idx.starts[lo]) / idx.lens[lo])) : 0;
return `${lo}:${inner.toFixed(6)}`;
}
function restoreFromAnchor(contentEl, anchor) { function restoreFromAnchor(contentEl, anchor) {
if (!anchor) return false; if (!anchor) return false;
const parts = anchor.split(':'); const parts = anchor.split(':');
@ -4136,6 +4231,239 @@ async function renderPdf(arrayBuffer, contentEl, scaleOverride, pivotPage) {
return {title: pdfTitle, author: pdfAuthor, toc, numPages: pdf.numPages}; 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;
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);
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, lang: ttsLang}),
});
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 <mark>. 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 = `
<button type="button" class="tts-pause-btn" title="Pause/Weiter">⏸</button>
<button type="button" class="tts-stop-btn" title="Vorlesen beenden">■</button>
`;
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 // Immersive reader mode — tap centre of screen to toggle bars
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -4143,7 +4471,7 @@ let _immBarsVisible = true;
function _immHandleTap(e) { function _immHandleTap(e) {
// Ignore taps on interactive elements (buttons, links, inputs, settings panel, footnote popover) // 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) — // In marker mode, taps have a dedicated meaning (highlight/create a note) —
// don't also toggle the immersive bars underneath. // don't also toggle the immersive bars underneath.
if (markerModeActive) return; if (markerModeActive) return;
@ -4189,6 +4517,7 @@ async function openBook(bookId) {
try { try {
loadReaderSettings(bookId); loadReaderSettings(bookId);
_charIndex = null;
const key = await getOrCreateEncKey(); const key = await getOrCreateEncKey();
let data_ct, data_iv; let data_ct, data_iv;
const cached = await _getCachedBook(bookId); const cached = await _getCachedBook(bookId);
@ -4277,7 +4606,8 @@ async function openBook(bookId) {
// If this book already has notes/highlights, surface them right away // If this book already has notes/highlights, surface them right away
// instead of making the reader hide them behind a manual toggle. // instead of making the reader hide them behind a manual toggle.
if (!isPdfBook && currentHighlights.length && !readerAnnotationsMarginOpen) { // Not on phones: there the margin covers the text; it stays one tap away (✎).
if (!isPdfBook && currentHighlights.length && !readerAnnotationsMarginOpen && !_isMobileReader()) {
toggleAnnotationsMargin(); toggleAnnotationsMargin();
} }
@ -4315,7 +4645,7 @@ async function openBook(bookId) {
} else { } else {
const pct = Math.min(100, Math.max(0, _parseLocalePercent(this.value))); const pct = Math.min(100, Math.max(0, _parseLocalePercent(this.value)));
this.value = _formatBookPercent(pct / 100); this.value = _formatBookPercent(pct / 100);
contentEl.scrollTop = (pct / 100) * contentEl.scrollHeight; restoreFromAnchor(contentEl, _anchorForFraction(contentEl, pct / 100));
} }
}); });
@ -4384,10 +4714,8 @@ async function openBook(bookId) {
} }
} }
progressInput.value = currentPage; progressInput.value = currentPage;
} else {
const f = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1);
progressInput.value = _formatBookPercent(f);
} }
// EPUB: shown from the position anchor, see the anchor tracking below.
}); });
// Auto-save progress every 10s and on scroll (debounced 2s) // Auto-save progress every 10s and on scroll (debounced 2s)
@ -4408,6 +4736,10 @@ async function openBook(bookId) {
clearTimeout(_anchorTrackDebounce); clearTimeout(_anchorTrackDebounce);
_anchorTrackDebounce = setTimeout(() => { _anchorTrackDebounce = setTimeout(() => {
_currentPositionAnchor = getPositionAnchor(contentEl); _currentPositionAnchor = getPositionAnchor(contentEl);
const f = _fractionFromAnchor(contentEl, _currentPositionAnchor);
if (progressInput && f !== null && document.activeElement !== progressInput) {
progressInput.value = _formatBookPercent(f);
}
}, 150); }, 150);
}, {passive: true}); }, {passive: true});
} }
@ -4511,13 +4843,16 @@ async function saveReaderProgress(force = false) {
} else { } else {
fraction = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1); fraction = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1);
} }
fraction = Math.min(1.0, Math.max(0.0, fraction));
let anchor = ''; let anchor = '';
if (!currentPdfDoc) { if (!currentPdfDoc) {
anchor = getPositionAnchor(contentEl); anchor = getPositionAnchor(contentEl);
_currentPositionAnchor = anchor; _currentPositionAnchor = anchor;
// EPUB: share of the text, the same on every device and layout.
const textFraction = _fractionFromAnchor(contentEl, anchor);
if (textFraction !== null) fraction = textFraction;
} }
fraction = Math.min(1.0, Math.max(0.0, fraction));
_updateCachedBookProgress(currentBookId, fraction, anchor, force); _updateCachedBookProgress(currentBookId, fraction, anchor, force);
@ -4542,6 +4877,7 @@ async function saveReaderProgress(force = false) {
function closeReader() { function closeReader() {
exitReaderImmersiveMode(); exitReaderImmersiveMode();
stopReadAloud();
// Save progress BEFORE hiding — scrollHeight/clientHeight return 0 once display:none // Save progress BEFORE hiding — scrollHeight/clientHeight return 0 once display:none
saveReaderProgress(); saveReaderProgress();
if (bookmarksDirty) saveBookmarks(); if (bookmarksDirty) saveBookmarks();
@ -4717,7 +5053,7 @@ function applyReaderSettings(isPdf) {
contentEl.style.lineHeight = readerSettings.lineHeight; contentEl.style.lineHeight = readerSettings.lineHeight;
contentEl.style.setProperty('--reader-max-width', readerSettings.maxWidth + 'ch'); contentEl.style.setProperty('--reader-max-width', readerSettings.maxWidth + 'ch');
const fontMap = { const fontMap = {
serif: "Georgia, 'Times New Roman', serif", serif: "'Source Serif 4', Georgia, 'Times New Roman', serif",
sans: 'system-ui, -apple-system, sans-serif', sans: 'system-ui, -apple-system, sans-serif',
humanist: 'Verdana, Geneva, sans-serif', humanist: 'Verdana, Geneva, sans-serif',
mono: "'Courier New', monospace", mono: "'Courier New', monospace",
@ -6279,7 +6615,19 @@ function _tocTitleForChapterSrc(chapterSrc) {
// relocate a highlight/note later. // relocate a highlight/note later.
function _percentFromRange(range) { function _percentFromRange(range) {
const contentEl = $('reader-content'); const contentEl = $('reader-content');
if (!contentEl || !range || !contentEl.scrollHeight) return null; if (!contentEl || !range) return null;
// Share of the text before the range, like the reading progress.
const block = _blockAncestor(range.startContainer);
if (block && contentEl.contains(block)) {
const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR));
const i = blocks.indexOf(block);
if (i >= 0) {
const idx = _bookCharIndex(contentEl);
const at = idx.starts[i] + _charOffsetInBlock(block, range.startContainer, range.startOffset);
return Math.max(0, Math.min(100, (at / idx.total) * 100));
}
}
if (!contentEl.scrollHeight) return null;
const contentRect = contentEl.getBoundingClientRect(); const contentRect = contentEl.getBoundingClientRect();
const rangeRect = range.getBoundingClientRect(); const rangeRect = range.getBoundingClientRect();
if (!rangeRect.width && !rangeRect.height) return null; if (!rangeRect.width && !rangeRect.height) return null;
@ -6541,6 +6889,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();

View file

@ -2,12 +2,16 @@
* diora service worker — caches the app shell for offline use. * diora service worker — caches the app shell for offline use.
*/ */
const CACHE = 'diora-v41'; const CACHE = 'diora-v46';
const PODCAST_CACHE = 'diora-podcast-v1'; const PODCAST_CACHE = 'diora-podcast-v1';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',
'/static/js/app.js', '/static/js/app.js',
'/static/manifest.json', '/static/manifest.json',
'/static/fonts/SourceSerif4-normal-latin.woff2',
'/static/fonts/SourceSerif4-normal-latin-ext.woff2',
'/static/fonts/SourceSerif4-italic-latin.woff2',
'/static/fonts/SourceSerif4-italic-latin-ext.woff2',
]; ];
// Install: pre-cache the app shell // Install: pre-cache the app shell
@ -53,6 +57,7 @@ self.addEventListener('fetch', function (event) {
if (url.pathname.startsWith('/radio/sse/') || if (url.pathname.startsWith('/radio/sse/') ||
url.pathname.startsWith('/radio/record/') || url.pathname.startsWith('/radio/record/') ||
url.pathname.startsWith('/radio/affiliate/') || url.pathname.startsWith('/radio/affiliate/') ||
url.pathname.startsWith('/tts/') ||
url.pathname.startsWith('/admin/') || url.pathname.startsWith('/admin/') ||
url.pathname.startsWith('/podcasts/progress/') || url.pathname.startsWith('/podcasts/progress/') ||
url.pathname.startsWith('/podcasts/queue/') || url.pathname.startsWith('/podcasts/queue/') ||

View file

@ -13,10 +13,12 @@
<button class="btn btn-play" id="play-stop-btn" onclick="togglePlayStop()" style="display:none;">&#9654; Play</button> <button class="btn btn-play" id="play-stop-btn" onclick="togglePlayStop()" style="display:none;">&#9654; Play</button>
<label class="volume-label"> <label class="volume-label">
<span>vol</span> <span>vol</span>
<input type="range" id="volume" min="0" max="255" value="204" class="volume-slider"> <input type="range" id="volume" min="0" max="255" value="102" class="volume-slider">
<input type="number" id="volume-num" min="0" max="255" value="204" class="volume-num"> <input type="number" id="volume-num" min="0" max="255" value="102" class="volume-num">
</label> </label>
<button class="btn btn-save" id="save-station-btn" style="display:none;" onclick="saveCurrentStation()">&#9733; Save</button> <button class="btn btn-save" id="save-station-btn" style="display:none;" onclick="saveCurrentStation()">&#9733; Save</button>
<button class="btn-icon" id="creamfresh-vote-up-btn" style="display:none;" onclick="creamfreshVote('up')" title="Gefällt mir">&#128077;</button>
<button class="btn-icon" id="creamfresh-vote-down-btn" style="display:none;" onclick="creamfreshVote('down')" title="Gefällt mir nicht">&#128078;</button>
<button class="btn-icon" id="dnd-btn" onclick="toggleDND()" title="Focus mode (hides UI, press Esc to exit)">⊙</button> <button class="btn-icon" id="dnd-btn" onclick="toggleDND()" title="Focus mode (hides UI, press Esc to exit)">⊙</button>
<button class="btn-icon" id="focus-station-btn" onclick="openRadioSidebar()" title="Radio">◉</button> <button class="btn-icon" id="focus-station-btn" onclick="openRadioSidebar()" title="Radio">◉</button>
</div> </div>
@ -354,6 +356,11 @@
</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 &amp; layout">⚙</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-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>
<button class="btn-icon" id="reader-toc-btn" onclick="openTocSidebar()" title="Table of contents">≡</button> <button class="btn-icon" id="reader-toc-btn" onclick="openTocSidebar()" title="Table of contents">≡</button>

0
tts/__init__.py Normal file
View file

6
tts/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class TtsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'tts'

41
tts/piper_engine.py Normal file
View file

@ -0,0 +1,41 @@
import io
import threading
import wave
from django.conf import settings
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(lang):
if lang not in _voices:
with _lock:
if lang not in _voices:
from piper import PiperVoice
_voices[lang] = PiperVoice.load(str(settings.TTS_VOICES[lang]))
return _voices[lang]
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(lang)
buf = io.BytesIO()
with _lock:
with wave.open(buf, 'wb') as wav_file:
voice.synthesize_wav(text, wav_file)
return buf.getvalue()

55
tts/tests.py Normal file
View file

@ -0,0 +1,55 @@
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_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.', '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)

7
tts/urls.py Normal file
View file

@ -0,0 +1,7 @@
from django.urls import path
from . import views
urlpatterns = [
path('synthesize/', views.synthesize, name='tts_synthesize'),
]

57
tts/views.py Normal file
View file

@ -0,0 +1,57 @@
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)
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, lang))
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