This commit was merged in pull request #1146.
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
<a href="/lightning/ledger" class="mc-test-link">LEDGER</a>
|
||||
<a href="/creative/ui" class="mc-test-link">CREATIVE</a>
|
||||
<a href="/voice/button" class="mc-test-link">VOICE</a>
|
||||
<a href="/voice/settings" class="mc-test-link">VOICE SETTINGS</a>
|
||||
<a href="/mobile" class="mc-test-link" title="Mobile-optimized view">MOBILE</a>
|
||||
<a href="/mobile/local" class="mc-test-link" title="Local AI on iPhone">LOCAL AI</a>
|
||||
</div>
|
||||
@@ -145,6 +146,7 @@
|
||||
<a href="/lightning/ledger" class="mc-mobile-link">LEDGER</a>
|
||||
<a href="/creative/ui" class="mc-mobile-link">CREATIVE</a>
|
||||
<a href="/voice/button" class="mc-mobile-link">VOICE</a>
|
||||
<a href="/voice/settings" class="mc-mobile-link">VOICE SETTINGS</a>
|
||||
<a href="/mobile" class="mc-mobile-link">MOBILE</a>
|
||||
<a href="/mobile/local" class="mc-mobile-link">LOCAL AI</a>
|
||||
<div class="mc-mobile-menu-footer">
|
||||
|
||||
131
src/dashboard/templates/voice_settings.html
Normal file
131
src/dashboard/templates/voice_settings.html
Normal file
@@ -0,0 +1,131 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "macros.html" import panel %}
|
||||
|
||||
{% block title %}Voice Settings{% endblock %}
|
||||
{% block extra_styles %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="voice-settings-page py-3">
|
||||
{% call panel("VOICE SETTINGS") %}
|
||||
<form id="voice-settings-form">
|
||||
|
||||
<div class="vs-field">
|
||||
<label class="vs-label" for="rate-slider">
|
||||
SPEED — <span class="vs-value" id="rate-val">{{ settings.rate }}</span> WPM
|
||||
</label>
|
||||
<input type="range" class="vs-slider" id="rate-slider" name="rate"
|
||||
min="50" max="400" step="5" value="{{ settings.rate }}"
|
||||
oninput="document.getElementById('rate-val').textContent=this.value">
|
||||
<div class="vs-range-labels"><span>Slow</span><span>Fast</span></div>
|
||||
</div>
|
||||
|
||||
<div class="vs-field">
|
||||
<label class="vs-label" for="vol-slider">
|
||||
VOLUME — <span class="vs-value" id="vol-val">{{ (settings.volume * 100)|int }}</span>%
|
||||
</label>
|
||||
<input type="range" class="vs-slider" id="vol-slider" name="volume"
|
||||
min="0" max="100" step="5" value="{{ (settings.volume * 100)|int }}"
|
||||
oninput="document.getElementById('vol-val').textContent=this.value">
|
||||
<div class="vs-range-labels"><span>Quiet</span><span>Loud</span></div>
|
||||
</div>
|
||||
|
||||
<div class="vs-field">
|
||||
<label class="vs-label" for="voice-select">VOICE MODEL</label>
|
||||
{% if voices %}
|
||||
<select class="vs-select" id="voice-select" name="voice_id">
|
||||
<option value="">— System Default —</option>
|
||||
{% for v in voices %}
|
||||
<option value="{{ v.id }}" {% if v.id == settings.voice_id %}selected{% endif %}>
|
||||
{{ v.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<div class="vs-unavailable">Server TTS (pyttsx3) unavailable — preview uses browser speech synthesis</div>
|
||||
<input type="hidden" id="voice-select" name="voice_id" value="{{ settings.voice_id }}">
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="vs-field">
|
||||
<label class="vs-label" for="preview-text">PREVIEW TEXT</label>
|
||||
<input type="text" class="vs-input" id="preview-text"
|
||||
value="Hello, I am Timmy. Your local AI assistant."
|
||||
placeholder="Enter text to preview...">
|
||||
</div>
|
||||
|
||||
<div class="vs-actions">
|
||||
<button type="button" class="vs-btn-preview" id="preview-btn" onclick="previewVoice()">
|
||||
▶ PREVIEW
|
||||
</button>
|
||||
<button type="button" class="vs-btn-save" id="save-btn" onclick="saveSettings()">
|
||||
SAVE SETTINGS
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function previewVoice() {
|
||||
var text = document.getElementById('preview-text').value.trim() ||
|
||||
'Hello, I am Timmy. Your local AI assistant.';
|
||||
var rate = parseInt(document.getElementById('rate-slider').value, 10);
|
||||
var volume = parseInt(document.getElementById('vol-slider').value, 10) / 100;
|
||||
|
||||
if (!('speechSynthesis' in window)) {
|
||||
McToast.show('Speech synthesis not supported in this browser', 'warn');
|
||||
return;
|
||||
}
|
||||
|
||||
window.speechSynthesis.cancel();
|
||||
var utterance = new SpeechSynthesisUtterance(text);
|
||||
// Web Speech API rate: 1.0 ≈ 175 WPM (default)
|
||||
utterance.rate = rate / 175;
|
||||
utterance.volume = volume;
|
||||
|
||||
// Best-effort voice match from server selection
|
||||
var voiceSelect = document.getElementById('voice-select');
|
||||
if (voiceSelect && voiceSelect.value) {
|
||||
var selectedText = voiceSelect.options[voiceSelect.selectedIndex].text.toLowerCase();
|
||||
var firstWord = selectedText.split(' ')[0];
|
||||
var browserVoices = window.speechSynthesis.getVoices();
|
||||
var matched = browserVoices.find(function(v) {
|
||||
return v.name.toLowerCase().includes(firstWord);
|
||||
});
|
||||
if (matched) { utterance.voice = matched; }
|
||||
}
|
||||
|
||||
window.speechSynthesis.speak(utterance);
|
||||
McToast.show('Playing preview\u2026', 'info');
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
var rate = document.getElementById('rate-slider').value;
|
||||
var volPct = parseInt(document.getElementById('vol-slider').value, 10);
|
||||
var voiceId = document.getElementById('voice-select').value;
|
||||
|
||||
var body = new URLSearchParams({
|
||||
rate: rate,
|
||||
volume: (volPct / 100).toFixed(2),
|
||||
voice_id: voiceId
|
||||
});
|
||||
|
||||
try {
|
||||
var resp = await fetch('/voice/settings/save', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString()
|
||||
});
|
||||
var data = await resp.json();
|
||||
if (data.saved) {
|
||||
McToast.show('Voice settings saved.', 'info');
|
||||
} else {
|
||||
McToast.show('Failed to save settings.', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
McToast.show('Error saving settings.', 'error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -2547,3 +2547,120 @@
|
||||
.tower-adv-title { font-size: 0.85rem; font-weight: 600; color: var(--text-bright); }
|
||||
.tower-adv-detail { font-size: 0.8rem; color: var(--text); margin-top: 2px; }
|
||||
.tower-adv-action { font-size: 0.75rem; color: var(--green); margin-top: 4px; font-style: italic; }
|
||||
|
||||
|
||||
/* ── Voice settings ───────────────────────────────────────── */
|
||||
.voice-settings-page { max-width: 600px; margin: 0 auto; }
|
||||
|
||||
.vs-field { margin-bottom: 1.5rem; }
|
||||
|
||||
.vs-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.vs-value { color: var(--green); font-family: var(--font); }
|
||||
|
||||
.vs-slider {
|
||||
width: 100%;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 4px;
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.vs-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--purple);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 6px rgba(124, 58, 237, 0.5);
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.vs-slider::-webkit-slider-thumb:hover { box-shadow: 0 0 12px rgba(124, 58, 237, 0.8); }
|
||||
.vs-slider::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--purple);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
box-shadow: 0 0 6px rgba(124, 58, 237, 0.5);
|
||||
}
|
||||
.vs-range-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.vs-select,
|
||||
.vs-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.vs-select { cursor: pointer; }
|
||||
.vs-select:focus,
|
||||
.vs-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--purple);
|
||||
box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.2);
|
||||
}
|
||||
|
||||
.vs-unavailable {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.vs-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.vs-btn-preview,
|
||||
.vs-btn-save {
|
||||
flex: 1;
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
cursor: pointer;
|
||||
min-height: 44px;
|
||||
transition: opacity 0.2s, box-shadow 0.2s, background 0.2s;
|
||||
}
|
||||
.vs-btn-preview {
|
||||
background: transparent;
|
||||
border: 1px solid var(--purple);
|
||||
color: var(--purple);
|
||||
}
|
||||
.vs-btn-preview:hover {
|
||||
background: rgba(124, 58, 237, 0.15);
|
||||
box-shadow: 0 0 8px rgba(124, 58, 237, 0.3);
|
||||
}
|
||||
.vs-btn-save {
|
||||
background: var(--green);
|
||||
border: none;
|
||||
color: var(--bg-deep);
|
||||
}
|
||||
.vs-btn-save:hover { opacity: 0.85; }
|
||||
|
||||
Reference in New Issue
Block a user