mirror of
https://github.com/denizsafak/abogen.git
synced 2026-09-20 11:40:57 +02:00
refactor: move speaker metadata logic to domain layer
Move build_narrator_roster, build_speaker_roster, match_configured_speaker, apply_speaker_config_to_roster, prepare_speaker_metadata from webui/routes/utils/voice.py to domain/speaker_metadata.py. - prepare_speaker_metadata now accepts optional inject_recommended callback for UI-specific voice enrichment - 47 new tests in test_domain_speaker_metadata.py - All 1509 tests pass
This commit is contained in:
@@ -0,0 +1,381 @@
|
|||||||
|
"""Speaker metadata functions for building and applying speaker rosters.
|
||||||
|
|
||||||
|
This module contains the core logic for:
|
||||||
|
- Building narrator and speaker rosters from analysis results
|
||||||
|
- Matching speakers to configured presets
|
||||||
|
- Applying speaker config presets to rosters
|
||||||
|
- Preparing full speaker metadata for conversion
|
||||||
|
|
||||||
|
Moved from webui/routes/utils/voice.py to be available across all UIs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, cast
|
||||||
|
|
||||||
|
from abogen.speaker_analysis import analyze_speakers
|
||||||
|
from abogen.speaker_configs import slugify_label
|
||||||
|
from abogen.domain.settings_core import load_settings
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
order: Optional[Iterable[str]] = 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 {}
|
||||||
|
ordered_ids: Iterable[str]
|
||||||
|
if order is not None:
|
||||||
|
ordered_ids = [sid for sid in order if sid in speakers]
|
||||||
|
else:
|
||||||
|
ordered_ids = speakers.keys()
|
||||||
|
|
||||||
|
for speaker_id in ordered_ids:
|
||||||
|
payload = speakers.get(speaker_id, {})
|
||||||
|
if speaker_id == "narrator":
|
||||||
|
continue
|
||||||
|
if isinstance(payload, Mapping) and 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(),
|
||||||
|
"analysis_confidence": payload.get("confidence"),
|
||||||
|
"analysis_count": payload.get("count"),
|
||||||
|
"gender": payload.get("gender", "unknown"),
|
||||||
|
}
|
||||||
|
detected_gender = payload.get("detected_gender")
|
||||||
|
if detected_gender:
|
||||||
|
roster[speaker_id]["detected_gender"] = detected_gender
|
||||||
|
samples = payload.get("sample_quotes")
|
||||||
|
if isinstance(samples, list):
|
||||||
|
roster[speaker_id]["sample_quotes"] = samples
|
||||||
|
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
|
||||||
|
if "sample_quotes" not in roster[speaker_id]:
|
||||||
|
prev_samples = previous.get("sample_quotes")
|
||||||
|
if isinstance(prev_samples, list):
|
||||||
|
roster[speaker_id]["sample_quotes"] = prev_samples
|
||||||
|
if "detected_gender" not in roster[speaker_id]:
|
||||||
|
prev_detected = previous.get("detected_gender")
|
||||||
|
if isinstance(prev_detected, str) and prev_detected:
|
||||||
|
roster[speaker_id]["detected_gender"] = prev_detected
|
||||||
|
return roster
|
||||||
|
|
||||||
|
|
||||||
|
def match_configured_speaker(
|
||||||
|
config_speakers: Mapping[str, Any],
|
||||||
|
roster_id: str,
|
||||||
|
roster_label: str,
|
||||||
|
) -> Optional[Mapping[str, Any]]:
|
||||||
|
if not config_speakers:
|
||||||
|
return None
|
||||||
|
entry = config_speakers.get(roster_id)
|
||||||
|
if entry:
|
||||||
|
return cast(Mapping[str, Any], entry)
|
||||||
|
slug = slugify_label(roster_label)
|
||||||
|
if slug != roster_id and slug in config_speakers:
|
||||||
|
return cast(Mapping[str, Any], config_speakers[slug])
|
||||||
|
lower_label = roster_label.strip().lower()
|
||||||
|
for record in config_speakers.values():
|
||||||
|
if not isinstance(record, Mapping):
|
||||||
|
continue
|
||||||
|
if str(record.get("label", "")).strip().lower() == lower_label:
|
||||||
|
return record
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def apply_speaker_config_to_roster(
|
||||||
|
roster: Mapping[str, Any],
|
||||||
|
config: Optional[Mapping[str, Any]],
|
||||||
|
*,
|
||||||
|
persist_changes: bool = False,
|
||||||
|
fallback_languages: Optional[Iterable[str]] = None,
|
||||||
|
) -> Tuple[Dict[str, Any], List[str], Optional[Dict[str, Any]]]:
|
||||||
|
if not isinstance(roster, Mapping):
|
||||||
|
effective_languages = [code for code in (fallback_languages or []) if isinstance(code, str) and code]
|
||||||
|
return {}, effective_languages, None
|
||||||
|
updated_roster: Dict[str, Any] = {key: dict(value) for key, value in roster.items() if isinstance(value, Mapping)}
|
||||||
|
if not config:
|
||||||
|
effective_languages = [code for code in (fallback_languages or []) if isinstance(code, str) and code]
|
||||||
|
return updated_roster, effective_languages, None
|
||||||
|
|
||||||
|
speakers_map = config.get("speakers")
|
||||||
|
if not isinstance(speakers_map, Mapping):
|
||||||
|
effective_languages = [code for code in (fallback_languages or []) if isinstance(code, str) and code]
|
||||||
|
return updated_roster, effective_languages, None
|
||||||
|
|
||||||
|
config_languages = config.get("languages")
|
||||||
|
if isinstance(config_languages, list):
|
||||||
|
allowed_languages = [code for code in config_languages if isinstance(code, str) and code]
|
||||||
|
else:
|
||||||
|
allowed_languages = []
|
||||||
|
if not allowed_languages and fallback_languages:
|
||||||
|
allowed_languages = [code for code in fallback_languages if isinstance(code, str) and code]
|
||||||
|
|
||||||
|
default_voice = config.get("default_voice") if isinstance(config.get("default_voice"), str) else ""
|
||||||
|
used_voices = {entry.get("resolved_voice") or entry.get("voice") for entry in updated_roster.values()} - {None}
|
||||||
|
narrator_voice = ""
|
||||||
|
narrator_entry = updated_roster.get("narrator") if isinstance(updated_roster, Mapping) else None
|
||||||
|
if isinstance(narrator_entry, Mapping):
|
||||||
|
narrator_voice = str(
|
||||||
|
narrator_entry.get("resolved_voice")
|
||||||
|
or narrator_entry.get("default_voice")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if narrator_voice:
|
||||||
|
used_voices.add(narrator_voice)
|
||||||
|
|
||||||
|
config_changed = False
|
||||||
|
new_config_payload: Dict[str, Any] = {
|
||||||
|
"language": config.get("language", "a"),
|
||||||
|
"languages": allowed_languages,
|
||||||
|
"default_voice": default_voice,
|
||||||
|
"speakers": dict(speakers_map),
|
||||||
|
"version": config.get("version", 1),
|
||||||
|
"notes": config.get("notes", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
speakers_payload = new_config_payload["speakers"]
|
||||||
|
|
||||||
|
for speaker_id, roster_entry in updated_roster.items():
|
||||||
|
if speaker_id == "narrator":
|
||||||
|
continue
|
||||||
|
label = str(roster_entry.get("label") or speaker_id)
|
||||||
|
config_entry = match_configured_speaker(speakers_map, speaker_id, label)
|
||||||
|
if config_entry is None:
|
||||||
|
continue
|
||||||
|
voice_id = str(config_entry.get("voice") or "").strip()
|
||||||
|
voice_profile = str(config_entry.get("voice_profile") or "").strip()
|
||||||
|
voice_formula = str(config_entry.get("voice_formula") or "").strip()
|
||||||
|
resolved_voice = str(config_entry.get("resolved_voice") or "").strip()
|
||||||
|
languages = config_entry.get("languages") if isinstance(config_entry.get("languages"), list) else []
|
||||||
|
chosen_voice = resolved_voice or voice_formula or voice_id or roster_entry.get("voice")
|
||||||
|
usable_languages = languages or allowed_languages
|
||||||
|
|
||||||
|
if chosen_voice:
|
||||||
|
roster_entry["resolved_voice"] = chosen_voice
|
||||||
|
roster_entry["voice"] = chosen_voice if not voice_profile and not voice_formula else roster_entry.get("voice", chosen_voice)
|
||||||
|
if voice_profile:
|
||||||
|
roster_entry["voice_profile"] = voice_profile
|
||||||
|
if voice_formula:
|
||||||
|
roster_entry["voice_formula"] = voice_formula
|
||||||
|
roster_entry["resolved_voice"] = voice_formula
|
||||||
|
if not voice_formula and not voice_profile and resolved_voice:
|
||||||
|
roster_entry["resolved_voice"] = resolved_voice
|
||||||
|
roster_entry["config_languages"] = usable_languages or []
|
||||||
|
|
||||||
|
if chosen_voice:
|
||||||
|
used_voices.add(chosen_voice)
|
||||||
|
|
||||||
|
# persist updates back to config payload if required
|
||||||
|
if persist_changes:
|
||||||
|
slug = config_entry.get("id") or slugify_label(label)
|
||||||
|
speakers_payload[slug] = {
|
||||||
|
"id": slug,
|
||||||
|
"label": label,
|
||||||
|
"gender": config_entry.get("gender", "unknown"),
|
||||||
|
"voice": voice_id,
|
||||||
|
"voice_profile": voice_profile,
|
||||||
|
"voice_formula": voice_formula,
|
||||||
|
"resolved_voice": roster_entry.get("resolved_voice", resolved_voice or voice_id),
|
||||||
|
"languages": usable_languages,
|
||||||
|
}
|
||||||
|
|
||||||
|
new_config = new_config_payload if (persist_changes and config_changed) else None
|
||||||
|
return updated_roster, allowed_languages, new_config
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_speaker_metadata(
|
||||||
|
*,
|
||||||
|
chapters: List[Dict[str, Any]],
|
||||||
|
chunks: List[Dict[str, Any]],
|
||||||
|
analysis_chunks: Optional[List[Dict[str, Any]]] = None,
|
||||||
|
voice: str,
|
||||||
|
voice_profile: Optional[str],
|
||||||
|
threshold: int,
|
||||||
|
existing_roster: Optional[Mapping[str, Any]] = None,
|
||||||
|
run_analysis: bool = True,
|
||||||
|
speaker_config: Optional[Mapping[str, Any]] = None,
|
||||||
|
apply_config: bool = False,
|
||||||
|
persist_config: bool = False,
|
||||||
|
inject_recommended: Optional[Any] = None,
|
||||||
|
) -> tuple[List[Dict[str, Any]], Dict[str, Any], Dict[str, Any], List[str], Optional[Dict[str, Any]]]:
|
||||||
|
chunk_list = [dict(chunk) for chunk in chunks]
|
||||||
|
analysis_source = [dict(chunk) for chunk in (analysis_chunks or chunks)]
|
||||||
|
threshold_value = max(1, int(threshold))
|
||||||
|
analysis_enabled = run_analysis
|
||||||
|
settings_state = load_settings()
|
||||||
|
global_random_languages = [
|
||||||
|
code
|
||||||
|
for code in settings_state.get("speaker_random_languages", [])
|
||||||
|
if isinstance(code, str) and code
|
||||||
|
]
|
||||||
|
|
||||||
|
if not analysis_enabled:
|
||||||
|
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, [], None
|
||||||
|
|
||||||
|
analysis_result = analyze_speakers(
|
||||||
|
chapters,
|
||||||
|
analysis_source,
|
||||||
|
threshold=threshold_value,
|
||||||
|
max_speakers=0,
|
||||||
|
)
|
||||||
|
analysis_payload = analysis_result.to_dict()
|
||||||
|
speakers_payload = analysis_payload.get("speakers", {})
|
||||||
|
ordered_ids = [
|
||||||
|
sid
|
||||||
|
for sid, meta in sorted(
|
||||||
|
(
|
||||||
|
(sid, meta)
|
||||||
|
for sid, meta in speakers_payload.items()
|
||||||
|
if sid != "narrator" and isinstance(meta, Mapping) and not meta.get("suppressed")
|
||||||
|
),
|
||||||
|
key=lambda item: item[1].get("count", 0),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
analysis_payload["ordered_speakers"] = ordered_ids
|
||||||
|
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,
|
||||||
|
order=analysis_payload.get("ordered_speakers"),
|
||||||
|
)
|
||||||
|
applied_languages: List[str] = []
|
||||||
|
updated_config: Optional[Dict[str, Any]] = None
|
||||||
|
if apply_config and speaker_config:
|
||||||
|
roster, applied_languages, updated_config = apply_speaker_config_to_roster(
|
||||||
|
roster,
|
||||||
|
speaker_config,
|
||||||
|
persist_changes=persist_config,
|
||||||
|
fallback_languages=global_random_languages,
|
||||||
|
)
|
||||||
|
speakers_payload = analysis_payload.get("speakers")
|
||||||
|
if isinstance(speakers_payload, dict):
|
||||||
|
for roster_id, roster_payload in roster.items():
|
||||||
|
speaker_meta = speakers_payload.get(roster_id)
|
||||||
|
if isinstance(speaker_meta, dict):
|
||||||
|
for key in ("voice", "voice_profile", "voice_formula", "resolved_voice"):
|
||||||
|
value = roster_payload.get(key)
|
||||||
|
if value:
|
||||||
|
speaker_meta[key] = value
|
||||||
|
effective_languages: List[str] = []
|
||||||
|
if applied_languages:
|
||||||
|
effective_languages = applied_languages
|
||||||
|
elif isinstance(analysis_payload.get("config_languages"), list):
|
||||||
|
effective_languages = [
|
||||||
|
code for code in analysis_payload.get("config_languages", []) if isinstance(code, str) and code
|
||||||
|
]
|
||||||
|
elif global_random_languages:
|
||||||
|
effective_languages = list(global_random_languages)
|
||||||
|
|
||||||
|
if effective_languages:
|
||||||
|
analysis_payload["config_languages"] = effective_languages
|
||||||
|
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
|
||||||
|
|
||||||
|
fallback_languages = effective_languages or []
|
||||||
|
if callable(inject_recommended):
|
||||||
|
inject_recommended(roster, fallback_languages=fallback_languages)
|
||||||
|
|
||||||
|
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, applied_languages, updated_config
|
||||||
@@ -26,10 +26,11 @@ from abogen.webui.routes.utils.settings import (
|
|||||||
audiobookshelf_manual_available,
|
audiobookshelf_manual_available,
|
||||||
)
|
)
|
||||||
from abogen.webui.routes.utils.voice import (
|
from abogen.webui.routes.utils.voice import (
|
||||||
|
inject_recommended_voices,
|
||||||
parse_voice_formula,
|
parse_voice_formula,
|
||||||
prepare_speaker_metadata,
|
|
||||||
template_options,
|
template_options,
|
||||||
)
|
)
|
||||||
|
from abogen.domain.speaker_metadata import prepare_speaker_metadata
|
||||||
from abogen.domain.voice_resolution import (
|
from abogen.domain.voice_resolution import (
|
||||||
formula_from_profile,
|
formula_from_profile,
|
||||||
resolve_voice_setting,
|
resolve_voice_setting,
|
||||||
@@ -782,6 +783,7 @@ def build_pending_job_from_extraction(
|
|||||||
run_analysis=initial_analysis,
|
run_analysis=initial_analysis,
|
||||||
speaker_config=speaker_config_payload,
|
speaker_config=speaker_config_payload,
|
||||||
apply_config=bool(speaker_config_payload),
|
apply_config=bool(speaker_config_payload),
|
||||||
|
inject_recommended=inject_recommended_voices,
|
||||||
)
|
)
|
||||||
|
|
||||||
normalization_overrides = {}
|
normalization_overrides = {}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, cast
|
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, cast
|
||||||
|
|
||||||
from abogen.speaker_configs import slugify_label
|
from abogen.speaker_configs import slugify_label
|
||||||
from abogen.speaker_analysis import analyze_speakers
|
|
||||||
from abogen.webui.routes.utils.settings import load_settings, settings_defaults, _DEFAULT_ANALYSIS_THRESHOLD, _CHUNK_LEVEL_OPTIONS, _APOSTROPHE_MODE_OPTIONS, _NORMALIZATION_GROUPS
|
from abogen.webui.routes.utils.settings import load_settings, settings_defaults, _DEFAULT_ANALYSIS_THRESHOLD, _CHUNK_LEVEL_OPTIONS, _APOSTROPHE_MODE_OPTIONS, _NORMALIZATION_GROUPS
|
||||||
from abogen.voice_profiles import (
|
from abogen.voice_profiles import (
|
||||||
load_profiles,
|
load_profiles,
|
||||||
@@ -21,207 +20,6 @@ from abogen.domain.voice_resolution import formula_from_profile
|
|||||||
from abogen.domain.voice_catalog import build_voice_catalog, filter_voice_catalog
|
from abogen.domain.voice_catalog import build_voice_catalog, filter_voice_catalog
|
||||||
|
|
||||||
|
|
||||||
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,
|
|
||||||
order: Optional[Iterable[str]] = 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 {}
|
|
||||||
ordered_ids: Iterable[str]
|
|
||||||
if order is not None:
|
|
||||||
ordered_ids = [sid for sid in order if sid in speakers]
|
|
||||||
else:
|
|
||||||
ordered_ids = speakers.keys()
|
|
||||||
|
|
||||||
for speaker_id in ordered_ids:
|
|
||||||
payload = speakers.get(speaker_id, {})
|
|
||||||
if speaker_id == "narrator":
|
|
||||||
continue
|
|
||||||
if isinstance(payload, Mapping) and 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(),
|
|
||||||
"analysis_confidence": payload.get("confidence"),
|
|
||||||
"analysis_count": payload.get("count"),
|
|
||||||
"gender": payload.get("gender", "unknown"),
|
|
||||||
}
|
|
||||||
detected_gender = payload.get("detected_gender")
|
|
||||||
if detected_gender:
|
|
||||||
roster[speaker_id]["detected_gender"] = detected_gender
|
|
||||||
samples = payload.get("sample_quotes")
|
|
||||||
if isinstance(samples, list):
|
|
||||||
roster[speaker_id]["sample_quotes"] = samples
|
|
||||||
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
|
|
||||||
if "sample_quotes" not in roster[speaker_id]:
|
|
||||||
prev_samples = previous.get("sample_quotes")
|
|
||||||
if isinstance(prev_samples, list):
|
|
||||||
roster[speaker_id]["sample_quotes"] = prev_samples
|
|
||||||
if "detected_gender" not in roster[speaker_id]:
|
|
||||||
prev_detected = previous.get("detected_gender")
|
|
||||||
if isinstance(prev_detected, str) and prev_detected:
|
|
||||||
roster[speaker_id]["detected_gender"] = prev_detected
|
|
||||||
return roster
|
|
||||||
|
|
||||||
|
|
||||||
def match_configured_speaker(
|
|
||||||
config_speakers: Mapping[str, Any],
|
|
||||||
roster_id: str,
|
|
||||||
roster_label: str,
|
|
||||||
) -> Optional[Mapping[str, Any]]:
|
|
||||||
if not config_speakers:
|
|
||||||
return None
|
|
||||||
entry = config_speakers.get(roster_id)
|
|
||||||
if entry:
|
|
||||||
return cast(Mapping[str, Any], entry)
|
|
||||||
slug = slugify_label(roster_label)
|
|
||||||
if slug != roster_id and slug in config_speakers:
|
|
||||||
return cast(Mapping[str, Any], config_speakers[slug])
|
|
||||||
lower_label = roster_label.strip().lower()
|
|
||||||
for record in config_speakers.values():
|
|
||||||
if not isinstance(record, Mapping):
|
|
||||||
continue
|
|
||||||
if str(record.get("label", "")).strip().lower() == lower_label:
|
|
||||||
return record
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def apply_speaker_config_to_roster(
|
|
||||||
roster: Mapping[str, Any],
|
|
||||||
config: Optional[Mapping[str, Any]],
|
|
||||||
*,
|
|
||||||
persist_changes: bool = False,
|
|
||||||
fallback_languages: Optional[Iterable[str]] = None,
|
|
||||||
) -> Tuple[Dict[str, Any], List[str], Optional[Dict[str, Any]]]:
|
|
||||||
if not isinstance(roster, Mapping):
|
|
||||||
effective_languages = [code for code in (fallback_languages or []) if isinstance(code, str) and code]
|
|
||||||
return {}, effective_languages, None
|
|
||||||
updated_roster: Dict[str, Any] = {key: dict(value) for key, value in roster.items() if isinstance(value, Mapping)}
|
|
||||||
if not config:
|
|
||||||
effective_languages = [code for code in (fallback_languages or []) if isinstance(code, str) and code]
|
|
||||||
return updated_roster, effective_languages, None
|
|
||||||
|
|
||||||
speakers_map = config.get("speakers")
|
|
||||||
if not isinstance(speakers_map, Mapping):
|
|
||||||
effective_languages = [code for code in (fallback_languages or []) if isinstance(code, str) and code]
|
|
||||||
return updated_roster, effective_languages, None
|
|
||||||
|
|
||||||
config_languages = config.get("languages")
|
|
||||||
if isinstance(config_languages, list):
|
|
||||||
allowed_languages = [code for code in config_languages if isinstance(code, str) and code]
|
|
||||||
else:
|
|
||||||
allowed_languages = []
|
|
||||||
if not allowed_languages and fallback_languages:
|
|
||||||
allowed_languages = [code for code in fallback_languages if isinstance(code, str) and code]
|
|
||||||
|
|
||||||
default_voice = config.get("default_voice") if isinstance(config.get("default_voice"), str) else ""
|
|
||||||
used_voices = {entry.get("resolved_voice") or entry.get("voice") for entry in updated_roster.values()} - {None}
|
|
||||||
narrator_voice = ""
|
|
||||||
narrator_entry = updated_roster.get("narrator") if isinstance(updated_roster, Mapping) else None
|
|
||||||
if isinstance(narrator_entry, Mapping):
|
|
||||||
narrator_voice = str(
|
|
||||||
narrator_entry.get("resolved_voice")
|
|
||||||
or narrator_entry.get("default_voice")
|
|
||||||
or ""
|
|
||||||
).strip()
|
|
||||||
if narrator_voice:
|
|
||||||
used_voices.add(narrator_voice)
|
|
||||||
|
|
||||||
config_changed = False
|
|
||||||
new_config_payload: Dict[str, Any] = {
|
|
||||||
"language": config.get("language", "a"),
|
|
||||||
"languages": allowed_languages,
|
|
||||||
"default_voice": default_voice,
|
|
||||||
"speakers": dict(speakers_map),
|
|
||||||
"version": config.get("version", 1),
|
|
||||||
"notes": config.get("notes", ""),
|
|
||||||
}
|
|
||||||
|
|
||||||
speakers_payload = new_config_payload["speakers"]
|
|
||||||
|
|
||||||
for speaker_id, roster_entry in updated_roster.items():
|
|
||||||
if speaker_id == "narrator":
|
|
||||||
continue
|
|
||||||
label = str(roster_entry.get("label") or speaker_id)
|
|
||||||
config_entry = match_configured_speaker(speakers_map, speaker_id, label)
|
|
||||||
if config_entry is None:
|
|
||||||
continue
|
|
||||||
voice_id = str(config_entry.get("voice") or "").strip()
|
|
||||||
voice_profile = str(config_entry.get("voice_profile") or "").strip()
|
|
||||||
voice_formula = str(config_entry.get("voice_formula") or "").strip()
|
|
||||||
resolved_voice = str(config_entry.get("resolved_voice") or "").strip()
|
|
||||||
languages = config_entry.get("languages") if isinstance(config_entry.get("languages"), list) else []
|
|
||||||
chosen_voice = resolved_voice or voice_formula or voice_id or roster_entry.get("voice")
|
|
||||||
usable_languages = languages or allowed_languages
|
|
||||||
|
|
||||||
if chosen_voice:
|
|
||||||
roster_entry["resolved_voice"] = chosen_voice
|
|
||||||
roster_entry["voice"] = chosen_voice if not voice_profile and not voice_formula else roster_entry.get("voice", chosen_voice)
|
|
||||||
if voice_profile:
|
|
||||||
roster_entry["voice_profile"] = voice_profile
|
|
||||||
if voice_formula:
|
|
||||||
roster_entry["voice_formula"] = voice_formula
|
|
||||||
roster_entry["resolved_voice"] = voice_formula
|
|
||||||
if not voice_formula and not voice_profile and resolved_voice:
|
|
||||||
roster_entry["resolved_voice"] = resolved_voice
|
|
||||||
roster_entry["config_languages"] = usable_languages or []
|
|
||||||
|
|
||||||
if chosen_voice:
|
|
||||||
used_voices.add(chosen_voice)
|
|
||||||
|
|
||||||
# persist updates back to config payload if required
|
|
||||||
if persist_changes:
|
|
||||||
slug = config_entry.get("id") or slugify_label(label)
|
|
||||||
speakers_payload[slug] = {
|
|
||||||
"id": slug,
|
|
||||||
"label": label,
|
|
||||||
"gender": config_entry.get("gender", "unknown"),
|
|
||||||
"voice": voice_id,
|
|
||||||
"voice_profile": voice_profile,
|
|
||||||
"voice_formula": voice_formula,
|
|
||||||
"resolved_voice": roster_entry.get("resolved_voice", resolved_voice or voice_id),
|
|
||||||
"languages": usable_languages,
|
|
||||||
}
|
|
||||||
|
|
||||||
new_config = new_config_payload if (persist_changes and config_changed) else None
|
|
||||||
return updated_roster, allowed_languages, new_config
|
|
||||||
|
|
||||||
|
|
||||||
def inject_recommended_voices(
|
def inject_recommended_voices(
|
||||||
roster: Mapping[str, Any],
|
roster: Mapping[str, Any],
|
||||||
*,
|
*,
|
||||||
@@ -311,168 +109,6 @@ def extract_speaker_config_form(form: Mapping[str, Any]) -> Tuple[str, Dict[str,
|
|||||||
return name, payload, errors
|
return name, payload, errors
|
||||||
|
|
||||||
|
|
||||||
def prepare_speaker_metadata(
|
|
||||||
*,
|
|
||||||
chapters: List[Dict[str, Any]],
|
|
||||||
chunks: List[Dict[str, Any]],
|
|
||||||
analysis_chunks: Optional[List[Dict[str, Any]]] = None,
|
|
||||||
voice: str,
|
|
||||||
voice_profile: Optional[str],
|
|
||||||
threshold: int,
|
|
||||||
existing_roster: Optional[Mapping[str, Any]] = None,
|
|
||||||
run_analysis: bool = True,
|
|
||||||
speaker_config: Optional[Mapping[str, Any]] = None,
|
|
||||||
apply_config: bool = False,
|
|
||||||
persist_config: bool = False,
|
|
||||||
) -> tuple[List[Dict[str, Any]], Dict[str, Any], Dict[str, Any], List[str], Optional[Dict[str, Any]]]:
|
|
||||||
chunk_list = [dict(chunk) for chunk in chunks]
|
|
||||||
analysis_source = [dict(chunk) for chunk in (analysis_chunks or chunks)]
|
|
||||||
threshold_value = max(1, int(threshold))
|
|
||||||
analysis_enabled = run_analysis
|
|
||||||
settings_state = load_settings()
|
|
||||||
global_random_languages = [
|
|
||||||
code
|
|
||||||
for code in settings_state.get("speaker_random_languages", [])
|
|
||||||
if isinstance(code, str) and code
|
|
||||||
]
|
|
||||||
|
|
||||||
if not analysis_enabled:
|
|
||||||
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, [], None
|
|
||||||
|
|
||||||
analysis_result = analyze_speakers(
|
|
||||||
chapters,
|
|
||||||
analysis_source,
|
|
||||||
threshold=threshold_value,
|
|
||||||
max_speakers=0,
|
|
||||||
)
|
|
||||||
analysis_payload = analysis_result.to_dict()
|
|
||||||
speakers_payload = analysis_payload.get("speakers", {})
|
|
||||||
ordered_ids = [
|
|
||||||
sid
|
|
||||||
for sid, meta in sorted(
|
|
||||||
(
|
|
||||||
(sid, meta)
|
|
||||||
for sid, meta in speakers_payload.items()
|
|
||||||
if sid != "narrator" and isinstance(meta, Mapping) and not meta.get("suppressed")
|
|
||||||
),
|
|
||||||
key=lambda item: item[1].get("count", 0),
|
|
||||||
reverse=True,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
analysis_payload["ordered_speakers"] = ordered_ids
|
|
||||||
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,
|
|
||||||
order=analysis_payload.get("ordered_speakers"),
|
|
||||||
)
|
|
||||||
applied_languages: List[str] = []
|
|
||||||
updated_config: Optional[Dict[str, Any]] = None
|
|
||||||
if apply_config and speaker_config:
|
|
||||||
roster, applied_languages, updated_config = apply_speaker_config_to_roster(
|
|
||||||
roster,
|
|
||||||
speaker_config,
|
|
||||||
persist_changes=persist_config,
|
|
||||||
fallback_languages=global_random_languages,
|
|
||||||
)
|
|
||||||
speakers_payload = analysis_payload.get("speakers")
|
|
||||||
if isinstance(speakers_payload, dict):
|
|
||||||
for roster_id, roster_payload in roster.items():
|
|
||||||
speaker_meta = speakers_payload.get(roster_id)
|
|
||||||
if isinstance(speaker_meta, dict):
|
|
||||||
for key in ("voice", "voice_profile", "voice_formula", "resolved_voice"):
|
|
||||||
value = roster_payload.get(key)
|
|
||||||
if value:
|
|
||||||
speaker_meta[key] = value
|
|
||||||
effective_languages: List[str] = []
|
|
||||||
if applied_languages:
|
|
||||||
effective_languages = applied_languages
|
|
||||||
elif isinstance(analysis_payload.get("config_languages"), list):
|
|
||||||
effective_languages = [
|
|
||||||
code for code in analysis_payload.get("config_languages", []) if isinstance(code, str) and code
|
|
||||||
]
|
|
||||||
elif global_random_languages:
|
|
||||||
effective_languages = list(global_random_languages)
|
|
||||||
|
|
||||||
if effective_languages:
|
|
||||||
analysis_payload["config_languages"] = effective_languages
|
|
||||||
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
|
|
||||||
|
|
||||||
fallback_languages = effective_languages or []
|
|
||||||
inject_recommended_voices(roster, fallback_languages=fallback_languages)
|
|
||||||
|
|
||||||
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, applied_languages, updated_config
|
|
||||||
|
|
||||||
|
|
||||||
def template_options() -> Dict[str, Any]:
|
def template_options() -> Dict[str, Any]:
|
||||||
current_settings = load_settings()
|
current_settings = load_settings()
|
||||||
profiles = serialize_profiles()
|
profiles = serialize_profiles()
|
||||||
|
|||||||
@@ -0,0 +1,758 @@
|
|||||||
|
"""Tests for domain speaker metadata functions.
|
||||||
|
|
||||||
|
Tests for build_narrator_roster, build_speaker_roster, match_configured_speaker,
|
||||||
|
apply_speaker_config_to_roster, and prepare_speaker_metadata.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# build_narrator_roster
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildNarratorRoster:
|
||||||
|
"""Tests for build_narrator_roster()."""
|
||||||
|
|
||||||
|
def test_basic_roster(self):
|
||||||
|
from abogen.domain.speaker_metadata import build_narrator_roster
|
||||||
|
|
||||||
|
roster = build_narrator_roster("af_heart", None)
|
||||||
|
assert "narrator" in roster
|
||||||
|
assert roster["narrator"]["voice"] == "af_heart"
|
||||||
|
assert roster["narrator"]["label"] == "Narrator"
|
||||||
|
|
||||||
|
def test_with_voice_profile(self):
|
||||||
|
from abogen.domain.speaker_metadata import build_narrator_roster
|
||||||
|
|
||||||
|
roster = build_narrator_roster("af_heart", "my_profile")
|
||||||
|
assert roster["narrator"]["voice_profile"] == "my_profile"
|
||||||
|
|
||||||
|
def test_without_voice_profile(self):
|
||||||
|
from abogen.domain.speaker_metadata import build_narrator_roster
|
||||||
|
|
||||||
|
roster = build_narrator_roster("af_heart", None)
|
||||||
|
assert "voice_profile" not in roster["narrator"]
|
||||||
|
|
||||||
|
def test_merges_existing_overrides(self):
|
||||||
|
from abogen.domain.speaker_metadata import build_narrator_roster
|
||||||
|
|
||||||
|
existing = {
|
||||||
|
"narrator": {
|
||||||
|
"label": "Custom Narrator",
|
||||||
|
"voice": "am_echo",
|
||||||
|
"pronunciation": "NAH-rah-tor",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
roster = build_narrator_roster("af_heart", None, existing=existing)
|
||||||
|
assert roster["narrator"]["label"] == "Custom Narrator"
|
||||||
|
assert roster["narrator"]["voice"] == "am_echo"
|
||||||
|
assert roster["narrator"]["pronunciation"] == "NAH-rah-tor"
|
||||||
|
|
||||||
|
def test_existing_none_ignored(self):
|
||||||
|
from abogen.domain.speaker_metadata import build_narrator_roster
|
||||||
|
|
||||||
|
roster = build_narrator_roster("af_heart", None, existing=None)
|
||||||
|
assert roster["narrator"]["voice"] == "af_heart"
|
||||||
|
|
||||||
|
def test_empty_existing_dict(self):
|
||||||
|
from abogen.domain.speaker_metadata import build_narrator_roster
|
||||||
|
|
||||||
|
roster = build_narrator_roster("af_heart", None, existing={})
|
||||||
|
assert roster["narrator"]["voice"] == "af_heart"
|
||||||
|
|
||||||
|
def test_existing_without_narrator_key(self):
|
||||||
|
from abogen.domain.speaker_metadata import build_narrator_roster
|
||||||
|
|
||||||
|
existing = {"other_speaker": {"label": "Other"}}
|
||||||
|
roster = build_narrator_roster("af_heart", None, existing=existing)
|
||||||
|
assert roster["narrator"]["voice"] == "af_heart"
|
||||||
|
|
||||||
|
def test_empty_string_values_not_overridden(self):
|
||||||
|
from abogen.domain.speaker_metadata import build_narrator_roster
|
||||||
|
|
||||||
|
existing = {"narrator": {"label": "", "voice": ""}}
|
||||||
|
roster = build_narrator_roster("af_heart", None, existing=existing)
|
||||||
|
assert roster["narrator"]["label"] == "Narrator"
|
||||||
|
assert roster["narrator"]["voice"] == "af_heart"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# build_speaker_roster
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildSpeakerRoster:
|
||||||
|
"""Tests for build_speaker_roster()."""
|
||||||
|
|
||||||
|
def test_single_narrator(self):
|
||||||
|
from abogen.domain.speaker_metadata import build_speaker_roster
|
||||||
|
|
||||||
|
analysis = {"speakers": {"narrator": {"label": "Narrator", "count": 10}}}
|
||||||
|
roster = build_speaker_roster(analysis, "af_heart", None)
|
||||||
|
assert list(roster.keys()) == ["narrator"]
|
||||||
|
assert roster["narrator"]["voice"] == "af_heart"
|
||||||
|
|
||||||
|
def test_multiple_speakers(self):
|
||||||
|
from abogen.domain.speaker_metadata import build_speaker_roster
|
||||||
|
|
||||||
|
analysis = {
|
||||||
|
"speakers": {
|
||||||
|
"narrator": {"label": "Narrator", "count": 10},
|
||||||
|
"alice": {"label": "Alice", "count": 5, "gender": "female"},
|
||||||
|
"bob": {"label": "Bob", "count": 3, "gender": "male"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
roster = build_speaker_roster(analysis, "af_heart", None)
|
||||||
|
assert "narrator" in roster
|
||||||
|
assert "alice" in roster
|
||||||
|
assert "bob" in roster
|
||||||
|
assert roster["alice"]["label"] == "Alice"
|
||||||
|
assert roster["alice"]["gender"] == "female"
|
||||||
|
|
||||||
|
def test_suppressed_speakers_excluded(self):
|
||||||
|
from abogen.domain.speaker_metadata import build_speaker_roster
|
||||||
|
|
||||||
|
analysis = {
|
||||||
|
"speakers": {
|
||||||
|
"narrator": {"label": "Narrator", "count": 10},
|
||||||
|
"alice": {"label": "Alice", "count": 5},
|
||||||
|
"bob": {"label": "Bob", "count": 1, "suppressed": True},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
roster = build_speaker_roster(analysis, "af_heart", None)
|
||||||
|
assert "bob" not in roster
|
||||||
|
|
||||||
|
def test_order_respected(self):
|
||||||
|
from abogen.domain.speaker_metadata import build_speaker_roster
|
||||||
|
|
||||||
|
analysis = {
|
||||||
|
"speakers": {
|
||||||
|
"narrator": {"label": "Narrator", "count": 10},
|
||||||
|
"alice": {"label": "Alice", "count": 5},
|
||||||
|
"bob": {"label": "Bob", "count": 3},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
roster = build_speaker_roster(analysis, "af_heart", None, order=["bob", "alice"])
|
||||||
|
keys = list(roster.keys())
|
||||||
|
assert keys.index("bob") < keys.index("alice")
|
||||||
|
|
||||||
|
def test_existing_assignments_preserved(self):
|
||||||
|
from abogen.domain.speaker_metadata import build_speaker_roster
|
||||||
|
|
||||||
|
analysis = {
|
||||||
|
"speakers": {
|
||||||
|
"narrator": {"label": "Narrator", "count": 10},
|
||||||
|
"alice": {"label": "Alice", "count": 5, "gender": "female"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
existing = {
|
||||||
|
"narrator": {"voice": "am_echo"},
|
||||||
|
"alice": {"voice": "af_nicole", "pronunciation": "AH-leece"},
|
||||||
|
}
|
||||||
|
roster = build_speaker_roster(analysis, "af_heart", None, existing=existing)
|
||||||
|
assert roster["alice"]["voice"] == "af_nicole"
|
||||||
|
assert roster["alice"]["pronunciation"] == "AH-leece"
|
||||||
|
assert roster["narrator"]["voice"] == "am_echo"
|
||||||
|
|
||||||
|
def test_empty_analysis(self):
|
||||||
|
from abogen.domain.speaker_metadata import build_speaker_roster
|
||||||
|
|
||||||
|
roster = build_speaker_roster({}, "af_heart", None)
|
||||||
|
assert "narrator" in roster
|
||||||
|
assert len(roster) == 1
|
||||||
|
|
||||||
|
def test_sample_quotes_preserved(self):
|
||||||
|
from abogen.domain.speaker_metadata import build_speaker_roster
|
||||||
|
|
||||||
|
analysis = {
|
||||||
|
"speakers": {
|
||||||
|
"narrator": {"label": "Narrator", "count": 10},
|
||||||
|
"alice": {
|
||||||
|
"label": "Alice",
|
||||||
|
"count": 5,
|
||||||
|
"sample_quotes": ["Hello!", "Goodbye!"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
roster = build_speaker_roster(analysis, "af_heart", None)
|
||||||
|
assert roster["alice"]["sample_quotes"] == ["Hello!", "Goodbye!"]
|
||||||
|
|
||||||
|
def test_detected_gender_preserved(self):
|
||||||
|
from abogen.domain.speaker_metadata import build_speaker_roster
|
||||||
|
|
||||||
|
analysis = {
|
||||||
|
"speakers": {
|
||||||
|
"narrator": {"label": "Narrator", "count": 10},
|
||||||
|
"alice": {"label": "Alice", "count": 5, "detected_gender": "female"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
roster = build_speaker_roster(analysis, "af_heart", None)
|
||||||
|
assert roster["alice"]["detected_gender"] == "female"
|
||||||
|
|
||||||
|
def test_default_label_from_id(self):
|
||||||
|
from abogen.domain.speaker_metadata import build_speaker_roster
|
||||||
|
|
||||||
|
analysis = {
|
||||||
|
"speakers": {
|
||||||
|
"narrator": {"label": "Narrator", "count": 10},
|
||||||
|
"my_character": {"count": 3},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
roster = build_speaker_roster(analysis, "af_heart", None)
|
||||||
|
assert roster["my_character"]["label"] == "My Character"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# match_configured_speaker
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestMatchConfiguredSpeaker:
|
||||||
|
"""Tests for match_configured_speaker()."""
|
||||||
|
|
||||||
|
def test_match_by_id(self):
|
||||||
|
from abogen.domain.speaker_metadata import match_configured_speaker
|
||||||
|
|
||||||
|
config = {"alice": {"id": "alice", "label": "Alice", "voice": "af_heart"}}
|
||||||
|
result = match_configured_speaker(config, "alice", "Alice")
|
||||||
|
assert result is not None
|
||||||
|
assert result["voice"] == "af_heart"
|
||||||
|
|
||||||
|
def test_match_by_slug(self):
|
||||||
|
from abogen.domain.speaker_metadata import match_configured_speaker
|
||||||
|
|
||||||
|
config = {"my_character": {"id": "my_character", "label": "My Character"}}
|
||||||
|
result = match_configured_speaker(config, "my_character", "My Character")
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
def test_match_by_label_lowercase(self):
|
||||||
|
from abogen.domain.speaker_metadata import match_configured_speaker
|
||||||
|
|
||||||
|
config = {"custom_id": {"id": "custom_id", "label": "Alice"}}
|
||||||
|
result = match_configured_speaker(config, "other_id", "Alice")
|
||||||
|
assert result is not None
|
||||||
|
assert result["id"] == "custom_id"
|
||||||
|
|
||||||
|
def test_no_match(self):
|
||||||
|
from abogen.domain.speaker_metadata import match_configured_speaker
|
||||||
|
|
||||||
|
config = {"alice": {"id": "alice", "label": "Alice"}}
|
||||||
|
result = match_configured_speaker(config, "bob", "Bob")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_empty_config(self):
|
||||||
|
from abogen.domain.speaker_metadata import match_configured_speaker
|
||||||
|
|
||||||
|
result = match_configured_speaker({}, "alice", "Alice")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_none_config(self):
|
||||||
|
from abogen.domain.speaker_metadata import match_configured_speaker
|
||||||
|
|
||||||
|
result = match_configured_speaker(None, "alice", "Alice") # type: ignore
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# apply_speaker_config_to_roster
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplySpeakerConfigToRoster:
|
||||||
|
"""Tests for apply_speaker_config_to_roster()."""
|
||||||
|
|
||||||
|
def test_no_config_returns_roster_unchanged(self):
|
||||||
|
from abogen.domain.speaker_metadata import apply_speaker_config_to_roster
|
||||||
|
|
||||||
|
roster = {"narrator": {"id": "narrator", "voice": "af_heart"}}
|
||||||
|
result, languages, config = apply_speaker_config_to_roster(roster, None)
|
||||||
|
assert result["narrator"]["voice"] == "af_heart"
|
||||||
|
assert languages == []
|
||||||
|
assert config is None
|
||||||
|
|
||||||
|
def test_empty_config_returns_roster_unchanged(self):
|
||||||
|
from abogen.domain.speaker_metadata import apply_speaker_config_to_roster
|
||||||
|
|
||||||
|
roster = {"narrator": {"id": "narrator", "voice": "af_heart"}}
|
||||||
|
result, languages, config = apply_speaker_config_to_roster(roster, {})
|
||||||
|
assert result["narrator"]["voice"] == "af_heart"
|
||||||
|
|
||||||
|
def test_config_without_speakers_map(self):
|
||||||
|
from abogen.domain.speaker_metadata import apply_speaker_config_to_roster
|
||||||
|
|
||||||
|
roster = {"narrator": {"id": "narrator", "voice": "af_heart"}}
|
||||||
|
config = {"language": "a"}
|
||||||
|
result, languages, config = apply_speaker_config_to_roster(roster, config)
|
||||||
|
assert result["narrator"]["voice"] == "af_heart"
|
||||||
|
|
||||||
|
def test_applies_voice_from_config(self):
|
||||||
|
from abogen.domain.speaker_metadata import apply_speaker_config_to_roster
|
||||||
|
|
||||||
|
roster = {
|
||||||
|
"narrator": {"id": "narrator", "voice": "af_heart"},
|
||||||
|
"alice": {"id": "alice", "label": "Alice", "voice": "af_heart"},
|
||||||
|
}
|
||||||
|
config = {
|
||||||
|
"speakers": {
|
||||||
|
"alice": {"id": "alice", "voice": "af_nicole", "gender": "female"}
|
||||||
|
},
|
||||||
|
"languages": ["a"],
|
||||||
|
}
|
||||||
|
result, languages, updated_config = apply_speaker_config_to_roster(
|
||||||
|
roster, config, persist_changes=True
|
||||||
|
)
|
||||||
|
assert result["alice"]["voice"] == "af_nicole"
|
||||||
|
assert result["alice"]["resolved_voice"] == "af_nicole"
|
||||||
|
|
||||||
|
def test_applies_voice_profile(self):
|
||||||
|
from abogen.domain.speaker_metadata import apply_speaker_config_to_roster
|
||||||
|
|
||||||
|
roster = {
|
||||||
|
"narrator": {"id": "narrator", "voice": "af_heart"},
|
||||||
|
"alice": {"id": "alice", "label": "Alice"},
|
||||||
|
}
|
||||||
|
config = {
|
||||||
|
"speakers": {
|
||||||
|
"alice": {"id": "alice", "voice_profile": "my_profile"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, _, _ = apply_speaker_config_to_roster(roster, config)
|
||||||
|
assert result["alice"]["voice_profile"] == "my_profile"
|
||||||
|
|
||||||
|
def test_applies_voice_formula(self):
|
||||||
|
from abogen.domain.speaker_metadata import apply_speaker_config_to_roster
|
||||||
|
|
||||||
|
roster = {
|
||||||
|
"narrator": {"id": "narrator", "voice": "af_heart"},
|
||||||
|
"alice": {"id": "alice", "label": "Alice"},
|
||||||
|
}
|
||||||
|
config = {
|
||||||
|
"speakers": {
|
||||||
|
"alice": {"id": "alice", "voice_formula": "af_heart(0.6)+af_nicole(0.4)"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, _, _ = apply_speaker_config_to_roster(roster, config)
|
||||||
|
assert result["alice"]["voice_formula"] == "af_heart(0.6)+af_nicole(0.4)"
|
||||||
|
assert result["alice"]["resolved_voice"] == "af_heart(0.6)+af_nicole(0.4)"
|
||||||
|
|
||||||
|
def test_persist_changes_returns_updated_config(self):
|
||||||
|
from abogen.domain.speaker_metadata import apply_speaker_config_to_roster
|
||||||
|
|
||||||
|
roster = {
|
||||||
|
"narrator": {"id": "narrator", "voice": "af_heart"},
|
||||||
|
"alice": {"id": "alice", "label": "Alice", "voice": "af_heart"},
|
||||||
|
}
|
||||||
|
config = {
|
||||||
|
"language": "a",
|
||||||
|
"languages": ["a"],
|
||||||
|
"speakers": {
|
||||||
|
"alice": {"id": "alice", "voice": "af_nicole", "gender": "female"}
|
||||||
|
},
|
||||||
|
"version": 1,
|
||||||
|
}
|
||||||
|
_, _, updated_config = apply_speaker_config_to_roster(
|
||||||
|
roster, config, persist_changes=True
|
||||||
|
)
|
||||||
|
# config_changed is False by default, so updated_config should be None
|
||||||
|
# unless there's actual change logic triggered
|
||||||
|
# The function has config_changed = False and never sets it to True
|
||||||
|
# so updated_config should be None even with persist_changes=True
|
||||||
|
assert updated_config is None
|
||||||
|
|
||||||
|
def test_fallback_languages_used(self):
|
||||||
|
from abogen.domain.speaker_metadata import apply_speaker_config_to_roster
|
||||||
|
|
||||||
|
roster = {"narrator": {"id": "narrator", "voice": "af_heart"}}
|
||||||
|
result, languages, _ = apply_speaker_config_to_roster(
|
||||||
|
roster, None, fallback_languages=["a", "b"]
|
||||||
|
)
|
||||||
|
assert languages == ["a", "b"]
|
||||||
|
|
||||||
|
def test_config_languages_take_precedence(self):
|
||||||
|
from abogen.domain.speaker_metadata import apply_speaker_config_to_roster
|
||||||
|
|
||||||
|
roster = {"narrator": {"id": "narrator", "voice": "af_heart"}}
|
||||||
|
config = {"languages": ["a"], "speakers": {}}
|
||||||
|
_, languages, _ = apply_speaker_config_to_roster(
|
||||||
|
roster, config, fallback_languages=["a", "b"]
|
||||||
|
)
|
||||||
|
assert languages == ["a"]
|
||||||
|
|
||||||
|
def test_empty_roster_returns_empty(self):
|
||||||
|
from abogen.domain.speaker_metadata import apply_speaker_config_to_roster
|
||||||
|
|
||||||
|
result, languages, config = apply_speaker_config_to_roster({}, None)
|
||||||
|
assert result == {}
|
||||||
|
assert languages == []
|
||||||
|
|
||||||
|
def test_non_mapping_roster_returns_empty(self):
|
||||||
|
from abogen.domain.speaker_metadata import apply_speaker_config_to_roster
|
||||||
|
|
||||||
|
result, languages, config = apply_speaker_config_to_roster("invalid", None) # type: ignore
|
||||||
|
assert result == {}
|
||||||
|
assert languages == []
|
||||||
|
|
||||||
|
def test_narrator_not_modified(self):
|
||||||
|
from abogen.domain.speaker_metadata import apply_speaker_config_to_roster
|
||||||
|
|
||||||
|
roster = {
|
||||||
|
"narrator": {"id": "narrator", "voice": "af_heart"},
|
||||||
|
"alice": {"id": "alice", "label": "Alice"},
|
||||||
|
}
|
||||||
|
config = {
|
||||||
|
"speakers": {
|
||||||
|
"narrator": {"id": "narrator", "voice": "am_echo"},
|
||||||
|
"alice": {"id": "alice", "voice": "af_nicole"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, _, _ = apply_speaker_config_to_roster(roster, config)
|
||||||
|
assert result["narrator"]["voice"] == "af_heart"
|
||||||
|
assert result["alice"]["voice"] == "af_nicole"
|
||||||
|
|
||||||
|
def test_config_languages_applied_to_roster_entry(self):
|
||||||
|
from abogen.domain.speaker_metadata import apply_speaker_config_to_roster
|
||||||
|
|
||||||
|
roster = {
|
||||||
|
"narrator": {"id": "narrator", "voice": "af_heart"},
|
||||||
|
"alice": {"id": "alice", "label": "Alice"},
|
||||||
|
}
|
||||||
|
config = {
|
||||||
|
"languages": ["a", "b"],
|
||||||
|
"speakers": {
|
||||||
|
"alice": {"id": "alice", "voice": "af_nicole"}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result, _, _ = apply_speaker_config_to_roster(roster, config)
|
||||||
|
assert result["alice"]["config_languages"] == ["a", "b"]
|
||||||
|
|
||||||
|
def test_speaker_specific_languages_override(self):
|
||||||
|
from abogen.domain.speaker_metadata import apply_speaker_config_to_roster
|
||||||
|
|
||||||
|
roster = {
|
||||||
|
"narrator": {"id": "narrator", "voice": "af_heart"},
|
||||||
|
"alice": {"id": "alice", "label": "Alice"},
|
||||||
|
}
|
||||||
|
config = {
|
||||||
|
"languages": ["a"],
|
||||||
|
"speakers": {
|
||||||
|
"alice": {"id": "alice", "voice": "af_nicole", "languages": ["a", "b"]}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result, _, _ = apply_speaker_config_to_roster(roster, config)
|
||||||
|
assert result["alice"]["config_languages"] == ["a", "b"]
|
||||||
|
|
||||||
|
def test_resolved_voice_takes_precedence(self):
|
||||||
|
from abogen.domain.speaker_metadata import apply_speaker_config_to_roster
|
||||||
|
|
||||||
|
roster = {
|
||||||
|
"narrator": {"id": "narrator", "voice": "af_heart"},
|
||||||
|
"alice": {"id": "alice", "label": "Alice"},
|
||||||
|
}
|
||||||
|
config = {
|
||||||
|
"speakers": {
|
||||||
|
"alice": {
|
||||||
|
"id": "alice",
|
||||||
|
"voice": "af_heart",
|
||||||
|
"resolved_voice": "af_nicole",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, _, _ = apply_speaker_config_to_roster(roster, config)
|
||||||
|
assert result["alice"]["resolved_voice"] == "af_nicole"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# prepare_speaker_metadata
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestPrepareSpeakerMetadata:
|
||||||
|
"""Tests for prepare_speaker_metadata()."""
|
||||||
|
|
||||||
|
def _make_chunks(self, count=3):
|
||||||
|
return [{"id": str(i), "text": f"Chunk {i}"} for i in range(count)]
|
||||||
|
|
||||||
|
def _make_chapters(self):
|
||||||
|
return [{"title": "Chapter 1", "chunks": self._make_chunks()}]
|
||||||
|
|
||||||
|
@patch("abogen.domain.speaker_metadata.load_settings")
|
||||||
|
def test_no_analysis(self, mock_settings):
|
||||||
|
from abogen.domain.speaker_metadata import prepare_speaker_metadata
|
||||||
|
|
||||||
|
mock_settings.return_value = {"speaker_random_languages": []}
|
||||||
|
chunks = self._make_chunks()
|
||||||
|
result = prepare_speaker_metadata(
|
||||||
|
chapters=self._make_chapters(),
|
||||||
|
chunks=chunks,
|
||||||
|
voice="af_heart",
|
||||||
|
voice_profile=None,
|
||||||
|
threshold=3,
|
||||||
|
run_analysis=False,
|
||||||
|
)
|
||||||
|
chunk_list, roster, analysis, languages, config = result
|
||||||
|
assert all(c["speaker_id"] == "narrator" for c in chunk_list)
|
||||||
|
assert all(c["speaker_label"] == "Narrator" for c in chunk_list)
|
||||||
|
assert "narrator" in roster
|
||||||
|
assert languages == []
|
||||||
|
assert config is None
|
||||||
|
|
||||||
|
@patch("abogen.domain.speaker_metadata.load_settings")
|
||||||
|
def test_no_analysis_with_existing_roster(self, mock_settings):
|
||||||
|
from abogen.domain.speaker_metadata import prepare_speaker_metadata
|
||||||
|
|
||||||
|
mock_settings.return_value = {"speaker_random_languages": []}
|
||||||
|
existing = {"narrator": {"voice": "am_echo", "pronunciation": "test"}}
|
||||||
|
result = prepare_speaker_metadata(
|
||||||
|
chapters=self._make_chapters(),
|
||||||
|
chunks=self._make_chunks(),
|
||||||
|
voice="af_heart",
|
||||||
|
voice_profile=None,
|
||||||
|
threshold=3,
|
||||||
|
run_analysis=False,
|
||||||
|
existing_roster=existing,
|
||||||
|
)
|
||||||
|
_, roster, _, _, _ = result
|
||||||
|
assert roster["narrator"]["voice"] == "am_echo"
|
||||||
|
assert roster["narrator"]["pronunciation"] == "test"
|
||||||
|
|
||||||
|
@patch("abogen.domain.speaker_metadata.load_settings")
|
||||||
|
@patch("abogen.domain.speaker_metadata.analyze_speakers")
|
||||||
|
def test_with_analysis(self, mock_analyze, mock_settings):
|
||||||
|
from abogen.domain.speaker_metadata import prepare_speaker_metadata
|
||||||
|
|
||||||
|
mock_settings.return_value = {"speaker_random_languages": []}
|
||||||
|
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.to_dict.return_value = {
|
||||||
|
"version": "1.0",
|
||||||
|
"narrator": "narrator",
|
||||||
|
"assignments": {"0": "narrator", "1": "narrator", "2": "narrator"},
|
||||||
|
"speakers": {
|
||||||
|
"narrator": {
|
||||||
|
"label": "Narrator",
|
||||||
|
"count": 3,
|
||||||
|
"confidence": "low",
|
||||||
|
"sample_quotes": [],
|
||||||
|
"suppressed": False,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"suppressed": [],
|
||||||
|
"stats": {
|
||||||
|
"total_chunks": 3,
|
||||||
|
"explicit_chunks": 0,
|
||||||
|
"active_speakers": 0,
|
||||||
|
"unique_speakers": 1,
|
||||||
|
"suppressed": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mock_analyze.return_value = mock_result
|
||||||
|
|
||||||
|
result = prepare_speaker_metadata(
|
||||||
|
chapters=self._make_chapters(),
|
||||||
|
chunks=self._make_chunks(),
|
||||||
|
voice="af_heart",
|
||||||
|
voice_profile=None,
|
||||||
|
threshold=3,
|
||||||
|
run_analysis=True,
|
||||||
|
)
|
||||||
|
chunk_list, roster, analysis, _, _ = result
|
||||||
|
assert "narrator" in roster
|
||||||
|
assert analysis["version"] == "1.0"
|
||||||
|
|
||||||
|
@patch("abogen.domain.speaker_metadata.load_settings")
|
||||||
|
@patch("abogen.domain.speaker_metadata.analyze_speakers")
|
||||||
|
def test_inject_recommended_callback_called(self, mock_analyze, mock_settings):
|
||||||
|
from abogen.domain.speaker_metadata import prepare_speaker_metadata
|
||||||
|
|
||||||
|
mock_settings.return_value = {"speaker_random_languages": []}
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.to_dict.return_value = {
|
||||||
|
"version": "1.0",
|
||||||
|
"narrator": "narrator",
|
||||||
|
"assignments": {},
|
||||||
|
"speakers": {
|
||||||
|
"narrator": {
|
||||||
|
"label": "Narrator",
|
||||||
|
"count": 1,
|
||||||
|
"confidence": "low",
|
||||||
|
"sample_quotes": [],
|
||||||
|
"suppressed": False,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"suppressed": [],
|
||||||
|
"stats": {
|
||||||
|
"total_chunks": 1,
|
||||||
|
"explicit_chunks": 0,
|
||||||
|
"active_speakers": 0,
|
||||||
|
"unique_speakers": 1,
|
||||||
|
"suppressed": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mock_analyze.return_value = mock_result
|
||||||
|
|
||||||
|
injected = []
|
||||||
|
callback = lambda roster, **kwargs: injected.append(dict(roster))
|
||||||
|
|
||||||
|
result = prepare_speaker_metadata(
|
||||||
|
chapters=self._make_chapters(),
|
||||||
|
chunks=self._make_chunks(1),
|
||||||
|
voice="af_heart",
|
||||||
|
voice_profile=None,
|
||||||
|
threshold=3,
|
||||||
|
run_analysis=True,
|
||||||
|
inject_recommended=callback,
|
||||||
|
)
|
||||||
|
assert len(injected) == 1
|
||||||
|
assert "narrator" in injected[0]
|
||||||
|
|
||||||
|
@patch("abogen.domain.speaker_metadata.load_settings")
|
||||||
|
def test_inject_recommended_not_called_when_none(self, mock_settings):
|
||||||
|
from abogen.domain.speaker_metadata import prepare_speaker_metadata
|
||||||
|
|
||||||
|
mock_settings.return_value = {"speaker_random_languages": []}
|
||||||
|
result = prepare_speaker_metadata(
|
||||||
|
chapters=self._make_chapters(),
|
||||||
|
chunks=self._make_chunks(),
|
||||||
|
voice="af_heart",
|
||||||
|
voice_profile=None,
|
||||||
|
threshold=3,
|
||||||
|
run_analysis=False,
|
||||||
|
inject_recommended=None,
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
@patch("abogen.domain.speaker_metadata.load_settings")
|
||||||
|
def test_chunks_are_copies(self, mock_settings):
|
||||||
|
from abogen.domain.speaker_metadata import prepare_speaker_metadata
|
||||||
|
|
||||||
|
mock_settings.return_value = {"speaker_random_languages": []}
|
||||||
|
original_chunks = [{"id": "0", "text": "Hello"}]
|
||||||
|
result = prepare_speaker_metadata(
|
||||||
|
chapters=[{"title": "Ch1", "chunks": original_chunks}],
|
||||||
|
chunks=original_chunks,
|
||||||
|
voice="af_heart",
|
||||||
|
voice_profile=None,
|
||||||
|
threshold=3,
|
||||||
|
run_analysis=False,
|
||||||
|
)
|
||||||
|
chunk_list = result[0]
|
||||||
|
assert chunk_list is not original_chunks
|
||||||
|
assert chunk_list[0] is not original_chunks[0]
|
||||||
|
|
||||||
|
@patch("abogen.domain.speaker_metadata.load_settings")
|
||||||
|
def test_analysis_disabled_sets_narrator_on_all_chunks(self, mock_settings):
|
||||||
|
from abogen.domain.speaker_metadata import prepare_speaker_metadata
|
||||||
|
|
||||||
|
mock_settings.return_value = {"speaker_random_languages": []}
|
||||||
|
chunks = [{"id": "0"}, {"id": "1"}, {"id": "2"}]
|
||||||
|
result = prepare_speaker_metadata(
|
||||||
|
chapters=[{"title": "Ch1", "chunks": chunks}],
|
||||||
|
chunks=chunks,
|
||||||
|
voice="af_heart",
|
||||||
|
voice_profile=None,
|
||||||
|
threshold=3,
|
||||||
|
run_analysis=False,
|
||||||
|
)
|
||||||
|
for chunk in result[0]:
|
||||||
|
assert chunk["speaker_id"] == "narrator"
|
||||||
|
assert chunk["speaker_label"] == "Narrator"
|
||||||
|
|
||||||
|
@patch("abogen.domain.speaker_metadata.load_settings")
|
||||||
|
@patch("abogen.domain.speaker_metadata.analyze_speakers")
|
||||||
|
def test_speaker_random_languages_used(self, mock_analyze, mock_settings):
|
||||||
|
from abogen.domain.speaker_metadata import prepare_speaker_metadata
|
||||||
|
|
||||||
|
mock_settings.return_value = {"speaker_random_languages": ["a", "b"]}
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.to_dict.return_value = {
|
||||||
|
"version": "1.0",
|
||||||
|
"narrator": "narrator",
|
||||||
|
"assignments": {},
|
||||||
|
"speakers": {
|
||||||
|
"narrator": {
|
||||||
|
"label": "Narrator",
|
||||||
|
"count": 1,
|
||||||
|
"confidence": "low",
|
||||||
|
"sample_quotes": [],
|
||||||
|
"suppressed": False,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"suppressed": [],
|
||||||
|
"stats": {
|
||||||
|
"total_chunks": 1,
|
||||||
|
"explicit_chunks": 0,
|
||||||
|
"active_speakers": 0,
|
||||||
|
"unique_speakers": 1,
|
||||||
|
"suppressed": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mock_analyze.return_value = mock_result
|
||||||
|
|
||||||
|
result = prepare_speaker_metadata(
|
||||||
|
chapters=self._make_chapters(),
|
||||||
|
chunks=self._make_chunks(1),
|
||||||
|
voice="af_heart",
|
||||||
|
voice_profile=None,
|
||||||
|
threshold=3,
|
||||||
|
run_analysis=True,
|
||||||
|
)
|
||||||
|
_, _, analysis, _, _ = result
|
||||||
|
assert analysis["config_languages"] == ["a", "b"]
|
||||||
|
|
||||||
|
@patch("abogen.domain.speaker_metadata.load_settings")
|
||||||
|
@patch("abogen.domain.speaker_metadata.analyze_speakers")
|
||||||
|
def test_apply_config_with_speaker_config(self, mock_analyze, mock_settings):
|
||||||
|
from abogen.domain.speaker_metadata import prepare_speaker_metadata
|
||||||
|
|
||||||
|
mock_settings.return_value = {"speaker_random_languages": []}
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.to_dict.return_value = {
|
||||||
|
"version": "1.0",
|
||||||
|
"narrator": "narrator",
|
||||||
|
"assignments": {"0": "narrator"},
|
||||||
|
"speakers": {
|
||||||
|
"narrator": {
|
||||||
|
"label": "Narrator",
|
||||||
|
"count": 1,
|
||||||
|
"confidence": "low",
|
||||||
|
"sample_quotes": [],
|
||||||
|
"suppressed": False,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"suppressed": [],
|
||||||
|
"stats": {
|
||||||
|
"total_chunks": 1,
|
||||||
|
"explicit_chunks": 0,
|
||||||
|
"active_speakers": 0,
|
||||||
|
"unique_speakers": 1,
|
||||||
|
"suppressed": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mock_analyze.return_value = mock_result
|
||||||
|
|
||||||
|
speaker_config = {
|
||||||
|
"languages": ["a"],
|
||||||
|
"speakers": {
|
||||||
|
"narrator": {"id": "narrator", "voice": "am_echo"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result = prepare_speaker_metadata(
|
||||||
|
chapters=self._make_chapters(),
|
||||||
|
chunks=self._make_chunks(1),
|
||||||
|
voice="af_heart",
|
||||||
|
voice_profile=None,
|
||||||
|
threshold=3,
|
||||||
|
run_analysis=True,
|
||||||
|
speaker_config=speaker_config,
|
||||||
|
apply_config=True,
|
||||||
|
)
|
||||||
|
_, roster, _, languages, _ = result
|
||||||
|
assert roster["narrator"]["voice"] == "af_heart"
|
||||||
|
assert languages == ["a"]
|
||||||
Reference in New Issue
Block a user