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

618 lines
26 KiB
HTML

{% extends "base.html" %}
{% block content %}
<div class="card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem">
<div>
<h2 style="margin:0">{{ chapter_title }}</h2>
<p style="color:#94a3b8; margin:0.25rem 0">{{ total_utterances }} utterances</p>
</div>
<a href="/chapters-audio" class="btn" style="background:#374151; color:#9ca3af; text-decoration:none">
← Back to Chapters
</a>
</div>
</div>
<div class="card" style="margin-bottom:1rem">
<div style="display:flex; gap:1rem; align-items:center; flex-wrap:wrap">
<button class="btn btn-generate" onclick="playAllUtterances()">
▶ Play All
</button>
<button class="btn" style="background:#374151; color:#9ca3af" onclick="stopAllUtterances()">
■ Stop
</button>
<button class="btn" style="background:#7c3aed; color:#fff" onclick="buildChapter()" id="buildBtn">
📦 Build Chapter
</button>
<button class="btn" style="background:#059669; color:#fff{% if not has_chapter_audio %}; display:none{% endif %}" id="playChapterBtn" onclick="playChapterAudio()">
▶ Play Chapter
</button>
<button class="btn" style="background:#dc2626; color:#fff; display:none" id="stopChapterBtn" onclick="stopChapterAudio()">
■ Stop Chapter
</button>
<a href="/api/chapter/{{ chapter_id }}/download-wav"
class="btn" style="background:#2563eb; color:#fff; text-decoration:none{% if not has_chapter_audio %}; display:none{% endif %}"
id="downloadWavBtn" download>
⬇ WAV
</a>
<a href="/api/chapter/{{ chapter_id }}/download-mp3"
class="btn" style="background:#7c3aed; color:#fff; text-decoration:none{% if not has_chapter_audio %}; display:none{% endif %}"
id="downloadMp3Btn" download>
⬇ MP3
</a>
<div id="buildStatus" style="display:none; background: rgba(0,191,255,0.1); border: 1px solid rgba(0,191,255,0.3); border-radius: 6px; padding: 8px 15px; font-size: 0.9em;">
<span id="buildStatusText">Building...</span>
<span id="buildProgress" style="color: #00bfff; margin-left: 8px;"></span>
</div>
<span style="color:#64748b; font-size:0.9rem" id="playback-status">Ready</span>
</div>
</div>
{% if chapter_id|string == '1' %}
<div class="card" style="margin-bottom:1rem; border-left:3px solid #f59e0b">
<h3 style="color:#f59e0b; margin-bottom:0.5rem">Personalization</h3>
<p style="color:#94a3b8; font-size:0.85rem; margin-bottom:0.75rem">
Add a personalization watermark to the title page. This line is spoken at the end for tracing.
</p>
<div style="display:flex; gap:0.5rem; align-items:center">
<input type="text" id="personalization-text"
value="This audiobook was generated for a special listener."
style="flex:1; padding:0.5rem; background:#1a1a2e; border:1px solid #333; border-radius:4px; color:#e0e0e0">
<button class="btn btn-generate" onclick="savePersonalization()">Save</button>
</div>
<p style="color:#64748b; font-size:0.75rem; margin-top:0.5rem" id="personalization-status"></p>
</div>
{% endif %}
<table>
<thead>
<tr>
<th>#</th>
<th>Speaker</th>
<th style="min-width:200px">Variation</th>
<th>Text</th>
<th>Audio</th>
</tr>
</thead>
<tbody>
{% for audio in audio_files %}
<tr id="utterance-{{ audio.index }}">
<td>
<small style="color:#64748b">{{ audio.index }}</small>
</td>
<td>
<select id="speaker-{{ audio.index }}" onchange="updateSpeaker({{ audio.utt_id }}, {{ audio.index }})"
style="padding:0.25rem; background:#1a1a2e; border:1px solid #333; border-radius:3px; color:#e0e0e0; font-size:0.8rem; max-width:180px">
{% for name in speaker_names %}
<option value="{{ name }}" {% if name == audio.speaker %}selected{% endif %}>{{ name }}</option>
{% endfor %}
</select>
</td>
<td>
<div style="display:flex; flex-direction:column; gap:0.3rem">
<div style="display:flex; gap:0.3rem; align-items:center">
<select id="emotion-{{ audio.index }}"
style="padding:0.2rem; background:#1a1a2e; border:1px solid #333; border-radius:3px; color:#e0e0e0; font-size:0.75rem; flex:1">
{% for e in ["neutral","angry","sad","happy","fearful","surprised","whisper","pleading","sarcastic","authoritative","thoughtful","excited","anxious","tired","romantic","disgusted","amused","nostalgic"] %}
<option value="{{ e }}" {% if e == audio.emotion %}selected{% endif %}>{{ e }}</option>
{% endfor %}
</select>
<select id="pace-{{ audio.index }}"
style="padding:0.2rem; background:#1a1a2e; border:1px solid #333; border-radius:3px; color:#e0e0e0; font-size:0.75rem; width:90px">
{% for p in ["slow","moderate","fast"] %}
<option value="{{ p }}" {% if p == audio.pace %}selected{% endif %}>{{ p }}</option>
{% endfor %}
</select>
</div>
<input type="text" id="instruct-{{ audio.index }}"
value="{{ audio.instruct }}"
placeholder="Custom instruction (optional)..."
style="padding:0.2rem; background:#1a1a2e; border:1px solid #333; border-radius:3px; color:#e0e0e0; font-size:0.75rem; width:100%">
<button id="save-btn-{{ audio.index }}" class="btn btn-approve" style="font-size:0.65rem; padding:0.1rem 0.4rem; width:fit-content"
onclick="saveVariation({{ audio.utt_id }}, {{ audio.index }})">
Save Variation
</button>
</div>
</td>
<td>
<div id="text-display-{{ audio.index }}" onclick="editText({{ audio.index }})" style="cursor:pointer; padding:0.2rem; border-radius:3px" title="Click to edit">
<small style="color:#94a3b8">{{ audio.text }}</small>
</div>
<div id="text-edit-{{ audio.index }}" style="display:none">
<textarea id="text-input-{{ audio.index }}" rows="2"
style="width:100%; padding:0.3rem; background:#1a1a2e; border:1px solid #7c3aed; border-radius:3px; color:#e0e0e0; font-size:0.8rem">{{ audio.full_text }}</textarea>
<div style="margin-top:0.25rem; display:flex; gap:0.25rem">
<button class="btn btn-approve" style="font-size:0.7rem; padding:0.15rem 0.4rem" onclick="saveText({{ audio.utt_id }}, {{ audio.index }})">Save</button>
<button class="btn" style="font-size:0.7rem; padding:0.15rem 0.4rem; background:#374151; color:#9ca3af" onclick="cancelEditText({{ audio.index }})">Cancel</button>
</div>
</div>
</td>
<td style="white-space:nowrap">
{% if audio.filename %}
<button class="btn btn-generate" style="font-size:0.8rem; padding:0.3rem 0.6rem"
onclick="playUtterance('{{ chapter_dir }}', '{{ audio.filename }}', {{ audio.index }})">
▶
</button>
<audio id="audio-{{ audio.index }}" style="height:32px; width:150px; margin-left:0.3rem">
<source src="/chapter-audio-file/{{ chapter_dir }}/{{ audio.filename }}" type="audio/wav">
</audio>
{% else %}
<span style="color:#64748b; font-size:0.75rem" id="audio-status-{{ audio.index }}">No audio</span>
{% endif %}
<button class="btn btn-generate" style="font-size:0.7rem; padding:0.2rem 0.5rem; margin-left:0.3rem"
id="gen-btn-{{ audio.index }}"
onclick="generateUtterance({{ audio.utt_id }}, {{ audio.index }})">
{% if audio.filename %}⟳{% else %}⚡{% endif %}
</button>
<button class="btn btn-reject" style="font-size:0.7rem; padding:0.2rem 0.5rem; margin-left:0.3rem"
onclick="deleteUtterance({{ audio.utt_id }}, {{ audio.index }})">
✕
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="card" style="margin-top:1rem">
<h3 style="margin-bottom:0.5rem">Add Utterance</h3>
<div style="display:flex; gap:0.5rem; align-items:flex-end">
<div style="flex:1">
<label style="color:#94a3b8; font-size:0.8rem; display:block; margin-bottom:0.25rem">Speaker</label>
<select id="new-utterance-speaker"
style="width:100%; padding:0.4rem; background:#1a1a2e; border:1px solid #333; border-radius:4px; color:#e0e0e0; font-size:0.85rem">
{% for name in speaker_names %}
<option value="{{ name }}" {% if name == 'Narrator' %}selected{% endif %}>{{ name }}</option>
{% endfor %}
</select>
</div>
<div style="flex:3">
<label style="color:#94a3b8; font-size:0.8rem; display:block; margin-bottom:0.25rem">Text</label>
<input type="text" id="new-utterance-text" placeholder="Enter utterance text..."
style="width:100%; padding:0.4rem; background:#1a1a2e; border:1px solid #333; border-radius:4px; color:#e0e0e0; font-size:0.85rem">
</div>
<button class="btn btn-generate" onclick="addUtterance()" style="white-space:nowrap">Add</button>
</div>
<p style="color:#64748b; font-size:0.75rem; margin-top:0.5rem" id="add-utterance-status"></p>
</div>
<script>
let currentAudio = null;
let playAllStopped = false;
let playAllIndex = -1;
function updateSpeaker(uttId, index) {
const select = document.getElementById('speaker-' + index);
const newSpeaker = select.value;
fetch('/api/utterance/' + uttId + '/speaker', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({speaker_name: newSpeaker})
}).then(r => r.json()).then(data => {
if (data.ok) { location.reload(); }
else { alert('Failed to update speaker'); }
});
}
function saveVariation(uttId, index) {
const emotion = document.getElementById('emotion-' + index).value;
const pace = document.getElementById('pace-' + index).value;
const instruct = document.getElementById('instruct-' + index).value;
const btn = document.getElementById('save-btn-' + index);
if (btn) {
btn.textContent = '...';
btn.disabled = true;
}
fetch('/api/utterance/' + uttId + '/variation', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({emotion: emotion, pace: pace, instruct: instruct})
}).then(r => r.json()).then(data => {
if (btn) {
if (data.ok) {
btn.textContent = '✓ Saved';
btn.style.background = '#10b981';
setTimeout(() => { btn.textContent = 'Save Variation'; btn.style.background = ''; btn.disabled = false; }, 1500);
} else {
btn.textContent = '✗ Failed';
btn.style.background = '#ef4444';
setTimeout(() => { btn.textContent = 'Save Variation'; btn.style.background = ''; btn.disabled = false; }, 2000);
}
}
}).catch(() => {
if (btn) {
btn.textContent = '✗ Error';
btn.style.background = '#ef4444';
setTimeout(() => { btn.textContent = 'Save Variation'; btn.style.background = ''; btn.disabled = false; }, 2000);
}
});
}
function showGenPopup(message) {
let el = document.getElementById('gen-popup');
if (!el) {
el = document.createElement('div');
el.id = 'gen-popup';
el.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.7);z-index:9999;display:flex;align-items:center;justify-content:center;';
el.innerHTML = '<div style="background:#1a1a2e;border:1px solid #7c3aed;border-radius:8px;padding:2rem;text-align:center;color:#e0e0e0;min-width:260px">'
+ '<div id="gen-popup-spinner" style="border:4px solid #333;border-top:4px solid #7c3aed;border-radius:50%;width:40px;height:40px;margin:0 auto 1rem;animation:spin 1s linear infinite"></div>'
+ '<p id="gen-popup-text" style="margin:0;font-size:1rem;color:#fff"></p>'
+ '<small style="color:#94a3b8;display:block;margin-top:0.5rem">You can listen once this closes</small>'
+ '</div>';
if (!document.getElementById('gen-popup-style')) {
const st = document.createElement('style');
st.id = 'gen-popup-style';
st.textContent = '@keyframes spin { to { transform: rotate(360deg); } }';
document.head.appendChild(st);
}
document.body.appendChild(el);
}
document.getElementById('gen-popup-text').textContent = message || 'Regenerating...';
el.style.display = 'flex';
}
function hideGenPopup() {
const el = document.getElementById('gen-popup');
if (el) { el.style.display = 'none'; }
}
function generateUtterance(uttId, index) {
const btn = document.getElementById('gen-btn-' + index);
const origText = btn.textContent;
btn.disabled = true;
btn.textContent = '...';
showGenPopup('Regenerating...');
// Persist any pending text edit before generating so the wording change
// is actually used. If the edit box is open, its value may differ from
// what's stored in the DB — flush it first.
const textEdit = document.getElementById('text-edit-' + index);
const textInput = document.getElementById('text-input-' + index);
const maybeSaveText = () => {
if (textEdit && textEdit.style.display !== 'none' && textInput) {
const newText = textInput.value;
return fetch('/api/utterance/' + uttId + '/text', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({text: newText})
}).then(r => r.json()).then(data => {
if (!data.ok) { throw new Error('Failed to save text: ' + (data.error || 'unknown')); }
});
}
return Promise.resolve();
};
maybeSaveText().then(() => {
return fetch('/api/utterance/' + uttId + '/generate', { method: 'POST' });
}).then(r => r.json()).then(data => {
if (data.ok) {
btn.textContent = '⏳';
btn.title = 'Generating...';
pollGeneration(uttId, index, data.filename);
} else {
btn.disabled = false;
btn.textContent = origText;
hideGenPopup();
alert('Failed: ' + (data.error || 'unknown'));
}
}).catch((err) => {
btn.disabled = false;
btn.textContent = origText;
hideGenPopup();
alert('Error: ' + (err.message || 'unknown'));
});
}
function pollGeneration(uttId, index, filename) {
const btn = document.getElementById('gen-btn-' + index);
let elapsed = 0;
const checkInterval = setInterval(() => {
elapsed += 2000;
fetch('/chapter-audio-file/{{ chapter_dir }}/' + filename, { method: 'HEAD' })
.then(r => {
if (r.ok) {
clearInterval(checkInterval);
hideGenPopup();
location.reload();
}
}).catch(() => {});
}, 2000);
setTimeout(() => {
clearInterval(checkInterval);
if (btn.textContent.includes('⏳')) {
btn.disabled = false;
btn.textContent = '⟳';
btn.title = 'Generation may still be in progress';
hideGenPopup();
}
}, 180000);
}
function editText(index) {
document.getElementById('text-display-' + index).style.display = 'none';
document.getElementById('text-edit-' + index).style.display = 'block';
document.getElementById('text-input-' + index).focus();
}
function cancelEditText(index) {
document.getElementById('text-display-' + index).style.display = 'block';
document.getElementById('text-edit-' + index).style.display = 'none';
}
function saveText(uttId, index) {
const newText = document.getElementById('text-input-' + index).value;
fetch('/api/utterance/' + uttId + '/text', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({text: newText})
}).then(r => r.json()).then(data => {
if (data.ok) { location.reload(); }
else { alert('Failed to update text'); }
});
}
function deleteUtterance(uttId, index) {
if (!confirm('Delete this utterance? This will also delete its audio and regenerate the chapter.')) return;
fetch('/api/utterance/' + uttId, { method: 'DELETE' })
.then(r => r.json()).then(data => {
if (data.ok) { location.reload(); }
else { alert('Failed to delete'); }
});
}
function addUtterance() {
const speaker = document.getElementById('new-utterance-speaker').value;
const text = document.getElementById('new-utterance-text').value.trim();
if (!text) { alert('Enter text first'); return; }
const status = document.getElementById('add-utterance-status');
status.textContent = 'Adding...';
status.style.color = '#94a3b8';
fetch('/api/chapter/{{ chapter_id }}/utterance', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({text: text, speaker_name: speaker})
}).then(r => r.json()).then(data => {
if (data.ok) {
status.textContent = 'Added! Reloading...';
status.style.color = '#10b981';
location.reload();
} else {
status.textContent = 'Failed: ' + (data.error || 'unknown');
status.style.color = '#ef4444';
}
});
}
{% if chapter_id|string == '1' %}
function savePersonalization() {
const text = document.getElementById('personalization-text').value;
const status = document.getElementById('personalization-status');
status.textContent = 'Adding...';
status.style.color = '#94a3b8';
fetch('/api/chapter/1/utterance', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({text: text, speaker_name: 'Narrator'})
}).then(r => r.json()).then(data => {
if (data.ok) {
status.textContent = 'Added! Reloading...';
status.style.color = '#10b981';
location.reload();
} else {
status.textContent = 'Failed: ' + (data.error || 'unknown');
status.style.color = '#ef4444';
}
});
}
{% endif %}
function playUtterance(chapterDir, filename, index) {
if (currentAudio) {
currentAudio.pause();
currentAudio.currentTime = 0;
}
playAllStopped = true;
const audio = document.getElementById('audio-' + index);
if (audio) {
currentAudio = audio;
audio.play();
document.querySelectorAll('tbody tr').forEach(r => r.style.background = '');
document.getElementById('utterance-' + index).style.background = '#1e293b';
}
}
function playAllUtterances() {
playAllStopped = false;
const rows = Array.from(document.querySelectorAll('tbody tr'));
if (rows.length === 0) return;
playAllIndex = 0;
document.getElementById('playback-status').textContent = 'Playing...';
document.querySelectorAll('tbody tr').forEach(r => r.style.background = '');
playNext(rows);
}
function playNext(rows) {
if (playAllStopped || playAllIndex >= rows.length) {
if (!playAllStopped) {
document.getElementById('playback-status').textContent = 'Complete';
}
return;
}
const row = rows[playAllIndex];
const index = row.id.replace('utterance-', '');
const audio = document.getElementById('audio-' + index);
if (currentAudio) {
currentAudio.pause();
currentAudio.currentTime = 0;
}
if (audio) {
currentAudio = audio;
row.style.background = '#1e293b';
document.getElementById('playback-status').textContent = 'Playing ' + (playAllIndex + 1) + '/' + rows.length + '...';
audio.onended = () => {
row.style.background = '';
playAllIndex++;
playNext(rows);
};
audio.play();
} else {
playAllIndex++;
playNext(rows);
}
}
function stopAllUtterances() {
playAllStopped = true;
if (currentAudio) {
currentAudio.pause();
currentAudio.currentTime = 0;
}
document.querySelectorAll('tbody tr').forEach(r => r.style.background = '');
document.getElementById('playback-status').textContent = 'Stopped';
}
function playChapterAudio() {
if (chapterAudio) {
chapterAudio.pause();
chapterAudio.currentTime = 0;
}
chapterAudio = new Audio('/api/chapter/{{ chapter_id }}/full-audio');
document.getElementById('playback-status').textContent = 'Playing chapter...';
document.getElementById('playChapterBtn').style.display = 'none';
document.getElementById('stopChapterBtn').style.display = 'inline-block';
chapterAudio.onended = () => {
document.getElementById('playback-status').textContent = 'Chapter complete';
document.getElementById('playChapterBtn').style.display = 'inline-block';
document.getElementById('stopChapterBtn').style.display = 'none';
};
chapterAudio.onerror = () => {
document.getElementById('playback-status').textContent = 'Error playing chapter';
document.getElementById('playChapterBtn').style.display = 'inline-block';
document.getElementById('stopChapterBtn').style.display = 'none';
};
chapterAudio.play();
}
function stopChapterAudio() {
if (chapterAudio) {
chapterAudio.pause();
chapterAudio.currentTime = 0;
chapterAudio = null;
}
document.getElementById('playback-status').textContent = 'Stopped';
document.getElementById('playChapterBtn').style.display = 'inline-block';
document.getElementById('stopChapterBtn').style.display = 'none';
}
let buildPollTimer = null;
let chapterAudio = null;
function showChapterAudioButtons(show) {
const display = show ? 'inline-block' : 'none';
const playBtn = document.getElementById('playChapterBtn');
const wavBtn = document.getElementById('downloadWavBtn');
const mp3Btn = document.getElementById('downloadMp3Btn');
if (playBtn) playBtn.style.display = display;
if (wavBtn) wavBtn.style.display = display;
if (mp3Btn) mp3Btn.style.display = display;
}
function buildChapter() {
const btn = document.getElementById('buildBtn');
const statusDiv = document.getElementById('buildStatus');
const statusText = document.getElementById('buildStatusText');
const progressText = document.getElementById('buildProgress');
btn.disabled = true;
btn.textContent = '⏳ Building...';
statusDiv.style.display = 'inline-block';
statusText.textContent = 'Starting build...';
progressText.textContent = '';
fetch('/api/chapter/{{ chapter_id }}/build', { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.ok) {
pollBuildStatus();
} else {
btn.disabled = false;
btn.textContent = '📦 Build Chapter';
statusText.textContent = 'Build failed: ' + (data.error || 'unknown');
}
}).catch(() => {
btn.disabled = false;
btn.textContent = '📦 Build Chapter';
statusText.textContent = 'Request failed';
});
}
function pollBuildStatus() {
if (buildPollTimer) clearInterval(buildPollTimer);
buildPollTimer = setInterval(() => {
fetch('/api/chapter/{{ chapter_id }}/build-status')
.then(r => r.json())
.then(data => {
const statusText = document.getElementById('buildStatusText');
const progressText = document.getElementById('buildProgress');
const btn = document.getElementById('buildBtn');
const phase = data.phase || data.status || 'idle';
const detail = data.detail || '';
const elapsed = data.elapsed || '';
const failed = data.failed || [];
const errors = data.errors || [];
let label = detail || phase;
if (elapsed) label += ' (' + elapsed + ')';
if (failed.length > 0) label += ' [' + failed.length + ' failed]';
statusText.textContent = label;
if (data.progress > 0) {
progressText.textContent = data.progress + '%';
}
if (phase === 'complete' || phase === 'warning') {
clearInterval(buildPollTimer);
buildPollTimer = null;
btn.disabled = false;
btn.textContent = '📦 Build Chapter';
progressText.textContent = phase === 'complete' ? '✓ Complete' : '⚠ Warnings';
showChapterAudioButtons(true);
} else if (phase === 'error') {
clearInterval(buildPollTimer);
buildPollTimer = null;
btn.disabled = false;
btn.textContent = '📦 Build Chapter';
progressText.textContent = '✗ Failed';
if (errors.length > 0) {
statusText.textContent += '\n' + errors.join('; ');
}
}
}).catch(() => {});
}, 2000);
setTimeout(() => {
if (buildPollTimer) {
clearInterval(buildPollTimer);
buildPollTimer = null;
const btn = document.getElementById('buildBtn');
btn.disabled = false;
btn.textContent = '📦 Build Chapter';
document.getElementById('buildStatusText').textContent = 'Timed out';
}
}, 300000);
}
</script>
{% endblock %}