forked from Rockachopa/Timmy-time-dashboard
132 lines
4.6 KiB
HTML
132 lines
4.6 KiB
HTML
{% 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 %}
|