Files
UmbraAudio/umbraaudio/web/templates/chapter_audio_detail.html
B.D. Murphy 2b294d7fa4 Per-utterance language flag + a working language selector (spec 31)
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.
2026-11-06 16:55:49 -08:00

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 %}