The review UI's language dropdown did nothing: every selection kept rendering the English text. Two independent causes, both fixed. 1. The translations were never being read. The pipeline's `utterance_translations` table holds one row in Deal Hunter, and that row stores the *English* text under a `zh-Hans` code. The real translations live in the on-disk sample files under `samples/translations/`. `umbraaudio/translations.py` now resolves both sources and merges them by (chapter_id, utterance_index): the table wins where it holds a real translation, the sample files fill the gaps. A candidate whose text is identical to the source is a placeholder, not a translation, so it is ignored - otherwise the page would show English under a Chinese heading and claim it was translated. 2. The query that read them was broken. `language_routes.py` selected `u.index` and `u.speaker`; the columns are `utterance_index` and `speaker_name`, so every read raised `sqlite3.OperationalError: near "index": syntax error` and `/language-mockup` returned 500. It also opened `<project>/project.db` - a 0-byte decoy - instead of `<project>/.umbra/project.db`, the file that actually holds the data. Also in this commit: * The chapter review page honours `?lang=`, remembers the choice in a cookie (set by a middleware, so it survives on every page), and shows the selected language's *name* in the "has not been translated yet" message. * The page now shows the translation, not the source, and the edit box is bound to the source text so saving cannot overwrite the English with the Chinese. * The stray language selector that sat after `</html>` in base.html is gone; the chapters-list page's dropdown had zero options because the route never passed the language list. * The language tag is wired into generation: `utterance.language_code` (NULL = project source language) reaches `engine.generate()` and the staleness marker, so a retagged line is re-rendered instead of keeping English phonology behind an unchanged text. The marker records the language; a marker written before the tag existed means English, so the ~4,300 existing clips are not invalidated. * `project.source_language` (spec 31 section 13.1) records the book's own language, with a migration for existing project DBs. * A scene-break marker deliberately records no language: it is silence, identical in every language, and tagging it would claim a phonology the audio does not have. Tests: 883 passed / 8 failed - the same 8 pre-existing failures (2 test_pronunciation, 6 voice_profile_manager accent). 26 new tests cover the resolver and the generation wiring. ruff introduces no new findings.
901 lines
42 KiB
HTML
901 lines
42 KiB
HTML
{% extends "base.html" %}
|
|
{% block header_extra %}
|
|
{# Spec 30: language selector beside Active project - shows per-language progress #}
|
|
<div style="display:flex; align-items:center; gap:0.75rem; margin:-1rem 0 1.5rem; padding:0.6rem 0.9rem; background:#16213e; border:1px solid #333; border-radius:8px;">
|
|
<label for="lang-select" style="color:#94a3b8; font-size:0.9rem; white-space:nowrap;">
|
|
Content language:
|
|
</label>
|
|
<select id="lang-select"
|
|
onchange="window.location.href = '/chapters-audio/{{ chapter_dir }}?lang=' + this.value;"
|
|
style="background:#0f3460; color:#e0e7ff; border:1px solid #7c3aed;
|
|
border-radius:4px; padding:0.4rem 0.75rem; font-size:0.95rem;
|
|
font-weight:600; cursor:pointer; min-width:10rem;">
|
|
{% for code, name in languages %}
|
|
<option value="{{ code }}" {% if code == active_language %}selected{% endif %}>{{ name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
<span style="color:#64748b; font-size:0.8rem;">
|
|
Applies to the current chapter — navigation, buttons and statuses stay English.
|
|
</span>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="card">
|
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem">
|
|
<div>
|
|
<div style="display:flex; gap:0.5rem; align-items:center; flex-wrap:wrap">
|
|
<input type="text" id="chapter-title-input" value="{{ chapter_title }}"
|
|
onkeydown="if(event.key === 'Enter'){event.preventDefault(); saveChapterTitle();}"
|
|
style="font-size:1.5rem; font-weight:bold; background:#1a1a2e; border:1px solid #333; border-radius:4px; color:#e0e0e0; padding:0.25rem 0.5rem; min-width:320px">
|
|
<button class="btn btn-approve" onclick="saveChapterTitle()">Save Title</button>
|
|
<button class="btn" style="background:#374151; color:#9ca3af" onclick="useFirstUtteranceTitle()"
|
|
title="Copy the first utterance's text into the title field">Use 1st Utterance</button>
|
|
<span id="chapter-title-status" style="color:#64748b; font-size:0.8rem"></span>
|
|
</div>
|
|
<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 blank_count %}
|
|
<div class="card" style="margin-bottom:1rem; border-left:3px solid #dc2626">
|
|
<h3 style="color:#dc2626; margin:0">⚠ {{ blank_count }} utterance{% if blank_count != 1 %}s{% endif %} with no text — flagged for review</h3>
|
|
<p style="color:#94a3b8; font-size:0.85rem; margin:0.25rem 0 0">Blank utterances are skipped at build time and their stray audio is no longer stitched into the chapter. Restore the text or delete the row (✕) to clear the flag.</p>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% 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 }}"{% if audio.is_blank %} style="background:rgba(220,38,38,0.08); outline:1px solid #dc2626"{% endif %}>
|
|
<td style="text-align:center; vertical-align:top">
|
|
<button class="btn" title="Move up" style="font-size:0.65rem; padding:0.05rem 0.35rem; display:block; margin:0 auto 0.15rem"
|
|
onclick="moveUtterance({{ audio.utt_id }}, 1)">↑</button>
|
|
<small style="color:#64748b">{{ audio.index }}</small>
|
|
<button class="btn" title="Move down" style="font-size:0.65rem; padding:0.05rem 0.35rem; display:block; margin:0.15rem auto 0"
|
|
onclick="moveUtterance({{ audio.utt_id }}, 0)">↓</button>
|
|
</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","tense","urgent","horror","furious","joyful","terrified","griefstricken","shocked"] %}
|
|
<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%">
|
|
<div style="display:flex; gap:0.3rem; align-items:center; flex-wrap:wrap">
|
|
<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>
|
|
{% if audio.qa_issues %}
|
|
<div id="qa-box-{{ audio.index }}" class="qa-box"
|
|
style="{% if audio.qa_accepted %}display:none{% endif %}">
|
|
<div class="qa-badge-wrap" tabindex="0" aria-label="QA issues">
|
|
<span class="qa-badge">⚠</span>
|
|
<div class="qa-tooltip">
|
|
<div class="qa-tl-head">QA flag — fix then Recheck, or Accept as-is:</div>
|
|
{% for iss in audio.qa_issues %}
|
|
<div class="qa-tl-item">
|
|
<b>[{{ iss.type }}]</b>
|
|
{% if iss.expected %} expected <i>{{ iss.expected }}</i>{% endif %}
|
|
(conf {{ "%.2f"|format(iss.confidence) }})<br>
|
|
{% if iss.evidence %}<span class="qa-tl-ev">{{ iss.evidence }}</span><br>{% endif %}
|
|
{% if iss.text %}<i>“{{ iss.text }}”</i>{% endif %}
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
<button id="qa-recheck-{{ audio.index }}" class="btn qa-btn qa-recheck"
|
|
onclick="recheckUtterance({{ audio.utt_id }}, {{ audio.index }})">↻ Recheck</button>
|
|
<button id="qa-accept-{{ audio.index }}" class="btn qa-btn qa-accept"
|
|
onclick="acceptUtterance({{ audio.utt_id }}, {{ audio.index }})">✓ Accept</button>
|
|
</div>
|
|
<span id="qa-accepted-{{ audio.index }}" class="qa-accepted-badge"
|
|
style="{% if not audio.qa_accepted %}display:none{% endif %}"
|
|
title="Accepted as-is — the box reappears if the speaker/text changes or a recheck finds issues">✓ Accepted</span>
|
|
{% endif %}
|
|
<span id="qa-status-{{ audio.index }}" style="color:#64748b; font-size:0.7rem"></span>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
{% if audio.is_blank %}<span class="badge badge-red" style="color:#ef4444">⚠ no text</span>{% endif %}
|
|
<div id="text-display-{{ audio.index }}" onclick="editText({{ audio.index }})" style="cursor:pointer; padding:0.2rem; border-radius:3px" title="Click to edit the source text">
|
|
{% if active_language != 'en' %}
|
|
{% if audio.has_translation %}
|
|
<small style="color:#e0e0e0">{{ audio.display_text }}</small>
|
|
<span style="color:#22c55e; font-size:0.7rem; margin-left:0.5rem">✓ {{ active_language_name }}</span>
|
|
{% else %}
|
|
<small style="color:#94a3b8">{{ audio.source_text or '(click to add text)' }}</small>
|
|
<span style="color:#f59e0b; font-size:0.7rem; margin-left:0.5rem">⚠ {{ active_language_name }} has not been translated yet</span>
|
|
{% endif %}
|
|
{% else %}
|
|
<small style="color:#94a3b8">{{ audio.source_text or '(click to add text)' }}</small>
|
|
{% endif %}
|
|
</div>
|
|
<div id="text-edit-{{ audio.index }}" style="display:none">
|
|
{% if audio.has_translation %}
|
|
<small style="color:#64748b; font-size:0.7rem; display:block; margin-bottom:0.2rem">
|
|
Source text ({{ active_language_name }} shown above — editing changes the source, not the translation)
|
|
</small>
|
|
{% endif %}
|
|
<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.source_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/{{ active_project(request).slug }}/{{ 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>
|
|
<div>
|
|
<label style="color:#94a3b8; font-size:0.8rem; display:block; margin-bottom:0.25rem">Insert before #</label>
|
|
<input type="number" id="new-utterance-index" min="0" placeholder="end"
|
|
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;
|
|
|
|
var FIRST_UTT_TEXT = {{ (audio_files[0].full_text if audio_files else '')|tojson }};
|
|
|
|
function saveChapterTitle() {
|
|
const input = document.getElementById('chapter-title-input');
|
|
const status = document.getElementById('chapter-title-status');
|
|
const title = input.value.trim();
|
|
status.textContent = 'Saving...';
|
|
status.style.color = '#94a3b8';
|
|
fetch('/api/chapter/{{ chapter_id }}/title', {
|
|
method: 'PUT',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({title: title})
|
|
}).then(r => r.json()).then(data => {
|
|
if (data.ok) {
|
|
status.textContent = '✓ Saved';
|
|
status.style.color = '#10b981';
|
|
setTimeout(() => { status.textContent = ''; }, 2000);
|
|
} else {
|
|
status.textContent = 'Failed: ' + (data.error || 'unknown');
|
|
status.style.color = '#ef4444';
|
|
}
|
|
}).catch(() => {
|
|
status.textContent = 'Failed: request error';
|
|
status.style.color = '#ef4444';
|
|
});
|
|
}
|
|
|
|
function useFirstUtteranceTitle() {
|
|
if (!FIRST_UTT_TEXT) { return; }
|
|
const input = document.getElementById('chapter-title-input');
|
|
input.value = FIRST_UTT_TEXT.length > 80 ? FIRST_UTT_TEXT.slice(0, 80) : FIRST_UTT_TEXT;
|
|
saveChapterTitle();
|
|
}
|
|
|
|
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 escHtml(s) {
|
|
const d = document.createElement('div');
|
|
d.textContent = String(s == null ? '' : s);
|
|
return d.innerHTML;
|
|
}
|
|
|
|
function qaTooltipHtml(issues) {
|
|
let html = '<div class="qa-tl-head">QA flag — fix then Recheck, or Accept as-is:</div>';
|
|
for (const iss of issues) {
|
|
html += '<div class="qa-tl-item"><b>[' + escHtml(iss.type) + ']</b>';
|
|
if (iss.expected) html += ' expected <i>' + escHtml(iss.expected) + '</i>';
|
|
html += ' (conf ' + (iss.confidence != null ? Number(iss.confidence).toFixed(2) : '?') + ')<br>';
|
|
if (iss.evidence) html += '<span class="qa-tl-ev">' + escHtml(iss.evidence) + '</span><br>';
|
|
if (iss.text) html += '<i>“' + escHtml(iss.text) + '”</i>';
|
|
html += '</div>';
|
|
}
|
|
return html;
|
|
}
|
|
|
|
function recheckUtterance(uttId, index) {
|
|
const btn = document.getElementById('qa-recheck-' + index);
|
|
const status = document.getElementById('qa-status-' + index);
|
|
const box = document.getElementById('qa-box-' + index);
|
|
if (btn) { btn.disabled = true; btn.textContent = '…'; }
|
|
if (status) { status.textContent = 'Rechecking…'; status.style.color = '#f59e0b'; }
|
|
|
|
// Flush any open text edit so the check sees the current wording.
|
|
const textEdit = document.getElementById('text-edit-' + index);
|
|
const textInput = document.getElementById('text-input-' + index);
|
|
const flush = (textEdit && textEdit.style.display !== 'none' && textInput)
|
|
? fetch('/api/utterance/' + uttId + '/text', {
|
|
method: 'PUT',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({text: textInput.value})
|
|
}).then(r => r.json()).then(d => { if (!d.ok) { throw new Error('save text failed'); } })
|
|
: Promise.resolve();
|
|
|
|
flush.then(() => fetch('/api/utterance/' + uttId + '/recheck', { method: 'POST' }))
|
|
.then(r => r.json()).then(data => {
|
|
if (!data.ok) { throw new Error(data.error || 'recheck failed'); }
|
|
// Apply any auto-fixed speaker so the dropdown matches the DB.
|
|
const fix = (data.fixed || []).find(f => f.utt_id === uttId);
|
|
if (fix) {
|
|
const sel = document.getElementById('speaker-' + index);
|
|
if (sel) {
|
|
const esc = fix.to.replace(/"/g, '\\"');
|
|
let opt = sel.querySelector('option[value="' + esc + '"]');
|
|
if (!opt) {
|
|
opt = document.createElement('option');
|
|
opt.value = fix.to;
|
|
opt.textContent = fix.to;
|
|
sel.appendChild(opt);
|
|
}
|
|
sel.value = fix.to;
|
|
}
|
|
}
|
|
if (box) {
|
|
if (data.issues && data.issues.length > 0) {
|
|
const tip = box.querySelector('.qa-tooltip');
|
|
if (tip) { tip.innerHTML = qaTooltipHtml(data.issues); }
|
|
box.style.display = 'inline-flex';
|
|
} else {
|
|
box.style.display = 'none';
|
|
const accBadge = document.getElementById('qa-accepted-' + index);
|
|
if (accBadge) { accBadge.style.display = 'inline-flex'; }
|
|
}
|
|
}
|
|
if (status) {
|
|
if (data.clean) {
|
|
status.textContent = '✓ No issues';
|
|
status.style.color = '#10b981';
|
|
} else if (fix) {
|
|
status.textContent = '✓ Auto-fixed to ' + fix.to + ' — still flagged';
|
|
status.style.color = '#f59e0b';
|
|
} else if (data.fixed && data.fixed.length > 0) {
|
|
status.textContent = '✓ Auto-fixed ' + data.fixed.length + ' in chapter — still flagged';
|
|
status.style.color = '#f59e0b';
|
|
} else {
|
|
status.textContent = '⚠ Still flagged';
|
|
status.style.color = '#ef4444';
|
|
}
|
|
}
|
|
if (btn) { btn.disabled = false; btn.textContent = '↻ Recheck'; }
|
|
}).catch((err) => {
|
|
if (status) { status.textContent = '✗ ' + (err.message || 'failed'); status.style.color = '#ef4444'; }
|
|
if (btn) { btn.disabled = false; btn.textContent = '↻ Recheck'; }
|
|
alert('Recheck failed: ' + (err.message || 'unknown'));
|
|
});
|
|
}
|
|
|
|
function acceptUtterance(uttId, index) {
|
|
const btn = document.getElementById('qa-accept-' + index);
|
|
if (btn) { btn.disabled = true; btn.textContent = '…'; }
|
|
fetch('/api/utterance/' + uttId + '/qa-accept', { method: 'POST' })
|
|
.then(r => r.json()).then(data => {
|
|
if (!data.ok) { throw new Error(data.error || 'accept failed'); }
|
|
const box = document.getElementById('qa-box-' + index);
|
|
const accBadge = document.getElementById('qa-accepted-' + index);
|
|
const status = document.getElementById('qa-status-' + index);
|
|
if (box) { box.style.display = 'none'; }
|
|
if (accBadge) { accBadge.style.display = 'inline-flex'; }
|
|
if (status) { status.textContent = '✓ Accepted'; status.style.color = '#10b981'; }
|
|
}).catch((err) => {
|
|
if (btn) { btn.disabled = false; btn.textContent = '✓ Accept'; }
|
|
alert('Accept failed: ' + (err.message || 'unknown'));
|
|
});
|
|
}
|
|
|
|
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/{{ active_project(request).slug }}/{{ 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';
|
|
const idxInput = document.getElementById('new-utterance-index');
|
|
const payload = {text: text, speaker_name: speaker};
|
|
const idxVal = idxInput.value.trim();
|
|
if (idxVal !== '') { payload.index = parseInt(idxVal, 10); }
|
|
fetch('/api/chapter/{{ chapter_id }}/utterance', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify(payload)
|
|
}).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';
|
|
}
|
|
});
|
|
}
|
|
|
|
function moveUtterance(uttId, up) {
|
|
fetch('/api/utterance/' + uttId + '/move', {
|
|
method: 'PUT',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({direction: up === 1 ? 'up' : 'down'})
|
|
}).then(r => r.json()).then(data => {
|
|
if (data.ok) { location.reload(); }
|
|
else { alert('Move failed: ' + (data.error || 'unknown')); }
|
|
}).catch(() => alert('Move failed'));
|
|
}
|
|
|
|
{% 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 %}
|
|
|
|
{# Language switching logic #}
|
|
<script>
|
|
(function() {
|
|
// Initialize: the URL may already contain a lang query param
|
|
var urlParams = new URLSearchParams(window.location.search);
|
|
var urlLang = urlParams.get('lang');
|
|
var select = document.getElementById('lang-select');
|
|
|
|
// Set the selected language from URL param if valid
|
|
if (urlLang && select) {
|
|
// languages_json is passed from the template as a JSON array of {code, name} objects
|
|
var validOptions = {{ languages_json | tojson }};
|
|
// Just set the selection - the page will reload on change
|
|
select.value = urlLang || 'en';
|
|
}
|
|
|
|
// Handle language change
|
|
if (select) {
|
|
select.onchange = function() {
|
|
var chosen = select.value;
|
|
// Navigate to the same chapter with the new language
|
|
window.location.href = '/chapters-audio/{{ chapter_dir }}?lang=' + chosen;
|
|
};
|
|
}
|
|
|
|
// On page load, show a brief notice about the selected language
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
if (select && select.value) {
|
|
var langName = select.options[select.selectedIndex] ? select.options[select.selectedIndex].text : select.value;
|
|
// Could add a transient notice here, but keep it subtle
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
|
|
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 %}
|