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
|
global _audio_tokenizer
|
||||||
|
|
||||||
audio, _ = sphn.read(wav_path, sample_rate=24000)
|
audio, _ = sphn.read(wav_path, sample_rate=24000)
|
||||||
# Pad with 2 s of silence so the model can flush its final tokens
|
# Pad with 0.5 s of silence so the model can flush its final tokens. Callers
|
||||||
audio = np.concatenate([audio, np.zeros((1, 48000), dtype=audio.dtype)], axis=-1)
|
# (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)
|
audio_mx = mx.array(audio)
|
||||||
|
|
||||||
with _lock:
|
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."""
|
"""One OpenAI-compatible chat call. Raises on transport/HTTP/shape error."""
|
||||||
resp = await http.post(
|
resp = await http.post(
|
||||||
f"{url.rstrip('/')}/v1/chat/completions",
|
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,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
if resp.status_code != 200:
|
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:
|
async def _chat_reply(http: httpx.AsyncClient, persona: str, history: list[dict]) -> str:
|
||||||
"""Produce the NPC reply text from the persona + conversation history.
|
"""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,
|
LOCAL-ONLY (user requirement): the only LLM is the on-box vllm-mlx granite.
|
||||||
no remote dependency); ailiance is a remote fallback if the local server is
|
There is NO remote fallback — if granite fails, we return a LOCAL spoken
|
||||||
down. If both fail, return a spoken fallback so the voice loop never hangs.
|
line so the voice loop never hangs, but we never leave this Mac.
|
||||||
"""
|
"""
|
||||||
messages = [{"role": "system", "content": persona + _PHONE_STYLE}] + history
|
messages = [{"role": "system", "content": persona + _PHONE_STYLE}] + history
|
||||||
backends = [
|
backends = [
|
||||||
# 8 s: a healthy warm granite answers in ~2 s, so 8 s gives margin while
|
# Local granite only. Generous timeout: granite answers in ~0.4 s idle
|
||||||
# failing over FAST when the local server is cold, hung, or deadlocked.
|
# but is slowed by Metal contention with the on-box Kyutai STT during a
|
||||||
# Waiting the full cold-load (~47 s) here would blow the turn budget —
|
# turn, so allow it to finish rather than giving up early. On a true hang
|
||||||
# the player hears only the filler and the reply never lands in time.
|
# we fall back to a LOCAL spoken line (never a remote backend).
|
||||||
# The keep-warm task is responsible for keeping granite warm; if it is
|
("local", settings.local_chat_url, settings.local_chat_model, 15.0),
|
||||||
# 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),
|
|
||||||
]
|
]
|
||||||
last_exc: Exception | None = None
|
last_exc: Exception | None = None
|
||||||
for name, url, model, timeout in backends:
|
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)
|
return await _chat_one(http, url, model, messages, timeout)
|
||||||
except Exception as exc: # noqa: BLE001 — try the next backend on any failure
|
except Exception as exc: # noqa: BLE001 — try the next backend on any failure
|
||||||
last_exc = exc
|
last_exc = exc
|
||||||
logging.warning("LLM backend %s failed (%s) — next", name, type(exc).__name__)
|
logging.warning("LLM backend %s failed (%s) — local spoken fallback", name, type(exc).__name__)
|
||||||
logging.error("all LLM backends unreachable (%s) — spoken fallback", last_exc)
|
logging.error("local LLM unreachable (%s) — local spoken fallback", last_exc)
|
||||||
return _CHAT_FALLBACK
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
# Surface our own INFO logs (HEARD/SAID transcriptions, prewarm) — uvicorn
|
# 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:
|
try:
|
||||||
if body.kind == "greeting":
|
if body.kind == "greeting":
|
||||||
said = entry.get("greeting") or await _chat_reply(http, persona, [])
|
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:
|
else:
|
||||||
# Text-only reply (no audio input — heard is always empty here).
|
# Text-only reply (no audio input — heard is always empty here).
|
||||||
# The transcription-driven path lives in POST /v1/voice/reply.
|
# The transcription-driven path lives in POST /v1/voice/reply.
|
||||||
|
|||||||
Reference in New Issue
Block a user