From 688d550f13db05cce7c30439aa95fa84be9f02ad Mon Sep 17 00:00:00 2001 From: JB Date: Thu, 30 Oct 2025 12:22:19 -0700 Subject: [PATCH] feat: Add outro text generation and voice resolution for audio conversion jobs --- abogen/web/conversion_runner.py | 90 +++++++++++ abogen/web/routes.py | 141 +++++++++++++++--- .../templates/partials/new_job_step_book.html | 4 + abogen/web/templates/settings.html | 32 +++- tests/test_prepare_form.py | 20 ++- 5 files changed, 262 insertions(+), 25 deletions(-) diff --git a/abogen/web/conversion_runner.py b/abogen/web/conversion_runner.py index a7497fc..5a27431 100644 --- a/abogen/web/conversion_runner.py +++ b/abogen/web/conversion_runner.py @@ -340,6 +340,35 @@ def _build_title_intro_text( return " ".join(sentences).strip() +def _build_outro_text( + metadata: Optional[Mapping[str, Any]], + fallback_basename: str, +) -> str: + normalized = _normalize_metadata_map(metadata) + fallback_title = Path(fallback_basename).stem if fallback_basename else "" + title = ( + normalized.get("title") + or normalized.get("book_title") + or normalized.get("album") + or fallback_title + ) + author_value = "" + for candidate in ("authors", "author", "album_artist", "artist", "writer", "composer"): + value = normalized.get(candidate) + if value: + author_value = value + break + author_sentence = _format_author_sentence(author_value) + authors_fragment = author_sentence[3:].strip() if author_sentence.lower().startswith("by ") else author_sentence.strip() + if title and authors_fragment: + return f"The end of {title} from {authors_fragment}." + if title: + return f"The end of {title}." + if authors_fragment: + return f"The end from {authors_fragment}." + return "The end." + + def _spec_to_voice_ids(spec: Any) -> Set[str]: text = str(spec or "").strip() if not text: @@ -1632,6 +1661,67 @@ def run_conversion_job(job: Job) -> None: marker["original_title"] = raw_title chapter_markers.append(marker) + outro_text = _build_outro_text(job.metadata_tags, job.original_filename) + outro_voice_spec = base_voice_spec or job.voice + if outro_voice_spec == "__custom_mix": + outro_voice_spec = base_voice_spec or "" + if not outro_voice_spec: + fallback_voice = next(iter(voice_cache.keys()), "") + if fallback_voice and fallback_voice != "__custom_mix": + outro_voice_spec = fallback_voice + if not outro_voice_spec and VOICES_INTERNAL: + outro_voice_spec = VOICES_INTERNAL[0] + + if outro_text and outro_voice_spec: + outro_start_time = current_time + outro_audio_path: Optional[Path] = None + outro_segments = 0 + outro_index = total_chapters + 1 + outro_voice_choice = voice_cache.get(outro_voice_spec) + if outro_voice_choice is None: + outro_voice_choice = _resolve_voice(pipeline, outro_voice_spec, job.use_gpu) + voice_cache[outro_voice_spec] = outro_voice_choice + + with ExitStack() as outro_sink_stack: + chapter_sink: Optional[AudioSink] = None + if chapter_dir is not None: + outro_audio_path = _build_output_path( + chapter_dir, + f"{Path(job.original_filename).stem}_outro", + job.separate_chapters_format, + ) + chapter_sink = _open_audio_sink( + outro_audio_path, + job, + outro_sink_stack, + fmt=job.separate_chapters_format, + ) + + outro_segments = emit_text( + outro_text, + voice_choice=outro_voice_choice, + chapter_sink=chapter_sink, + preview_prefix="Outro", + ) + outro_end_time = current_time + + if outro_segments > 0: + job.add_log(f"Appended outro sequence: {outro_text}") + if outro_audio_path is not None: + job.result.artifacts[f"chapter_{outro_index:02d}"] = outro_audio_path + chapter_paths.append(outro_audio_path) + chapter_markers.append( + { + "index": outro_index, + "title": "Outro", + "start": outro_start_time, + "end": outro_end_time, + "voice": outro_voice_spec, + } + ) + else: + job.add_log("No audio generated for outro sequence.", level="warning") + if not audio_path and chapter_paths: job.result.audio_path = chapter_paths[0] diff --git a/abogen/web/routes.py b/abogen/web/routes.py index 12a4941..915586e 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -1756,25 +1756,37 @@ def _apply_book_step_form( profile_selection = (form.get("voice_profile") or pending.voice_profile or "__standard").strip() custom_formula_raw = (form.get("voice_formula") or "").strip() - narrator_voice = (form.get("voice") or pending.voice or settings.get("default_voice") or "").strip() + narrator_voice_raw = (form.get("voice") or pending.voice or settings.get("default_voice") or "").strip() - if profile_selection in {"__standard", "", None}: - profile_name = "" - custom_formula = "" - elif profile_selection == "__formula": + profiles_map = dict(profiles) if isinstance(profiles, Mapping) else dict(profiles or {}) + resolved_default_voice, inferred_profile, _ = _resolve_voice_setting( + narrator_voice_raw, + profiles=profiles_map, + ) + + if profile_selection in {"__standard", "", None} and inferred_profile: + profile_selection = inferred_profile + + if profile_selection == "__formula": profile_name = "" custom_formula = custom_formula_raw + elif profile_selection in {"__standard", "", None}: + profile_name = "" + custom_formula = "" else: profile_name = profile_selection custom_formula = "" - profile_map = profiles if isinstance(profiles, dict) else dict(profiles) + base_voice_spec = resolved_default_voice or narrator_voice_raw + if not base_voice_spec and VOICES_INTERNAL: + base_voice_spec = VOICES_INTERNAL[0] + voice_choice, resolved_language, selected_profile = _resolve_voice_choice( pending.language, - narrator_voice, + base_voice_spec, profile_name, custom_formula, - profile_map, + profiles_map, ) if resolved_language: @@ -1788,7 +1800,8 @@ def _apply_book_step_form( pending.voice = voice_choice else: pending.voice_profile = None - pending.voice = voice_choice or narrator_voice + fallback_voice = base_voice_spec or narrator_voice_raw + pending.voice = voice_choice or fallback_voice pending.applied_speaker_config = (form.get("speaker_config") or "").strip() or None @@ -1967,6 +1980,49 @@ def _template_options() -> Dict[str, Any]: } +def _split_profile_spec(value: Any) -> tuple[str, Optional[str]]: + text = str(value or "").strip() + if not text: + return "", None + if text.lower().startswith("profile:"): + _, _, remainder = text.partition(":") + name = remainder.strip() + return "", name or None + return text, None + + +def _resolve_profile_voice( + profile_name: Optional[str], + *, + profiles: Optional[Mapping[str, Any]] = None, +) -> tuple[str, Optional[str]]: + if not profile_name: + return "", None + source = profiles if isinstance(profiles, Mapping) else None + if source is None: + source = load_profiles() + entry = source.get(profile_name) if isinstance(source, Mapping) else None + if not isinstance(entry, Mapping): + return "", None + formula = _formula_from_profile(dict(entry)) or "" + language = entry.get("language") if isinstance(entry.get("language"), str) else None + if isinstance(language, str): + language = language.strip().lower() or None + return formula, language + + +def _resolve_voice_setting( + value: Any, + *, + profiles: Optional[Mapping[str, Any]] = None, +) -> tuple[str, Optional[str], Optional[str]]: + base_spec, profile_name = _split_profile_spec(value) + if profile_name: + formula, language = _resolve_profile_voice(profile_name, profiles=profiles) + return formula or "", profile_name, language + return base_spec, None, None + + SAVE_MODE_LABELS = { "save_next_to_input": "Save next to input file", "save_to_desktop": "Save to Desktop", @@ -2167,8 +2223,14 @@ def _normalize_setting_value(key: str, value: Any, defaults: Dict[str, Any]) -> return normalized return defaults[key] if key == "default_voice": - if isinstance(value, str) and value in VOICES_INTERNAL: - return value + if isinstance(value, str): + text = value.strip() + if not text: + return defaults[key] + spec, profile_name = _split_profile_spec(text) + if profile_name: + return f"profile:{profile_name}" + return spec return defaults[key] if key == "chunk_level": if isinstance(value, str) and value in _CHUNK_LEVEL_VALUES: @@ -3196,10 +3258,18 @@ def api_normalization_preview() -> ResponseReturnValue: except LLMClientError as exc: return jsonify({"error": str(exc)}), 400 - voice_spec = str(payload.get("voice") or base_settings.get("default_voice") or "").strip() + raw_voice_spec = str(payload.get("voice") or base_settings.get("default_voice") or "").strip() + profiles_map = load_profiles() + resolved_voice_spec, _, profile_language = _resolve_voice_setting( + raw_voice_spec, + profiles=profiles_map, + ) + voice_spec = resolved_voice_spec or raw_voice_spec if not voice_spec and VOICES_INTERNAL: voice_spec = VOICES_INTERNAL[0] language = str(payload.get("language") or base_settings.get("language") or "a").strip() or "a" + if (not str(payload.get("language") or "").strip()) and profile_language: + language = profile_language try: speed = float(payload.get("speed", 1.0) or 1.0) except (TypeError, ValueError): @@ -3420,11 +3490,19 @@ def api_entity_pronunciation_preview() -> ResponseReturnValue: except LLMClientError as exc: return jsonify({"error": str(exc)}), 400 - voice_spec = str(payload.get("voice") or settings.get("default_voice") or "").strip() + raw_voice_spec = str(payload.get("voice") or settings.get("default_voice") or "").strip() + profiles_map = load_profiles() + resolved_voice_spec, _, profile_language = _resolve_voice_setting( + raw_voice_spec, + profiles=profiles_map, + ) + voice_spec = resolved_voice_spec or raw_voice_spec if not voice_spec and VOICES_INTERNAL: voice_spec = VOICES_INTERNAL[0] language = str(payload.get("language") or runtime_settings.get("language") or "a").strip() or "a" + if (not str(payload.get("language") or "").strip()) and profile_language: + language = profile_language use_gpu = runtime_settings.get("use_gpu", True) max_seconds = 6.0 try: @@ -3777,8 +3855,17 @@ def api_preview_voice_mix() -> ResponseReturnValue: def api_speaker_preview() -> ResponseReturnValue: 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" + raw_voice_spec = (payload.get("voice") or "").strip() + profiles_map = load_profiles() + resolved_voice_spec, _, profile_language = _resolve_voice_setting( + raw_voice_spec, + profiles=profiles_map, + ) + voice_spec = resolved_voice_spec or raw_voice_spec + language_override = payload.get("language") + language = (language_override or "a").strip() or "a" + if (not isinstance(language_override, str) or not language_override.strip()) and profile_language: + language = profile_language speed_input = payload.get("speed", 1.0) try: speed = float(speed_input) @@ -4011,19 +4098,31 @@ def _build_pending_job_from_extraction( _ensure_at_least_one_chapter_enabled(chapters_payload) language = str(form.get("language") or "a").strip() or "a" - default_voice = str(settings.get("default_voice") or "af_alloy") - base_voice = str(form.get("voice") or default_voice or "af_alloy").strip() + profiles_map = dict(profiles) if isinstance(profiles, Mapping) else dict(profiles or {}) + default_voice_setting = settings.get("default_voice") or "" + resolved_default_voice, inferred_profile, inferred_language = _resolve_voice_setting( + default_voice_setting, + profiles=profiles_map, + ) + base_voice_input = str(form.get("voice") or "").strip() profile_selection = (form.get("voice_profile") or "__standard").strip() custom_formula_raw = str(form.get("voice_formula") or "").strip() + + if profile_selection in {"__standard", ""} and inferred_profile: + profile_selection = inferred_profile + + base_voice = base_voice_input or resolved_default_voice or str(default_voice_setting).strip() + if not base_voice and VOICES_INTERNAL: + base_voice = VOICES_INTERNAL[0] selected_speaker_config = (form.get("speaker_config") or "").strip() speaker_config_payload = get_config(selected_speaker_config) if selected_speaker_config else None - if profile_selection in {"__standard", ""}: - profile_name = "" - custom_formula = "" - elif profile_selection == "__formula": + if profile_selection == "__formula": profile_name = "" custom_formula = custom_formula_raw + elif profile_selection in {"__standard", ""}: + profile_name = "" + custom_formula = "" else: profile_name = profile_selection custom_formula = "" diff --git a/abogen/web/templates/partials/new_job_step_book.html b/abogen/web/templates/partials/new_job_step_book.html index b10013b..81144ec 100644 --- a/abogen/web/templates/partials/new_job_step_book.html +++ b/abogen/web/templates/partials/new_job_step_book.html @@ -112,6 +112,10 @@ {% else %} {% set narrator_voice = settings_dict.get('default_voice', options.voices[0] if options.voices else '') %} {% endif %} +{% if (not narrator_profile) and narrator_voice and narrator_voice[:8]|lower == 'profile:' %} + {% set narrator_profile = narrator_voice[8:]|trim %} + {% set narrator_voice = '' %} +{% endif %} {% set normalization_overrides = pending.normalization_overrides if pending and pending.normalization_overrides else {} %} {% set apostrophe_toggles = [ { diff --git a/abogen/web/templates/settings.html b/abogen/web/templates/settings.html index 193ec6f..45bc4b1 100644 --- a/abogen/web/templates/settings.html +++ b/abogen/web/templates/settings.html @@ -29,9 +29,35 @@

Used whenever “Standard voice” is selected for a new job.

diff --git a/tests/test_prepare_form.py b/tests/test_prepare_form.py index 25cbfb2..719e2e4 100644 --- a/tests/test_prepare_form.py +++ b/tests/test_prepare_form.py @@ -2,7 +2,7 @@ from pathlib import Path from werkzeug.datastructures import MultiDict -from abogen.web.routes import _apply_prepare_form +from abogen.web.routes import _apply_prepare_form, _resolve_voice_setting from abogen.web.service import PendingJob @@ -62,3 +62,21 @@ def test_apply_prepare_form_handles_custom_mix_for_speakers(): assert hero["voice_formula"] == "af_nova*0.6+am_liam*0.4" assert hero["resolved_voice"] == "af_nova*0.6+am_liam*0.4" assert "voice" not in hero or hero["voice"] != "__custom_mix" + + +def test_resolve_voice_setting_handles_profile_reference(): + profiles = { + "Blend": { + "language": "b", + "voices": [ + ("af_nova", 1.0), + ("am_liam", 1.0), + ], + } + } + + voice, profile_name, language = _resolve_voice_setting("profile:Blend", profiles=profiles) + + assert voice == "af_nova*0.5+am_liam*0.5" + assert profile_name == "Blend" + assert language == "b"