Compare commits
No commits in common. "master" and "testing" have entirely different histories.
17 changed files with 5 additions and 569 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -19,7 +19,6 @@ env/
|
||||||
media/
|
media/
|
||||||
staticfiles/
|
staticfiles/
|
||||||
.env
|
.env
|
||||||
tts_models/
|
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
|
|
|
||||||
16
Dockerfile
16
Dockerfile
|
|
@ -4,27 +4,11 @@ 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}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ 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
|
||||||
|
|
@ -123,7 +122,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 = 102 # out of 255 (40%)
|
VOLUME_DEFAULT = 204 # out of 255
|
||||||
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)
|
||||||
|
|
@ -151,11 +150,3 @@ 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')),
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,4 @@ 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'),
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -581,74 +581,6 @@ 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()
|
||||||
|
|
|
||||||
|
|
@ -16,4 +16,3 @@ 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
|
|
||||||
|
|
|
||||||
|
|
@ -1636,12 +1636,6 @@ 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) {
|
||||||
|
|
@ -2203,12 +2197,6 @@ 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); }
|
||||||
|
|
||||||
|
|
|
||||||
282
static/js/app.js
282
static/js/app.js
|
|
@ -9,10 +9,6 @@
|
||||||
// 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;
|
||||||
|
|
@ -180,12 +176,6 @@ 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);
|
||||||
|
|
@ -230,8 +220,6 @@ 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();
|
||||||
|
|
@ -616,36 +604,6 @@ 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/', {
|
||||||
|
|
@ -4178,239 +4136,6 @@ 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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -4418,7 +4143,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, .tts-bar')) return;
|
if (e.target.closest('button, a, input, select, label, #reader-settings-panel, .reader-header, .footnote-popover, #reader-margin, #highlight-popover, .note-bottom-sheet')) 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;
|
||||||
|
|
@ -4817,7 +4542,6 @@ 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();
|
||||||
|
|
@ -6817,10 +6541,6 @@ 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-v43';
|
const CACHE = 'diora-v41';
|
||||||
const PODCAST_CACHE = 'diora-podcast-v1';
|
const PODCAST_CACHE = 'diora-podcast-v1';
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
'/static/css/app.css',
|
'/static/css/app.css',
|
||||||
|
|
@ -53,7 +53,6 @@ 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/') ||
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,10 @@
|
||||||
<button class="btn btn-play" id="play-stop-btn" onclick="togglePlayStop()" style="display:none;">▶ Play</button>
|
<button class="btn btn-play" id="play-stop-btn" onclick="togglePlayStop()" style="display:none;">▶ 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="102" class="volume-slider">
|
<input type="range" id="volume" min="0" max="255" value="204" class="volume-slider">
|
||||||
<input type="number" id="volume-num" min="0" max="255" value="102" class="volume-num">
|
<input type="number" id="volume-num" min="0" max="255" value="204" class="volume-num">
|
||||||
</label>
|
</label>
|
||||||
<button class="btn btn-save" id="save-station-btn" style="display:none;" onclick="saveCurrentStation()">★ Save</button>
|
<button class="btn btn-save" id="save-station-btn" style="display:none;" onclick="saveCurrentStation()">★ Save</button>
|
||||||
<button class="btn-icon" id="creamfresh-vote-up-btn" style="display:none;" onclick="creamfreshVote('up')" title="Gefällt mir">👍</button>
|
|
||||||
<button class="btn-icon" id="creamfresh-vote-down-btn" style="display:none;" onclick="creamfreshVote('down')" title="Gefällt mir nicht">👎</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>
|
||||||
|
|
@ -356,11 +354,6 @@
|
||||||
</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-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>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class TtsConfig(AppConfig):
|
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
|
||||||
name = 'tts'
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
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
55
tts/tests.py
|
|
@ -1,55 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
from django.urls import path
|
|
||||||
|
|
||||||
from . import views
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path('synthesize/', views.synthesize, name='tts_synthesize'),
|
|
||||||
]
|
|
||||||
57
tts/views.py
57
tts/views.py
|
|
@ -1,57 +0,0 @@
|
||||||
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
|
|
||||||
Loading…
Add table
Reference in a new issue