mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 05:40:26 +02:00
feat: Add outro text generation and voice resolution for audio conversion jobs
This commit is contained in:
@@ -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]
|
||||
|
||||
|
||||
+120
-21
@@ -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 = ""
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -29,9 +29,35 @@
|
||||
<div class="field">
|
||||
<label for="default_voice">Narrator Voice</label>
|
||||
<select id="default_voice" name="default_voice">
|
||||
{% for voice in options.voices %}
|
||||
<option value="{{ voice }}" {% if settings.default_voice == voice %}selected{% endif %}>{{ voice }}</option>
|
||||
{% endfor %}
|
||||
<optgroup label="Standard voices">
|
||||
{% for voice in options.voices %}
|
||||
<option value="{{ voice }}" {% if settings.default_voice == voice %}selected{% endif %}>{{ voice }}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% if options.voice_profile_options %}
|
||||
<optgroup label="Saved mixes">
|
||||
{% for profile in options.voice_profile_options %}
|
||||
{% set profile_value = 'profile:' ~ profile.name %}
|
||||
<option value="{{ profile_value }}" {% if settings.default_voice == profile_value %}selected{% endif %}>{{ profile.name }}{% if profile.language %} · {{ profile.language|upper }}{% endif %}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
{% set current_default = settings.default_voice %}
|
||||
{% if current_default %}
|
||||
{% set known_default = namespace(value=False) %}
|
||||
{% if current_default in options.voices %}
|
||||
{% set known_default.value = True %}
|
||||
{% else %}
|
||||
{% for profile in options.voice_profile_options %}
|
||||
{% if current_default == 'profile:' ~ profile.name %}
|
||||
{% set known_default.value = True %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if not known_default.value %}
|
||||
<option value="{{ current_default }}" selected>{{ current_default }}</option>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</select>
|
||||
<p class="hint">Used whenever “Standard voice” is selected for a new job.</p>
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user