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
53 lines
1.8 KiB
Python
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
|