Files
scrapyard-cogworks/ttstoy/webui/templates/dashboard.html
T

398 lines
19 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}Dashboard — TtsToy{% endblock %}
{% block content %}
<div style="margin-bottom:1.25rem;display:flex;align-items:baseline;gap:1rem;">
<h1 style="font-size:1rem;font-weight:300;color:#e0ecff;text-shadow:0 0 16px rgba(80,150,255,0.2);text-transform:uppercase;letter-spacing:2px;">Dashboard</h1>
<span class="muted">{{ session.get('discord_name','') }}</span>
</div>
<div class="row">
<div class="col">
<div class="card">
<h2>Active Mode</h2>
<div style="font-size:1rem;margin-bottom:0.4rem;color:#c8daf0;">
<span class="badge badge-mode">{{ mode.upper() }}</span>
</div>
<div class="muted">Global bot mode: <strong style="color:rgba(200,220,255,0.7);">{{ global_mode }}</strong></div>
</div>
</div>
<div class="col">
<div class="card">
<h2>Active Voice</h2>
<div style="font-size:0.95rem;margin-bottom:0.4rem;color:#c8daf0;" id="active-voice-display">{{ active_voice_display }}</div>
<div class="muted">
{% if mode == "chatterbox" %}<a href="{{ url_for('page_chatterbox') }}">Manage voices</a>
{% elif mode == "minimax" %}<a href="{{ url_for('page_minimax') }}">Browse voices</a>
{% elif mode == "dectalk" %}<a href="{{ url_for('page_dectalk') }}">Voice commands</a>
{% elif mode == "vox" %}<a href="{{ url_for('page_vox') }}">Word list</a>
{% endif %}
</div>
</div>
</div>
</div>
<div class="card">
<h2>Submit TTS Message</h2>
<div class="muted" style="margin-bottom:1rem;"><strong>Speak in VC</strong> plays in your voice channel. <strong>Generate Only</strong> creates the audio for playback here — no VC needed.</div>
<div class="row" style="margin-bottom:0.75rem;">
<div class="col form-group" style="max-width:180px;">
<label>Mode</label>
<select id="tts-mode-select" onchange="onModeChange()">
{% for m in ["minimax","chatterbox","dectalk","morshu","vox"] %}
<option value="{{ m }}" {% if m == mode %}selected{% endif %}>{{ m }}</option>
{% endfor %}
</select>
</div>
<div class="col form-group" id="voice-select-group">
<label>Voice</label>
<select id="tts-voice-select" onchange="onVoiceChange()">
<option value="">— use my saved voice —</option>
</select>
</div>
</div>
<!-- Chatterbox knobs -->
<div id="cb-knobs" style="display:none;margin-bottom:1rem;">
<div style="background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.10);border-radius:4px;padding:0.9rem;">
<div style="font-size:0.72rem;color:rgba(140,180,230,0.4);margin-bottom:0.75rem;text-transform:uppercase;letter-spacing:.05em;">Voice Settings</div>
<div class="row">
<div class="col form-group">
<label>Temperature <span class="muted">0–1.5</span></label>
<input type="range" id="cb-temp" min="0" max="1.5" step="0.05" style="width:100%;" oninput="updateKnobLabel('cb-temp','cb-temp-val')">
<div style="display:flex;justify-content:space-between;font-size:0.72rem;color:rgba(140,180,230,0.4);margin-top:2px;">
<span>consistent</span><span id="cb-temp-val" style="color:rgba(200,220,255,0.7);">—</span><span>varied</span>
</div>
</div>
<div class="col form-group">
<label>Exaggeration <span class="muted">0.25–2.0</span></label>
<input type="range" id="cb-exag" min="0.25" max="2.0" step="0.05" style="width:100%;" oninput="updateKnobLabel('cb-exag','cb-exag-val')">
<div style="display:flex;justify-content:space-between;font-size:0.72rem;color:rgba(140,180,230,0.4);margin-top:2px;">
<span>flat</span><span id="cb-exag-val" style="color:rgba(200,220,255,0.7);">—</span><span>dramatic</span>
</div>
</div>
<div class="col form-group">
<label>Volume dB <span class="muted">-20–+20</span></label>
<input type="range" id="cb-vol" min="-20" max="20" step="0.5" style="width:100%;" oninput="updateKnobLabel('cb-vol','cb-vol-val')">
<div style="display:flex;justify-content:space-between;font-size:0.72rem;color:rgba(140,180,230,0.4);margin-top:2px;">
<span>-20</span><span id="cb-vol-val" style="color:rgba(200,220,255,0.7);">0</span><span>+20</span>
</div>
</div>
<div class="col form-group">
<label>Speed <span class="muted">0.25–4.0</span></label>
<input type="range" id="cb-speed" min="0.25" max="4.0" step="0.05" style="width:100%;" oninput="updateKnobLabel('cb-speed','cb-speed-val')">
<div style="display:flex;justify-content:space-between;font-size:0.72rem;color:rgba(140,180,230,0.4);margin-top:2px;">
<span>slow</span><span id="cb-speed-val" style="color:rgba(200,220,255,0.7);">1.0</span><span>fast</span>
</div>
</div>
</div>
<div style="display:flex;gap:0.5rem;align-items:center;margin-top:0.25rem;">
<button class="btn btn-secondary btn-sm" onclick="saveKnobs()">Save for this voice</button>
<button class="btn btn-secondary btn-sm" onclick="resetKnobs()">Reset</button>
<span id="knob-status" class="muted"></span>
</div>
</div>
</div>
<div class="form-group">
<label for="tts-text">Message</label>
<div style="position:relative;">
<textarea id="tts-text" placeholder="Type something to say..." style="padding-right:2.5rem;"></textarea>
<button type="button" id="sfx-toggle-btn" onclick="toggleSfxDrawer()" title="Insert SFX emoji"
style="position:absolute;right:6px;top:6px;background:none;border:1px solid rgba(255,255,255,0.12);
border-radius:4px;padding:2px 6px;cursor:pointer;font-size:1.1rem;line-height:1;
color:rgba(200,220,255,0.6);transition:background 0.12s;"
onmouseenter="this.style.background='rgba(255,255,255,0.06)'" onmouseleave="this.style.background='none'">🔊</button>
</div>
<div id="sfx-drawer" style="display:none;margin-top:0.5rem;background:rgba(255,255,255,0.04);
border:1px solid rgba(255,255,255,0.10);border-radius:4px;padding:0.6rem 0.75rem;">
<div style="font-size:0.72rem;color:rgba(140,180,230,0.4);text-transform:uppercase;letter-spacing:.05em;margin-bottom:0.5rem;">Sound Effects — click to insert</div>
<div id="sfx-grid" style="display:flex;flex-wrap:wrap;gap:0.35rem;"></div>
</div>
</div>
<div style="display:flex;gap:0.75rem;align-items:center;flex-wrap:wrap;">
<button class="btn btn-primary" onclick="submitTTS()">
<span id="spinner" class="spin" style="display:none;"></span> Speak in VC
</button>
<button class="btn btn-secondary" id="gen-only-btn" onclick="generateOnly()" style="display:none;">
<span id="gen-spinner" class="spin" style="display:none;"></span> Generate Only
</button>
<span id="tts-status" class="muted"></span>
</div>
<div id="tts-result" style="margin-top:0.75rem;display:none;">
<audio id="tts-audio" controls></audio>
</div>
</div>
<div class="card">
<h2>Mode Preference</h2>
<div class="muted" style="margin-bottom:0.75rem;">Your personal mode override — does not affect other users.</div>
<div style="display:flex;gap:0.5rem;align-items:center;">
<button class="btn btn-secondary btn-sm" onclick="saveMode()">Save current mode selection</button>
<span id="mode-status" class="muted"></span>
</div>
</div>
<div class="card">
<h2>TTS Tips</h2>
<table>
<tr>
<td style="color:rgba(140,180,230,0.4);width:35%;vertical-align:top;">Voice switching</td>
<td>Use <code>[mode|voice]</code> tags to change voice mid-message. Everything after the tag uses that voice until the next tag. The voice part is optional for modes that don't need it.<br>
<span class="muted" style="font-size:0.78rem;">Example: <code>[minimax|robotnik] I am the eggman [dectalk] [:nh]now I'm Harry</code></span>
</td>
</tr>
<tr>
<td style="color:rgba(140,180,230,0.4);vertical-align:top;">Available modes</td>
<td>
<code>minimax</code> · <code>chatterbox</code> · <code>dectalk</code> · <code>morshu</code> · <code>vox</code>
</td>
</tr>
<tr>
<td style="color:rgba(140,180,230,0.4);vertical-align:top;">Voice names</td>
<td>
MiniMax: <code>[minimax|Robotnik]</code>, <code>[minimax|Gnome]</code>, etc.<br>
Chatterbox: <code>[chatterbox|YourVoiceName]</code> (uploaded filename)<br>
DECTalk: just <code>[dectalk]</code> — control voices in the text with <code>[:np]</code> Paul, <code>[:nb]</code> Betty, <code>[:nh]</code> Harry, etc.<br>
Morshu / VOX: <code>[morshu]</code> or <code>[vox]</code> — no voice selection needed
</td>
</tr>
<tr>
<td style="color:rgba(140,180,230,0.4);vertical-align:top;">Sound effects</td>
<td>Insert SFX emoji anywhere in your text — click the 🔊 button on the message box to browse available sounds.</td>
</tr>
<tr>
<td style="color:rgba(140,180,230,0.4);vertical-align:top;">Chatterbox tokens</td>
<td><code>[laugh]</code> <code>[chuckle]</code> <code>[sigh]</code> <code>[gasp]</code> <code>[cough]</code> <code>[clear throat]</code> <code>[sniff]</code> <code>[groan]</code> <code>[shush]</code></td>
</tr>
</table>
</div>
{% endblock %}
{% block scripts %}
<script>
let settings = {};
async function loadSettings() {
// Remember current voice selection so it survives the re-render
const prevVoice = document.getElementById('tts-voice-select')?.value || null;
const r = await apiFetch('/api/settings');
settings = await r.json();
populateVoiceSelect(document.getElementById('tts-mode-select').value, prevVoice);
}
function updateKnobLabel(sliderId, labelId) {
document.getElementById(labelId).textContent = parseFloat(document.getElementById(sliderId).value).toFixed(2);
}
function loadKnobsForVoice(filename) {
const t = settings.chatterbox_temperature_offsets?.[filename];
const e = settings.chatterbox_exaggeration_offsets?.[filename];
const v = settings.chatterbox_volume_offsets?.[filename] ?? 0;
const s = settings.chatterbox_speed_offsets?.[filename] ?? 1.0;
document.getElementById('cb-temp').value = t ?? 0.5;
document.getElementById('cb-exag').value = e ?? 1.0;
document.getElementById('cb-vol').value = v;
document.getElementById('cb-speed').value = s;
document.getElementById('cb-temp-val').textContent = t != null ? parseFloat(t).toFixed(2) : '—';
document.getElementById('cb-exag-val').textContent = e != null ? parseFloat(e).toFixed(2) : '—';
document.getElementById('cb-vol-val').textContent = parseFloat(v).toFixed(1);
document.getElementById('cb-speed-val').textContent = parseFloat(s).toFixed(2);
document.getElementById('knob-status').textContent = '';
}
function populateVoiceSelect(mode, restoreVoice) {
const sel = document.getElementById('tts-voice-select');
const group = document.getElementById('voice-select-group');
const knobs = document.getElementById('cb-knobs');
sel.innerHTML = '<option value="">— use my saved voice —</option>';
knobs.style.display = 'none';
if (mode === 'chatterbox') {
group.style.display = '';
(settings.chatterbox_voices || []).forEach(filename => {
const parts = filename.split('_');
const display = parts.length > 1 ? parts.slice(1).join('_').replace(/\.[^.]+$/, '') : filename;
const opt = document.createElement('option');
opt.value = filename; opt.textContent = display;
sel.appendChild(opt);
});
// Restore previous selection, fall back to saved active voice
const target = restoreVoice || settings.user_voice || '';
if (target) sel.value = target;
// If the target wasn't in the list, sel.value will be "" (default option) — that's fine
const active = sel.value || settings.user_voice;
if (active) { knobs.style.display = ''; loadKnobsForVoice(active); }
if (settings.user_voice) {
const parts = settings.user_voice.split('_');
document.getElementById('active-voice-display').textContent =
parts.length > 1 ? parts.slice(1).join('_').replace(/\.[^.]+$/, '') : settings.user_voice;
}
} else if (mode === 'minimax') {
group.style.display = '';
const voices = settings.minimax_voices || {};
Object.entries(voices).forEach(([name, id]) => {
const opt = document.createElement('option');
opt.value = id; opt.textContent = name;
sel.appendChild(opt);
});
const target = restoreVoice || settings.user_voice || '';
if (target) sel.value = target;
} else {
group.style.display = 'none';
}
}
function onVoiceChange() {
const mode = document.getElementById('tts-mode-select').value;
const voice = document.getElementById('tts-voice-select').value;
const knobs = document.getElementById('cb-knobs');
if (mode === 'chatterbox' && voice) { knobs.style.display = ''; loadKnobsForVoice(voice); }
else knobs.style.display = 'none';
}
function onModeChange() {
populateVoiceSelect(document.getElementById('tts-mode-select').value);
document.getElementById('gen-only-btn').style.display = '';
}
async function saveKnobs() {
const voice = document.getElementById('tts-voice-select').value || settings.user_voice;
if (!voice) return;
const params = {
temperature: parseFloat(document.getElementById('cb-temp').value),
exaggeration: parseFloat(document.getElementById('cb-exag').value),
volume_db: parseFloat(document.getElementById('cb-vol').value),
speed: parseFloat(document.getElementById('cb-speed').value),
};
const r = await apiFetch('/api/settings/chatterbox_params', {method:'POST', body: JSON.stringify({voice, params})});
const d = await r.json();
document.getElementById('knob-status').textContent = d.ok ? 'Saved.' : 'Error: ' + d.error;
if (d.ok) await loadSettings();
}
async function resetKnobs() {
const voice = document.getElementById('tts-voice-select').value || settings.user_voice;
if (!voice) return;
const r = await apiFetch('/api/settings/chatterbox_params', {method:'POST', body: JSON.stringify({voice, params:{temperature:null,exaggeration:null,volume_db:0,speed:1.0}})});
const d = await r.json();
document.getElementById('knob-status').textContent = d.ok ? 'Reset to defaults.' : 'Error: ' + d.error;
if (d.ok) await loadSettings();
}
async function submitTTS() {
const text = document.getElementById('tts-text').value.trim();
if (!text) return;
const mode = document.getElementById('tts-mode-select').value;
const voice = document.getElementById('tts-voice-select').value || null;
const spinner = document.getElementById('spinner');
const status = document.getElementById('tts-status');
const result = document.getElementById('tts-result');
spinner.style.display = 'inline-block';
status.textContent = 'Sending to bot...';
result.style.display = 'none';
const r = await apiFetch('/api/tts', {method:'POST', body: JSON.stringify({text, mode, voice})});
const {job_id, error} = await r.json();
if (error) { spinner.style.display='none'; status.textContent = 'Error: ' + error; return; }
const poll = setInterval(async () => {
const s = await apiFetch(`/api/tts/${job_id}/status`);
const job = await s.json();
if (job.status === 'done') {
clearInterval(poll);
spinner.style.display = 'none';
status.textContent = 'Playing in VC — posted to Discord channel.';
document.getElementById('tts-audio').src = `/api/tts/${job_id}/audio`;
result.style.display = 'block';
} else if (job.status === 'error') {
clearInterval(poll);
spinner.style.display = 'none';
status.textContent = 'Error: ' + job.error;
}
}, 800);
}
async function generateOnly() {
const text = document.getElementById('tts-text').value.trim();
if (!text) return;
const mode = document.getElementById('tts-mode-select').value;
const voice = document.getElementById('tts-voice-select').value || null;
const spinner = document.getElementById('gen-spinner');
const status = document.getElementById('tts-status');
const result = document.getElementById('tts-result');
spinner.style.display = 'inline-block';
status.textContent = 'Generating audio...';
result.style.display = 'none';
const r = await apiFetch('/api/tts/generate', {method:'POST', body: JSON.stringify({text, mode, voice})});
const {job_id, error} = await r.json();
if (error) { spinner.style.display='none'; status.textContent = 'Error: ' + error; return; }
const poll = setInterval(async () => {
const s = await apiFetch(`/api/tts/${job_id}/status`);
const job = await s.json();
if (job.status === 'done') {
clearInterval(poll);
spinner.style.display = 'none';
status.textContent = 'Audio ready — posted to Discord channel.';
document.getElementById('tts-audio').src = `/api/tts/${job_id}/audio`;
result.style.display = 'block';
} else if (job.status === 'error') {
clearInterval(poll);
spinner.style.display = 'none';
status.textContent = 'Error: ' + job.error;
}
}, 800);
}
async function saveMode() {
const mode = document.getElementById('tts-mode-select').value;
const r = await apiFetch('/api/settings/mode', {method:'POST', body: JSON.stringify({mode})});
const d = await r.json();
document.getElementById('mode-status').textContent = d.ok ? 'Saved: ' + mode : 'Error: ' + d.error;
}
loadSettings();
loadSfxEmojis();
// Show generate-only button for all modes
document.getElementById('gen-only-btn').style.display = '';
let sfxEmojis = [];
let sfxDrawerOpen = false;
async function loadSfxEmojis() {
try {
const r = await apiFetch('/api/sfx_emojis');
sfxEmojis = await r.json();
renderSfxGrid();
} catch(e) { console.warn('Could not load SFX emojis', e); }
}
function renderSfxGrid() {
const grid = document.getElementById('sfx-grid');
if (!sfxEmojis.length) { grid.innerHTML = '<span class="muted">No SFX available.</span>'; return; }
grid.innerHTML = sfxEmojis.map(s =>
`<button type="button" onclick="insertSfx('${s.emoji}')" title="${s.name}"
style="background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.10);border-radius:4px;
padding:0.3rem 0.5rem;cursor:pointer;font-size:1rem;line-height:1.2;display:flex;
flex-direction:column;align-items:center;gap:2px;min-width:52px;transition:background 0.12s;"
onmouseenter="this.style.background='rgba(255,255,255,0.08)'" onmouseleave="this.style.background='rgba(255,255,255,0.04)'">
<span style="font-size:1.25rem;">${s.emoji}</span>
<span style="font-size:0.62rem;color:rgba(140,180,230,0.4);">${s.name}</span>
</button>`
).join('');
}
function toggleSfxDrawer() {
sfxDrawerOpen = !sfxDrawerOpen;
document.getElementById('sfx-drawer').style.display = sfxDrawerOpen ? '' : 'none';
}
function insertSfx(emoji) {
const ta = document.getElementById('tts-text');
const start = ta.selectionStart;
const end = ta.selectionEnd;
ta.value = ta.value.substring(0, start) + emoji + ta.value.substring(end);
ta.selectionStart = ta.selectionEnd = start + emoji.length;
ta.focus();
}
loadSfxEmojis();
</script>
{% endblock %}