diff --git a/ESP32_ZACUS b/ESP32_ZACUS index 7fe0bd2..185d118 160000 --- a/ESP32_ZACUS +++ b/ESP32_ZACUS @@ -1 +1 @@ -Subproject commit 7fe0bd2af69c6e5326b99ae4542192429bfdb247 +Subproject commit 185d1186fe168498b7e06412754f806898f48fce diff --git a/frontend-v3/apps/atelier/src/components/ConsolePane.tsx b/frontend-v3/apps/atelier/src/components/ConsolePane.tsx index 2db6dba..0ca2964 100644 --- a/frontend-v3/apps/atelier/src/components/ConsolePane.tsx +++ b/frontend-v3/apps/atelier/src/components/ConsolePane.tsx @@ -12,6 +12,8 @@ const SEVERITY_COLOR: Record = { 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() { ))} + {currentSceneId ? ( + + ▶ {currentSceneId} + + ) : null} {entries.length === 0 ? 'No issues' @@ -60,6 +70,11 @@ export function ConsolePane() { [{entry.severity}] {entry.message} ))} + {simLog.map((line, i) => ( +
+ {line} +
+ ))} ); diff --git a/tools/macstudio/voice-bridge/main.py b/tools/macstudio/voice-bridge/main.py index cce62b6..cdf8723 100644 --- a/tools/macstudio/voice-bridge/main.py +++ b/tools/macstudio/voice-bridge/main.py @@ -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)