mirror of https://github.com/razor-ai/soup.git
215 lines
7.4 KiB
Python
215 lines
7.4 KiB
Python
"""v0.52.0 Part A — TTS (text-to-speech) fine-tuning schema helpers.
|
|
|
|
Schema-only support for ``task='tts'`` paired with ``modality='audio_out'``.
|
|
Five upstream TTS model families are recognised: orpheus / sesame_csm /
|
|
llasa / spark / oute. Each has a stable name string + per-family ``emotion``
|
|
allowlist so trainer wiring (deferred to v0.52.1) can route correctly.
|
|
|
|
Mirrors v0.50.0 stub-then-live pattern: this module exposes pure validators
|
|
and a frozen ``TTSFamilySpec`` dataclass; the live ``TTSTrainerWrapper``
|
|
lands in v0.52.1.
|
|
|
|
Security:
|
|
- Pure schema-time validation; no filesystem touch.
|
|
- All validators raise ``ValueError`` / ``TypeError`` with actionable
|
|
messages.
|
|
- Bool rejected before int / str checks (project bool-as-int policy —
|
|
matches v0.30.0 ``Candidate``).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from types import MappingProxyType
|
|
from typing import Mapping
|
|
|
|
# Closed allowlist — wrapped via MappingProxyType so callers cannot mutate
|
|
# the registry at runtime (mirrors v0.36.0 ``_REGISTRY`` / v0.51.0 ``hubs``
|
|
# policy).
|
|
SUPPORTED_TTS_FAMILIES: frozenset[str] = frozenset(
|
|
{"orpheus", "sesame_csm", "llasa", "spark", "oute"}
|
|
)
|
|
|
|
_MAX_TTS_FAMILY_LEN: int = 32
|
|
_MAX_EMOTION_LEN: int = 32
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TTSFamilySpec:
|
|
"""Metadata for a TTS family. Frozen so callers cannot mutate."""
|
|
|
|
name: str
|
|
description: str
|
|
supports_emotion: bool
|
|
live_wired: bool
|
|
|
|
|
|
_TTS_FAMILY_METADATA: Mapping[str, TTSFamilySpec] = MappingProxyType({
|
|
"orpheus": TTSFamilySpec(
|
|
name="orpheus",
|
|
description="Orpheus emotional TTS (canopylabs)",
|
|
supports_emotion=True,
|
|
live_wired=False,
|
|
),
|
|
"sesame_csm": TTSFamilySpec(
|
|
name="sesame_csm",
|
|
description="Sesame CSM conversational speech",
|
|
supports_emotion=False,
|
|
live_wired=False,
|
|
),
|
|
"llasa": TTSFamilySpec(
|
|
name="llasa",
|
|
description="Llasa-TTS (HKUSTAudio)",
|
|
supports_emotion=False,
|
|
live_wired=False,
|
|
),
|
|
"spark": TTSFamilySpec(
|
|
name="spark",
|
|
description="Spark-TTS (SparkAudio)",
|
|
supports_emotion=False,
|
|
live_wired=False,
|
|
),
|
|
"oute": TTSFamilySpec(
|
|
name="oute",
|
|
description="Oute-TTS (outeai)",
|
|
supports_emotion=True,
|
|
live_wired=False,
|
|
),
|
|
})
|
|
|
|
# Per-family emotion allowlists. Orpheus + Oute support emotion conditioning;
|
|
# the others ignore the tag. Closed allowlist keeps trainer dispatch
|
|
# deterministic.
|
|
ORPHEUS_EMOTIONS: frozenset[str] = frozenset({
|
|
"neutral", "happy", "sad", "angry", "excited", "calm", "whisper", "laugh",
|
|
})
|
|
|
|
# Oute supports a tighter set focused on prosody / register.
|
|
OUTE_EMOTIONS: frozenset[str] = frozenset({
|
|
"neutral", "happy", "sad", "angry", "calm", "excited",
|
|
})
|
|
|
|
_FAMILY_EMOTIONS: Mapping[str, frozenset[str]] = MappingProxyType({
|
|
"orpheus": ORPHEUS_EMOTIONS,
|
|
"oute": OUTE_EMOTIONS,
|
|
})
|
|
|
|
|
|
def validate_tts_family(name: object) -> str:
|
|
"""Validate a TTS family name and return the canonical (lowercase) form."""
|
|
if isinstance(name, bool):
|
|
raise TypeError(f"tts_family must not be bool, got {name!r}")
|
|
if not isinstance(name, str):
|
|
raise TypeError(
|
|
f"tts_family must be str, got {type(name).__name__}"
|
|
)
|
|
if not name:
|
|
raise ValueError("tts_family must be non-empty")
|
|
if "\x00" in name:
|
|
raise ValueError("tts_family must not contain null bytes")
|
|
if len(name) > _MAX_TTS_FAMILY_LEN:
|
|
raise ValueError(
|
|
f"tts_family too long (max {_MAX_TTS_FAMILY_LEN} chars)"
|
|
)
|
|
canonical = name.lower()
|
|
if canonical not in SUPPORTED_TTS_FAMILIES:
|
|
supported = ", ".join(sorted(SUPPORTED_TTS_FAMILIES))
|
|
raise ValueError(
|
|
f"tts_family {name!r} not supported. Supported: {supported}"
|
|
)
|
|
return canonical
|
|
|
|
|
|
def get_tts_family_spec(name: str) -> TTSFamilySpec:
|
|
"""Return the frozen :class:`TTSFamilySpec` for ``name`` or raise."""
|
|
canonical = validate_tts_family(name)
|
|
return _TTS_FAMILY_METADATA[canonical]
|
|
|
|
|
|
def family_supports_emotion(name: str) -> bool:
|
|
"""Whether the named family supports an ``emotion`` tag."""
|
|
canonical = validate_tts_family(name)
|
|
return _TTS_FAMILY_METADATA[canonical].supports_emotion
|
|
|
|
|
|
def validate_emotion_tag(emotion: object, *, family: str) -> str:
|
|
"""Validate an ``emotion`` tag for ``family`` (currently Orpheus only).
|
|
|
|
Raises if the family does not support emotion conditioning or the tag
|
|
is not in the per-family allowlist. ``family`` is canonicalised first
|
|
so callers don't have to.
|
|
"""
|
|
canonical_family = validate_tts_family(family)
|
|
if isinstance(emotion, bool):
|
|
raise TypeError(f"emotion must not be bool, got {emotion!r}")
|
|
if not isinstance(emotion, str):
|
|
raise TypeError(
|
|
f"emotion must be str, got {type(emotion).__name__}"
|
|
)
|
|
if not emotion:
|
|
raise ValueError("emotion must be non-empty")
|
|
if "\x00" in emotion:
|
|
raise ValueError("emotion must not contain null bytes")
|
|
if len(emotion) > _MAX_EMOTION_LEN:
|
|
raise ValueError(
|
|
f"emotion too long (max {_MAX_EMOTION_LEN} chars)"
|
|
)
|
|
spec = _TTS_FAMILY_METADATA[canonical_family]
|
|
if not spec.supports_emotion:
|
|
raise ValueError(
|
|
f"tts_family={canonical_family!r} does not support emotion "
|
|
"conditioning"
|
|
)
|
|
canonical = emotion.lower()
|
|
# Per-family allowlist — data-driven so future emotion-supporting
|
|
# families cannot silently bypass the check.
|
|
family_allowlist = _FAMILY_EMOTIONS.get(canonical_family)
|
|
if family_allowlist is not None and canonical not in family_allowlist:
|
|
allowed = ", ".join(sorted(family_allowlist))
|
|
raise ValueError(
|
|
f"emotion {emotion!r} not in {canonical_family} allowlist. "
|
|
f"Allowed: {allowed}"
|
|
)
|
|
return canonical
|
|
|
|
|
|
def validate_tts_compat(*, task: str, modality: str, backend: str) -> None:
|
|
"""Schema-time gate for ``task='tts'``.
|
|
|
|
Rejects:
|
|
- non-string / bool args (defence-in-depth — sister-function bool
|
|
guards align with v0.30.0 ``Candidate`` policy).
|
|
- non-TTS task (intended for ``task == 'tts'``).
|
|
- ``modality != 'audio_out'`` (TTS is audio-output by definition).
|
|
- ``backend == 'mlx'`` (no MLX TTS path in v0.52.0).
|
|
"""
|
|
for name, value in (("task", task), ("modality", modality), ("backend", backend)):
|
|
if isinstance(value, bool):
|
|
raise TypeError(f"{name} must not be bool, got {value!r}")
|
|
if not isinstance(value, str) or not value:
|
|
raise ValueError(f"{name} must be a non-empty string")
|
|
if task != "tts":
|
|
raise ValueError(
|
|
f"validate_tts_compat called with task={task!r} (expected 'tts')"
|
|
)
|
|
if modality != "audio_out":
|
|
raise ValueError(
|
|
f"task='tts' requires modality='audio_out'; got modality={modality!r}"
|
|
)
|
|
if backend == "mlx":
|
|
raise ValueError(
|
|
"task='tts' is not supported on backend=mlx in v0.52.0"
|
|
)
|
|
|
|
|
|
def build_tts_trainer() -> None:
|
|
"""Live TTS trainer factory — deferred to v0.52.1.
|
|
|
|
Mirrors v0.50.0 ``build_prm_trainer`` / v0.49.0 ``apply_longlora_forward_override``
|
|
stub-then-live pattern.
|
|
"""
|
|
raise NotImplementedError(
|
|
"TTS trainer (task='tts') live wiring deferred to v0.52.1. "
|
|
"Schema accepts the value but no trainer wrapper is registered yet."
|
|
)
|