feat: Implement speaker analysis and EPUB 3 export functionality

- Added speaker analysis module to infer speaker identities from text chunks.
- Introduced SpeakerGuess and SpeakerAnalysis data classes for managing speaker data.
- Developed functions for analyzing speaker occurrences and confidence levels.
- Created EPUB 3 exporter to generate EPUB packages with synchronized narration and media overlays.
- Implemented configurable chunking options for TTS synthesis and EPUB alignment.
- Enhanced JavaScript for speaker preview functionality in the web interface.
- Added comprehensive tests for chunking and EPUB exporting features.
- Documented upgrade plan for transitioning to EPUB 3 with multi-speaker support.
This commit is contained in:
JB
2025-10-07 17:57:53 -07:00
parent bacf1b2f9e
commit 41f56a8491
18 changed files with 2844 additions and 14 deletions
+24
View File
@@ -61,6 +61,30 @@
{% endfor %}
</select>
</div>
<div class="field">
<label for="chunk_level">Chunk granularity</label>
<select id="chunk_level" name="chunk_level">
{% for option in options.chunk_levels %}
<option value="{{ option.value }}" {% if settings.chunk_level == option.value %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
<p class="hint">Controls how chapters are split into TTS-ready chunks.</p>
</div>
<div class="field">
<label for="speaker_mode">Speaker mode</label>
<select id="speaker_mode" name="speaker_mode">
{% for option in options.speaker_modes %}
<option value="{{ option.value }}" {% if settings.speaker_mode == option.value %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label class="toggle-pill">
<input type="checkbox" name="generate_epub3" value="true" {% if settings.generate_epub3 %}checked{% endif %}>
<span>Generate EPUB 3 (experimental)</span>
</label>
<p class="hint">Creates a synchronized EPUB alongside audio output.</p>
</div>
</div>
<div class="grid">
<div class="field field--full">
+94
View File
@@ -26,6 +26,10 @@
<li><strong>Chapter intro delay:</strong> {{ '%.1f'|format(job.chapter_intro_delay) }}s</li>
<li><strong>Max words per subtitle:</strong> {{ job.max_subtitle_words }}</li>
<li><strong>Project folder:</strong> {{ 'Yes' if job.save_as_project else 'No' }}</li>
<li><strong>Chunk granularity:</strong> {{ job.chunk_level|replace('_', ' ')|title }}</li>
<li><strong>Speaker mode:</strong> {{ job.speaker_mode|replace('_', ' ')|title }}</li>
<li><strong>Speaker analysis threshold:</strong> {{ job.speaker_analysis_threshold }}</li>
<li><strong>Generate EPUB 3:</strong> {{ 'Yes' if job.generate_epub3 else 'No' }}</li>
</ul>
</article>
<article>
@@ -45,7 +49,97 @@
</div>
</section>
{% set analysis = job.speaker_analysis or {} %}
{% if analysis %}
{% set preview_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %}
<section class="card">
<div class="card__title">Speaker analysis</div>
<div class="grid grid--two">
<article>
<h2>Summary</h2>
{% set stats = analysis.get('stats', {}) %}
<ul>
<li><strong>Total chunks:</strong> {{ stats.get('total_chunks', '—') }}</li>
<li><strong>Explicit dialogue chunks:</strong> {{ stats.get('explicit_chunks', '—') }}</li>
<li><strong>Active speakers:</strong> {{ stats.get('active_speakers', '—') }}</li>
<li><strong>Unique speakers observed:</strong> {{ stats.get('unique_speakers', '—') }}</li>
<li><strong>Suppressed speakers:</strong> {{ stats.get('suppressed', 0) }}</li>
</ul>
</article>
<article>
<h2>Detected speakers</h2>
{% set speakers = analysis.get('speakers', {}) %}
{% set narrator_id = analysis.get('narrator', 'narrator') %}
{% if speakers %}
<ul>
{% for speaker_id, payload in speakers.items() if speaker_id != narrator_id and not payload.get('suppressed') %}
{% set spoken_name = payload.get('pronunciation') or payload.get('label') or speaker_id|replace('_', ' ')|title %}
{% set preview_text = preview_template | replace("{{name}}", spoken_name) %}
<li>
<div class="speaker-line">
<strong>{{ payload.get('label', speaker_id|replace('_', ' ')|title) }}</strong>
<button type="button"
class="icon-button speaker-list__preview"
data-role="speaker-preview"
data-job-id="{{ job.id }}"
data-speaker-id="{{ speaker_id }}"
data-preview-text="{{ preview_text|e }}"
data-language="{{ job.language }}"
data-voice="{{ payload.get('resolved_voice') or payload.get('voice_formula') or payload.get('voice') or job.voice }}"
data-speed="{{ '%.2f'|format(job.speed) }}"
data-use-gpu="{{ 'true' if job.use_gpu else 'false' }}"
aria-label="Preview pronunciation for {{ payload.get('label', speaker_id|replace('_', ' ')|title) }}"
title="Preview pronunciation">
<span class="icon-button__glyph" aria-hidden="true">🔊</span>
<span class="spinner spinner--sm spinner--muted" aria-hidden="true"></span>
</button>
</div>
<div class="meta">
<span>{{ payload.get('count', 0) }} chunks</span>
<span>Confidence: {{ payload.get('confidence', 'low')|title }}</span>
{% if payload.get('pronunciation') %}
<span>Pronunciation: {{ payload.get('pronunciation') }}</span>
{% endif %}
</div>
{% set quotes = payload.get('sample_quotes', []) %}
{% if quotes %}
<details>
<summary>Sample quotes</summary>
<ul>
{% for quote in quotes %}
<li>{{ quote }}</li>
{% endfor %}
</ul>
</details>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p>No additional speakers detected.</p>
{% endif %}
{% set suppressed = analysis.get('suppressed_details') or analysis.get('suppressed', []) %}
{% if suppressed %}
<p class="muted">
Suppressed speakers:
{% if suppressed[0] is string %}
{{ suppressed | join(', ') }}
{% else %}
{{ suppressed | map(attribute='label') | join(', ') }}
{% endif %}
</p>
{% endif %}
</article>
</div>
</section>
{% endif %}
<section class="card" id="logs" hx-get="{{ url_for('web.job_logs_partial', job_id=job.id) }}" hx-trigger="load, every 2s" hx-target="#logs" hx-swap="innerHTML">
{% include "partials/logs.html" %}
</section>
{% endblock %}
{% block scripts %}
{{ super() }}
<script type="module" src="{{ url_for('static', filename='speakers.js') }}"></script>
{% endblock %}
+110
View File
@@ -33,7 +33,88 @@
<dt>Chapter intro delay</dt>
<dd>{{ '%.1f'|format(pending.chapter_intro_delay) }} seconds</dd>
</div>
<div>
<dt>Chunk granularity</dt>
<dd>{{ pending.chunk_level|replace('_', ' ')|title }}</dd>
</div>
<div>
<dt>Speaker mode</dt>
<dd>{{ pending.speaker_mode|replace('_', ' ')|title }}</dd>
</div>
<div>
<dt>Speaker analysis threshold</dt>
<dd>{{ pending.speaker_analysis_threshold }} {{ 'mention' if pending.speaker_analysis_threshold == 1 else 'mentions' }}</dd>
</div>
<div>
<dt>EPUB 3 package</dt>
<dd>{% if pending.generate_epub3 %}Enabled{% else %}Disabled{% endif %}</dd>
</div>
</dl>
{% set analysis = pending.speaker_analysis or {} %}
{% set analysis_speakers = analysis.get('speakers', {}) %}
{% if analysis_speakers %}
{% set active = namespace(items=[]) %}
{% for sid, payload in analysis_speakers.items() %}
{% if sid != 'narrator' and not payload.get('suppressed') %}
{% set _ = active.items.append(payload) %}
{% endif %}
{% endfor %}
<div class="prepare-analysis">
<h2>Detected speakers</h2>
{% if active.items %}
<ul>
{% for speaker in active.items|sort(attribute='label') %}
<li><strong>{{ speaker.label }}</strong> · {{ speaker.count }} lines · confidence {{ speaker.confidence|title }}</li>
{% endfor %}
</ul>
{% else %}
<p>No additional speakers met the threshold yet. All dialogue will use the narrator voice.</p>
{% endif %}
</div>
{% endif %}
{% set roster = pending.speakers or {} %}
{% if roster %}
{% set preview_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %}
<div class="prepare-speakers">
<h2>Speaker pronunciation guide</h2>
<p class="hint">Add a phonetic spelling (IPA or plain text) so pronunciations sound right. Leave blank to use the written label.</p>
<ul class="speaker-list">
{% for speaker_id, speaker in roster.items() %}
{% set spoken_name = speaker.pronunciation or speaker.label %}
{% set preview_text = preview_template | replace("{{name}}", spoken_name) %}
<li class="speaker-list__item">
<div class="speaker-list__header">
<span class="speaker-list__name">{{ speaker.label }}</span>
<button type="button"
class="icon-button speaker-list__preview"
data-role="speaker-preview"
data-speaker-id="{{ speaker_id }}"
data-preview-text="{{ preview_text|e }}"
data-language="{{ pending.language }}"
data-voice="{{ speaker.resolved_voice or speaker.voice_formula or speaker.voice or pending.voice }}"
data-speed="{{ '%.2f'|format(pending.speed) }}"
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}"
aria-label="Preview pronunciation for {{ speaker.label }}"
title="Preview pronunciation">
🔊
</button>
</div>
<label class="speaker-list__field" for="speaker-{{ speaker_id }}-pronunciation">
<span>Pronunciation</span>
<input type="text"
id="speaker-{{ speaker_id }}-pronunciation"
name="speaker-{{ speaker_id }}-pronunciation"
value="{{ speaker.pronunciation or '' }}"
placeholder="{{ speaker.label }}">
</label>
{% if speaker.get('analysis_count') %}
<p class="hint speaker-list__stats">{{ speaker.analysis_count }} detected lines · confidence {{ speaker.analysis_confidence|default('low')|title }}</p>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if pending.metadata_tags %}
<div class="prepare-metadata">
<h2>Metadata</h2>
@@ -110,11 +191,39 @@
{% endfor %}
</div>
<div class="prepare-options">
<div class="field">
<label for="chunk_level">Chunk granularity</label>
<select id="chunk_level" name="chunk_level">
{% for option in options.chunk_levels %}
<option value="{{ option.value }}" {% if pending.chunk_level == option.value %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
<p class="hint">Paragraphs work well for long-form narration; sentences give finer subtitle sync.</p>
</div>
<div class="field">
<label for="speaker_mode">Speaker mode</label>
<select id="speaker_mode" name="speaker_mode">
{% for option in options.speaker_modes %}
<option value="{{ option.value }}" {% if pending.speaker_mode == option.value %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="speaker_analysis_threshold">Speaker analysis minimum mentions</label>
<input type="number" min="1" max="25" id="speaker_analysis_threshold" name="speaker_analysis_threshold" value="{{ pending.speaker_analysis_threshold }}">
<p class="hint">Only speakers that appear at least this many times will keep unique voices in multi-speaker mode.</p>
</div>
<div class="field">
<label for="chapter_intro_delay">Pause after chapter titles (seconds)</label>
<input type="number" step="0.1" min="0" id="chapter_intro_delay" name="chapter_intro_delay" value="{{ '%.2f'|format(pending.chapter_intro_delay) }}">
<p class="hint">Set to 0 to disable the pause after speaking each chapter title.</p>
</div>
<div class="field field--choices">
<label class="toggle-pill">
<input type="checkbox" name="generate_epub3" value="true" {% if pending.generate_epub3 %}checked{% endif %}>
<span>Generate EPUB 3 (experimental)</span>
</label>
</div>
</div>
<div class="prepare-actions">
<button type="submit" class="button">Queue conversion</button>
@@ -127,5 +236,6 @@
{% block scripts %}
{{ super() }}
<script type="module" src="{{ url_for('static', filename='speakers.js') }}"></script>
<script type="module" src="{{ url_for('static', filename='prepare.js') }}"></script>
{% endblock %}
+30
View File
@@ -73,6 +73,10 @@
<input type="checkbox" name="save_as_project" value="true" {% if settings.save_as_project %}checked{% endif %}>
<span>Save as Project With Metadata</span>
</label>
<label class="toggle-pill">
<input type="checkbox" name="generate_epub3" value="true" {% if settings.generate_epub3 %}checked{% endif %}>
<span>Generate EPUB 3 (experimental)</span>
</label>
</div>
<div class="field">
<label for="separate_chapters_format">Separate Chapter Format</label>
@@ -83,6 +87,32 @@
</select>
</div>
<div class="field">
<label for="chunk_level_default">Chunk Granularity</label>
<select id="chunk_level_default" name="chunk_level">
{% for option in options.chunk_levels %}
<option value="{{ option.value }}" {% if settings.chunk_level == option.value %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="speaker_mode_default">Speaker Mode</label>
<select id="speaker_mode_default" name="speaker_mode">
{% for option in options.speaker_modes %}
<option value="{{ option.value }}" {% if settings.speaker_mode == option.value %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="speaker_analysis_threshold">Speaker Analysis Minimum Mentions</label>
<input type="number" min="1" max="25" id="speaker_analysis_threshold" name="speaker_analysis_threshold" value="{{ settings.speaker_analysis_threshold }}">
<p class="hint">Speakers detected fewer times than this fallback to the narrator voice.</p>
</div>
<div class="field">
<label for="speaker_pronunciation_sentence">Speaker Pronunciation Preview</label>
<input type="text" id="speaker_pronunciation_sentence" name="speaker_pronunciation_sentence" value="{{ settings.speaker_pronunciation_sentence }}" placeholder="This is {{ '{{name}}' }} speaking.">
<p class="hint">Sentence template used when previewing name pronunciation. Include <code>{{ '{{name}}' }}</code> where the speaker name should be inserted.</p>
</div>
<div class="field">
<label for="silence_between_chapters">Silence Between Chapters (Seconds)</label>
<input type="number" step="0.5" min="0" id="silence_between_chapters" name="silence_between_chapters" value="{{ settings.silence_between_chapters }}">
</div>