Files
UmbraAudio/umbraaudio/web/templates/proper_names.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

424 lines
19 KiB
HTML

{% extends "base.html" %}
{% block content %}
<div class="card">
<h2>Proper Names</h2>
<p style="color:#94a3b8">
All proper nouns extracted from the manuscript. Look up the most common pronunciation,
listen to the generated preview, then confirm it. Only confirmed spellings are used in
speech synthesis.
</p>
<p style="color:#64748b; font-size:0.85rem; margin-top:0.5rem">
<span class="badge badge-yellow">Needs review</span> candidate found, listen and confirm
&nbsp;&nbsp;
<span class="badge badge-green">Confirmed</span> spelling is used in synthesis
</p>
</div>
<div style="display:flex; gap:1rem; margin-bottom:1rem; align-items:center; flex-wrap:wrap">
<input type="text" id="name-filter" placeholder="Filter names..."
oninput="filterNames()"
style="padding:0.5rem; background:#1a1a2e; border:1px solid #333; border-radius:4px; color:#e0e0e0; font-size:0.85rem; width:220px">
<select id="status-filter" onchange="filterNames()"
style="padding:0.5rem; background:#1a1a2e; border:1px solid #333; border-radius:4px; color:#e0e0e0; font-size:0.85rem">
<option value="all">All statuses</option>
<option value="pending">Needs review</option>
<option value="confirmed">Confirmed</option>
<option value="none">No pronunciation</option>
</select>
<span style="color:#94a3b8; font-size:0.85rem" id="name-count">{{ names|length }} names found</span>
<button class="btn" style="background:#0f3460; color:#7c3aed; border:1px solid #7c3aed" onclick="openLibNamesModal()">🕮 Lib</button>
<button class="btn btn-generate" id="auto-lookup-btn" onclick="autoLookupAll()" style="font-size:0.8rem; margin-left:auto">
Look Up All Pronunciations
</button>
</div>
<div id="auto-lookup-status" style="font-size:0.8rem; color:#94a3b8; margin-bottom:0.5rem"></div>
<div id="auto-lookup-progress-wrap" style="display:none; margin-bottom:1rem">
<div class="progress-bar"><div class="progress-fill" id="auto-lookup-progress" style="width:0%"></div></div>
</div>
<table>
<thead>
<tr>
<th>Name</th>
<th>Occurrences</th>
<th>Status</th>
<th>Phonetic Spelling</th>
</tr>
</thead>
<tbody id="names-tbody">
{% for entry in names %}
<tr class="name-row"
data-name="{{ entry.name }}"
data-rec='{{ {'phonetic': entry.phonetic, 'source': entry.source, 'confirmed': entry.confirmed, 'common': entry.common, 'audio_error': entry.audio_error, 'has_audio': entry.has_audio}|tojson }}'
data-status="{% if entry.phonetic and entry.common %}common{% elif entry.phonetic and entry.confirmed %}confirmed{% elif entry.phonetic %}pending{% else %}none{% endif %}">
<td>
<strong>{{ entry.name }}</strong>
{% if entry.is_speaker %}
<span class="badge badge-blue" style="font-size:0.65rem; margin-left:0.3rem">Speaker</span>
{% endif %}
</td>
<td>
<span style="color:#94a3b8; font-size:0.85rem">{{ entry.count }}</span>
</td>
<td>
<span class="badge status-badge"></span>
</td>
<td>
<div class="phonetic-display" onclick="editPhonetic(this)"
style="cursor:pointer; padding:0.25rem; border-radius:3px; min-height:1.5rem" title="Click to edit">
{% if entry.phonetic %}
<code class="phonetic-text" style="color:#f59e0b; font-size:0.85rem">{{ entry.phonetic }}</code>
{% else %}
<small class="phonetic-text" style="color:#64748b; font-style:italic">click to set pronunciation</small>
{% endif %}
</div>
<div class="phonetic-edit" style="display:none; margin-top:0.25rem">
<input type="text" class="phonetic-input"
value="{{ entry.phonetic }}"
placeholder='e.g. "KAYN-dah"'
onkeydown="if(event.key==='Enter')savePhonetic(this); if(event.key==='Escape')cancelPhonetic(this)"
style="width:100%; padding:0.3rem; background:#1a1a2e; border:1px solid #f59e0b; border-radius:3px; color:#e0e0e0; font-size:0.85rem">
<div style="margin-top:0.25rem; display:flex; gap:0.25rem; align-items:center">
<button class="btn btn-approve" style="font-size:0.7rem; padding:0.15rem 0.4rem" onclick="savePhonetic(this)">Save</button>
<button class="btn" style="font-size:0.7rem; padding:0.15rem 0.4rem; background:#374151; color:#9ca3af" onclick="cancelPhonetic(this)">Cancel</button>
</div>
</div>
<div class="audio-wrap" style="margin-top:0.4rem"></div>
<div class="audio-error" style="color:#f87171; font-size:0.7rem; margin-top:0.25rem"></div>
<div style="margin-top:0.4rem; display:flex; gap:0.3rem; align-items:center; flex-wrap:wrap">
<button class="btn btn-lookup" style="font-size:0.7rem; padding:0.15rem 0.45rem; background:#1e3a5f; color:#60a5fa"
onclick="lookupName(this)">
Lookup
</button>
<button class="btn btn-approve btn-confirm" style="font-size:0.7rem; padding:0.15rem 0.45rem; display:none"
onclick="confirmName(this)">
Confirm
</button>
<button class="btn btn-reject btn-clear" style="font-size:0.7rem; padding:0.15rem 0.45rem; display:none"
onclick="clearName(this)">
Clear
</button>
<span class="source-label" style="font-size:0.7rem; color:#64748b"></span>
<span class="action-status" style="font-size:0.7rem; color:#94a3b8"></span>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div id="lib-names-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 Names to Library</h3>
<p style="color:#94a3b8; font-size:0.85rem; margin-bottom:1rem">
Selected proper-name pronunciations 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-names" onchange="toggleAllLibNames(this)"> Select all</label>
<span style="color:#64748b; font-size:0.8rem" id="lib-names-count">0 selected</span>
</div>
<div id="lib-names-list" style="max-height:320px; overflow-y:auto; border:1px solid #333; border-radius:4px; padding:0.5rem">
{% for entry in names %}
<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-name-cb" value="{{ entry.name }}" onchange="updateLibNamesCount()">
<strong>{{ entry.name }}</strong>
{% if entry.phonetic %}<code style="color:#f59e0b; font-size:0.8rem">{{ entry.phonetic }}</code>{% endif %}
{% if entry.common %}<span class="badge badge-blue" style="font-size:0.65rem">common</span>
{% elif entry.confirmed %}<span class="badge badge-green" style="font-size:0.65rem">confirmed</span>
{% else %}<span class="badge badge-yellow" style="font-size:0.65rem">pending</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="closeLibNamesModal()">Cancel</button>
<button class="btn btn-generate" id="lib-add-names-btn" onclick="addNamesToLib()">Add to Library</button>
</div>
<p style="color:#10b981; font-size:0.85rem; margin-top:0.5rem" id="lib-names-status"></p>
</div>
</div>
<script>
function rowOf(el) {
return el.closest('tr');
}
function nameOf(el) {
return rowOf(el).dataset.name;
}
function statusLabel(rec) {
if (!rec || !rec.phonetic) return { text: '', cls: '' };
if (rec.common) return { text: 'Common', cls: 'badge-blue' };
if (rec.confirmed) return { text: 'Confirmed', cls: 'badge-green' };
return { text: 'Needs review', cls: 'badge-yellow' };
}
function sourceLabel(rec) {
if (!rec || !rec.source) return '';
const labels = { dictionaryapi: 'dictionary', wiktionary: 'Wiktionary', user: 'manual', legacy: 'legacy' };
return labels[rec.source] || rec.source;
}
function renderIntoRow(row, rec) {
const badge = row.querySelector('.status-badge');
const label = statusLabel(rec);
badge.textContent = label.text;
badge.className = 'badge status-badge ' + label.cls;
row.dataset.status = label.text === 'Common' ? 'common'
: label.text === 'Confirmed' ? 'confirmed'
: label.text === 'Needs review' ? 'pending'
: 'none';
const dispText = row.querySelector('.phonetic-text');
if (rec && rec.phonetic) {
dispText.outerHTML = '<code class="phonetic-text" style="color:#f59e0b; font-size:0.85rem"></code>';
row.querySelector('.phonetic-text').textContent = rec.phonetic;
} else {
dispText.outerHTML = '<small class="phonetic-text" style="color:#64748b; font-style:italic">click to set pronunciation</small>';
}
row.querySelector('.phonetic-input').value = rec && rec.phonetic ? rec.phonetic : '';
const audioWrap = row.querySelector('.audio-wrap');
const hasAudio = !!(rec && rec.has_audio);
if (hasAudio) {
const el = document.createElement('audio');
el.controls = true;
el.preload = 'none';
el.src = '/api/pronunciations/audio?name=' + encodeURIComponent(rec.name) + '&t=' + Date.now();
audioWrap.innerHTML = '';
audioWrap.appendChild(el);
} else {
audioWrap.innerHTML = '';
}
const errEl = row.querySelector('.audio-error');
errEl.textContent = (rec && rec.audio_error) ? 'Audio failed: ' + rec.audio_error : '';
row.querySelector('.source-label').textContent = sourceLabel(rec);
const confirmBtn = row.querySelector('.btn-confirm');
confirmBtn.style.display = (rec && rec.phonetic && !rec.common && !rec.confirmed) ? '' : 'none';
const clearBtn = row.querySelector('.btn-clear');
clearBtn.style.display = (rec && rec.phonetic) ? '' : 'none';
}
function editPhonetic(el) {
const row = rowOf(el);
row.querySelector('.phonetic-display').style.display = 'none';
row.querySelector('.phonetic-edit').style.display = 'block';
row.querySelector('.phonetic-input').focus();
}
function cancelPhonetic(el) {
const row = rowOf(el);
row.querySelector('.phonetic-edit').style.display = 'none';
row.querySelector('.phonetic-display').style.display = '';
}
function setActionStatus(el, text, color) {
const span = rowOf(el).querySelector('.action-status');
span.textContent = text;
span.style.color = color || '#94a3b8';
}
function savePhonetic(el) {
const row = rowOf(el);
const name = row.dataset.name;
const val = row.querySelector('.phonetic-input').value.trim();
setActionStatus(el, 'Saving audio...');
fetch('/api/phonetic/' + encodeURIComponent(name), {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({phonetic: val})
}).then(r => r.json()).then(data => {
if (data.ok) {
const rec = Object.assign({name: name}, data.record || {});
rec.has_audio = !!(data.audio && data.audio.ok);
renderIntoRow(row, rec);
cancelPhonetic(el);
} else {
setActionStatus(el, 'Failed: ' + (data.error || 'unknown'), '#f87171');
}
}).catch(err => setActionStatus(el, 'Error: ' + err.message, '#f87171'));
}
function clearName(el) {
const row = rowOf(el);
const name = row.dataset.name;
setActionStatus(el, 'Clearing...');
fetch('/api/pronunciations/' + encodeURIComponent(name) + '/clear', {method: 'POST'})
.then(r => r.json()).then(data => {
if (data.ok) {
renderIntoRow(row, {name: name});
} else {
setActionStatus(el, 'Failed: ' + (data.error || 'unknown'), '#f87171');
}
}).catch(err => setActionStatus(el, 'Error: ' + err.message, '#f87171'));
}
function confirmName(el) {
const row = rowOf(el);
const name = row.dataset.name;
setActionStatus(el, 'Confirming...');
fetch('/api/pronunciations/' + encodeURIComponent(name) + '/confirm', {method: 'POST'})
.then(r => r.json()).then(data => {
if (data.ok) {
const rec = Object.assign({name: name}, data.record || {});
rec.has_audio = row.querySelector('.audio-wrap audio') !== null;
renderIntoRow(row, rec);
setActionStatus(el, 'Confirmed', '#10b981');
} else {
setActionStatus(el, 'Failed: ' + (data.error || 'unknown'), '#f87171');
}
}).catch(err => setActionStatus(el, 'Error: ' + err.message, '#f87171'));
}
function lookupName(el) {
const row = rowOf(el);
const name = row.dataset.name;
const btn = el;
btn.disabled = true;
setActionStatus(el, 'Looking up...');
fetch('/api/pronunciations/' + encodeURIComponent(name) + '/lookup', {method: 'POST'})
.then(r => r.json()).then(data => {
btn.disabled = false;
if (data.ok) {
const rec = Object.assign({name: name}, data.record || {});
rec.has_audio = !!(data.audio && data.audio.ok) || row.querySelector('.audio-wrap audio') !== null;
renderIntoRow(row, rec);
const msg = rec.common ? 'Common word, auto-confirmed' : 'Candidate found - listen and confirm';
setActionStatus(el, msg, rec.common ? '#10b981' : '#fbbf24');
} else {
setActionStatus(el, 'Not found: ' + (data.error || 'no pronunciation'), '#f87171');
}
}).catch(err => {
btn.disabled = false;
setActionStatus(el, 'Error: ' + err.message, '#f87171');
});
}
function filterNames() {
const query = document.getElementById('name-filter').value.toLowerCase();
const statusFilter = document.getElementById('status-filter').value;
let visible = 0;
document.querySelectorAll('.name-row').forEach(row => {
const name = row.dataset.name.toLowerCase();
const status = row.dataset.status;
const show = name.includes(query) && (statusFilter === 'all' || status === statusFilter);
row.style.display = show ? '' : 'none';
if (show) visible++;
});
document.getElementById('name-count').textContent = visible + ' names found';
}
function autoLookupAll() {
const btn = document.getElementById('auto-lookup-btn');
const status = document.getElementById('auto-lookup-status');
const wrap = document.getElementById('auto-lookup-progress-wrap');
const fill = document.getElementById('auto-lookup-progress');
btn.disabled = true;
status.textContent = 'Looking up pronunciations...';
status.style.color = '#94a3b8';
wrap.style.display = '';
fetch('/api/auto-pronunciations', {method: 'POST'})
.then(r => r.json())
.then(data => {
if (!data.ok) throw new Error(data.error || 'failed to start');
const timer = setInterval(() => {
fetch('/api/pronunciations/status')
.then(r => r.json())
.then(st => {
if (st.status === 'complete') {
clearInterval(timer);
status.textContent = st.detail || 'Done';
status.style.color = '#10b981';
fill.style.width = '100%';
setTimeout(() => location.reload(), 1500);
} else if (st.status === 'error') {
clearInterval(timer);
btn.disabled = false;
status.textContent = st.detail || 'Lookup failed';
status.style.color = '#ef4444';
} else {
const pct = st.total ? Math.round(100 * st.done / st.total) : 0;
fill.style.width = pct + '%';
status.textContent = st.detail || 'Running...';
}
}).catch(() => {});
}, 1500);
})
.catch(err => {
btn.disabled = false;
status.textContent = 'Error: ' + err.message;
status.style.color = '#ef4444';
});
}
(function initRows() {
document.querySelectorAll('.name-row').forEach(row => {
let rec = {name: row.dataset.name};
try { rec = Object.assign(rec, JSON.parse(row.dataset.rec || '{}')); } catch (e) {}
renderIntoRow(row, rec);
});
})();
// --- Library (multi-select add names) ---
function openLibNamesModal() {
var modal = document.getElementById('lib-names-modal');
modal.style.display = 'flex';
updateLibNamesCount();
}
function closeLibNamesModal() {
document.getElementById('lib-names-modal').style.display = 'none';
document.getElementById('lib-names-status').textContent = '';
}
function toggleAllLibNames(el) {
var boxes = document.querySelectorAll('.lib-name-cb');
boxes.forEach(function(b) { b.checked = el.checked; });
updateLibNamesCount();
}
function updateLibNamesCount() {
var boxes = document.querySelectorAll('.lib-name-cb');
var n = 0;
boxes.forEach(function(b) { if (b.checked) n++; });
document.getElementById('lib-names-count').textContent = n + ' selected';
}
function addNamesToLib() {
var boxes = document.querySelectorAll('.lib-name-cb');
var names = [];
boxes.forEach(function(b) { if (b.checked) names.push(b.value); });
if (names.length === 0) { alert('Select at least one name.'); return; }
var status = document.getElementById('lib-names-status');
var btn = document.getElementById('lib-add-names-btn');
btn.disabled = true;
btn.textContent = 'Adding...';
status.style.color = '#94a3b8';
status.textContent = 'Adding to library...';
fetch('/api/library/names', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({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(', '));
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>
{% endblock %}