195 lines
9.0 KiB
HTML
195 lines
9.0 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}Chatterbox — TtsToy{% endblock %}
|
||
{% block content %}
|
||
<div style="margin-bottom:1.25rem;">
|
||
<h1 style="font-size:1rem;font-weight:300;color:#e0ecff;text-transform:uppercase;letter-spacing:2px;text-shadow:0 0 16px rgba(80,150,255,0.2);">Chatterbox Voices</h1>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>Upload New Voice</h2>
|
||
<div class="muted" style="margin-bottom:0.9rem;">Upload a 5–15 second .wav or .mp3 clip of a single speaker. Short clips are auto-looped.</div>
|
||
<div class="row">
|
||
<div class="col form-group">
|
||
<label>Voice Name</label>
|
||
<input type="text" id="upload-name" placeholder="e.g. MyVoice">
|
||
</div>
|
||
<div class="col form-group">
|
||
<label>Audio File (.wav / .mp3)</label>
|
||
<input type="file" id="upload-file" accept=".wav,.mp3">
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:0.5rem;align-items:center;">
|
||
<button class="btn btn-primary btn-sm" onclick="uploadVoice()">Upload</button>
|
||
<span id="upload-status" class="muted"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>Your Voices</h2>
|
||
<ul class="voice-list" id="voice-list"><li class="muted">Loading...</li></ul>
|
||
</div>
|
||
|
||
<div class="card" id="edit-card" style="display:none;">
|
||
<h2>Edit Voice: <span id="edit-voice-name" style="color:#c8daf0;font-weight:300;"></span></h2>
|
||
<input type="hidden" id="edit-voice-filename">
|
||
<div class="row">
|
||
<div class="col form-group">
|
||
<label>Temperature (0.0–1.5)</label>
|
||
<input type="number" id="edit-temp" step="0.05" min="0" max="1.5" placeholder="server default">
|
||
</div>
|
||
<div class="col form-group">
|
||
<label>Exaggeration (0.25–2.0)</label>
|
||
<input type="number" id="edit-exag" step="0.05" min="0.25" max="2.0" placeholder="server default">
|
||
</div>
|
||
<div class="col form-group">
|
||
<label>Volume dB (-20–+20)</label>
|
||
<input type="number" id="edit-vol" step="0.5" min="-20" max="20" placeholder="0">
|
||
</div>
|
||
<div class="col form-group">
|
||
<label>Speed (0.25–4.0)</label>
|
||
<input type="number" id="edit-speed" step="0.05" min="0.25" max="4.0" placeholder="1.0">
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;">
|
||
<button class="btn btn-primary btn-sm" id="save-btn">Save</button>
|
||
<button class="btn btn-secondary btn-sm" onclick="setActiveVoice()">Set as Active</button>
|
||
<button class="btn btn-danger btn-sm" onclick="deleteVoice()">Delete</button>
|
||
<button class="btn btn-secondary btn-sm" onclick="closeEdit()">Cancel</button>
|
||
<span id="edit-status" class="muted"></span>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
{% block scripts %}
|
||
<script>
|
||
let voices = [];
|
||
let settings = {};
|
||
|
||
async function loadVoices() {
|
||
const [vr, sr] = await Promise.all([apiFetch('/api/chatterbox/voices'), apiFetch('/api/settings')]);
|
||
voices = await vr.json();
|
||
settings = await sr.json();
|
||
renderVoices();
|
||
}
|
||
|
||
function renderVoices() {
|
||
const ul = document.getElementById('voice-list');
|
||
if (!voices.length) { ul.innerHTML = '<li class="muted">No voices uploaded yet.</li>'; return; }
|
||
ul.innerHTML = voices.map(v => {
|
||
const active = v.filename === settings.user_voice;
|
||
return `<li>
|
||
<span class="name">${v.display} ${active ? '<span class="badge badge-active">active</span>' : ''}</span>
|
||
<button class="btn btn-secondary btn-sm" onclick="openEdit('${v.filename}','${v.display}')">Edit</button>
|
||
</li>`;
|
||
}).join('');
|
||
}
|
||
|
||
function openEdit(filename, display) {
|
||
const alreadyOpen = document.getElementById('edit-card').style.display !== 'none'
|
||
&& document.getElementById('edit-voice-filename').value === filename;
|
||
document.getElementById('edit-card').style.display = 'block';
|
||
document.getElementById('edit-voice-name').textContent = display;
|
||
document.getElementById('edit-voice-filename').value = filename;
|
||
// Don't overwrite fields if this voice is already open (e.g. after a save re-render)
|
||
if (!alreadyOpen) {
|
||
document.getElementById('edit-status').textContent = '';
|
||
document.getElementById('edit-temp').value = settings.chatterbox_temperature_offsets?.[filename] ?? '';
|
||
document.getElementById('edit-exag').value = settings.chatterbox_exaggeration_offsets?.[filename] ?? '';
|
||
document.getElementById('edit-vol').value = settings.chatterbox_volume_offsets?.[filename] ?? '';
|
||
document.getElementById('edit-speed').value = settings.chatterbox_speed_offsets?.[filename] ?? '';
|
||
document.getElementById('edit-card').scrollIntoView({behavior:'smooth'});
|
||
}
|
||
}
|
||
|
||
function closeEdit() { document.getElementById('edit-card').style.display = 'none'; }
|
||
|
||
// Capture input values on mousedown, before the button click causes blur+snap on number inputs
|
||
let _pendingSave = null;
|
||
document.getElementById('save-btn').addEventListener('mousedown', () => {
|
||
_pendingSave = {
|
||
t: document.getElementById('edit-temp').value,
|
||
e: document.getElementById('edit-exag').value,
|
||
v: document.getElementById('edit-vol').value,
|
||
s: document.getElementById('edit-speed').value,
|
||
};
|
||
});
|
||
document.getElementById('save-btn').addEventListener('click', saveVoiceParams);
|
||
|
||
async function saveVoiceParams() {
|
||
const filename = document.getElementById('edit-voice-filename').value;
|
||
// Use values captured on mousedown (before blur snapped them), fall back to current
|
||
const raw = _pendingSave || {
|
||
t: document.getElementById('edit-temp').value,
|
||
e: document.getElementById('edit-exag').value,
|
||
v: document.getElementById('edit-vol').value,
|
||
s: document.getElementById('edit-speed').value,
|
||
};
|
||
_pendingSave = null;
|
||
const params = {};
|
||
if (raw.t !== '') params.temperature = parseFloat(raw.t);
|
||
if (raw.e !== '') params.exaggeration = parseFloat(raw.e);
|
||
if (raw.v !== '') params.volume_db = parseFloat(raw.v);
|
||
if (raw.s !== '') params.speed = parseFloat(raw.s);
|
||
const r = await apiFetch('/api/settings/chatterbox_params', {method:'POST', body: JSON.stringify({voice: filename, params})});
|
||
const d = await r.json();
|
||
if (d.ok) {
|
||
// Update local settings cache so re-render doesn't clobber the form with stale disk values
|
||
if (!settings.chatterbox_temperature_offsets) settings.chatterbox_temperature_offsets = {};
|
||
if (!settings.chatterbox_exaggeration_offsets) settings.chatterbox_exaggeration_offsets = {};
|
||
if (!settings.chatterbox_volume_offsets) settings.chatterbox_volume_offsets = {};
|
||
if (!settings.chatterbox_speed_offsets) settings.chatterbox_speed_offsets = {};
|
||
if (params.temperature !== undefined) settings.chatterbox_temperature_offsets[filename] = params.temperature;
|
||
if (params.exaggeration !== undefined) settings.chatterbox_exaggeration_offsets[filename] = params.exaggeration;
|
||
if (params.volume_db !== undefined) settings.chatterbox_volume_offsets[filename] = params.volume_db;
|
||
if (params.speed !== undefined) settings.chatterbox_speed_offsets[filename] = params.speed;
|
||
// Restore field values to what was actually saved (blur may have snapped them)
|
||
if (raw.t !== '') document.getElementById('edit-temp').value = raw.t;
|
||
if (raw.e !== '') document.getElementById('edit-exag').value = raw.e;
|
||
if (raw.v !== '') document.getElementById('edit-vol').value = raw.v;
|
||
if (raw.s !== '') document.getElementById('edit-speed').value = raw.s;
|
||
document.getElementById('edit-status').textContent = 'Saved.';
|
||
} else {
|
||
document.getElementById('edit-status').textContent = 'Error: ' + d.error;
|
||
}
|
||
}
|
||
|
||
async function setActiveVoice() {
|
||
const filename = document.getElementById('edit-voice-filename').value;
|
||
const r = await apiFetch('/api/settings/voice', {method:'POST', body: JSON.stringify({voice: filename})});
|
||
const d = await r.json();
|
||
document.getElementById('edit-status').textContent = d.ok ? 'Set as active voice.' : 'Error: ' + d.error;
|
||
if (d.ok) await loadVoices();
|
||
}
|
||
|
||
async function deleteVoice() {
|
||
const filename = document.getElementById('edit-voice-filename').value;
|
||
if (!confirm('Delete this voice? This cannot be undone.')) return;
|
||
const r = await fetch(`/api/chatterbox/voices/${encodeURIComponent(filename)}`, {method:'DELETE'});
|
||
const d = await r.json();
|
||
if (d.ok) { closeEdit(); await loadVoices(); }
|
||
else document.getElementById('edit-status').textContent = 'Error: ' + d.error;
|
||
}
|
||
|
||
async function uploadVoice() {
|
||
const name = document.getElementById('upload-name').value.trim();
|
||
const file = document.getElementById('upload-file').files[0];
|
||
if (!file) { document.getElementById('upload-status').textContent = 'Select a file first.'; return; }
|
||
const fd = new FormData();
|
||
fd.append('file', file);
|
||
if (name) fd.append('name', name);
|
||
document.getElementById('upload-status').textContent = 'Uploading...';
|
||
const r = await fetch('/api/chatterbox/voices/upload', {method:'POST', body: fd});
|
||
const d = await r.json();
|
||
if (d.ok) {
|
||
document.getElementById('upload-status').textContent = 'Uploaded.';
|
||
document.getElementById('upload-name').value = '';
|
||
document.getElementById('upload-file').value = '';
|
||
await loadVoices();
|
||
} else {
|
||
document.getElementById('upload-status').textContent = 'Error: ' + d.error;
|
||
}
|
||
}
|
||
|
||
loadVoices();
|
||
</script>
|
||
{% endblock %}
|