Files
UmbraAudio/umbraaudio/web/templates/_speakers_list.html
B.D. Murphy a172610b0e Add voice library, embedding-mode generation, and webUI improvements
- Add cross-project voice/name library (library.py + webUI library tabs)
- chapter_builder: embedding-mode generation + auto-load TTS base model in preflight
- qwen_tts: vmatrix capture, embedding/voice-prompt refinements
- registry: vmatrix_path column + migration for approved voices
- webUI: speakers list, chapter audio detail, proper names updates
- docs: update voice generation and tts_server README
2026-09-02 17:33:15 -07:00

328 lines
17 KiB
HTML

<div class="card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem">
<h3 style="margin:0">
Speakers ({{ speakers|length }})
{% set flagged_count = speakers|selectattr('flagged')|list|length %}
{% if flagged_count %}
<span class="badge badge-red" style="font-size:0.7rem; vertical-align:middle">{{ flagged_count }} need review</span>
{% endif %}
</h3>
<div style="display:flex; gap:0.5rem; align-items:center">
<a href="/orphaned-utterances" class="btn" style="font-size:0.8rem; padding:0.3rem 0.6rem; background:#374151; color:#94a3b8">
🚫 Orphaned
</a>
<button class="btn btn-generate" id="gen-all-voices-btn" onclick="generateAllVoices()">🎙 Generate All Voices</button>
<button class="btn btn-generate" onclick="toggleAddForm()">+ Add Speaker</button>
<button class="btn" style="background:#0f3460; color:#7c3aed; border:1px solid #7c3aed" onclick="openLibSpeakersModal()">🕮 Lib</button>
</div>
</div>
<div id="add-speaker-form" style="display:none; margin-bottom:1.5rem; padding:1rem; background:#0f3460; border-radius:8px">
<form hx-post="/api/speakers"
hx-target="#speakers-list"
hx-swap="innerHTML">
<div style="display:grid; grid-template-columns:1fr 1fr 1fr 1fr; gap:0.75rem; margin-bottom:0.75rem">
<div>
<label style="color:#94a3b8; font-size:0.85rem; display:block; margin-bottom:0.25rem">Primary Name *</label>
<input type="text" name="primary_name" required
style="width:100%; padding:0.5rem; background:#1a1a2e; border:1px solid #333; border-radius:4px; color:#e0e0e0">
</div>
<div>
<label style="color:#94a3b8; font-size:0.85rem; display:block; margin-bottom:0.25rem">Type</label>
<select name="speaker_type" style="width:100%; padding:0.5rem; background:#1a1a2e; border:1px solid #333; border-radius:4px; color:#e0e0e0">
<option value="Human">Human</option>
<option value="Robot">Robot</option>
<option value="Computer">Computer</option>
<option value="AI">AI</option>
<option value="Alien">Alien</option>
<option value="Unknown">Unknown</option>
</select>
</div>
<div>
<label style="color:#94a3b8; font-size:0.85rem; display:block; margin-bottom:0.25rem">Gender</label>
<select name="gender" style="width:100%; padding:0.5rem; background:#1a1a2e; border:1px solid #333; border-radius:4px; color:#e0e0e0">
<option value="Unknown">Unknown</option>
<option value="Male">Male</option>
<option value="Female">Female</option>
<option value="Non-binary">Non-binary</option>
</select>
</div>
<div>
<label style="color:#94a3b8; font-size:0.85rem; display:block; margin-bottom:0.25rem">Accent</label>
<select name="accent" style="width:100%; padding:0.5rem; background:#1a1a2e; border:1px solid #333; border-radius:4px; color:#e0e0e0">
<option value="none">None (auto-detect)</option>
<option value="american">American</option>
<option value="british">British</option>
<option value="australian">Australian</option>
<option value="indian">Indian</option>
<option value="irish">Irish</option>
<option value="canadian">Canadian</option>
<option value="nigerian">Nigerian</option>
<option value="kenyan">Kenyan</option>
<option value="hong_kong">Hong Kong</option>
</select>
</div>
</div>
<div style="margin-bottom:0.75rem">
<label style="color:#94a3b8; font-size:0.85rem; display:block; margin-bottom:0.25rem">Aliases (comma-separated)</label>
<input type="text" name="aliases" placeholder="e.g. the captain, Weston"
style="width:100%; padding:0.5rem; background:#1a1a2e; border:1px solid #333; border-radius:4px; color:#e0e0e0">
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:0.75rem; margin-bottom:0.75rem">
<div>
<label style="color:#94a3b8; font-size:0.85rem; display:block; margin-bottom:0.25rem">Vocal Traits (comma-separated)</label>
<input type="text" name="vocal_traits" placeholder="e.g. raspy, deep, fast-talker"
style="width:100%; padding:0.5rem; background:#1a1a2e; border:1px solid #333; border-radius:4px; color:#e0e0e0">
</div>
<div>
<label style="color:#94a3b8; font-size:0.85rem; display:block; margin-bottom:0.25rem">Communication Register</label>
<select name="communication_register" style="width:100%; padding:0.5rem; background:#1a1a2e; border:1px solid #333; border-radius:4px; color:#e0e0e0">
<option value="Casual">Casual</option>
<option value="Formal">Formal</option>
<option value="Authoritative">Authoritative</option>
<option value="Academic">Academic</option>
<option value="Frantic">Frantic</option>
<option value="Professional">Professional</option>
</select>
</div>
</div>
<div style="margin-bottom:0.75rem">
<label style="color:#94a3b8; font-size:0.85rem; display:block; margin-bottom:0.25rem">Style Notes</label>
<input type="text" name="style_notes" placeholder="e.g. shifts to casual when speaking to allies"
style="width:100%; padding:0.5rem; background:#1a1a2e; border:1px solid #333; border-radius:4px; color:#e0e0e0">
</div>
<div style="margin-bottom:0.75rem">
<label style="color:#94a3b8; font-size:0.85rem; display:block; margin-bottom:0.25rem">Description</label>
<input type="text" name="description"
style="width:100%; padding:0.5rem; background:#1a1a2e; border:1px solid #333; border-radius:4px; color:#e0e0e0">
</div>
<div style="margin-bottom:0.75rem">
<label style="color:#94a3b8; font-size:0.85rem; display:block; margin-bottom:0.25rem">Sample Dialogue</label>
<input type="text" name="sample_dialogue"
style="width:100%; padding:0.5rem; background:#1a1a2e; border:1px solid #333; border-radius:4px; color:#e0e0e0">
</div>
<div style="display:flex; gap:0.5rem; align-items:center">
<label style="color:#94a3b8; font-size:0.85rem">
<input type="checkbox" name="is_narrator" value="true"> Narrator
</label>
<label style="color:#94a3b8; font-size:0.85rem">
<input type="checkbox" name="is_group" value="true"> Group
</label>
</div>
<div style="margin-top:0.75rem">
<button type="submit" class="btn btn-approve">Save Speaker</button>
<button type="button" class="btn" style="background:#333;color:#94a3b8" onclick="toggleAddForm()">Cancel</button>
</div>
</form>
</div>
</div>
<table>
<thead>
<tr>
<th>Speaker</th>
<th style="min-width:180px">Aliases <span style="color:#64748b;font-weight:400">(get these right first)</span></th>
<th>Demographics</th>
<th>Vocal Traits</th>
<th>Voice Sample</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for speaker in speakers %}
{% set speaker_idx = loop.index0 %}
{% include "_speaker_row.html" %}
{% endfor %}
</tbody>
</table>
<div id="alias-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.7); z-index:1000; display:none; justify-content:center; align-items:center">
<div style="background:#16213e; border-radius:8px; padding:1.5rem; max-width:500px; width:90%; border:1px solid #333">
<h3 style="color:#a78bfa; margin-bottom:1rem">Alias: <span id="modal-alias-name"></span></h3>
<p style="color:#94a3b8; margin-bottom:1rem">
This alias belongs to <strong id="modal-source-name"></strong>. What would you like to do?
</p>
<div style="display:flex; flex-direction:column; gap:0.75rem">
<button onclick="removeAlias()" class="btn btn-reject" style="width:100%; text-align:left; padding:0.75rem">
🗑️ <strong>Remove</strong> — just delete this alias
</button>
<div style="display:flex; gap:0.5rem; align-items:center">
<select id="modal-target-speaker" style="flex:1; padding:0.5rem; background:#1a1a2e; border:1px solid #333; border-radius:4px; color:#e0e0e0">
</select>
<button onclick="moveAlias()" class="btn btn-generate" style="white-space:nowrap; padding:0.75rem">
📦 <strong>Move</strong>
</button>
</div>
<button onclick="createSpeakerFromAlias()" class="btn btn-primary" style="width:100%; text-align:left; padding:0.75rem">
✨ <strong>Create New Speaker</strong> — "<span id="modal-alias-name-create"></span>" becomes a new speaker
</button>
<button onclick="closeAliasModal()" class="btn" style="background:#374151; color:#9ca3af; width:100%">Cancel</button>
</div>
</div>
</div>
<div id="lib-speakers-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.7); z-index:1001; justify-content:center; align-items:center">
<div style="background:#16213e; border-radius:8px; padding:1.5rem; max-width:560px; width:92%; border:1px solid #7c3aed">
<h3 style="color:#a78bfa; margin-bottom:0.25rem">Add Speakers to Library</h3>
<p style="color:#94a3b8; font-size:0.85rem; margin-bottom:1rem">
Selected speakers (and their voice reference samples) will be saved to the shared library for reuse on other projects.
</p>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:0.5rem">
<label style="color:#94a3b8; font-size:0.85rem"><input type="checkbox" id="lib-check-all-speakers" onchange="toggleAllLibSpeakers(this)"> Select all</label>
<span style="color:#64748b; font-size:0.8rem" id="lib-speakers-count">0 selected</span>
</div>
<div id="lib-speakers-list" style="max-height:320px; overflow-y:auto; border:1px solid #333; border-radius:4px; padding:0.5rem">
{% for speaker in speakers %}
<label style="display:flex; align-items:center; gap:0.5rem; padding:0.35rem 0.25rem; border-bottom:1px solid #24263f; cursor:pointer">
<input type="checkbox" class="lib-speaker-cb" value="{{ speaker.primary_name }}" onchange="updateLibSpeakersCount()">
<strong>{{ speaker.primary_name }}</strong>
{% if speaker.is_narrator %}<span class="badge badge-blue" style="font-size:0.65rem">Narrator</span>{% endif %}
{% if speaker.has_voice_ref %}<span class="badge badge-green" style="font-size:0.65rem">voice</span>
{% else %}<span class="badge badge-yellow" style="font-size:0.65rem">no voice</span>{% endif %}
</label>
{% endfor %}
</div>
<div style="margin-top:1rem; display:flex; gap:0.5rem; justify-content:flex-end">
<button class="btn" style="background:#374151; color:#9ca3af" onclick="closeLibSpeakersModal()">Cancel</button>
<button class="btn btn-generate" id="lib-add-speakers-btn" onclick="addSelectedToLib()">Add to Library</button>
</div>
<p style="color:#10b981; font-size:0.85rem; margin-top:0.5rem" id="lib-speakers-status"></p>
</div>
</div>
<script>
function showAliasModal(speakerIdx, alias) {
window._aliasModal = { speakerIdx, alias };
document.getElementById('modal-alias-name').textContent = alias;
document.getElementById('modal-alias-name-create').textContent = alias;
document.getElementById('modal-source-name').textContent = window._speakerNames ? window._speakerNames[speakerIdx] : 'speaker #' + speakerIdx;
var targetSelect = document.getElementById('modal-target-speaker');
targetSelect.innerHTML = '';
if (window._speakerNames) {
for (var i = 0; i < window._speakerNames.length; i++) {
if (i !== speakerIdx) {
var opt = document.createElement('option');
opt.value = i;
opt.textContent = window._speakerNames[i];
targetSelect.appendChild(opt);
}
}
}
var modal = document.getElementById('alias-modal');
modal.style.display = 'flex';
}
function closeAliasModal() {
document.getElementById('alias-modal').style.display = 'none';
}
function removeAlias() {
var m = window._aliasModal;
htmx.ajax('DELETE', '/api/speakers/' + m.speakerIdx + '/alias/' + encodeURIComponent(m.alias), {
target: '#speakers-list',
swap: 'innerHTML'
});
closeAliasModal();
}
function moveAlias() {
var m = window._aliasModal;
var targetIdx = document.getElementById('modal-target-speaker').value;
htmx.ajax('POST', '/api/speakers/' + m.speakerIdx + '/alias/' + encodeURIComponent(m.alias) + '/move/' + targetIdx, {
target: '#speakers-list',
swap: 'innerHTML'
});
closeAliasModal();
}
function createSpeakerFromAlias() {
var m = window._aliasModal;
htmx.ajax('POST', '/api/speakers/' + m.speakerIdx + '/alias/' + encodeURIComponent(m.alias) + '/create', {
target: '#speakers-list',
swap: 'innerHTML'
});
closeAliasModal();
}
function generateAllVoices() {
var btn = document.getElementById('gen-all-voices-btn');
if (!confirm('Generate voice samples for all speakers? This will temporarily unload LM Studio models.')) return;
btn.disabled = true;
btn.textContent = 'Generating...';
fetch('/api/speakers/generate-all-voices', { method: 'POST' })
.then(r => r.json())
.then(d => {
btn.textContent = 'Started — check logs';
setTimeout(() => { btn.disabled = false; btn.textContent = '🎙 Generate All Voices'; }, 5000);
})
.catch(e => {
btn.disabled = false;
btn.textContent = '🎙 Generate All Voices';
alert('Failed: ' + e);
});
}
// --- Library (multi-select add) ---
function openLibSpeakersModal() {
var modal = document.getElementById('lib-speakers-modal');
modal.style.display = 'flex';
updateLibSpeakersCount();
}
function closeLibSpeakersModal() {
document.getElementById('lib-speakers-modal').style.display = 'none';
document.getElementById('lib-speakers-status').textContent = '';
}
function toggleAllLibSpeakers(el) {
var boxes = document.querySelectorAll('.lib-speaker-cb');
boxes.forEach(function(b) { b.checked = el.checked; });
updateLibSpeakersCount();
}
function updateLibSpeakersCount() {
var boxes = document.querySelectorAll('.lib-speaker-cb');
var n = 0;
boxes.forEach(function(b) { if (b.checked) n++; });
document.getElementById('lib-speakers-count').textContent = n + ' selected';
}
function addSelectedToLib() {
var boxes = document.querySelectorAll('.lib-speaker-cb');
var names = [];
boxes.forEach(function(b) { if (b.checked) names.push(b.value); });
if (names.length === 0) { alert('Select at least one speaker.'); return; }
var status = document.getElementById('lib-speakers-status');
var btn = document.getElementById('lib-add-speakers-btn');
btn.disabled = true;
btn.textContent = 'Adding...';
status.style.color = '#94a3b8';
status.textContent = 'Adding to library...';
fetch('/api/library/speakers', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({speaker_names: names})
}).then(r => r.json()).then(d => {
btn.disabled = false;
btn.textContent = 'Add to Library';
if (d.ok) {
var parts = [];
if (d.added && d.added.length) parts.push('Added: ' + d.added.join(', '));
if (d.already && d.already.length) parts.push('Already in library: ' + d.already.join(', '));
if (d.skipped && d.skipped.length) parts.push('Skipped: ' + d.skipped.join(', '));
status.style.color = '#10b981';
status.textContent = parts.join(' · ') || 'Done';
} else {
status.style.color = '#ef4444';
status.textContent = 'Failed: ' + (d.error || 'unknown');
}
}).catch(e => {
btn.disabled = false;
btn.textContent = 'Add to Library';
status.style.color = '#ef4444';
status.textContent = 'Error: ' + e;
});
}
</script>