mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 21:50:28 +02:00
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:
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user