Ctrl K
ComunidadGuías…

rex/custom_voices.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Custom voice management for XTTS speaker cloning (US-VC-002).

Accepts an audio file (WAV or MP3), validates it meets the minimum duration
requirement, and saves it to the ``voices/`` directory so it appears in the
XTTS voice dropdown.  XTTS v2 uses WAV files directly as speaker references —
no model training is required.
"""

from __future__ import annotations

import logging
import re
import wave
from pathlib import Path

logger = logging.getLogger(__name__)

# Minimum audio duration required for a usable XTTS speaker reference.
MIN_DURATION_SECONDS: float = 10.0

# Maximum audio duration accepted for a speaker reference.
MAX_DURATION_SECONDS: float = 300.0

# Formats supported natively (WAV) or via soundfile (MP3, FLAC, OGG).
ACCEPTED_FORMATS: tuple[str, ...] = (".wav", ".mp3", ".flac", ".ogg")

# Directory where XTTS speaker WAV files are stored (project root / voices).
_VOICES_DIR = Path(__file__).resolve().parent.parent / "voices"


def get_audio_duration(file_path: str | Path) -> float:
    """Return the duration of an audio file in seconds.

    Supports WAV via the stdlib ``wave`` module.  For other formats (MP3,
    FLAC, OGG) falls back to ``soundfile`` when installed.

    Raises ``ValueError`` if the file cannot be read or the format is
    unsupported.
    """
    path = Path(file_path)
    if not path.exists():
        raise ValueError(f"File not found: {path}")

    suffix = path.suffix.lower()

    if suffix not in ACCEPTED_FORMATS:
        fmt_label = suffix.lstrip(".").upper() if suffix else "(no extension)"
        accepted = ", ".join(f.lstrip(".").upper() for f in ACCEPTED_FORMATS)
        raise ValueError(f"Unsupported format: {fmt_label}. Accepted formats: {accepted}")

    if suffix == ".wav":
        try:
            with wave.open(str(path), "rb") as wf:
                return wf.getnframes() / wf.getframerate()
        except wave.Error as exc:
            raise ValueError(f"Cannot read WAV file: {exc}") from exc

    # Non-WAV formats: try soundfile.
    try:
        import soundfile as sf

        info = sf.info(str(path))
        return float(info.duration)
    except ImportError:
        fmt_label = suffix.lstrip(".").upper()
        accepted = ", ".join(f.lstrip(".").upper() for f in ACCEPTED_FORMATS)
        raise ValueError(
            f"soundfile is required to read {fmt_label} files. "
            "Install it with: pip install soundfile"
        )
    except Exception as exc:
        raise ValueError(f"Cannot read audio file: {exc}") from exc


def _sanitize_name(name: str) -> str:
    """Return a filesystem-safe stem derived from *name*."""
    sanitized = re.sub(r"[^\w\s-]", "", name).strip()
    sanitized = re.sub(r"[\s-]+", "_", sanitized).lower()
    return sanitized or "custom_voice"


def save_custom_voice(
    file_path: str | Path,
    voice_name: str,
    *,
    voices_dir: Path | None = None,
) -> dict:
    """Validate and save an audio file as a custom XTTS speaker voice.

    Args:
        file_path: Path to the source audio file (WAV or MP3).
        voice_name: Human-readable name for the voice (used as filename stem).
        voices_dir: Override the default ``voices/`` directory (for testing).

    Returns:
        A dict with keys:
        - ``ok`` (bool): True if the voice was saved successfully.
        - ``voice_id`` (str): Absolute path of the saved WAV file.
        - ``voice_name`` (str): Sanitized display name.
        - ``duration`` (float): Audio duration in seconds.
        - ``error`` (str, only on failure): Human-readable error message.
    """
    source = Path(file_path)
    directory = voices_dir or _VOICES_DIR

    # Validate duration first (cheap — avoids unnecessary file I/O).
    try:
        duration = get_audio_duration(source)
    except ValueError as exc:
        return {"ok": False, "error": str(exc), "duration": 0.0}

    if duration < MIN_DURATION_SECONDS:
        return {
            "ok": False,
            "error": f"Sample is {duration:.1f}s, minimum is {MIN_DURATION_SECONDS:.0f}s (too short)",
            "duration": duration,
        }

    if duration > MAX_DURATION_SECONDS:
        return {
            "ok": False,
            "error": (f"Sample is {duration:.1f}s, maximum is {MAX_DURATION_SECONDS:.0f}s"),
            "duration": duration,
        }

    stem = _sanitize_name(voice_name)
    directory.mkdir(parents=True, exist_ok=True)
    dest = directory / f"{stem}.wav"

    # Convert to WAV if needed, or copy directly.
    suffix = source.suffix.lower()
    try:
        if suffix == ".wav":
            import shutil

            shutil.copy2(str(source), str(dest))
        else:
            # MP3 / other: re-encode to WAV via soundfile.
            try:
                import soundfile as sf

                data, sample_rate = sf.read(str(source))
                sf.write(str(dest), data, sample_rate)
            except ImportError:
                return {
                    "ok": False,
                    "error": "soundfile is required to convert MP3 to WAV.",
                    "duration": duration,
                }
    except Exception as exc:
        return {"ok": False, "error": f"Failed to save voice file: {exc}", "duration": duration}

    display_name = stem.replace("_", " ").title()
    logger.info("[custom_voices] Saved %r → %s (%.1fs)", voice_name, dest, duration)

    return {
        "ok": True,
        "voice_id": str(dest),
        "voice_name": display_name,
        "duration": duration,
    }


__all__ = [
    "MIN_DURATION_SECONDS",
    "MAX_DURATION_SECONDS",
    "ACCEPTED_FORMATS",
    "get_audio_duration",
    "save_custom_voice",
]