feat(voice): local-only LLM + faster turns
Repo State / repo-state (pull_request) Failing after 43s
Validate Zacus refactor / validate (pull_request) Failing after 6m35s
Repo State / repo-state (push) Failing after 45s
Validate Zacus refactor / validate (push) Failing after 13m52s

Gateway: drop the remote ailiance fallback — the NPC LLM is now the
on-box granite ONLY (local spoken line on failure, never remote).
Prewarm the persona KV-cache in the background at greeting time so the
first reply is fast instead of paying ~17 s of cold prompt processing.
Add temperature + repetition_penalty so granite4-tiny stops looping.
STT: cut the trailing-silence pad 2 s -> 0.5 s (callers already pad),
shaving STT latency. Bump ESP32_ZACUS to cfe429d (hook debounce).
This commit was merged in pull request #166.
This commit is contained in:
clement
2026-06-17 19:49:10 +02:00
parent 0764c873a7
commit 643c3852de
3 changed files with 48 additions and 17 deletions
+5 -2
View File
@@ -110,8 +110,11 @@ def _run_inference(wav_path: str) -> str:
global _audio_tokenizer
audio, _ = sphn.read(wav_path, sample_rate=24000)
# Pad with 2 s of silence so the model can flush its final tokens
audio = np.concatenate([audio, np.zeros((1, 48000), dtype=audio.dtype)], axis=-1)
# Pad with 0.5 s of silence so the model can flush its final tokens. Callers
# (the gateway) already append ~0.8 s of trailing silence, so a short pad here
# is enough to flush — and every padded second costs ~1.5x realtime of STT
# latency, which dominates the conversation turn time.
audio = np.concatenate([audio, np.zeros((1, 12000), dtype=audio.dtype)], axis=-1)
audio_mx = mx.array(audio)
with _lock:
+42 -14
View File
@@ -224,7 +224,16 @@ async def _chat_one(http: httpx.AsyncClient, url: str, model: str,
"""One OpenAI-compatible chat call. Raises on transport/HTTP/shape error."""
resp = await http.post(
f"{url.rstrip('/')}/v1/chat/completions",
json={"model": model, "messages": messages, "max_tokens": 48},
json={
"model": model,
"messages": messages,
"max_tokens": 48,
# granite4-tiny loops ("parlez par-dessus, parlez par-dessous…")
# without a repetition penalty; a little temperature + penalty keep
# the one-sentence phone reply varied and on-track.
"temperature": 0.7,
"repetition_penalty": 1.3,
},
timeout=timeout,
)
if resp.status_code != 200:
@@ -235,20 +244,17 @@ async def _chat_one(http: httpx.AsyncClient, url: str, model: str,
async def _chat_reply(http: httpx.AsyncClient, persona: str, history: list[dict]) -> str:
"""Produce the NPC reply text from the persona + conversation history.
Primary backend is the LOCAL vllm-mlx LLM on this Mac M1 (native FR, fast,
no remote dependency); ailiance is a remote fallback if the local server is
down. If both fail, return a spoken fallback so the voice loop never hangs.
LOCAL-ONLY (user requirement): the only LLM is the on-box vllm-mlx granite.
There is NO remote fallback if granite fails, we return a LOCAL spoken
line so the voice loop never hangs, but we never leave this Mac.
"""
messages = [{"role": "system", "content": persona + _PHONE_STYLE}] + history
backends = [
# 8 s: a healthy warm granite answers in ~2 s, so 8 s gives margin while
# failing over FAST when the local server is cold, hung, or deadlocked.
# Waiting the full cold-load (~47 s) here would blow the turn budget —
# the player hears only the filler and the reply never lands in time.
# The keep-warm task is responsible for keeping granite warm; if it is
# unreachable we want the remote fallback immediately, not a 70 s stall.
("local", settings.local_chat_url, settings.local_chat_model, 8.0),
("ailiance", settings.ailiance_tts_url, settings.ailiance_chat_model, 12.0),
# Local granite only. Generous timeout: granite answers in ~0.4 s idle
# but is slowed by Metal contention with the on-box Kyutai STT during a
# turn, so allow it to finish rather than giving up early. On a true hang
# we fall back to a LOCAL spoken line (never a remote backend).
("local", settings.local_chat_url, settings.local_chat_model, 15.0),
]
last_exc: Exception | None = None
for name, url, model, timeout in backends:
@@ -256,11 +262,29 @@ async def _chat_reply(http: httpx.AsyncClient, persona: str, history: list[dict]
return await _chat_one(http, url, model, messages, timeout)
except Exception as exc: # noqa: BLE001 — try the next backend on any failure
last_exc = exc
logging.warning("LLM backend %s failed (%s) — next", name, type(exc).__name__)
logging.error("all LLM backends unreachable (%s) — spoken fallback", last_exc)
logging.warning("LLM backend %s failed (%s) — local spoken fallback", name, type(exc).__name__)
logging.error("local LLM unreachable (%s) — local spoken fallback", last_exc)
return _CHAT_FALLBACK
async def _warm_persona(http: httpx.AsyncClient, persona: str) -> None:
"""Prime granite's KV cache with this persona's system-prompt prefix.
The first granite call for a persona pays ~15-17 s of prompt processing
(511-char system prompt, slow on the M1 while the Kyutai STT shares Metal);
later calls reuse the cached prefix in ~0.5 s. Fired in the background at
greeting time so the player's FIRST reply already hits a warm cache instead
of timing out. Best-effort: errors are swallowed.
"""
try:
messages = [{"role": "system", "content": persona + _PHONE_STYLE},
{"role": "user", "content": "Allô ?"}]
await _chat_one(http, settings.local_chat_url, settings.local_chat_model, messages, 30.0)
logging.info("LLM persona prewarm done")
except Exception as exc: # noqa: BLE001
logging.warning("LLM persona prewarm failed (%s)", type(exc).__name__)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# Surface our own INFO logs (HEARD/SAID transcriptions, prewarm) — uvicorn
@@ -2011,6 +2035,10 @@ async def voice_turn(body: VoiceTurnRequest, request: Request, _: None = Depends
try:
if body.kind == "greeting":
said = entry.get("greeting") or await _chat_reply(http, persona, [])
# Prime granite's KV cache with this persona NOW (background), so the
# player's first spoken reply hits a warm cache (~0.5 s) instead of
# the ~17 s cold prompt-processing that was timing out.
asyncio.create_task(_warm_persona(http, persona))
else:
# Text-only reply (no audio input — heard is always empty here).
# The transcription-driven path lives in POST /v1/voice/reply.