diora-web/tts/views.py
marwin 05216a8613
All checks were successful
Build and push Docker image / build (push) Successful in 1m3s
Test / test (push) Successful in 1m32s
Vorlesen-Funktion im Reader: Piper/Thorsten-TTS (SW v42)
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

53 lines
1.8 KiB
Python

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