feat(voice): local-only LLM + faster turns
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:
+1
-1
Submodule ESP32_ZACUS updated: 82759ee536...cfe429d885
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user