A delivered gap is not a missing translation. The delivery arrives and says this target language does not voice this utterance, with a reason - the translation renders a different paragraph, say. That is a different problem with a different fix, and the reviewer needs to be able to tell them apart. The reason was being thrown away. The row is stored with an empty text and a review_reason; the resolver skipped every row with empty text, so the gap was dropped and the review lane said "Chinese has not been translated yet". That is a false statement about a translation that was delivered and rejected. So the resolver now keeps a not-voiced row as a gap with its reason, the chapter page renders it as a gap rather than as missing text, and the language page lists the delivered gaps under "Needs attention" with their reasons. A gap row is also no longer counted as translated text, so the coverage the reviewer sees is the coverage the book has. 4 new tests. Suite: 913 passed, 8 failed (the same 8 pre-existing failures). Also adds scripts/probe_cross_language_tts.py: the open prerequisite for any target-language audio lane is whether Qwen3-TTS can speak Chinese with an English voice reference. The probe answers it by transcription rather than by listening - Whisper transcribes each clip and reports what language it heard, and the English-reference clip reading Chinese is compared against the same clip reading Chinese with English phonology, so the only variable is the language parameter.
905 lines
42 KiB
HTML
905 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>
|
|
{% elif audio.translation_gap %}
|
|
<small style="color:#94a3b8">{{ audio.source_text or '(click to add text)' }}</small>
|
|
<span style="color:#f97316; font-size:0.7rem; margin-left:0.5rem"
|
|
title="{{ audio.translation_gap_reason }}">⚠ {{ active_language_name }} gap: {{ audio.translation_gap_reason[:120] }}</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 %}
|