mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
- Added new styles for the voice mixer components in styles.css. - Updated base.html to include a block for scripts. - Refactored voices.html to create a structured voice mixer interface with profile management features. - Introduced voices.js to handle voice mixer logic, including profile creation, editing, and previewing. - Implemented actions for importing and exporting voice profiles. - Enhanced user experience with loading states and status messages.
167 lines
5.1 KiB
Python
167 lines
5.1 KiB
Python
import json
|
|
import os
|
|
from typing import Dict, Iterable, List, Tuple
|
|
|
|
from abogen.constants import VOICES_INTERNAL
|
|
from abogen.utils import get_user_config_path
|
|
|
|
|
|
def _get_profiles_path():
|
|
config_path = get_user_config_path()
|
|
config_dir = os.path.dirname(config_path)
|
|
return os.path.join(config_dir, "voice_profiles.json")
|
|
|
|
|
|
def load_profiles():
|
|
"""Load all voice profiles from JSON file."""
|
|
path = _get_profiles_path()
|
|
if os.path.exists(path):
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
# always expect abogen_voice_profiles wrapper
|
|
if isinstance(data, dict) and "abogen_voice_profiles" in data:
|
|
return data["abogen_voice_profiles"]
|
|
# fallback: treat as profiles dict
|
|
if isinstance(data, dict):
|
|
return data
|
|
except Exception:
|
|
return {}
|
|
return {}
|
|
|
|
|
|
def save_profiles(profiles):
|
|
"""Save all voice profiles to JSON file."""
|
|
path = _get_profiles_path()
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
# always save with abogen_voice_profiles wrapper
|
|
json.dump({"abogen_voice_profiles": profiles}, f, indent=2)
|
|
|
|
|
|
def delete_profile(name):
|
|
"""Remove a profile by name."""
|
|
profiles = load_profiles()
|
|
if name in profiles:
|
|
del profiles[name]
|
|
save_profiles(profiles)
|
|
|
|
|
|
def duplicate_profile(src, dest):
|
|
"""Duplicate an existing profile."""
|
|
profiles = load_profiles()
|
|
if src in profiles and dest:
|
|
profiles[dest] = profiles[src]
|
|
save_profiles(profiles)
|
|
|
|
|
|
def export_profiles(export_path):
|
|
"""Export all profiles to specified JSON file."""
|
|
profiles = load_profiles()
|
|
with open(export_path, "w", encoding="utf-8") as f:
|
|
json.dump({"abogen_voice_profiles": profiles}, f, indent=2)
|
|
|
|
|
|
def serialize_profiles() -> Dict[str, Dict[str, Iterable[Tuple[str, float]]]]:
|
|
"""Return profiles in canonical dictionary form."""
|
|
return load_profiles()
|
|
|
|
|
|
def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
|
|
normalized: List[Tuple[str, float]] = []
|
|
for item in entries or []:
|
|
if isinstance(item, dict):
|
|
voice = item.get("id") or item.get("voice")
|
|
weight = item.get("weight")
|
|
elif isinstance(item, (list, tuple)) and len(item) >= 2:
|
|
voice, weight = item[0], item[1]
|
|
else:
|
|
continue
|
|
if voice not in VOICES_INTERNAL:
|
|
continue
|
|
if weight is None:
|
|
continue
|
|
try:
|
|
weight_val = float(weight)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if weight_val <= 0:
|
|
continue
|
|
normalized.append((voice, weight_val))
|
|
return normalized
|
|
|
|
|
|
def normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]:
|
|
"""Public helper to normalize voice-weight pairs from arbitrary payloads."""
|
|
|
|
return _normalize_voice_entries(entries)
|
|
|
|
|
|
def save_profile(name: str, *, language: str, voices: Iterable) -> None:
|
|
"""Persist a single profile after validating its data."""
|
|
|
|
name = (name or "").strip()
|
|
if not name:
|
|
raise ValueError("Profile name is required")
|
|
|
|
normalized = _normalize_voice_entries(voices)
|
|
if not normalized:
|
|
raise ValueError("At least one voice with a weight above zero is required")
|
|
|
|
if not language:
|
|
language = "a"
|
|
|
|
profiles = load_profiles()
|
|
profiles[name] = {"language": language, "voices": normalized}
|
|
save_profiles(profiles)
|
|
|
|
|
|
def remove_profile(name: str) -> None:
|
|
delete_profile(name)
|
|
|
|
|
|
def import_profiles_data(data: Dict, *, replace_existing: bool = False) -> List[str]:
|
|
"""Merge profiles from a dictionary structure and persist them.
|
|
|
|
Returns the list of profile names that were added or updated.
|
|
"""
|
|
|
|
if not isinstance(data, dict):
|
|
raise ValueError("Invalid profile payload")
|
|
|
|
if "abogen_voice_profiles" in data:
|
|
data = data["abogen_voice_profiles"]
|
|
|
|
if not isinstance(data, dict):
|
|
raise ValueError("Invalid profile payload")
|
|
|
|
current = load_profiles()
|
|
updated: List[str] = []
|
|
for name, entry in data.items():
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
voices = _normalize_voice_entries(entry.get("voices", []))
|
|
if not voices:
|
|
continue
|
|
language = entry.get("language", "a")
|
|
if name in current and not replace_existing:
|
|
# skip duplicates unless explicit replacement is requested
|
|
continue
|
|
current[name] = {"language": language, "voices": voices}
|
|
updated.append(name)
|
|
|
|
if updated:
|
|
save_profiles(current)
|
|
return updated
|
|
|
|
|
|
def export_profiles_payload(names: Iterable[str] | None = None) -> Dict[str, Dict]:
|
|
"""Return profiles limited to the provided names for download/export."""
|
|
|
|
profiles = load_profiles()
|
|
if names is None:
|
|
subset = profiles
|
|
else:
|
|
subset = {name: profiles[name] for name in names if name in profiles}
|
|
return {"abogen_voice_profiles": subset}
|