mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-19 22:00:28 +02:00
feat: Add speaker configuration management and UI enhancements
- Introduced a new speaker configuration page with the ability to create, edit, and delete speaker presets. - Added a step indicator to guide users through the audiobook workflow. - Enhanced the audiobook creation process by allowing users to select speaker presets and configure individual speaker settings. - Implemented dynamic UI elements for managing speaker rows, including adding and removing speakers. - Updated existing templates to integrate speaker configuration features and improve user experience. - Added JavaScript functionality for managing speaker rows and ensuring proper form handling. - Created a new module for handling speaker configuration data storage and retrieval.
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
{% 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.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>
|
||||
</nav>
|
||||
|
||||
@@ -4,6 +4,20 @@
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<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>
|
||||
<span class="step-indicator__item">
|
||||
<span class="step-indicator__index">2</span>
|
||||
<span class="step-indicator__label">Speakers</span>
|
||||
</span>
|
||||
<span class="step-indicator__item">
|
||||
<span class="step-indicator__index">3</span>
|
||||
<span class="step-indicator__label">Queue</span>
|
||||
</span>
|
||||
</div>
|
||||
<h1 class="card__title">Create a New Audiobook</h1>
|
||||
<form action="{{ url_for('web.enqueue_job') }}" method="post" enctype="multipart/form-data" class="grid grid--two form-grid">
|
||||
<div class="grid">
|
||||
@@ -45,6 +59,16 @@
|
||||
<label for="voice_formula">Custom Voice Formula</label>
|
||||
<input type="text" id="voice_formula" name="voice_formula" placeholder="af_nova*0.4+am_liam*0.6" data-role="voice-formula">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="speaker_config">Speaker preset</label>
|
||||
<select id="speaker_config" name="speaker_config">
|
||||
<option value="">None</option>
|
||||
{% for config in options.speaker_configs %}
|
||||
<option value="{{ config.name }}">{{ config.name }} · {{ config.speakers|length }} speaker{% if config.speakers|length != 1 %}s{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="hint">Optional: reuse a saved roster to keep character voices consistent.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="speed">Speed <span class="tag" id="speed_value">1.00×</span></label>
|
||||
<input type="range" id="speed" name="speed" min="0.6" max="1.4" step="0.05" value="1.0" oninput="document.getElementById('speed_value').textContent = Number.parseFloat(this.value).toFixed(2) + '×';">
|
||||
|
||||
@@ -4,150 +4,309 @@
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<div class="step-indicator" aria-label="Audiobook workflow">
|
||||
<span class="step-indicator__item is-complete">
|
||||
<span class="step-indicator__index">1</span>
|
||||
<span class="step-indicator__label">Upload</span>
|
||||
</span>
|
||||
<span class="step-indicator__item is-active">
|
||||
<span class="step-indicator__index">2</span>
|
||||
<span class="step-indicator__label">Speakers</span>
|
||||
</span>
|
||||
<span class="step-indicator__item">
|
||||
<span class="step-indicator__index">3</span>
|
||||
<span class="step-indicator__label">Queue</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card__title">Prepare audiobook</div>
|
||||
<p class="card__subtitle">Review the detected chapters, choose which ones to render, and optionally override the voice per chapter before sending the job to the queue.</p>
|
||||
<p class="card__subtitle">Review the detected chapters, tune the speaker roster, and optionally override the voice per chapter before sending the job to the queue.</p>
|
||||
|
||||
{% set analysis = pending.speaker_analysis or {} %}
|
||||
{% set analysis_speakers = analysis.get('speakers', {}) %}
|
||||
{% set show_analysis_prompt = pending.speaker_mode == 'multi' and not pending.analysis_requested %}
|
||||
{% set has_metadata = pending.metadata_tags %}
|
||||
<div class="prepare-summary">
|
||||
<div class="prepare-summary__stats">
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Source</dt>
|
||||
<dd>{{ pending.original_filename }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Language</dt>
|
||||
<dd>{{ options.languages.get(pending.language, pending.language|upper) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Default voice</dt>
|
||||
<dd>{% if pending.voice_profile %}Profile · {{ pending.voice_profile }}{% else %}{{ pending.voice }}{% endif %}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Total chapters</dt>
|
||||
<dd>{{ pending.chapters|length }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Characters</dt>
|
||||
<dd>{{ '{:,}'.format(pending.total_characters|default(0)) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
{% if analysis_speakers or show_analysis_prompt or has_metadata %}
|
||||
<div class="prepare-summary__insights">
|
||||
{% 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>
|
||||
{% elif show_analysis_prompt %}
|
||||
<div class="prepare-analysis">
|
||||
<h2>Detected speakers</h2>
|
||||
<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>
|
||||
{% 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 error %}
|
||||
<div class="alert alert--error">{{ error }}</div>
|
||||
{% endif %}
|
||||
{% if notice %}
|
||||
<div class="alert alert--info">{{ notice }}</div>
|
||||
{% endif %}
|
||||
{% set speaker_configs = options.speaker_configs or [] %}
|
||||
{% set applied_languages = pending.speaker_voice_languages or analysis.get('config_languages', []) or [] %}
|
||||
{% set recommended_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %}
|
||||
|
||||
<form method="post" action="{{ url_for('web.finalize_job', pending_id=pending.id) }}" class="prepare-form" id="prepare-form">
|
||||
<div class="prepare-summary">
|
||||
<div class="prepare-summary__stats">
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Source</dt>
|
||||
<dd>{{ pending.original_filename }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Language</dt>
|
||||
<dd>{{ options.languages.get(pending.language, pending.language|upper) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Default voice</dt>
|
||||
<dd>{% if pending.voice_profile %}Profile · {{ pending.voice_profile }}{% else %}{{ pending.voice }}{% endif %}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Total chapters</dt>
|
||||
<dd>{{ pending.chapters|length }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Characters</dt>
|
||||
<dd>{{ '{:,}'.format(pending.total_characters|default(0)) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
{% if applied_languages %}
|
||||
<div>
|
||||
<dt>Config languages</dt>
|
||||
<dd>{{ applied_languages | map('upper') | join(', ') }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
{% if analysis_speakers or show_analysis_prompt or has_metadata %}
|
||||
<div class="prepare-summary__insights">
|
||||
{% if analysis_speakers %}
|
||||
{% set ordered_ids = analysis.get('ordered_speakers', []) %}
|
||||
<div class="prepare-analysis">
|
||||
<h2>Detected speakers</h2>
|
||||
{% if ordered_ids %}
|
||||
<ul>
|
||||
{% for sid in ordered_ids %}
|
||||
{% set speaker = analysis_speakers.get(sid) %}
|
||||
{% if speaker %}
|
||||
<li>
|
||||
<strong>{{ speaker.label }}</strong>
|
||||
{% if speaker.gender and speaker.gender != 'unknown' %}
|
||||
· {{ speaker.gender|title }}
|
||||
{% endif %}
|
||||
· {{ speaker.count }} lines · confidence {{ speaker.confidence|title }}
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>No additional speakers met the threshold yet. All dialogue will use the narrator voice.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% elif show_analysis_prompt %}
|
||||
<div class="prepare-analysis">
|
||||
<h2>Detected speakers</h2>
|
||||
<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>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert--error">{{ error }}</div>
|
||||
{% endif %}
|
||||
{% if notice %}
|
||||
<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>
|
||||
|
||||
{% 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>
|
||||
<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>
|
||||
<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>
|
||||
</label>
|
||||
<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>
|
||||
<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 %}
|
||||
@@ -206,6 +365,7 @@
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="prepare-options">
|
||||
<div class="field">
|
||||
<label for="chunk_level">Chunk granularity</label>
|
||||
@@ -241,6 +401,7 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="prepare-actions">
|
||||
<button type="submit"
|
||||
class="button button--ghost"
|
||||
|
||||
@@ -3,7 +3,27 @@
|
||||
{% block title %}abogen · Queue{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card" 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 }}
|
||||
<section class="card">
|
||||
<div class="step-indicator" aria-label="Audiobook workflow">
|
||||
<span class="step-indicator__item is-complete">
|
||||
<span class="step-indicator__index">1</span>
|
||||
<span class="step-indicator__label">Upload</span>
|
||||
</span>
|
||||
<span class="step-indicator__item is-complete">
|
||||
<span class="step-indicator__index">2</span>
|
||||
<span class="step-indicator__label">Speakers</span>
|
||||
</span>
|
||||
<span class="step-indicator__item is-active">
|
||||
<span class="step-indicator__index">3</span>
|
||||
<span class="step-indicator__label">Queue</span>
|
||||
</span>
|
||||
</div>
|
||||
<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 %}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
{% 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 }}">
|
||||
<input type="hidden" name="speaker_rows" value="{{ row_key }}">
|
||||
<input type="hidden" name="speaker-{{ row_key }}-id" value="{{ speaker.id or row_key }}">
|
||||
<div class="speaker-config-row__grid">
|
||||
<label class="field" for="speaker-{{ row_key }}-label">
|
||||
<span>Label</span>
|
||||
<input type="text" id="speaker-{{ row_key }}-label" name="speaker-{{ row_key }}-label" value="{{ speaker.label or '' }}" placeholder="Character name" required>
|
||||
</label>
|
||||
<label class="field" for="speaker-{{ row_key }}-gender">
|
||||
<span>Gender</span>
|
||||
<select id="speaker-{{ row_key }}-gender" name="speaker-{{ row_key }}-gender">
|
||||
<option value="unknown" {% if gender == 'unknown' %}selected{% endif %}>Unknown</option>
|
||||
<option value="female" {% if gender == 'female' %}selected{% endif %}>Female</option>
|
||||
<option value="male" {% if gender == 'male' %}selected{% endif %}>Male</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field" for="speaker-{{ row_key }}-voice">
|
||||
<span>Preferred voice</span>
|
||||
<select id="speaker-{{ row_key }}-voice" name="speaker-{{ row_key }}-voice">
|
||||
<option value="" {% if not speaker.voice %}selected{% endif %}>Random compatible voice</option>
|
||||
{% for voice in options.voice_catalog %}
|
||||
<option value="{{ voice.id }}" {% if speaker.voice == voice.id %}selected{% endif %}>{{ voice.display_name }} · {{ voice.language_label }} · {{ voice.gender }}</option>
|
||||
{% 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">
|
||||
<input type="checkbox" name="speaker-{{ row_key }}-randomize" value="1" {% if randomize %}checked{% endif %}>
|
||||
<span>Randomize compatible voice when applied</span>
|
||||
</label>
|
||||
<button type="button" class="button button--ghost button--small" data-action="remove-speaker">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% block title %}Speaker presets{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card speaker-configs">
|
||||
<div class="step-indicator" aria-label="Audiobook workflow">
|
||||
<span class="step-indicator__item is-complete">
|
||||
<span class="step-indicator__index">1</span>
|
||||
<span class="step-indicator__label">Upload</span>
|
||||
</span>
|
||||
<span class="step-indicator__item is-active">
|
||||
<span class="step-indicator__index">2</span>
|
||||
<span class="step-indicator__label">Speakers</span>
|
||||
</span>
|
||||
<span class="step-indicator__item">
|
||||
<span class="step-indicator__index">3</span>
|
||||
<span class="step-indicator__label">Queue</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card__title">Speaker presets</div>
|
||||
<p class="card__subtitle">Store recurring casts, control randomization defaults, and keep voices consistent between books.</p>
|
||||
|
||||
{% if message %}
|
||||
<div class="alert alert--success">{{ message }}</div>
|
||||
{% endif %}
|
||||
{% if error %}
|
||||
<div class="alert alert--error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="speaker-configs__layout">
|
||||
<aside class="speaker-configs__sidebar">
|
||||
<div class="speaker-configs__sidebar-header">
|
||||
<h2>Saved presets</h2>
|
||||
<a class="button button--ghost button--small" href="{{ url_for('web.speaker_configs_page') }}">New preset</a>
|
||||
</div>
|
||||
<ul class="speaker-configs__list">
|
||||
{% for config in configs %}
|
||||
<li class="speaker-configs__entry {% if editing_name == config.name %}is-active{% endif %}">
|
||||
<a href="{{ url_for('web.speaker_configs_page', config=config.name) }}">{{ config.name }}</a>
|
||||
<span class="speaker-configs__meta">{{ config.speakers|length }} speaker{% if config.speakers|length != 1 %}s{% endif %}</span>
|
||||
<form method="post" action="{{ url_for('web.delete_speaker_config_route', name=config.name) }}" class="speaker-configs__delete" onsubmit="return confirm('Delete preset {{ config.name }}?');">
|
||||
<button type="submit" class="link-button">Delete</button>
|
||||
</form>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="speaker-configs__empty">No presets yet. Create your first configuration on the right.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</aside>
|
||||
<div class="speaker-configs__editor">
|
||||
<form method="post" class="speaker-config-form" id="speaker-config-form">
|
||||
<input type="hidden" name="config_version" value="{{ editing.version or 1 }}">
|
||||
<div class="speaker-config-form__grid">
|
||||
<label class="field" for="config_name">
|
||||
<span>Preset name</span>
|
||||
<input type="text" id="config_name" name="config_name" value="{{ editing_name }}" placeholder="Mystery duo" required>
|
||||
</label>
|
||||
<label class="field" for="config_language">
|
||||
<span>Primary language</span>
|
||||
<select id="config_language" name="config_language">
|
||||
{% for code, label in options.languages.items() %}
|
||||
<option value="{{ code }}" {% if editing.language == code %}selected{% endif %}>{{ label }}</option>
|
||||
{% 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">
|
||||
<option value="" {% if not editing.default_voice %}selected{% endif %}>Use narrator voice</option>
|
||||
{% for voice in options.voice_catalog %}
|
||||
<option value="{{ voice.id }}" {% if editing.default_voice == voice.id %}selected{% endif %}>{{ voice.display_name }} · {{ voice.language_label }} · {{ voice.gender }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field field--full" for="config_notes">
|
||||
<span>Notes</span>
|
||||
<textarea id="config_notes" name="config_notes" rows="3" placeholder="Pronunciation reminders, references, or character context...">{{ editing.notes or '' }}</textarea>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<section class="speaker-config-rows">
|
||||
<div class="speaker-config-rows__header">
|
||||
<h3>Speakers</h3>
|
||||
<button type="button" class="button button--ghost button--small" data-action="add-speaker">Add speaker</button>
|
||||
</div>
|
||||
<p class="hint">Add each recurring character, set their gender, and decide whether to randomize a compatible voice automatically.</p>
|
||||
<div class="speaker-config-rows__list" data-role="speaker-rows">
|
||||
{% set speakers_map = editing.speakers or {} %}
|
||||
{% if speakers_map %}
|
||||
{% for speaker_id, speaker in speakers_map|dictsort(attribute='1.label') %}
|
||||
{{ speaker_row(speaker_id, speaker, options) }}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="speaker-config-rows__empty" data-role="empty-state">No speakers yet. Add your first character.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="speaker-configs__actions">
|
||||
<button type="submit" class="button">Save configuration</button>
|
||||
<a class="button button--ghost" href="{{ url_for('web.index') }}">Back to dashboard</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template id="speaker-row-template">
|
||||
{{ speaker_row('__ROW__', {'id': '', 'label': '', 'gender': 'unknown', 'languages': [], 'voice': '', 'randomize': True}, options) | safe }}
|
||||
</template>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script type="module" src="{{ url_for('static', filename='speaker-configs.js') }}"></script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user