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
+193 -7
View File
@@ -7,15 +7,17 @@ import re
import subprocess
import sys
import tempfile
from collections import defaultdict
from contextlib import ExitStack
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, cast
from typing import Any, Callable, Dict, Iterable, List, Optional, cast
import numpy as np
import soundfile as sf
import static_ffmpeg
from abogen.epub3.exporter import build_epub3_package
from abogen.kokoro_text_normalization import (
ApostropheConfig,
apply_phoneme_hints,
@@ -320,6 +322,62 @@ def _chapter_voice_spec(job: Job, override: Optional[Dict[str, Any]]) -> str:
return job.voice or ""
def _chunk_voice_spec(job: Any, chunk: Dict[str, Any], fallback: str) -> str:
for key in ("resolved_voice", "voice_formula", "voice"):
value = chunk.get(key)
if value:
return str(value)
speaker_id = chunk.get("speaker_id")
speakers = getattr(job, "speakers", None)
if isinstance(speakers, dict) and speaker_id in speakers:
speaker_entry = speakers.get(speaker_id) or {}
if isinstance(speaker_entry, dict):
for key in ("resolved_voice", "voice_formula", "voice"):
value = speaker_entry.get(key)
if value:
return str(value)
profile_formula = speaker_entry.get("voice_formula")
if profile_formula:
return str(profile_formula)
profile_name = chunk.get("voice_profile")
if profile_name:
if isinstance(speakers, dict):
speaker_entry = speakers.get(profile_name)
if isinstance(speaker_entry, dict):
for key in ("resolved_voice", "voice_formula", "voice"):
value = speaker_entry.get(key)
if value:
return str(value)
return fallback or getattr(job, "voice", "") or ""
def _group_chunks_by_chapter(chunks: Iterable[Dict[str, Any]]) -> Dict[int, List[Dict[str, Any]]]:
grouped: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
for entry in chunks or []:
if not isinstance(entry, dict):
continue
try:
chapter_index = int(entry.get("chapter_index", 0))
except (TypeError, ValueError):
chapter_index = 0
grouped[chapter_index].append(dict(entry))
for chapter_index, items in grouped.items():
items.sort(key=lambda payload: _safe_int(payload.get("chunk_index")))
return grouped
def _safe_int(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def _escape_ffmetadata_value(value: str) -> str:
escaped = str(value).replace("\\", "\\\\").replace("\n", "\\n")
escaped = escaped.replace("=", "\\=").replace(";", "\\;").replace("#", "\\#")
@@ -559,7 +617,8 @@ def run_conversion_job(job: Job) -> None:
subtitle_writer: Optional[SubtitleWriter] = None
chapter_paths: list[Path] = []
chapter_markers: List[Dict[str, Any]] = []
metadata_payload: Dict[str, Any] = {"metadata": {}, "chapters": []}
chunk_markers: List[Dict[str, Any]] = []
metadata_payload: Dict[str, Any] = {}
audio_output_path: Optional[Path] = None
try:
pipeline = _load_pipeline(job)
@@ -598,6 +657,7 @@ def run_conversion_job(job: Job) -> None:
metadata_overrides: Dict[str, Any] = dict(job.metadata_tags or {})
active_chapter_configs: List[Dict[str, Any]] = []
chunk_groups: Dict[int, List[Dict[str, Any]]] = {}
if job.chapters:
selected_chapters, chapter_metadata, diagnostics = _apply_chapter_overrides(
extraction.chapters,
@@ -615,8 +675,12 @@ def run_conversion_job(job: Job) -> None:
active_chapter_configs = [
entry for entry in job.chapters if _coerce_truthy(entry.get("enabled", True))
][: len(selected_chapters)]
if job.chunks:
chunk_groups = _group_chunks_by_chapter(job.chunks)
else:
raise ValueError("No chapters were enabled in the requested job.")
elif job.chunks:
chunk_groups = _group_chunks_by_chapter(job.chunks)
job.metadata_tags = _merge_metadata(extraction.metadata, metadata_overrides)
@@ -661,6 +725,10 @@ def run_conversion_job(job: Job) -> None:
subtitle_index = 1
current_time = 0.0
total_chapters = len(extraction.chapters)
if chunk_groups:
chunk_groups = {
idx: items for idx, items in chunk_groups.items() if 0 <= idx < total_chapters
}
job.add_log(f"Detected {total_chapters} chapter{'s' if total_chapters != 1 else ''}")
def emit_text(
@@ -800,11 +868,93 @@ def run_conversion_job(job: Job) -> None:
chapter_sink=chapter_sink,
)
segments_emitted += emit_text(
chapter.text,
voice_choice=voice_choice,
chapter_sink=chapter_sink,
)
chunks_for_chapter = chunk_groups.get(idx - 1, []) if chunk_groups else []
body_segments = 0
if chunks_for_chapter:
job.add_log(
f"Emitting {len(chunks_for_chapter)} {job.chunk_level} chunks for chapter {idx}.",
level="debug",
)
for chunk_entry in chunks_for_chapter:
chunk_text = str(chunk_entry.get("text") or "").strip()
if not chunk_text:
continue
chunk_voice_spec = _chunk_voice_spec(
job,
chunk_entry,
chapter_voice_spec or base_voice_spec,
)
if not chunk_voice_spec:
chunk_voice_spec = chapter_voice_spec or base_voice_spec
if chunk_voice_spec == chapter_voice_spec:
chunk_voice_choice = voice_choice
else:
chunk_voice_choice = voice_cache.get(chunk_voice_spec)
if chunk_voice_choice is None:
chunk_voice_choice = _resolve_voice(
pipeline,
chunk_voice_spec,
job.use_gpu,
)
voice_cache[chunk_voice_spec] = chunk_voice_choice
chunk_start = current_time
emitted = emit_text(
chunk_text,
voice_choice=chunk_voice_choice,
chapter_sink=chapter_sink,
preview_prefix=f"Chunk {chunk_entry.get('id') or chunk_entry.get('chunk_index')}",
)
if emitted <= 0:
continue
body_segments += emitted
segments_emitted += emitted
chunk_markers.append(
{
"id": chunk_entry.get("id"),
"chapter_index": idx - 1,
"chunk_index": _safe_int(
chunk_entry.get("chunk_index"), len(chunk_markers)
),
"start": chunk_start,
"end": current_time,
"speaker_id": chunk_entry.get("speaker_id", "narrator"),
"voice": chunk_voice_spec,
"level": chunk_entry.get("level", job.chunk_level),
"characters": len(chunk_text),
}
)
if body_segments == 0:
chapter_body_start = current_time
emitted = emit_text(
chapter.text,
voice_choice=voice_choice,
chapter_sink=chapter_sink,
)
if emitted > 0:
segments_emitted += emitted
chunk_markers.append(
{
"id": None,
"chapter_index": idx - 1,
"chunk_index": 0,
"start": chapter_body_start,
"end": current_time,
"speaker_id": "narrator",
"voice": chapter_voice_spec,
"level": job.chunk_level,
"characters": len(chapter.text or ""),
}
)
elif chunks_for_chapter:
job.add_log(
"No audio generated for supplied chunks; chapter text also empty.",
level="warning",
)
chapter_end_time = current_time
@@ -849,6 +999,11 @@ def run_conversion_job(job: Job) -> None:
metadata_payload = {
"metadata": dict(job.metadata_tags or {}),
"chapters": chapter_markers,
"chunks": chunk_markers,
"chunk_level": job.chunk_level,
"speaker_mode": job.speaker_mode,
"speakers": dict(getattr(job, "speakers", {}) or {}),
"generate_epub3": job.generate_epub3,
}
if metadata_dir:
@@ -857,6 +1012,37 @@ def run_conversion_job(job: Job) -> None:
metadata_file.write_text(json.dumps(metadata_payload, indent=2), encoding="utf-8")
job.result.artifacts["metadata"] = metadata_file
if job.generate_epub3:
audio_asset = job.result.audio_path
if not audio_asset and chapter_paths:
audio_asset = chapter_paths[0]
if audio_asset:
try:
epub_root = project_root if job.save_as_project else base_output_dir
epub_output_path = _build_output_path(epub_root, job.original_filename, "epub")
job.add_log("Generating EPUB 3 package with synchronized narration…")
epub_path = build_epub3_package(
output_path=epub_output_path,
book_id=job.id,
extraction=extraction,
metadata_tags=metadata_payload.get("metadata") or {},
chapter_markers=chapter_markers,
chunk_markers=chunk_markers,
chunks=job.chunks,
audio_path=audio_asset,
speaker_mode=job.speaker_mode,
cover_image_path=job.cover_image_path,
cover_image_mime=job.cover_image_mime,
)
job.result.epub_path = epub_path
job.result.artifacts["epub3"] = epub_path
job.add_log(f"EPUB 3 package created at {epub_path}")
except Exception as exc:
job.add_log(f"Failed to generate EPUB 3 package: {exc}", level="error")
else:
job.add_log("Skipped EPUB 3 generation: audio output unavailable.", level="warning")
if job.save_as_project:
job.result.artifacts["project_root"] = project_root
+430 -4
View File
@@ -9,7 +9,7 @@ import threading
import time
import uuid
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple, cast
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, cast
from flask import (
Blueprint,
@@ -35,6 +35,7 @@ from abogen.constants import (
SUPPORTED_SOUND_FORMATS,
VOICES_INTERNAL,
)
from abogen.chunking import ChunkLevel, build_chunks_for_chapters
from abogen.utils import (
calculate_text_length,
clean_text,
@@ -57,6 +58,7 @@ from abogen.voice_profiles import (
)
from abogen.voice_formulas import get_new_voice
from abogen.speaker_analysis import analyze_speakers
from abogen.text_extractor import extract_from_path
from .conversion_runner import SPLIT_PATTERN, SAMPLE_RATE, _select_device, _to_float32
from .service import ConversionService, Job, JobStatus, PendingJob
@@ -69,6 +71,174 @@ _preview_pipeline_lock = threading.RLock()
_preview_pipelines: Dict[Tuple[str, str], Any] = {}
_CHUNK_LEVEL_OPTIONS = [
{"value": "paragraph", "label": "Paragraphs"},
{"value": "sentence", "label": "Sentences"},
]
_SPEAKER_MODE_OPTIONS = [
{"value": "single", "label": "Single Speaker"},
{"value": "multi", "label": "Multi-Speaker"},
]
_CHUNK_LEVEL_VALUES = {option["value"] for option in _CHUNK_LEVEL_OPTIONS}
_SPEAKER_MODE_VALUES = {option["value"] for option in _SPEAKER_MODE_OPTIONS}
_DEFAULT_ANALYSIS_THRESHOLD = 3
_MAX_ANALYSIS_SPEAKERS = 6
def _build_narrator_roster(
voice: str,
voice_profile: Optional[str],
existing: Optional[Mapping[str, Any]] = None,
) -> Dict[str, Any]:
roster: Dict[str, Any] = {
"narrator": {
"id": "narrator",
"label": "Narrator",
"voice": voice,
}
}
if voice_profile:
roster["narrator"]["voice_profile"] = voice_profile
existing_entry: Optional[Mapping[str, Any]] = None
if existing is not None:
existing_entry = existing.get("narrator") if isinstance(existing, Mapping) else None
if isinstance(existing_entry, Mapping):
roster_entry = roster["narrator"]
for key in ("label", "voice", "voice_profile", "voice_formula", "pronunciation"):
value = existing_entry.get(key)
if value is not None and value != "":
roster_entry[key] = value
return roster
def _build_speaker_roster(
analysis: Dict[str, Any],
base_voice: str,
voice_profile: Optional[str],
existing: Optional[Mapping[str, Any]] = None,
) -> Dict[str, Any]:
roster = _build_narrator_roster(base_voice, voice_profile, existing)
existing_map: Dict[str, Any] = dict(existing) if isinstance(existing, Mapping) else {}
speakers = analysis.get("speakers", {}) if isinstance(analysis, dict) else {}
for speaker_id, payload in speakers.items():
if speaker_id == "narrator":
continue
if payload.get("suppressed"):
continue
previous = existing_map.get(speaker_id)
roster[speaker_id] = {
"id": speaker_id,
"label": payload.get("label") or speaker_id.replace("_", " ").title(),
"voice": base_voice,
"analysis_confidence": payload.get("confidence"),
"analysis_count": payload.get("count"),
}
if isinstance(previous, Mapping):
for key in ("voice", "voice_profile", "voice_formula", "resolved_voice", "pronunciation"):
value = previous.get(key)
if value is not None and value != "":
roster[speaker_id][key] = value
return roster
def _prepare_speaker_metadata(
*,
chapters: List[Dict[str, Any]],
chunks: List[Dict[str, Any]],
speaker_mode: str,
voice: str,
voice_profile: Optional[str],
threshold: int,
existing_roster: Optional[Mapping[str, Any]] = None,
) -> tuple[List[Dict[str, Any]], Dict[str, Any], Dict[str, Any]]:
chunk_list = [dict(chunk) for chunk in chunks]
threshold_value = max(1, int(threshold))
if speaker_mode != "multi":
for chunk in chunk_list:
chunk["speaker_id"] = "narrator"
chunk["speaker_label"] = "Narrator"
analysis_payload = {
"version": "1.0",
"narrator": "narrator",
"assignments": {str(chunk.get("id")): "narrator" for chunk in chunk_list},
"speakers": {
"narrator": {
"id": "narrator",
"label": "Narrator",
"count": len(chunk_list),
"confidence": "low",
"sample_quotes": [],
"suppressed": False,
}
},
"suppressed": [],
"stats": {
"total_chunks": len(chunk_list),
"explicit_chunks": 0,
"active_speakers": 0,
"unique_speakers": 1,
"suppressed": 0,
},
}
roster = _build_narrator_roster(voice, voice_profile, existing_roster)
narrator_pron = roster["narrator"].get("pronunciation")
if narrator_pron:
analysis_payload["speakers"]["narrator"]["pronunciation"] = narrator_pron
return chunk_list, roster, analysis_payload
analysis_result = analyze_speakers(
chapters, chunk_list, threshold=threshold_value, max_speakers=_MAX_ANALYSIS_SPEAKERS
)
analysis_payload = analysis_result.to_dict()
assignments = analysis_payload.get("assignments", {})
suppressed_ids = analysis_payload.get("suppressed", [])
suppressed_details: List[Dict[str, Any]] = []
speakers_payload = analysis_payload.get("speakers", {})
if isinstance(suppressed_ids, Iterable):
for suppressed_id in suppressed_ids:
speaker_meta = speakers_payload.get(suppressed_id) if isinstance(speakers_payload, dict) else None
if isinstance(speaker_meta, dict):
suppressed_details.append(
{
"id": suppressed_id,
"label": speaker_meta.get("label")
or str(suppressed_id).replace("_", " ").title(),
"pronunciation": speaker_meta.get("pronunciation"),
}
)
else:
suppressed_details.append(
{
"id": suppressed_id,
"label": str(suppressed_id).replace("_", " ").title(),
"pronunciation": None,
}
)
analysis_payload["suppressed_details"] = suppressed_details
roster = _build_speaker_roster(analysis_payload, voice, voice_profile, existing=existing_roster)
speakers_payload = analysis_payload.get("speakers")
if isinstance(speakers_payload, dict):
for roster_id, roster_payload in roster.items():
if roster_id in speakers_payload and isinstance(roster_payload, dict):
pronunciation_value = roster_payload.get("pronunciation")
if pronunciation_value:
speakers_payload[roster_id]["pronunciation"] = pronunciation_value
for chunk in chunk_list:
chunk_id = str(chunk.get("id"))
speaker_id = assignments.get(chunk_id, "narrator")
chunk["speaker_id"] = speaker_id
speaker_meta = roster.get(speaker_id)
chunk["speaker_label"] = speaker_meta.get("label") if isinstance(speaker_meta, dict) else speaker_id
return chunk_list, roster, analysis_payload
_SUPPLEMENT_TITLE_PATTERNS: List[tuple[re.Pattern[str], float]] = [
(re.compile(r"\btitle\s+page\b"), 3.0),
(re.compile(r"\bcopyright\b"), 2.4),
@@ -196,6 +366,7 @@ def _build_voice_catalog() -> List[Dict[str, str]]:
def _template_options() -> Dict[str, Any]:
current_settings = _load_settings()
profiles = serialize_profiles()
ordered_profiles = sorted(profiles.items())
profile_options = []
@@ -219,6 +390,14 @@ def _template_options() -> Dict[str, Any]:
"voice_catalog": _build_voice_catalog(),
"sample_voice_texts": SAMPLE_VOICE_TEXTS,
"voice_profiles_data": profiles,
"chunk_levels": _CHUNK_LEVEL_OPTIONS,
"speaker_modes": _SPEAKER_MODE_OPTIONS,
"speaker_analysis_threshold": current_settings.get(
"speaker_analysis_threshold", _DEFAULT_ANALYSIS_THRESHOLD
),
"speaker_pronunciation_sentence": current_settings.get(
"speaker_pronunciation_sentence", _settings_defaults()["speaker_pronunciation_sentence"]
),
}
@@ -237,10 +416,11 @@ BOOLEAN_SETTINGS = {
"save_chapters_separately",
"merge_chapters_at_end",
"save_as_project",
"generate_epub3",
}
FLOAT_SETTINGS = {"silence_between_chapters", "chapter_intro_delay"}
INT_SETTINGS = {"max_subtitle_words"}
INT_SETTINGS = {"max_subtitle_words", "speaker_analysis_threshold"}
def _has_output_override() -> bool:
@@ -262,6 +442,11 @@ def _settings_defaults() -> Dict[str, Any]:
"silence_between_chapters": 2.0,
"chapter_intro_delay": 0.5,
"max_subtitle_words": 50,
"chunk_level": "paragraph",
"speaker_mode": "single",
"generate_epub3": False,
"speaker_analysis_threshold": _DEFAULT_ANALYSIS_THRESHOLD,
"speaker_pronunciation_sentence": "This is {{name}} speaking.",
}
@@ -323,6 +508,14 @@ def _normalize_setting_value(key: str, value: Any, defaults: Dict[str, Any]) ->
if isinstance(value, str) and value in VOICES_INTERNAL:
return value
return defaults[key]
if key == "chunk_level":
if isinstance(value, str) and value in _CHUNK_LEVEL_VALUES:
return value
return defaults[key]
if key == "speaker_mode":
if isinstance(value, str) and value in _SPEAKER_MODE_VALUES:
return value
return defaults[key]
return value if value is not None else defaults.get(key)
@@ -519,6 +712,12 @@ def settings_page() -> Response | str:
)
for key in sorted(BOOLEAN_SETTINGS):
updated[key] = _coerce_bool(form.get(key), False)
updated["chunk_level"] = _normalize_setting_value(
"chunk_level", form.get("chunk_level"), defaults
)
updated["speaker_mode"] = _normalize_setting_value(
"speaker_mode", form.get("speaker_mode"), defaults
)
updated["separate_chapters_format"] = _normalize_setting_value(
"separate_chapters_format", form.get("separate_chapters_format"), defaults
)
@@ -531,6 +730,16 @@ def settings_page() -> Response | str:
updated["max_subtitle_words"] = _coerce_int(
form.get("max_subtitle_words"), defaults["max_subtitle_words"]
)
updated["speaker_analysis_threshold"] = _coerce_int(
form.get("speaker_analysis_threshold"),
defaults["speaker_analysis_threshold"],
minimum=1,
maximum=25,
)
sentence_value = (form.get("speaker_pronunciation_sentence") or "").strip()
if not sentence_value:
sentence_value = defaults["speaker_pronunciation_sentence"]
updated["speaker_pronunciation_sentence"] = sentence_value
cfg = load_config() or {}
cfg.update(updated)
@@ -800,6 +1009,102 @@ def api_preview_voice_mix() -> Response:
return response
@api_bp.post("/speaker-preview")
def api_speaker_preview() -> Response:
payload = request.get_json(force=True, silent=False)
text = (payload.get("text") or "").strip()
voice_spec = (payload.get("voice") or "").strip()
language = (payload.get("language") or "a").strip() or "a"
speed_input = payload.get("speed", 1.0)
try:
speed = float(speed_input)
except (TypeError, ValueError):
speed = 1.0
max_seconds_input = payload.get("max_seconds", 8.0)
try:
max_seconds = max(1.0, min(15.0, float(max_seconds_input)))
except (TypeError, ValueError):
max_seconds = 8.0
if not text:
abort(400, "Preview text is required")
if not voice_spec:
abort(400, "Voice selection is required")
settings = _load_settings()
use_gpu_default = settings.get("use_gpu", True)
if "use_gpu" in payload:
use_gpu = _coerce_bool(payload.get("use_gpu"), use_gpu_default)
else:
use_gpu = use_gpu_default
device = "cpu"
if use_gpu:
try:
device = _select_device()
except Exception: # pragma: no cover - fallback
device = "cpu"
use_gpu = False
try:
pipeline = _get_preview_pipeline(language, device)
except Exception as exc: # pragma: no cover - defensive guard
abort(500, f"Failed to initialise preview pipeline: {exc}")
if pipeline is None: # pragma: no cover - defensive double-check
abort(500, "Preview pipeline initialisation failed")
voice_choice: Any = voice_spec
if "*" in voice_spec:
try:
voice_choice = get_new_voice(pipeline, voice_spec, use_gpu)
except ValueError as exc:
abort(400, str(exc))
segments = pipeline(
text,
voice=voice_choice,
speed=speed,
split_pattern=SPLIT_PATTERN,
)
audio_chunks: List[np.ndarray] = []
accumulated = 0
max_samples = int(max_seconds * SAMPLE_RATE)
for segment in segments:
graphemes = getattr(segment, "graphemes", "").strip()
if not graphemes:
continue
audio = _to_float32(getattr(segment, "audio", None))
if audio.size == 0:
continue
remaining = max_samples - accumulated
if remaining <= 0:
break
if audio.shape[0] > remaining:
audio = audio[:remaining]
audio_chunks.append(audio)
accumulated += audio.shape[0]
if accumulated >= max_samples:
break
if not audio_chunks:
abort(500, "Preview could not be generated")
audio_data = np.concatenate(audio_chunks)
buffer = io.BytesIO()
sf.write(buffer, audio_data, SAMPLE_RATE, format="WAV")
buffer.seek(0)
response = send_file(
buffer,
mimetype="audio/wav",
as_attachment=False,
download_name="speaker_preview.wav",
)
response.headers["Cache-Control"] = "no-store"
return response
@web_bp.post("/jobs")
def enqueue_job() -> Response:
service = _service()
@@ -921,6 +1226,41 @@ def enqueue_job() -> Response:
chapter_intro_delay = settings["chapter_intro_delay"]
max_subtitle_words = settings["max_subtitle_words"]
chunk_level_default = str(settings.get("chunk_level", "paragraph")).strip().lower()
raw_chunk_level = (request.form.get("chunk_level") or chunk_level_default).strip().lower()
if raw_chunk_level not in _CHUNK_LEVEL_VALUES:
raw_chunk_level = chunk_level_default if chunk_level_default in _CHUNK_LEVEL_VALUES else "paragraph"
chunk_level_value = raw_chunk_level
chunk_level_literal = cast(ChunkLevel, chunk_level_value)
speaker_mode_default = str(settings.get("speaker_mode", "single")).strip().lower()
raw_speaker_mode = (request.form.get("speaker_mode") or speaker_mode_default).strip().lower()
if raw_speaker_mode not in _SPEAKER_MODE_VALUES:
raw_speaker_mode = "single"
speaker_mode_value = raw_speaker_mode
generate_epub3_default = bool(settings.get("generate_epub3", False))
generate_epub3 = _coerce_bool(request.form.get("generate_epub3"), generate_epub3_default)
selected_chapter_sources = [entry for entry in chapters_payload if entry.get("enabled")]
raw_chunks = build_chunks_for_chapters(selected_chapter_sources, level=chunk_level_literal)
analysis_threshold = _coerce_int(
settings.get("speaker_analysis_threshold"),
_DEFAULT_ANALYSIS_THRESHOLD,
minimum=1,
maximum=25,
)
processed_chunks, speakers, analysis_payload = _prepare_speaker_metadata(
chapters=selected_chapter_sources,
chunks=raw_chunks,
speaker_mode=speaker_mode_value,
voice=voice,
voice_profile=selected_profile or None,
threshold=analysis_threshold,
)
pending = PendingJob(
id=uuid.uuid4().hex,
original_filename=original_name,
@@ -941,7 +1281,7 @@ def enqueue_job() -> Response:
separate_chapters_format=separate_chapters_format,
silence_between_chapters=silence_between_chapters,
save_as_project=save_as_project,
voice_profile=selected_profile,
voice_profile=selected_profile or None,
max_subtitle_words=max_subtitle_words,
metadata_tags=metadata_tags,
chapters=chapters_payload,
@@ -949,6 +1289,13 @@ def enqueue_job() -> Response:
cover_image_path=cover_path,
cover_image_mime=cover_mime,
chapter_intro_delay=chapter_intro_delay,
chunk_level=chunk_level_value,
speaker_mode=speaker_mode_value,
generate_epub3=generate_epub3,
chunks=processed_chunks,
speakers=speakers,
speaker_analysis=analysis_payload,
speaker_analysis_threshold=analysis_threshold,
)
service.store_pending_job(pending)
@@ -972,6 +1319,62 @@ def finalize_job(pending_id: str) -> Response:
abort(404)
pending = cast(PendingJob, pending)
raw_chunk_level = (request.form.get("chunk_level") or pending.chunk_level or "paragraph").strip().lower()
if raw_chunk_level not in _CHUNK_LEVEL_VALUES:
raw_chunk_level = pending.chunk_level if pending.chunk_level in _CHUNK_LEVEL_VALUES else "paragraph"
pending.chunk_level = raw_chunk_level
chunk_level_literal = cast(ChunkLevel, pending.chunk_level)
raw_speaker_mode = (request.form.get("speaker_mode") or pending.speaker_mode or "single").strip().lower()
if raw_speaker_mode not in _SPEAKER_MODE_VALUES:
raw_speaker_mode = "single"
pending.speaker_mode = raw_speaker_mode
pending.generate_epub3 = _coerce_bool(request.form.get("generate_epub3"), False)
threshold_default = getattr(pending, "speaker_analysis_threshold", _DEFAULT_ANALYSIS_THRESHOLD)
raw_threshold = request.form.get("speaker_analysis_threshold")
if raw_threshold is not None:
pending.speaker_analysis_threshold = _coerce_int(
raw_threshold,
threshold_default,
minimum=1,
maximum=25,
)
else:
pending.speaker_analysis_threshold = threshold_default
if not pending.speakers:
narrator: Dict[str, Any] = {
"id": "narrator",
"label": "Narrator",
"voice": pending.voice,
}
if pending.voice_profile:
narrator["voice_profile"] = pending.voice_profile
pending.speakers = {"narrator": narrator}
else:
existing_narrator = pending.speakers.get("narrator")
if isinstance(existing_narrator, dict):
existing_narrator.setdefault("id", "narrator")
existing_narrator["label"] = existing_narrator.get("label", "Narrator")
existing_narrator["voice"] = pending.voice
if pending.voice_profile:
existing_narrator["voice_profile"] = pending.voice_profile
pending.speakers["narrator"] = existing_narrator
if isinstance(pending.speakers, dict):
for speaker_id, payload in list(pending.speakers.items()):
if not isinstance(payload, dict):
continue
field_key = f"speaker-{speaker_id}-pronunciation"
raw_value = request.form.get(field_key, "")
pronunciation = raw_value.strip()
if pronunciation:
payload["pronunciation"] = pronunciation
else:
payload.pop("pronunciation", None)
profiles = serialize_profiles()
delay_value = pending.chapter_intro_delay
raw_delay = request.form.get("chapter_intro_delay")
@@ -1038,9 +1441,25 @@ def finalize_job(pending_id: str) -> Response:
overrides.append(entry)
pending.chapters[index] = dict(entry)
if not any(item.get("enabled") for item in overrides):
enabled_overrides = [entry for entry in overrides if entry.get("enabled")]
if not enabled_overrides:
pending.chunks = []
return _render_prepare_page(pending, error="Select at least one chapter to convert.")
raw_chunks = build_chunks_for_chapters(enabled_overrides, level=chunk_level_literal)
processed_chunks, roster, analysis_payload = _prepare_speaker_metadata(
chapters=enabled_overrides,
chunks=raw_chunks,
speaker_mode=pending.speaker_mode,
voice=pending.voice,
voice_profile=pending.voice_profile,
threshold=pending.speaker_analysis_threshold,
existing_roster=pending.speakers,
)
pending.chunks = processed_chunks
pending.speakers = roster
pending.speaker_analysis = analysis_payload
if errors:
return _render_prepare_page(pending, error=" ".join(errors))
@@ -1074,6 +1493,13 @@ def finalize_job(pending_id: str) -> Response:
cover_image_path=pending.cover_image_path,
cover_image_mime=pending.cover_image_mime,
chapter_intro_delay=pending.chapter_intro_delay,
chunk_level=pending.chunk_level,
chunks=processed_chunks,
speakers=roster,
speaker_mode=pending.speaker_mode,
speaker_analysis=analysis_payload,
speaker_analysis_threshold=pending.speaker_analysis_threshold,
generate_epub3=pending.generate_epub3,
)
return redirect(url_for("web.queue_page"))
+111 -1
View File
@@ -20,7 +20,7 @@ def _create_set_event() -> threading.Event:
return event
STATE_VERSION = 3
STATE_VERSION = 5
class JobStatus(str, Enum):
@@ -44,6 +44,7 @@ class JobResult:
audio_path: Optional[Path] = None
subtitle_paths: List[Path] = field(default_factory=list)
artifacts: Dict[str, Path] = field(default_factory=dict)
epub_path: Optional[Path] = None
@dataclass
@@ -89,6 +90,13 @@ class Job:
pause_event: threading.Event = field(default_factory=_create_set_event, repr=False, compare=False)
cover_image_path: Optional[Path] = None
cover_image_mime: Optional[str] = None
chunk_level: str = "paragraph"
chunks: List[Dict[str, Any]] = field(default_factory=list)
speakers: Dict[str, Any] = field(default_factory=dict)
speaker_mode: str = "single"
generate_epub3: bool = False
speaker_analysis: Dict[str, Any] = field(default_factory=dict)
speaker_analysis_threshold: int = 3
def add_log(self, message: str, level: str = "info") -> None:
self.logs.append(JobLog(timestamp=time.time(), message=message, level=level))
@@ -139,6 +147,13 @@ class Job:
}
for entry in self.chapters
],
"chunk_level": self.chunk_level,
"chunks": [dict(chunk) for chunk in self.chunks],
"speakers": dict(self.speakers),
"speaker_mode": self.speaker_mode,
"generate_epub3": self.generate_epub3,
"speaker_analysis": dict(self.speaker_analysis),
"speaker_analysis_threshold": self.speaker_analysis_threshold,
}
@@ -171,6 +186,13 @@ class PendingJob:
cover_image_path: Optional[Path] = None
cover_image_mime: Optional[str] = None
chapter_intro_delay: float = 0.5
chunk_level: str = "paragraph"
chunks: List[Dict[str, Any]] = field(default_factory=list)
speakers: Dict[str, Any] = field(default_factory=dict)
speaker_mode: str = "single"
generate_epub3: bool = False
speaker_analysis: Dict[str, Any] = field(default_factory=dict)
speaker_analysis_threshold: int = 3
class ConversionService:
@@ -234,10 +256,18 @@ class ConversionService:
cover_image_path: Optional[Path] = None,
cover_image_mime: Optional[str] = None,
chapter_intro_delay: float = 0.5,
chunk_level: str = "paragraph",
chunks: Optional[Iterable[Any]] = None,
speakers: Optional[Mapping[str, Any]] = None,
speaker_mode: str = "single",
generate_epub3: bool = False,
speaker_analysis: Optional[Mapping[str, Any]] = None,
speaker_analysis_threshold: int = 3,
) -> Job:
job_id = uuid.uuid4().hex
normalized_metadata = self._normalize_metadata_tags(metadata_tags)
normalized_chapters = self._normalize_chapters(chapters)
normalized_chunks = self._normalize_chunks(chunks)
if total_characters <= 0 and normalized_chapters:
total_characters = sum(len(str(entry.get("text", ""))) for entry in normalized_chapters)
job = Job(
@@ -268,6 +298,13 @@ class ConversionService:
cover_image_path=cover_image_path,
cover_image_mime=cover_image_mime,
chapter_intro_delay=chapter_intro_delay,
chunk_level=chunk_level,
chunks=normalized_chunks,
speakers=dict(speakers or {}),
speaker_mode=speaker_mode,
generate_epub3=bool(generate_epub3),
speaker_analysis=dict(speaker_analysis or {}),
speaker_analysis_threshold=int(speaker_analysis_threshold or 3),
)
with self._lock:
self._jobs[job_id] = job
@@ -490,6 +527,7 @@ class ConversionService:
result_audio = str(job.result.audio_path) if job.result.audio_path else None
result_subtitles = [str(path) for path in job.result.subtitle_paths]
result_artifacts = {key: str(path) for key, path in job.result.artifacts.items()}
result_epub = str(job.result.epub_path) if job.result.epub_path else None
return {
"id": job.id,
"original_filename": job.original_filename,
@@ -525,6 +563,7 @@ class ConversionService:
"audio_path": result_audio,
"subtitle_paths": result_subtitles,
"artifacts": result_artifacts,
"epub_path": result_epub,
},
"chapters": [dict(entry) for entry in job.chapters],
"queue_position": job.queue_position,
@@ -535,6 +574,13 @@ class ConversionService:
"cover_image_path": str(job.cover_image_path) if job.cover_image_path else None,
"cover_image_mime": job.cover_image_mime,
"chapter_intro_delay": job.chapter_intro_delay,
"chunk_level": job.chunk_level,
"chunks": [dict(entry) for entry in job.chunks],
"speakers": dict(job.speakers),
"speaker_mode": job.speaker_mode,
"generate_epub3": job.generate_epub3,
"speaker_analysis": dict(job.speaker_analysis),
"speaker_analysis_threshold": job.speaker_analysis_threshold,
}
def _persist_state(self) -> None:
@@ -631,6 +677,8 @@ class ConversionService:
job.result.artifacts = {
key: Path(value) for key, value in result_payload.get("artifacts", {}).items()
}
epub_path_raw = result_payload.get("epub_path")
job.result.epub_path = Path(epub_path_raw) if epub_path_raw else None
job.chapters = payload.get("chapters", [])
job.queue_position = payload.get("queue_position")
job.cancel_requested = bool(payload.get("cancel_requested", False))
@@ -640,6 +688,15 @@ class ConversionService:
cover_path_raw = payload.get("cover_image_path")
job.cover_image_path = Path(cover_path_raw) if cover_path_raw else None
job.cover_image_mime = payload.get("cover_image_mime")
job.chunk_level = str(payload.get("chunk_level", job.chunk_level or "paragraph"))
job.chunks = self._normalize_chunks(payload.get("chunks"))
job.speakers = dict(payload.get("speakers", {}))
job.speaker_mode = str(payload.get("speaker_mode", job.speaker_mode or "single"))
job.generate_epub3 = bool(payload.get("generate_epub3", job.generate_epub3))
job.speaker_analysis = payload.get("speaker_analysis", {})
job.speaker_analysis_threshold = int(
payload.get("speaker_analysis_threshold", job.speaker_analysis_threshold or 3)
)
job.pause_event.set()
return job
@@ -837,6 +894,59 @@ class ConversionService:
return normalized
@classmethod
def _normalize_chunks(cls, chunks: Optional[Iterable[Any]]) -> List[Dict[str, Any]]:
if not chunks:
return []
normalized: List[Dict[str, Any]] = []
for order, raw in enumerate(chunks):
if raw is None:
continue
if isinstance(raw, dict):
entry = dict(raw)
else:
continue
chunk: Dict[str, Any] = {}
identifier = entry.get("id") or entry.get("chunk_id")
if identifier is not None:
chunk["id"] = str(identifier)
try:
chunk_index = int(entry.get("chunk_index", order))
except (TypeError, ValueError):
chunk_index = order
chunk["chunk_index"] = chunk_index
try:
chapter_index = int(entry.get("chapter_index", 0))
except (TypeError, ValueError):
chapter_index = 0
chunk["chapter_index"] = chapter_index
level_raw = str(entry.get("level", "paragraph")).lower()
if level_raw not in {"paragraph", "sentence"}:
level_raw = "paragraph"
chunk["level"] = level_raw
text_value = entry.get("text")
if text_value is not None:
chunk["text"] = str(text_value)
else:
chunk["text"] = ""
speaker_value = entry.get("speaker_id", entry.get("speaker"))
chunk["speaker_id"] = str(speaker_value) if speaker_value else "narrator"
for key in ("voice", "voice_profile", "voice_formula", "audio_path", "start", "end"):
if key in entry and entry[key] is not None:
chunk[key] = entry[key]
normalized.append(chunk)
return normalized
def default_storage_root() -> Path:
base = Path.cwd()
+97
View File
@@ -0,0 +1,97 @@
const audioElement = new Audio();
let activeButton = null;
let activeUrl = null;
const setLoadingState = (button, isLoading) => {
if (!button) return;
button.disabled = isLoading;
if (isLoading) {
button.setAttribute("data-loading", "true");
} else {
button.removeAttribute("data-loading");
}
};
const stopCurrentPlayback = () => {
if (audioElement && !audioElement.paused) {
audioElement.pause();
}
if (activeUrl) {
URL.revokeObjectURL(activeUrl);
activeUrl = null;
}
if (activeButton) {
setLoadingState(activeButton, false);
activeButton = null;
}
};
audioElement.addEventListener("ended", () => {
stopCurrentPlayback();
});
audioElement.addEventListener("pause", () => {
if (audioElement.currentTime === 0 || audioElement.currentTime >= audioElement.duration) {
stopCurrentPlayback();
}
});
const playPreview = async (button) => {
const text = (button.dataset.previewText || "").trim();
const voice = (button.dataset.voice || "").trim();
const language = (button.dataset.language || "a").trim() || "a";
const speedRaw = button.dataset.speed || "1";
const useGpu = (button.dataset.useGpu || "true") !== "false";
const speed = Number.parseFloat(speedRaw);
if (!text) {
console.warn("Skipping speaker preview: no text provided");
return;
}
if (!voice) {
console.warn("Skipping speaker preview: no voice provided");
return;
}
const payload = {
text,
voice,
language,
speed: Number.isFinite(speed) ? speed : 1.0,
use_gpu: useGpu,
max_seconds: 8,
};
stopCurrentPlayback();
activeButton = button;
setLoadingState(button, true);
try {
const response = await fetch("/api/speaker-preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || `Preview failed with status ${response.status}`);
}
const blob = await response.blob();
activeUrl = URL.createObjectURL(blob);
audioElement.src = activeUrl;
await audioElement.play();
} catch (error) {
console.error("Failed to play speaker preview", error);
stopCurrentPlayback();
} finally {
setLoadingState(button, false);
}
};
document.addEventListener("click", (event) => {
const trigger = event.target.closest('[data-role="speaker-preview"]');
if (!trigger) return;
event.preventDefault();
if (trigger.disabled) return;
playPreview(trigger);
});
+148 -1
View File
@@ -673,6 +673,112 @@ body {
font-weight: 500;
}
.prepare-speakers {
margin-top: 2rem;
border-top: 1px solid var(--panel-border);
padding-top: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.speaker-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.speaker-list__item {
background: rgba(148, 163, 184, 0.05);
border: 1px solid var(--panel-border);
border-radius: 18px;
padding: 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.speaker-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.speaker-list__name {
font-weight: 600;
font-size: 1rem;
}
.speaker-list__preview {
font-size: 1.1rem;
line-height: 1;
width: 2.4rem;
height: 2.4rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 999px;
border: 1px solid var(--panel-border);
color: var(--accent);
background: rgba(56, 189, 248, 0.08);
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
}
.speaker-list__preview:hover {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.15);
transform: translateY(-1px);
}
.speaker-list__preview .spinner {
display: none;
}
.speaker-list__preview[data-loading="true"] .spinner {
display: inline-block;
}
.speaker-list__preview[data-loading="true"] .icon-button__glyph {
display: none;
}
.speaker-list__preview[data-loading="true"] {
cursor: progress;
box-shadow: none;
color: transparent;
}
.speaker-list__field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.speaker-list__field input {
border-radius: 12px;
border: 1px solid var(--panel-border);
background: rgba(15, 23, 42, 0.6);
padding: 0.6rem 0.8rem;
color: var(--text);
font-size: 0.95rem;
}
.speaker-list__field input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.2);
}
.speaker-list__stats {
margin: 0;
color: var(--muted);
font-size: 0.85rem;
}
.prepare-metadata h2 {
font-size: 1rem;
margin: 0 0 0.6rem;
@@ -1518,6 +1624,20 @@ input[data-state="locked"] {
box-shadow: none;
}
.icon-button--borderless {
background: transparent;
border-color: transparent;
}
.icon-button--borderless:hover {
background: rgba(148, 163, 184, 0.1);
border-color: rgba(148, 163, 184, 0.3);
}
.icon-button--borderless:focus-visible {
border-color: var(--accent);
}
.icon-button--primary {
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
border: none;
@@ -1553,6 +1673,32 @@ input[data-state="locked"] {
box-shadow: none;
}
.spinner {
display: inline-block;
width: 1.1rem;
height: 1.1rem;
border-radius: 50%;
border: 2px solid rgba(148, 163, 184, 0.28);
border-top-color: var(--accent);
animation: spin 0.8s linear infinite;
}
.spinner--sm {
width: 0.85rem;
height: 0.85rem;
}
.spinner--lg {
width: 1.5rem;
height: 1.5rem;
border-width: 3px;
}
.spinner--muted {
border-color: rgba(148, 163, 184, 0.2);
border-top-color: rgba(148, 163, 184, 0.6);
}
.button[data-role="preview-button"] {
position: relative;
}
@@ -1577,7 +1723,8 @@ input[data-state="locked"] {
}
@media (prefers-reduced-motion: reduce) {
.button[data-role="preview-button"][data-loading="true"]::after {
.button[data-role="preview-button"][data-loading="true"]::after,
.spinner {
animation-duration: 1.6s;
}
}
+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>