402 lines
19 KiB
HTML
402 lines
19 KiB
HTML
{% 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>
|
||
<label style="display:inline-flex;align-items:center;gap:0.3rem;margin-left:0.75rem;cursor:pointer;">
|
||
<input type="checkbox" id="post-to-discord" checked> <span class="muted" style="font-size:0.85rem;">Post to Discord</span>
|
||
</label>
|
||
<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 post_to_discord = document.getElementById('post-to-discord').checked;
|
||
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, post_to_discord})});
|
||
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 = post_to_discord ? 'Audio ready — posted to Discord channel.' : 'Audio ready.';
|
||
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 %}
|