feat: sim log in console + TTS rate/emotion
Repo State / repo-state (pull_request) Failing after 43s
Repo State / repo-state (push) Failing after 38s
Validate Zacus refactor / validate (pull_request) Failing after 21m48s

- ConsolePane shows the ScenePlayer narration (simLog, green) under
  the validation entries, plus a current-scene badge in the header.
- voice-bridge accepts rate + emotion on /tts: rate (clamped 0.5..2.0)
  drives the F5 sampler speed; emotion is accepted and cache-keyed but
  not rendered yet (no F5 emotion control). Legacy cache keys are
  preserved when both fields are absent/default — the NPC pool
  generator contract is untouched.
- Bump ESP32_ZACUS: GET/POST /game/peers (PR ESP32_ZACUS#4) — PLIP
  provisioning by curl, live registration without reboot.

Verified: py_compile OK, tsc clean, vite build green, master firmware
build green.
This commit is contained in:
Claude Worker claude2
2026-06-10 11:56:25 +02:00
parent 34b7c3144a
commit db63da7d2e
3 changed files with 58 additions and 10 deletions
@@ -12,6 +12,8 @@ const SEVERITY_COLOR: Record<ValidationEntry['severity'], string> = {
export function ConsolePane() {
const mode = useSimStore((s) => s.mode);
const setMode = useSimStore((s) => s.setMode);
const simLog = useSimStore((s) => s.simLog);
const currentSceneId = useSimStore((s) => s.currentSceneId);
const entries = useValidationStore((s) => s.entries);
const errorCount = entries.filter((e) => e.severity === 'error').length;
@@ -34,6 +36,14 @@ export function ConsolePane() {
</button>
))}
</div>
{currentSceneId ? (
<span
data-testid="current-scene"
style={{ fontSize: 12, color: '#5085F2', fontWeight: 600 }}
>
{currentSceneId}
</span>
) : null}
<span style={{ marginLeft: 'auto', fontSize: 12, color: '#9a9a9d' }}>
{entries.length === 0
? 'No issues'
@@ -60,6 +70,11 @@ export function ConsolePane() {
[{entry.severity}] {entry.message}
</div>
))}
{simLog.map((line, i) => (
<div key={`sim-${i}`} style={{ padding: '2px 0', color: '#86efac' }}>
{line}
</div>
))}
</div>
</div>
);
+42 -9
View File
@@ -435,6 +435,14 @@ class TtsRequest(BaseModel):
# rather than a 422. Pydantic only ensures the value is an int.
steps: int = Field(default=F5_DEFAULT_STEPS)
voice_ref: Optional[str] = None
# Speaking rate from the « Zacus dit » block (IR tts_say.rate). Mapped to
# the F5 sampler's `speed` parameter; clamped to [0.5, 2.0] in the
# handler. None/1.0 keeps legacy behaviour AND legacy cache keys.
rate: Optional[float] = None
# Emotion tag from the block (IR tts_say.emotion). Accepted and cache-keyed
# but NOT rendered yet — F5 has no emotion control; kept so clients can
# send it today and synthesis can honour it when a backend supports it.
emotion: Optional[str] = None
# Legacy alias for the older daemon ("input" instead of "text") is handled
# in the handler before validation, so the schema stays small.
@@ -516,9 +524,22 @@ def _voice_ref_token(voice_ref: Optional[str]) -> str:
return hashlib.sha256(voice_ref.encode("utf-8")).hexdigest()[:16]
def _cache_key(text: str, ref_token: str, steps: int) -> str:
payload = f"{text}|{ref_token}|{steps}".encode("utf-8")
return hashlib.sha256(payload).hexdigest()[:16]
def _cache_key(
text: str,
ref_token: str,
steps: int,
rate: Optional[float] = None,
emotion: Optional[str] = None,
) -> str:
# Legacy layout `text|ref|steps` is preserved verbatim when rate/emotion
# are absent or at their defaults, so every pre-existing cache entry (and
# tools/tts/generate_npc_pool.py's mirrored computation) stays valid.
payload = f"{text}|{ref_token}|{steps}"
if rate is not None and abs(rate - 1.0) > 1e-9:
payload += f"|r{rate:g}"
if emotion and emotion != "neutral":
payload += f"|e{emotion}"
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
def _scan_cache_dir() -> dict[str, Path]:
@@ -533,7 +554,7 @@ def _scan_cache_dir() -> dict[str, Path]:
# ── F5-TTS in-process synthesizer ───────────────────────────────────────────
def _f5_synthesize_sync(text: str, steps: int) -> bytes:
def _f5_synthesize_sync(text: str, steps: int, speed: float = 1.0) -> bytes:
"""Run F5 inference on the cached model; returns 24 kHz mono WAV bytes."""
import mlx.core as mx # noqa: WPS433
from f5_tts_mlx.utils import convert_char_to_pinyin
@@ -558,7 +579,7 @@ def _f5_synthesize_sync(text: str, steps: int) -> bytes:
duration=None,
steps=steps,
method="rk4",
speed=1.0,
speed=speed,
cfg_strength=2.0,
sway_sampling_coef=-1.0,
seed=None,
@@ -572,11 +593,11 @@ def _f5_synthesize_sync(text: str, steps: int) -> bytes:
return buf.getvalue()
async def _run_f5(text: str, steps: int) -> bytes:
async def _run_f5(text: str, steps: int, speed: float = 1.0) -> bytes:
"""Run synthesis serialized in the asyncio main thread."""
assert _F5_LOCK is not None
async with _F5_LOCK:
return _f5_synthesize_sync(text, steps)
return _f5_synthesize_sync(text, steps, speed)
# ── FastAPI app ──────────────────────────────────────────────────────────────
@@ -949,11 +970,23 @@ async def tts(request: Request) -> Response:
requested=raw_steps, clamped=steps,
min=F5_STEPS_MIN, max=F5_STEPS_MAX)
voice_ref = payload_model.voice_ref # accepted; reserved for multi-voice
rate = payload_model.rate
if rate is not None:
clamped = max(0.5, min(2.0, float(rate)))
if abs(clamped - rate) > 1e-9:
_jlog("tts_rate_clamped", request_id=request_id,
requested=rate, clamped=clamped)
rate = clamped
emotion = (payload_model.emotion or "").strip() or None
if emotion and emotion != "neutral":
# Accepted + cache-keyed, not rendered yet (no F5 emotion control).
_jlog("tts_emotion_accepted", request_id=request_id, emotion=emotion)
started = time.monotonic()
# 1) Cache lookup -------------------------------------------------------
cache_key = _cache_key(text, _voice_ref_token(voice_ref), steps)
cache_key = _cache_key(text, _voice_ref_token(voice_ref), steps,
rate, emotion)
cached_path = _CACHE_INDEX.get(cache_key)
if cached_path is None:
# Fallback: file may have been dropped in by the pool generator after
@@ -994,7 +1027,7 @@ async def tts(request: Request) -> Response:
if _F5_MODEL_OBJ is not None and _F5_LOCK is not None:
try:
wav_bytes = await asyncio.wait_for(
_run_f5(text, steps),
_run_f5(text, steps, rate if rate is not None else 1.0),
timeout=F5_TIMEOUT_S,
)
await _write_cache(cache_key, wav_bytes)