Refactor templates for audiobook preparation and settings

- Updated navigation in base.html to include a link to the queue section.
- Modified index.html to change step labels and added a jobs panel for real-time updates.
- Enhanced prepare_job.html with a wizard-style step navigation and improved speaker configuration options.
- Added new fields for speaker analysis settings and chapter options in prepare_job.html.
- Introduced a voice selection modal for better user experience in speaker selection.
- Updated settings.html to allow selection of randomizer languages for speakers.
- Cleaned up speakers.html by removing unnecessary language selection fields and ensuring consistency in speaker configuration.
This commit is contained in:
JB
2025-10-08 12:50:50 -07:00
parent d01887f31b
commit 881717c4cb
8 changed files with 953 additions and 339 deletions
+4 -4
View File
@@ -17,11 +17,11 @@
</div>
<nav class="top-actions">
{% set endpoint = request.endpoint or '' %}
<a href="{{ url_for('web.index') }}" class="btn{% if endpoint == 'web.index' %} is-active{% endif %}">Dashboard</a>
<a href="{{ url_for('web.index') }}" class="btn{% if endpoint == 'web.index' %} is-active{% endif %}">Dashboard</a>
<a href="{{ url_for('web.voice_profiles_page') }}" class="btn{% if endpoint == 'web.voice_profiles_page' %} is-active{% endif %}">Voice Mixer</a>
<a href="{{ url_for('web.speaker_configs_page') }}" class="btn{% if endpoint == 'web.speaker_configs_page' %} is-active{% endif %}">Speakers</a>
<a href="{{ url_for('web.queue_page') }}" class="btn{% if endpoint in ['web.queue_page', 'web.job_detail'] %} is-active{% endif %}">Queue</a>
<a href="{{ url_for('web.settings_page') }}" class="btn{% if endpoint == 'web.settings_page' %} is-active{% endif %}">Settings</a>
<a href="{{ url_for('web.speaker_configs_page') }}" class="btn{% if endpoint == 'web.speaker_configs_page' %} is-active{% endif %}">Speakers</a>
<a href="{{ url_for('web.index', _anchor='queue') }}" class="btn{% if endpoint in ['web.queue_page', 'web.job_detail'] %} is-active{% endif %}">Queue</a>
<a href="{{ url_for('web.settings_page') }}" class="btn{% if endpoint == 'web.settings_page' %} is-active{% endif %}">Settings</a>
</nav>
</header>
<main class="main">
+13 -3
View File
@@ -7,15 +7,15 @@
<div class="step-indicator" aria-label="Audiobook workflow">
<span class="step-indicator__item is-active">
<span class="step-indicator__index">1</span>
<span class="step-indicator__label">Upload</span>
<span class="step-indicator__label">Upload &amp; settings</span>
</span>
<span class="step-indicator__item">
<span class="step-indicator__index">2</span>
<span class="step-indicator__label">Speakers</span>
<span class="step-indicator__label">Chapters</span>
</span>
<span class="step-indicator__item">
<span class="step-indicator__index">3</span>
<span class="step-indicator__label">Queue</span>
<span class="step-indicator__label">Speakers</span>
</span>
</div>
<h1 class="card__title">Create a New Audiobook</h1>
@@ -132,6 +132,16 @@
</div>
</form>
</section>
<section class="card" id="queue">
<div id="jobs-panel"
hx-get="{{ url_for('web.jobs_partial') }}"
hx-trigger="load, every 3s"
hx-target="#jobs-panel"
hx-swap="innerHTML">
{{ jobs_panel|safe }}
</div>
</section>
{% endblock %}
{% block scripts %}
+378 -286
View File
@@ -3,19 +3,19 @@
{% block title %}Prepare · {{ pending.original_filename }}{% endblock %}
{% block content %}
<section class="card">
<div class="step-indicator" aria-label="Audiobook workflow">
<span class="step-indicator__item is-complete">
<section class="card" data-role="prepare-wizard">
<div class="step-indicator" aria-label="Audiobook workflow" data-role="wizard-indicator">
<span class="step-indicator__item is-complete" data-role="wizard-step" data-step-key="setup">
<span class="step-indicator__index">1</span>
<span class="step-indicator__label">Upload</span>
<span class="step-indicator__label">Upload &amp; settings</span>
</span>
<span class="step-indicator__item is-active">
<span class="step-indicator__item is-active" data-role="wizard-step" data-step-key="chapters">
<span class="step-indicator__index">2</span>
<span class="step-indicator__label">Speakers</span>
<span class="step-indicator__label">Chapters</span>
</span>
<span class="step-indicator__item">
<span class="step-indicator__item" data-role="wizard-step" data-step-key="speakers">
<span class="step-indicator__index">3</span>
<span class="step-indicator__label">Queue</span>
<span class="step-indicator__label">Speakers</span>
</span>
</div>
<div class="card__title">Prepare audiobook</div>
@@ -113,16 +113,6 @@
<p>Press <strong>Analyze speakers</strong> after selecting chapters to discover recurring voices.</p>
</div>
{% endif %}
{% if has_metadata %}
<div class="prepare-metadata">
<h2>Metadata</h2>
<ul>
{% for key, value in pending.metadata_tags.items() %}
<li><strong>{{ key|replace('_', ' ')|title }}:</strong> {{ value }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% endif %}
</div>
@@ -134,294 +124,396 @@
<div class="alert alert--info">{{ notice }}</div>
{% endif %}
<div class="prepare-speaker-config">
<div class="prepare-speaker-config__header">
<h2>Speaker configuration</h2>
<p class="hint">Reuse saved presets to keep character voices consistent between projects.</p>
</div>
<div class="prepare-speaker-config__grid">
<label class="field prepare-speaker-config__field" for="applied_speaker_config">
<span>Saved preset</span>
<select id="applied_speaker_config" name="applied_speaker_config">
<option value="">None</option>
{% for config in speaker_configs %}
<option value="{{ config.name }}" {% if pending.applied_speaker_config == config.name %}selected{% endif %}>
{{ config.name }} · {{ config.speakers|length }} speaker{% if config.speakers|length != 1 %}s{% endif %}
</option>
{% endfor %}
</select>
<p class="hint">Presets are managed on the <a href="{{ url_for('web.speaker_configs_page') }}">Speakers page</a>.</p>
</label>
<div class="prepare-speaker-config__actions">
<button type="submit"
class="button button--ghost"
name="apply_speaker_config"
value="1"
formaction="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"
formmethod="post"
formnovalidate
{% if not speaker_configs %}disabled{% endif %}>
Apply preset
</button>
<button type="submit"
class="button button--ghost"
name="randomize_speaker_config"
value="1"
formaction="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"
formmethod="post"
formnovalidate
{% if not speaker_configs %}disabled{% endif %}>
Randomize compatible voices
</button>
<label class="toggle-pill">
<input type="checkbox" name="save_speaker_config" value="1">
<span>Save roster updates back to preset</span>
</label>
</div>
</div>
<div class="prepare-step-nav" data-role="prepare-step-nav">
<button type="button" class="button button--ghost is-active" data-step-target="chapters">Step 2 · Chapters</button>
<button type="button" class="button button--ghost" data-step-target="speakers" disabled aria-disabled="true" data-state="locked">Step 3 · Speakers</button>
</div>
{% if roster %}
<div class="prepare-speakers">
<h2>Speaker settings</h2>
<p class="hint">Set pronunciations, lock specific voices, or limit the randomizer by language and gender.</p>
<ul class="speaker-list">
{% for speaker_id, speaker in roster.items() %}
{% set spoken_name = speaker.pronunciation or speaker.label %}
{% set preview_text = recommended_template | replace("{{name}}", spoken_name) %}
{% set selected_voice = speaker.resolved_voice or speaker.voice %}
{% set allowed_langs = speaker.config_languages or speaker.languages or [] %}
{% set seen = namespace(values=[]) %}
<li class="speaker-list__item" data-speaker-id="{{ speaker_id }}">
<div class="speaker-line 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="{{ selected_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 class="prepare-steps" data-role="prepare-step-panels">
<section class="prepare-step" data-step-panel="chapters">
<header class="prepare-step__header">
<h2>Step 2 · Select chapters</h2>
<p class="hint">Choose which chapters to convert and tweak conversion options.</p>
</header>
<div class="chapter-grid">
{% for chapter in pending.chapters %}
{% set is_enabled = chapter.enabled is not defined or chapter.enabled %}
{% set selected_option = '__default' %}
{% if chapter.voice_profile %}
{% set selected_option = 'profile:' ~ chapter.voice_profile %}
{% elif chapter.voice %}
{% set selected_option = 'voice:' ~ chapter.voice %}
{% elif chapter.voice_formula %}
{% set selected_option = 'formula' %}
{% endif %}
<article class="chapter-card" data-role="chapter-row">
<header class="chapter-card__header">
<div class="chapter-card__title">
<label class="chapter-card__checkbox">
<input type="checkbox" name="chapter-{{ loop.index0 }}-enabled" data-role="chapter-enabled" {% if is_enabled %}checked{% endif %}>
<span>Chapter {{ loop.index }} · {{ chapter.title }}</span>
</label>
</div>
<div class="chapter-card__metrics">
{{ '{:,}'.format(chapter.characters) }} characters
</div>
</header>
<div class="chapter-card__body">
<div class="chapter-card__field">
<label for="chapter-{{ loop.index0 }}-title">Title</label>
<input type="text" id="chapter-{{ loop.index0 }}-title" name="chapter-{{ loop.index0 }}-title" value="{{ chapter.title }}">
</div>
<div class="chapter-card__preview">
<details>
<summary>Preview text</summary>
<pre>{{ chapter.text[:2000] }}{% if chapter.text|length > 2000 %}…{% endif %}</pre>
</details>
</div>
<div class="chapter-card__field">
<label for="chapter-{{ loop.index0 }}-voice">Voice override</label>
<select id="chapter-{{ loop.index0 }}-voice" name="chapter-{{ loop.index0 }}-voice" data-role="voice-select">
<option value="__default" {% if selected_option == '__default' %}selected{% endif %}>Use job default</option>
<optgroup label="Voices">
{% for voice in options.voices %}
<option value="voice:{{ voice }}" {% if selected_option == 'voice:' ~ voice %}selected{% endif %}>{{ voice }}</option>
{% endfor %}
</optgroup>
{% if options.voice_profile_options %}
<optgroup label="Profiles">
{% for profile in options.voice_profile_options %}
<option value="profile:{{ profile.name }}" {% if selected_option == 'profile:' ~ profile.name %}selected{% endif %}>{{ profile.name }}{% if profile.language %} · {{ profile.language|upper }}{% endif %}</option>
{% endfor %}
</optgroup>
{% endif %}
<option value="formula" {% if selected_option == 'formula' %}selected{% endif %}>Custom formula…</option>
</select>
<input type="text" name="chapter-{{ loop.index0 }}-formula" class="chapter-card__formula" data-role="formula-input" placeholder="af_nova*0.4+am_liam*0.6" value="{{ chapter.voice_formula or '' }}" {% if selected_option != 'formula' %}hidden aria-hidden="true"{% else %}aria-hidden="false"{% endif %}>
</div>
</div>
</article>
{% 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="speaker-list__meta">
{% if speaker.gender and speaker.gender != 'unknown' %}
<span class="badge badge--info">{{ speaker.gender|title }}</span>
{% else %}
<span class="badge badge--muted">Gender unknown</span>
{% endif %}
{% if speaker.get('analysis_count') %}
<span class="badge badge--muted">{{ speaker.analysis_count }} lines · {{ speaker.analysis_confidence|default('low')|title }} confidence</span>
{% endif %}
<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>
<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>
<div class="speaker-list__controls">
<label class="speaker-list__field" for="speaker-{{ speaker_id }}-voice">
<span>Assigned voice</span>
<select id="speaker-{{ speaker_id }}-voice"
name="speaker-{{ speaker_id }}-voice"
data-role="speaker-voice"
data-default-voice="{{ pending.voice }}"
{% if speaker.randomize %}disabled{% endif %}>
<option value="" {% if not selected_voice %}selected{% endif %}>Use narrator voice ({{ pending.voice }})</option>
{% if speaker.recommended_voices %}
<optgroup label="Recommended">
{% for voice_id in speaker.recommended_voices[:6] %}
{% if voice_id not in seen.values %}
{% set voice_meta = options.voice_catalog_map.get(voice_id) or {} %}
<option value="{{ voice_id }}" {% if selected_voice == voice_id %}selected{% endif %}>
{{ voice_meta.display_name or voice_id }} · {{ voice_meta.language_label or voice_id[0]|upper }} · {{ voice_meta.gender or 'Unknown' }}
</option>
{% set _ = seen.values.append(voice_id) %}
{% endif %}
{% endfor %}
</optgroup>
{% endif %}
<optgroup label="All voices">
{% for voice in options.voice_catalog %}
{% if voice.id not in seen.values %}
<option value="{{ voice.id }}"
{% if selected_voice == voice.id %}selected{% endif %}>
{{ voice.display_name }} · {{ voice.language_label }} · {{ voice.gender }}
</option>
{% endif %}
{% endfor %}
</optgroup>
</select>
<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 class="speaker-list__inline">
</div>
</div>
<div class="prepare-step__actions">
<button type="button" class="button" data-role="step-next" data-step-target="speakers">Continue to speakers</button>
</div>
</section>
<section class="prepare-step" data-step-panel="speakers" hidden>
<header class="prepare-step__header">
<h2>Step 3 · Select speakers</h2>
<p class="hint">Assign voices, audition samples, and finalize your roster.</p>
</header>
<div class="prepare-speaker-config">
<div class="prepare-speaker-config__header">
<h2>Speaker configuration</h2>
<p class="hint">Reuse saved presets to keep character voices consistent between projects.</p>
</div>
<div class="prepare-speaker-config__grid">
<label class="field prepare-speaker-config__field" for="applied_speaker_config">
<span>Saved preset</span>
<select id="applied_speaker_config" name="applied_speaker_config">
<option value="">None</option>
{% for config in speaker_configs %}
<option value="{{ config.name }}" {% if pending.applied_speaker_config == config.name %}selected{% endif %}>
{{ config.name }} · {{ config.speakers|length }} speaker{% if config.speakers|length != 1 %}s{% endif %}
</option>
{% endfor %}
</select>
<p class="hint">Presets are managed on the <a href="{{ url_for('web.speaker_configs_page') }}">Speakers page</a>.</p>
</label>
<div class="prepare-speaker-config__actions">
<button type="submit"
class="button button--ghost"
name="apply_speaker_config"
value="1"
formaction="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"
formmethod="post"
formnovalidate
{% if not speaker_configs %}disabled{% endif %}>
Apply preset
</button>
<button type="submit"
class="button button--ghost"
name="randomize_speaker_config"
value="1"
formaction="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"
formmethod="post"
formnovalidate
{% if not speaker_configs %}disabled{% endif %}>
Randomize compatible voices
</button>
<label class="toggle-pill">
<input type="checkbox"
name="speaker-{{ speaker_id }}-randomize"
value="1"
data-role="randomize-toggle"
{% if speaker.randomize %}checked{% endif %}>
<span>Randomize compatible voice on analyze</span>
<input type="checkbox" name="save_speaker_config" value="1">
<span>Save roster updates back to preset</span>
</label>
</div>
<label class="speaker-list__field" for="speaker-{{ speaker_id }}-languages">
<span>Allowed languages</span>
<select id="speaker-{{ speaker_id }}-languages"
name="speaker-{{ speaker_id }}-languages"
multiple
size="4"
data-role="speaker-languages"
{% if not allowed_langs %}data-placeholder="All languages"{% endif %}>
{% for code, label in options.languages.items() %}
<option value="{{ code }}" {% if code in allowed_langs %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<p class="hint">When randomizing, voices will be selected from these languages.</p>
</label>
</div>
{% if speaker.recommended_voices %}
<div class="speaker-list__recommendations">
<span class="hint">Suggested:</span>
{% for voice_id in speaker.recommended_voices[:6] %}
{% set voice_meta = options.voice_catalog_map.get(voice_id) or {} %}
<button type="button"
class="chip"
data-role="recommended-voice"
data-voice="{{ voice_id }}"
title="{{ voice_meta.display_name or voice_id }} · {{ voice_meta.language_label or voice_id[0]|upper }} · {{ voice_meta.gender or 'Unknown' }}">
{{ voice_meta.display_name or voice_id }}
</button>
{% endfor %}
</div>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
<div class="chapter-grid">
{% for chapter in pending.chapters %}
{% set is_enabled = chapter.enabled is not defined or chapter.enabled %}
{% set selected_option = '__default' %}
{% if chapter.voice_profile %}
{% set selected_option = 'profile:' ~ chapter.voice_profile %}
{% elif chapter.voice %}
{% set selected_option = 'voice:' ~ chapter.voice %}
{% elif chapter.voice_formula %}
{% set selected_option = 'formula' %}
{% endif %}
<article class="chapter-card" data-role="chapter-row">
<header class="chapter-card__header">
<div class="chapter-card__title">
<label class="chapter-card__checkbox">
<input type="checkbox" name="chapter-{{ loop.index0 }}-enabled" data-role="chapter-enabled" {% if is_enabled %}checked{% endif %}>
<span>Chapter {{ loop.index }} · {{ chapter.title }}</span>
</label>
</div>
<div class="chapter-card__metrics">
{{ '{:,}'.format(chapter.characters) }} characters
</div>
</header>
<div class="chapter-card__body">
<div class="chapter-card__field">
<label for="chapter-{{ loop.index0 }}-title">Title</label>
<input type="text" id="chapter-{{ loop.index0 }}-title" name="chapter-{{ loop.index0 }}-title" value="{{ chapter.title }}">
</div>
<div class="chapter-card__preview">
<details>
<summary>Preview text</summary>
<pre>{{ chapter.text[:2000] }}{% if chapter.text|length > 2000 %}…{% endif %}</pre>
</details>
</div>
<div class="chapter-card__field">
<label for="chapter-{{ loop.index0 }}-voice">Voice override</label>
<select id="chapter-{{ loop.index0 }}-voice" name="chapter-{{ loop.index0 }}-voice" data-role="voice-select">
<option value="__default" {% if selected_option == '__default' %}selected{% endif %}>Use job default</option>
<optgroup label="Voices">
{% for voice in options.voices %}
<option value="voice:{{ voice }}" {% if selected_option == 'voice:' ~ voice %}selected{% endif %}>{{ voice }}</option>
{% endfor %}
</optgroup>
{% if options.voice_profile_options %}
<optgroup label="Profiles">
{% for profile in options.voice_profile_options %}
<option value="profile:{{ profile.name }}" {% if selected_option == 'profile:' ~ profile.name %}selected{% endif %}>{{ profile.name }}{% if profile.language %} · {{ profile.language|upper }}{% endif %}</option>
{% endfor %}
</optgroup>
{% endif %}
<option value="formula" {% if selected_option == 'formula' %}selected{% endif %}>Custom formula…</option>
</select>
<input type="text" name="chapter-{{ loop.index0 }}-formula" class="chapter-card__formula" data-role="formula-input" placeholder="af_nova*0.4+am_liam*0.6" value="{{ chapter.voice_formula or '' }}" {% if selected_option != 'formula' %}hidden aria-hidden="true"{% else %}aria-hidden="false"{% endif %}>
</div>
</div>
</article>
{% 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>
{% if roster %}
<div class="prepare-speakers">
<h2>Speaker settings</h2>
<p class="hint">Set pronunciations, lock specific voices, and audition sample paragraphs to hear casting choices.</p>
<ul class="speaker-list">
{% for speaker_id, speaker in roster.items() %}
{% set spoken_name = speaker.pronunciation or speaker.label %}
{% set preview_text = recommended_template | replace("{{name}}", spoken_name) %}
{% set selected_voice = speaker.resolved_voice or speaker.voice %}
{% set seen = namespace(values=[]) %}
{% set sample_quotes = speaker.sample_quotes or [] %}
<li class="speaker-list__item" data-speaker-id="{{ speaker_id }}">
<div class="speaker-line 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="{{ selected_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>
<template data-role="speaker-samples">{{ sample_quotes | tojson }}</template>
<div class="speaker-list__meta">
{% if speaker.gender and speaker.gender != 'unknown' %}
<span class="badge badge--info">{{ speaker.gender|title }}</span>
{% else %}
<span class="badge badge--muted">Gender unknown</span>
{% endif %}
{% if speaker.get('analysis_count') %}
<span class="badge badge--muted">{{ speaker.analysis_count }} lines · {{ speaker.analysis_confidence|default('low')|title }} confidence</span>
{% endif %}
{% if settings.speaker_random_languages %}
<span class="badge badge--muted">Randomizer: {{ settings.speaker_random_languages | map('upper') | join(', ') }}</span>
{% endif %}
</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>
<div class="speaker-list__controls">
<div class="speaker-list__selection">
<label class="speaker-list__field" for="speaker-{{ speaker_id }}-voice">
<span>Assigned voice</span>
<select id="speaker-{{ speaker_id }}-voice"
name="speaker-{{ speaker_id }}-voice"
data-role="speaker-voice"
data-default-voice="{{ pending.voice }}"
{% if speaker.randomize %}disabled{% endif %}>
<option value="" {% if not selected_voice %}selected{% endif %}>Use narrator voice ({{ pending.voice }})</option>
{% if speaker.recommended_voices %}
<optgroup label="Recommended">
{% for voice_id in speaker.recommended_voices[:6] %}
{% if voice_id not in seen.values %}
{% set voice_meta = options.voice_catalog_map.get(voice_id) or {} %}
<option value="{{ voice_id }}" {% if selected_voice == voice_id %}selected{% endif %}>
{{ voice_meta.display_name or voice_id }} · {{ voice_meta.language_label or voice_id[0]|upper }} · {{ voice_meta.gender or 'Unknown' }}
</option>
{% set _ = seen.values.append(voice_id) %}
{% endif %}
{% endfor %}
</optgroup>
{% endif %}
<optgroup label="All voices">
{% for voice in options.voice_catalog %}
{% if voice.id not in seen.values %}
<option value="{{ voice.id }}"
{% if selected_voice == voice.id %}selected{% endif %}>
{{ voice.display_name }} · {{ voice.language_label }} · {{ voice.gender }}
</option>
{% endif %}
{% endfor %}
</optgroup>
</select>
</label>
<button type="button"
class="button button--ghost button--small"
data-role="open-voice-browser"
data-speaker-id="{{ speaker_id }}">
Browse voices
</button>
</div>
<div class="speaker-list__inline">
<label class="toggle-pill">
<input type="checkbox"
name="speaker-{{ speaker_id }}-randomize"
value="1"
data-role="randomize-toggle"
{% if speaker.randomize %}checked{% endif %}>
<span>Randomize compatible voice on analyze</span>
</label>
</div>
</div>
<details class="speaker-list__samples" {% if not sample_quotes %}data-state="empty"{% endif %}>
<summary>Sample paragraphs</summary>
{% if sample_quotes %}
{% for quote in sample_quotes %}
<article class="speaker-sample" data-sample-index="{{ loop.index0 }}">
<p>{{ quote }}</p>
<div class="speaker-sample__actions">
<button type="button"
class="button button--ghost button--small"
data-role="speaker-preview"
data-speaker-id="{{ speaker_id }}"
data-preview-text="{{ quote|e }}"
data-language="{{ pending.language }}"
data-voice="{{ selected_voice or pending.voice }}"
data-speed="{{ '%.2f'|format(pending.speed) }}"
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}">
Preview with assigned voice
</button>
<button type="button"
class="button button--ghost button--small"
data-role="open-voice-browser"
data-speaker-id="{{ speaker_id }}"
data-sample-index="{{ loop.index0 }}">
Preview in voice browser
</button>
</div>
</article>
{% endfor %}
{% else %}
<p class="hint">No paragraphs captured yet. Analyze speakers to collect dialogue samples.</p>
{% endif %}
</details>
{% if speaker.recommended_voices %}
<div class="speaker-list__recommendations">
<span class="hint">Suggested:</span>
{% for voice_id in speaker.recommended_voices[:6] %}
{% set voice_meta = options.voice_catalog_map.get(voice_id) or {} %}
<button type="button"
class="chip"
data-role="recommended-voice"
data-voice="{{ voice_id }}"
title="{{ voice_meta.display_name or voice_id }} · {{ voice_meta.language_label or voice_id[0]|upper }} · {{ voice_meta.gender or 'Unknown' }}">
{{ voice_meta.display_name or voice_id }}
</button>
{% endfor %}
</div>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% else %}
<p class="hint">No additional speakers detected yet. The narrator voice will be used for all dialogue.</p>
{% endif %}
<div class="prepare-actions">
<button type="submit"
class="button button--ghost"
data-role="analyze-button"
formaction="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"
formmethod="post"
formnovalidate
{% if pending.speaker_mode != 'multi' %}hidden aria-hidden="true"{% else %}aria-hidden="false"{% endif %}>
Analyze speakers
</button>
<button type="submit" class="button">Queue conversion</button>
<button type="submit" class="button button--ghost" form="cancel-form">Cancel</button>
<div class="prepare-actions">
<button type="button" class="button button--ghost" data-role="step-prev" data-step-target="chapters">Back to chapters</button>
<button type="submit"
class="button button--ghost"
data-role="analyze-button"
formaction="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"
formmethod="post"
formnovalidate
{% if pending.speaker_mode != 'multi' %}hidden aria-hidden="true"{% else %}aria-hidden="false"{% endif %}>
Analyze speakers
</button>
<button type="submit" class="button">Queue conversion</button>
<button type="submit" class="button button--ghost" form="cancel-form">Cancel</button>
</div>
</section>
</div>
</form>
<form method="post" action="{{ url_for('web.cancel_pending_job', pending_id=pending.id) }}" id="cancel-form"></form>
<div class="modal" data-role="voice-modal" hidden>
<div class="modal__overlay" data-role="voice-modal-close" tabindex="-1"></div>
<div class="modal__content voice-browser" role="dialog" aria-modal="true" aria-labelledby="voice-modal-title">
<header class="voice-browser__header">
<h2 id="voice-modal-title">Choose a voice</h2>
<button type="button" class="icon-button" data-role="voice-modal-close" aria-label="Close voice browser"></button>
</header>
<div class="voice-browser__body">
<aside class="voice-browser__filters">
<label class="field">
<span>Search</span>
<input type="search" data-role="voice-modal-search" placeholder="Search by name or language">
</label>
<label class="field">
<span>Language</span>
<select data-role="voice-modal-language">
<option value="">All languages</option>
{% for code, label in options.languages.items() %}
<option value="{{ code }}">{{ label }} ({{ code|upper }})</option>
{% endfor %}
</select>
</label>
<div class="voice-browser__gender" role="group" aria-label="Filter by gender">
<button type="button" class="button button--ghost button--small" data-role="voice-modal-gender" data-value="">All</button>
<button type="button" class="button button--ghost button--small" data-role="voice-modal-gender" data-value="f">Female</button>
<button type="button" class="button button--ghost button--small" data-role="voice-modal-gender" data-value="m">Male</button>
</div>
</aside>
<section class="voice-browser__catalog" aria-label="Voice catalog">
<ul class="voice-browser__list" data-role="voice-modal-list"></ul>
</section>
<section class="voice-browser__preview" data-role="voice-modal-preview">
<h3 data-role="voice-modal-selected-name">Select a voice to preview</h3>
<p class="tag" data-role="voice-modal-selected-meta"></p>
<div class="voice-browser__samples" data-role="voice-modal-samples"></div>
<div class="voice-browser__actions">
<button type="button" class="button" data-role="voice-modal-apply" disabled>Use this voice</button>
</div>
</section>
</div>
</div>
</div>
</section>
{% endblock %}
{% block scripts %}
{{ super() }}
<script id="voice-catalog-data" type="application/json">{{ options.voice_catalog | tojson }}</script>
<script id="voice-language-map" type="application/json">{{ options.languages | tojson }}</script>
<script type="module" src="{{ url_for('static', filename='speakers.js') }}"></script>
<script type="module" src="{{ url_for('static', filename='prepare.js') }}"></script>
{% endblock %}
+10
View File
@@ -102,6 +102,16 @@
{% endfor %}
</select>
</div>
<div class="field">
<label for="speaker_random_languages">Randomizer Languages</label>
{% set selected_languages = settings.speaker_random_languages or [] %}
<select id="speaker_random_languages" name="speaker_random_languages" multiple size="6">
{% for code, label in options.languages.items() %}
<option value="{{ code }}" {% if code in selected_languages %}selected{% endif %}>{{ label }} ({{ code|upper }})</option>
{% endfor %}
</select>
<p class="hint">Limits random voice selection for speakers marked as random. Leave unused to allow any language.</p>
</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 }}">
+1 -21
View File
@@ -1,7 +1,6 @@
{% extends "base.html" %}
{% macro speaker_row(row_key, speaker, options) %}
{% set languages = speaker.languages or speaker.config_languages or [] %}
{% set gender = (speaker.gender or 'unknown') %}
{% set randomize = speaker.randomize %}
<div class="speaker-config-row" data-role="speaker-row" data-row-id="{{ row_key }}">
@@ -29,15 +28,6 @@
{% endfor %}
</select>
</label>
<label class="field" for="speaker-{{ row_key }}-languages">
<span>Allowed languages</span>
<select id="speaker-{{ row_key }}-languages" name="speaker-{{ row_key }}-languages" multiple size="4">
{% for code, label in options.languages.items() %}
<option value="{{ code }}" {% if code in languages %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<p class="hint">Limit randomization to specific TTS language families.</p>
</label>
</div>
<div class="speaker-config-row__footer">
<label class="toggle-pill">
@@ -115,16 +105,6 @@
{% endfor %}
</select>
</label>
<label class="field" for="config_languages">
<span>Allowed languages</span>
{% set allowed_languages = editing.languages or [] %}
<select id="config_languages" name="config_languages" multiple size="5">
{% for code, label in options.languages.items() %}
<option value="{{ code }}" {% if code in allowed_languages %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<p class="hint">Optional: restrict auto-randomized voices to these languages.</p>
</label>
<label class="field" for="config_default_voice">
<span>Fallback voice</span>
<select id="config_default_voice" name="config_default_voice">
@@ -167,7 +147,7 @@
</div>
<template id="speaker-row-template">
{{ speaker_row('__ROW__', {'id': '', 'label': '', 'gender': 'unknown', 'languages': [], 'voice': '', 'randomize': True}, options) | safe }}
{{ speaker_row('__ROW__', {'id': '', 'label': '', 'gender': 'unknown', 'voice': '', 'randomize': True}, options) | safe }}
</template>
</section>
{% endblock %}