From 7e03985368c17726880c2e8628785b825374951c Mon Sep 17 00:00:00 2001 From: "Claude (Opus 4.6)" Date: Mon, 23 Mar 2026 18:39:47 +0000 Subject: [PATCH] [claude] feat: Agent Voice Customization UI (#1017) (#1146) --- src/dashboard/routes/voice.py | 86 ++++++++++++- src/dashboard/templates/base.html | 2 + src/dashboard/templates/voice_settings.html | 131 ++++++++++++++++++++ static/css/mission-control.css | 117 +++++++++++++++++ 4 files changed, 334 insertions(+), 2 deletions(-) create mode 100644 src/dashboard/templates/voice_settings.html diff --git a/src/dashboard/routes/voice.py b/src/dashboard/routes/voice.py index 10ea95ad..b94a1a9b 100644 --- a/src/dashboard/routes/voice.py +++ b/src/dashboard/routes/voice.py @@ -1,11 +1,14 @@ """Voice routes — /voice/* and /voice/enhanced/* endpoints. Provides NLU intent detection, TTS control, the full voice-to-action -pipeline (detect intent → execute → optionally speak), and the voice -button UI page. +pipeline (detect intent → execute → optionally speak), the voice +button UI page, and voice settings customisation. """ +import asyncio +import json import logging +from pathlib import Path from fastapi import APIRouter, Form, Request from fastapi.responses import HTMLResponse @@ -14,6 +17,30 @@ from dashboard.templating import templates from integrations.voice.nlu import detect_intent, extract_command from timmy.agent import create_timmy +# ── Voice settings persistence ─────────────────────────────────────────────── + +_VOICE_SETTINGS_FILE = Path("data/voice_settings.json") +_DEFAULT_VOICE_SETTINGS: dict = {"rate": 175, "volume": 0.9, "voice_id": ""} + + +def _load_voice_settings() -> dict: + """Read persisted voice settings from disk; return defaults on any error.""" + try: + if _VOICE_SETTINGS_FILE.exists(): + return json.loads(_VOICE_SETTINGS_FILE.read_text()) + except Exception as exc: + logger.warning("Failed to load voice settings: %s", exc) + return dict(_DEFAULT_VOICE_SETTINGS) + + +def _save_voice_settings(data: dict) -> None: + """Persist voice settings to disk; log and continue on any error.""" + try: + _VOICE_SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True) + _VOICE_SETTINGS_FILE.write_text(json.dumps(data)) + except Exception as exc: + logger.warning("Failed to save voice settings: %s", exc) + logger = logging.getLogger(__name__) router = APIRouter(prefix="/voice", tags=["voice"]) @@ -152,3 +179,58 @@ async def process_voice_input( "error": error, "spoken": speak_response and response_text is not None, } + + +# ── Voice settings UI ──────────────────────────────────────────────────────── + + +@router.get("/settings", response_class=HTMLResponse) +async def voice_settings_page(request: Request): + """Render the voice customisation settings page.""" + current = await asyncio.to_thread(_load_voice_settings) + voices: list[dict] = [] + try: + from timmy_serve.voice_tts import voice_tts + + if voice_tts.available: + voices = await asyncio.to_thread(voice_tts.get_voices) + except Exception as exc: + logger.debug("Voice settings page: TTS not available — %s", exc) + return templates.TemplateResponse( + request, + "voice_settings.html", + {"settings": current, "voices": voices}, + ) + + +@router.get("/settings/data") +async def voice_settings_data(): + """Return current voice settings as JSON.""" + return await asyncio.to_thread(_load_voice_settings) + + +@router.post("/settings/save") +async def voice_settings_save( + rate: int = Form(175), + volume: float = Form(0.9), + voice_id: str = Form(""), +): + """Persist voice settings and apply them to the running TTS engine.""" + rate = max(50, min(400, rate)) + volume = max(0.0, min(1.0, volume)) + data = {"rate": rate, "volume": volume, "voice_id": voice_id} + + # Apply to the live TTS engine (graceful degradation when unavailable) + try: + from timmy_serve.voice_tts import voice_tts + + if voice_tts.available: + await asyncio.to_thread(voice_tts.set_rate, rate) + await asyncio.to_thread(voice_tts.set_volume, volume) + if voice_id: + await asyncio.to_thread(voice_tts.set_voice, voice_id) + except Exception as exc: + logger.warning("Voice settings: failed to apply to TTS engine — %s", exc) + + await asyncio.to_thread(_save_voice_settings, data) + return {"saved": True, "settings": data} diff --git a/src/dashboard/templates/base.html b/src/dashboard/templates/base.html index 7a1b234d..b92cb4b4 100644 --- a/src/dashboard/templates/base.html +++ b/src/dashboard/templates/base.html @@ -88,6 +88,7 @@ LEDGER CREATIVE VOICE + VOICE SETTINGS MOBILE LOCAL AI @@ -145,6 +146,7 @@ LEDGER CREATIVE VOICE + VOICE SETTINGS MOBILE LOCAL AI