2026-08-31 22:29:13 +02:00
|
|
|
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)
|
|
|
|
|
|
2026-09-01 07:04:27 +02:00
|
|
|
lang = body.get('lang', piper_engine.DEFAULT_LANGUAGE)
|
|
|
|
|
if lang not in piper_engine.SUPPORTED_LANGUAGES:
|
|
|
|
|
return JsonResponse({'error': 'unsupported lang'}, status=400)
|
|
|
|
|
|
2026-08-31 22:29:13 +02:00
|
|
|
try:
|
2026-09-01 07:04:27 +02:00
|
|
|
audio = _synth_pool.apply(piper_engine.synthesize_wav, (text, lang))
|
2026-08-31 22:29:13 +02:00
|
|
|
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
|