feat: sim log in console + TTS rate/emotion
- 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:
+1
-1
Submodule ESP32_ZACUS updated: 7fe0bd2af6...185d1186fe
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user