mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +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()
|
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]:
|
def _spec_to_voice_ids(spec: Any) -> Set[str]:
|
||||||
text = str(spec or "").strip()
|
text = str(spec or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
@@ -1632,6 +1661,67 @@ def run_conversion_job(job: Job) -> None:
|
|||||||
marker["original_title"] = raw_title
|
marker["original_title"] = raw_title
|
||||||
chapter_markers.append(marker)
|
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:
|
if not audio_path and chapter_paths:
|
||||||
job.result.audio_path = chapter_paths[0]
|
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()
|
profile_selection = (form.get("voice_profile") or pending.voice_profile or "__standard").strip()
|
||||||
custom_formula_raw = (form.get("voice_formula") or "").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}:
|
profiles_map = dict(profiles) if isinstance(profiles, Mapping) else dict(profiles or {})
|
||||||
profile_name = ""
|
resolved_default_voice, inferred_profile, _ = _resolve_voice_setting(
|
||||||
custom_formula = ""
|
narrator_voice_raw,
|
||||||
elif profile_selection == "__formula":
|
profiles=profiles_map,
|
||||||
|
)
|
||||||
|
|
||||||
|
if profile_selection in {"__standard", "", None} and inferred_profile:
|
||||||
|
profile_selection = inferred_profile
|
||||||
|
|
||||||
|
if profile_selection == "__formula":
|
||||||
profile_name = ""
|
profile_name = ""
|
||||||
custom_formula = custom_formula_raw
|
custom_formula = custom_formula_raw
|
||||||
|
elif profile_selection in {"__standard", "", None}:
|
||||||
|
profile_name = ""
|
||||||
|
custom_formula = ""
|
||||||
else:
|
else:
|
||||||
profile_name = profile_selection
|
profile_name = profile_selection
|
||||||
custom_formula = ""
|
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(
|
voice_choice, resolved_language, selected_profile = _resolve_voice_choice(
|
||||||
pending.language,
|
pending.language,
|
||||||
narrator_voice,
|
base_voice_spec,
|
||||||
profile_name,
|
profile_name,
|
||||||
custom_formula,
|
custom_formula,
|
||||||
profile_map,
|
profiles_map,
|
||||||
)
|
)
|
||||||
|
|
||||||
if resolved_language:
|
if resolved_language:
|
||||||
@@ -1788,7 +1800,8 @@ def _apply_book_step_form(
|
|||||||
pending.voice = voice_choice
|
pending.voice = voice_choice
|
||||||
else:
|
else:
|
||||||
pending.voice_profile = None
|
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
|
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_MODE_LABELS = {
|
||||||
"save_next_to_input": "Save next to input file",
|
"save_next_to_input": "Save next to input file",
|
||||||
"save_to_desktop": "Save to Desktop",
|
"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 normalized
|
||||||
return defaults[key]
|
return defaults[key]
|
||||||
if key == "default_voice":
|
if key == "default_voice":
|
||||||
if isinstance(value, str) and value in VOICES_INTERNAL:
|
if isinstance(value, str):
|
||||||
return value
|
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]
|
return defaults[key]
|
||||||
if key == "chunk_level":
|
if key == "chunk_level":
|
||||||
if isinstance(value, str) and value in _CHUNK_LEVEL_VALUES:
|
if isinstance(value, str) and value in _CHUNK_LEVEL_VALUES:
|
||||||
@@ -3196,10 +3258,18 @@ def api_normalization_preview() -> ResponseReturnValue:
|
|||||||
except LLMClientError as exc:
|
except LLMClientError as exc:
|
||||||
return jsonify({"error": str(exc)}), 400
|
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:
|
if not voice_spec and VOICES_INTERNAL:
|
||||||
voice_spec = VOICES_INTERNAL[0]
|
voice_spec = VOICES_INTERNAL[0]
|
||||||
language = str(payload.get("language") or base_settings.get("language") or "a").strip() or "a"
|
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:
|
try:
|
||||||
speed = float(payload.get("speed", 1.0) or 1.0)
|
speed = float(payload.get("speed", 1.0) or 1.0)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
@@ -3420,11 +3490,19 @@ def api_entity_pronunciation_preview() -> ResponseReturnValue:
|
|||||||
except LLMClientError as exc:
|
except LLMClientError as exc:
|
||||||
return jsonify({"error": str(exc)}), 400
|
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:
|
if not voice_spec and VOICES_INTERNAL:
|
||||||
voice_spec = VOICES_INTERNAL[0]
|
voice_spec = VOICES_INTERNAL[0]
|
||||||
|
|
||||||
language = str(payload.get("language") or runtime_settings.get("language") or "a").strip() or "a"
|
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)
|
use_gpu = runtime_settings.get("use_gpu", True)
|
||||||
max_seconds = 6.0
|
max_seconds = 6.0
|
||||||
try:
|
try:
|
||||||
@@ -3777,8 +3855,17 @@ def api_preview_voice_mix() -> ResponseReturnValue:
|
|||||||
def api_speaker_preview() -> ResponseReturnValue:
|
def api_speaker_preview() -> ResponseReturnValue:
|
||||||
payload = request.get_json(force=True, silent=False)
|
payload = request.get_json(force=True, silent=False)
|
||||||
text = (payload.get("text") or "").strip()
|
text = (payload.get("text") or "").strip()
|
||||||
voice_spec = (payload.get("voice") or "").strip()
|
raw_voice_spec = (payload.get("voice") or "").strip()
|
||||||
language = (payload.get("language") or "a").strip() or "a"
|
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)
|
speed_input = payload.get("speed", 1.0)
|
||||||
try:
|
try:
|
||||||
speed = float(speed_input)
|
speed = float(speed_input)
|
||||||
@@ -4011,19 +4098,31 @@ def _build_pending_job_from_extraction(
|
|||||||
_ensure_at_least_one_chapter_enabled(chapters_payload)
|
_ensure_at_least_one_chapter_enabled(chapters_payload)
|
||||||
|
|
||||||
language = str(form.get("language") or "a").strip() or "a"
|
language = str(form.get("language") or "a").strip() or "a"
|
||||||
default_voice = str(settings.get("default_voice") or "af_alloy")
|
profiles_map = dict(profiles) if isinstance(profiles, Mapping) else dict(profiles or {})
|
||||||
base_voice = str(form.get("voice") or default_voice or "af_alloy").strip()
|
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()
|
profile_selection = (form.get("voice_profile") or "__standard").strip()
|
||||||
custom_formula_raw = str(form.get("voice_formula") or "").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()
|
selected_speaker_config = (form.get("speaker_config") or "").strip()
|
||||||
speaker_config_payload = get_config(selected_speaker_config) if selected_speaker_config else None
|
speaker_config_payload = get_config(selected_speaker_config) if selected_speaker_config else None
|
||||||
|
|
||||||
if profile_selection in {"__standard", ""}:
|
if profile_selection == "__formula":
|
||||||
profile_name = ""
|
|
||||||
custom_formula = ""
|
|
||||||
elif profile_selection == "__formula":
|
|
||||||
profile_name = ""
|
profile_name = ""
|
||||||
custom_formula = custom_formula_raw
|
custom_formula = custom_formula_raw
|
||||||
|
elif profile_selection in {"__standard", ""}:
|
||||||
|
profile_name = ""
|
||||||
|
custom_formula = ""
|
||||||
else:
|
else:
|
||||||
profile_name = profile_selection
|
profile_name = profile_selection
|
||||||
custom_formula = ""
|
custom_formula = ""
|
||||||
|
|||||||
@@ -112,6 +112,10 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
{% set narrator_voice = settings_dict.get('default_voice', options.voices[0] if options.voices else '') %}
|
{% set narrator_voice = settings_dict.get('default_voice', options.voices[0] if options.voices else '') %}
|
||||||
{% endif %}
|
{% 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 normalization_overrides = pending.normalization_overrides if pending and pending.normalization_overrides else {} %}
|
||||||
{% set apostrophe_toggles = [
|
{% set apostrophe_toggles = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,9 +29,35 @@
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="default_voice">Narrator Voice</label>
|
<label for="default_voice">Narrator Voice</label>
|
||||||
<select id="default_voice" name="default_voice">
|
<select id="default_voice" name="default_voice">
|
||||||
|
<optgroup label="Standard voices">
|
||||||
{% for voice in options.voices %}
|
{% for voice in options.voices %}
|
||||||
<option value="{{ voice }}" {% if settings.default_voice == voice %}selected{% endif %}>{{ voice }}</option>
|
<option value="{{ voice }}" {% if settings.default_voice == voice %}selected{% endif %}>{{ voice }}</option>
|
||||||
{% endfor %}
|
{% 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>
|
</select>
|
||||||
<p class="hint">Used whenever “Standard voice” is selected for a new job.</p>
|
<p class="hint">Used whenever “Standard voice” is selected for a new job.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from werkzeug.datastructures import MultiDict
|
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
|
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["voice_formula"] == "af_nova*0.6+am_liam*0.4"
|
||||||
assert hero["resolved_voice"] == "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"
|
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