- 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
424 lines
19 KiB
HTML
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
|
|
|
|
<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 %}
|