feat(voice): Lucys Stimme ins LAN (/api/lucy/stimme) + Raphael-Zielbild
Ampel / ampel (push) Failing after 24s
Ampel / ampel (push) Failing after 24s
Die Desktop-Lucy spricht nach dem Raphael-Umbau (Lucy-Repo, feature/raphael-innere-stimme)
nicht mehr mit einem eigenen pocket_server am PC, sondern mit lucy-stimme.service (:8021,
pocket-tts german_24l) auf der Box — dieselbe Stimme wie die Telegram-Sprachnachrichten.
Der Dienst bindet nur Loopback, darum reicht MC2 ihn jetzt duenn durch:
GET /api/lucy/stimme/health -> pocket /health (ok|loading)
POST /api/lucy/stimme/tts -> WAV (Warm-up, Jobs)
POST /api/lucy/stimme/tts/stream -> PCM16-Stream, X-Sample-Rate durchgereicht,
Upstream schliesst bei Client-Abbruch (Barge-in)
config: LUCY_STIMME_URL (Env MC_LUCY_STIMME_URL, Default http://127.0.0.1:8021).
Kein Frontend-Build noetig (nur Backend + Doku).
Doku: docs/wissen/RAPHAEL.md (Entscheid, Annahme-Reihenfolge — DIESE Karte zuerst —,
Box-Handschritte fuer die OpenAI-Fassade + Hermes-TTS auf openai/base_url :8021, Mobil via
Telegram, SOUL.md-Vorschlag im Raphael-Ton), ZIELBILD Punkt 8, OFFENE-FAEDEN, wissen/README.
VERDIKTE.md bewusst unveraendert — Ersatz erst nach Ohr-Test.
Gates: py_compile + ruff gruen.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
e9c2599542
commit
b570e9410d
@@ -17,7 +17,7 @@ import sys as _sys
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
|
||||
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, LUCY_STIMME_URL, VOICE_SERVICE_URL
|
||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
@@ -355,3 +355,71 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
|
||||
_commit() # Turn immer verbuchen (auch bei Fehler/Abbruch)
|
||||
|
||||
return StreamingResponse(gen(), media_type="text/event-stream")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# Lucys Stimme ins LAN reichen (Raphael-Umbau 04.09.2026). lucy-stimme.service (:8021, pocket-tts
|
||||
# german_24l) bindet nur Loopback; die Desktop-Lucy am PC spricht seit dem Umbau nicht mehr mit
|
||||
# einem eigenen pocket_server, sondern mit DIESEM — dieselbe Stimme wie die Telegram-Sprachnachrichten.
|
||||
# Dünner Proxy, API 1:1 (pocket_server: /health, /tts -> WAV, /tts/stream -> PCM16 + X-Sample-Rate).
|
||||
# LAN-only wie alle MC2-Endpoints.
|
||||
|
||||
class LucyTtsIn(BaseModel):
|
||||
text: str
|
||||
emo: str | None = None # Stimmungs-Profil (pocket_server EMO_PROFILES); Raphael-Lucy setzt keins
|
||||
|
||||
|
||||
@router.get("/lucy/stimme/health")
|
||||
def lucy_stimme_health() -> dict:
|
||||
"""Bereitschaft von Lucys Stimme (pocket_server /health: status ok|loading)."""
|
||||
try:
|
||||
r = httpx.get(f"{LUCY_STIMME_URL}/health", timeout=httpx.Timeout(5.0))
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as exc:
|
||||
raise HTTPException(502, f"Lucys Stimme (:8021) nicht erreichbar: {exc}")
|
||||
|
||||
|
||||
@router.post("/lucy/stimme/tts")
|
||||
async def lucy_stimme_tts(body: LucyTtsIn) -> Response:
|
||||
"""Text -> WAV (ganzer Text). Warm-up der Desktop-Lucy + Jobs, die eine Datei brauchen."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=5.0)) as client:
|
||||
with Timer("lucy_tts"):
|
||||
r = await client.post(f"{LUCY_STIMME_URL}/tts", json=body.model_dump(exclude_none=True))
|
||||
r.raise_for_status()
|
||||
return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"),
|
||||
headers={k: v for k, v in r.headers.items() if k.lower().startswith("x-")})
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise HTTPException(exc.response.status_code, f"Lucys Stimme: {exc.response.text[:200]}")
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}")
|
||||
|
||||
|
||||
@router.post("/lucy/stimme/tts/stream")
|
||||
async def lucy_stimme_tts_stream(body: LucyTtsIn) -> StreamingResponse:
|
||||
"""Text -> rohes PCM16-mono, satzweise gestreamt (Samplerate im Header X-Sample-Rate).
|
||||
Der Live-Pfad der Desktop-Lucy: erstes Audio nach dem ersten Satz. Der Upstream-Stream bleibt
|
||||
offen, solange der Client liest — bricht der Client ab (Barge-in), schließt httpx den Upstream."""
|
||||
client = httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0))
|
||||
try:
|
||||
req = client.build_request("POST", f"{LUCY_STIMME_URL}/tts/stream", json=body.model_dump(exclude_none=True))
|
||||
upstream = await client.send(req, stream=True)
|
||||
except httpx.HTTPError as exc:
|
||||
await client.aclose()
|
||||
raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}")
|
||||
if upstream.status_code != 200:
|
||||
detail = (await upstream.aread()).decode("utf-8", "replace")[:200]
|
||||
await upstream.aclose(); await client.aclose()
|
||||
raise HTTPException(upstream.status_code, f"Lucys Stimme: {detail}")
|
||||
|
||||
async def gen():
|
||||
try:
|
||||
async for chunk in upstream.aiter_raw():
|
||||
yield chunk
|
||||
finally:
|
||||
await upstream.aclose()
|
||||
await client.aclose()
|
||||
|
||||
return StreamingResponse(gen(), media_type="application/octet-stream",
|
||||
headers={"X-Sample-Rate": upstream.headers.get("x-sample-rate", "24000")})
|
||||
|
||||
Reference in New Issue
Block a user