From 1e9a73333dfc9d911b50dc2a5404ce76af46e98d Mon Sep 17 00:00:00 2001 From: Mohammed Anas Nathani Date: Thu, 23 Jul 2026 05:07:28 +0530 Subject: [PATCH] feat(tts): declarative TTS_PROVIDERS table + resolve/preflight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a leaf tts module mirroring llm.ProviderSpec: openai/elevenlabs/local specs, resolve_tts_backend/model/voice, and check_tts_sdk/model. Auto never selects noncommercial local backends; cloud providers have no hardcoded default model. Unwired — no Config/CLI/pyproject changes. Closes #193 --- src/readme2demo/tts.py | 222 ++++++++++++++++++++++++++++++++++++ tests/test_tts_providers.py | 127 +++++++++++++++++++++ 2 files changed, 349 insertions(+) create mode 100644 src/readme2demo/tts.py create mode 100644 tests/test_tts_providers.py diff --git a/src/readme2demo/tts.py b/src/readme2demo/tts.py new file mode 100644 index 0000000..38e740c --- /dev/null +++ b/src/readme2demo/tts.py @@ -0,0 +1,222 @@ +"""TTS provider registry: resolution and preflight validation (no audio). + +First slice of #113. Pure data + env/SDK checks — nothing synthesizes audio, +nothing hits the network, and nothing is wired into CLI/config yet. +Mirrors the shape of ``llm.ProviderSpec`` / ``check_sdk`` / ``_provider_model``. +""" + +from __future__ import annotations + +import importlib +import os +from dataclasses import dataclass +from typing import Optional + + +class TTSError(RuntimeError): + """Raised when a TTS backend/model/voice cannot be resolved or preflighted.""" + + +@dataclass(frozen=True) +class TTSProviderSpec: + """One TTS backend (OpenAI / ElevenLabs / local). + + ``sdk`` is ``(import_module, pip_name, sentinel_attr)`` for preflight, or + ``None`` when no Python SDK is required (e.g. a local binary path later). + """ + + name: str + title: str + key_env: Optional[str] + model_env: str + voice_env: str + model_prefixes: tuple[str, ...] + sdk: Optional[tuple[str, str, str]] # import name, pip name, sentinel + extra: Optional[str] + default_model: Optional[str] + default_voice: Optional[str] + models_url: Optional[str] = None + # When True, "auto" must never select this backend (e.g. CC-BY-NC local). + noncommercial: bool = False + license_note: Optional[str] = None + + +# Deterministic insertion order: first commercial key-bearing backend wins auto. +TTS_PROVIDERS: dict[str, TTSProviderSpec] = { + "openai": TTSProviderSpec( + name="openai", + title="OpenAI TTS", + key_env="OPENAI_API_KEY", + model_env="OPENAI_TTS_MODEL", + voice_env="OPENAI_TTS_VOICE", + model_prefixes=("tts-", "gpt-4o-mini-tts"), + sdk=("openai", "openai", "OpenAI"), + extra="openai", + default_model=None, # never guess — cloud models get retired with hard 404s + default_voice="alloy", + models_url="https://platform.openai.com/docs/guides/text-to-speech", + ), + "elevenlabs": TTSProviderSpec( + name="elevenlabs", + title="ElevenLabs", + key_env="ELEVENLABS_API_KEY", + model_env="ELEVENLABS_TTS_MODEL", + voice_env="ELEVENLABS_TTS_VOICE", + model_prefixes=("eleven_", "eleven-"), + sdk=("elevenlabs", "elevenlabs", "ElevenLabs"), + extra=None, # install hint falls back to pip name + default_model=None, + default_voice="Rachel", + models_url="https://elevenlabs.io/docs/api-reference/text-to-speech", + ), + "local": TTSProviderSpec( + name="local", + title="Local TTS (OuteTTS)", + key_env=None, # no cloud key; explicit name required + model_env="LOCAL_TTS_MODEL", + voice_env="LOCAL_TTS_VOICE", + model_prefixes=("oute", "local"), + sdk=None, + extra=None, + default_model="oute-default", + default_voice="default", + noncommercial=True, + license_note="CC-BY-NC (OuteTTS 0.2) — commercial use requires explicit opt-in", + ), +} + + +def resolve_tts_backend(name: Optional[str] = None) -> str: + """Resolve a TTS backend name, including ``auto``. + + Explicit names whose ``key_env`` is unset raise naming the variable. + ``auto`` picks the first *commercial* provider (table order) whose key is + set. Non-commercial backends are never auto-selected (#113 CC-BY-NC note). + """ + b = (name or "auto").strip().lower() or "auto" + if b != "auto": + if b not in TTS_PROVIDERS: + raise TTSError( + f"Unknown TTS backend {b!r}. Available: " + f"{', '.join(sorted(TTS_PROVIDERS))}" + ) + spec = TTS_PROVIDERS[b] + if spec.key_env and not os.environ.get(spec.key_env): + raise TTSError( + f"{spec.key_env} is not set. Export it to use the {spec.title} " + f"backend (or pass another --tts-backend)." + ) + return b + + for key, spec in TTS_PROVIDERS.items(): + if spec.noncommercial: + continue + if spec.key_env and os.environ.get(spec.key_env): + return key + options = ", ".join( + f"{s.key_env} ({s.title})" + for s in TTS_PROVIDERS.values() + if s.key_env and not s.noncommercial + ) + raise TTSError( + "No TTS backend available: set one of " + f"{options}. Non-commercial local backends are never auto-selected — " + "name them explicitly if you intend to use them." + ) + + +def _matches_other_provider_prefix(spec: TTSProviderSpec, name: str) -> bool: + """True when *name* looks like it belongs to a *different* TTS provider.""" + lowered = name.lower() + for other in TTS_PROVIDERS.values(): + if other.name == spec.name: + continue + if any(lowered.startswith(p.lower()) for p in other.model_prefixes): + return True + return False + + +def resolve_tts_model(backend: str, model: Optional[str] = None) -> str: + """Resolve the model name for a TTS backend. + + Order: explicit (with optional ``/`` prefix stripped) → env → + spec default → loud error. A name matching another provider's prefixes + counts as unspecified (cross-provider config-default leak guard). + """ + if backend not in TTS_PROVIDERS: + raise TTSError(f"Unknown TTS backend {backend!r}") + spec = TTS_PROVIDERS[backend] + name = (model or "").strip() + if name.startswith(f"{spec.name}/"): + name = name.split("/", 1)[1] + if not name or _matches_other_provider_prefix(spec, name): + name = os.environ.get(spec.model_env, "").strip() or (spec.default_model or "") + if not name: + url_note = f" Current names: {spec.models_url}" if spec.models_url else "" + raise TTSError( + f"No {spec.title} model specified. Pass one with --tts-model " + f", or export {spec.model_env}.{url_note}" + ) + return name + + +def resolve_tts_voice(backend: str, voice: Optional[str] = None) -> str: + """Resolve the voice name for a TTS backend (explicit → env → default).""" + if backend not in TTS_PROVIDERS: + raise TTSError(f"Unknown TTS backend {backend!r}") + spec = TTS_PROVIDERS[backend] + name = (voice or "").strip() + if not name: + name = os.environ.get(spec.voice_env, "").strip() or (spec.default_voice or "") + if not name: + raise TTSError( + f"No {spec.title} voice specified. Pass one with --tts-voice " + f", or export {spec.voice_env}." + ) + return name + + +def check_tts_sdk(backend: str) -> None: + """Fail fast when *backend* needs an SDK that cannot serve it. + + Three cases, each with its own actionable message: absent → install hint + from ``spec.extra``; importable but raises → quote the real error; imports + but missing sentinel → upgrade hint. No-op when ``spec.sdk`` is None. + """ + spec = TTS_PROVIDERS.get(backend) + if spec is None or spec.sdk is None: + return + module, pip_name, sentinel = spec.sdk + try: + mod = importlib.import_module(module) + except ImportError as e: + failed = (getattr(e, "name", None) or "").split(".")[0] + extra_hint = ( + f"pip install 'readme2demo[{spec.extra}]' (or pip install {pip_name})" + if spec.extra + else f"pip install {pip_name}" + ) + if failed == module.split(".")[0]: + raise TTSError( + f"{pip_name} is not installed. Install it: {extra_hint}." + ) from e + raise TTSError( + f"{pip_name} is installed but failed to import " + f"({type(e).__name__}: {e}). Reinstall: " + f"pip install -U {pip_name}." + ) from e + if not hasattr(mod, sentinel): + raise TTSError( + f"{pip_name} is installed but too old for readme2demo (no " + f"{module}.{sentinel}). Upgrade: pip install -U {pip_name}." + ) + + +def check_tts_model(backend: str, model: Optional[str]) -> None: + """Preflight: prove a model name is resolvable for known TTS backends. + + No-op for unknown backends (mirrors ``llm.check_model``). + """ + if backend not in TTS_PROVIDERS: + return + resolve_tts_model(backend, model) diff --git a/tests/test_tts_providers.py b/tests/test_tts_providers.py new file mode 100644 index 0000000..e1d5129 --- /dev/null +++ b/tests/test_tts_providers.py @@ -0,0 +1,127 @@ +"""Tests for the TTS provider registry (slice 1 of #113). Pure — no network/audio.""" + +from __future__ import annotations + +import sys +import types + +import pytest + +from readme2demo import tts + + +def test_providers_table_shape(): + assert set(tts.TTS_PROVIDERS) == {"openai", "elevenlabs", "local"} + assert tts.TTS_PROVIDERS["openai"].default_model is None + assert tts.TTS_PROVIDERS["elevenlabs"].default_model is None + assert tts.TTS_PROVIDERS["openai"].default_voice is not None + assert tts.TTS_PROVIDERS["elevenlabs"].default_voice is not None + assert tts.TTS_PROVIDERS["local"].noncommercial is True + assert tts.TTS_PROVIDERS["local"].license_note + assert "CC-BY-NC" in (tts.TTS_PROVIDERS["local"].license_note or "") + + +def test_resolve_backend_explicit_requires_key(monkeypatch): + monkeypatch.delenv("ELEVENLABS_API_KEY", raising=False) + with pytest.raises(tts.TTSError, match="ELEVENLABS_API_KEY"): + tts.resolve_tts_backend("elevenlabs") + monkeypatch.setenv("ELEVENLABS_API_KEY", "sk-test") + assert tts.resolve_tts_backend("elevenlabs") == "elevenlabs" + + +def test_resolve_backend_auto_picks_first_commercial_key(monkeypatch): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("ELEVENLABS_API_KEY", raising=False) + with pytest.raises(tts.TTSError, match="No TTS backend available"): + tts.resolve_tts_backend("auto") + monkeypatch.setenv("ELEVENLABS_API_KEY", "sk-el") + assert tts.resolve_tts_backend("auto") == "elevenlabs" + monkeypatch.setenv("OPENAI_API_KEY", "sk-oa") + # table order: openai first + assert tts.resolve_tts_backend("auto") == "openai" + + +def test_resolve_backend_auto_never_selects_noncommercial(monkeypatch): + """Regression: #113 CC-BY-NC local backend must never win auto-selection.""" + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("ELEVENLABS_API_KEY", raising=False) + # Even if we invent a key env for local, noncommercial blocks auto. + # local.key_env is None — auto still fails rather than picking local. + with pytest.raises(tts.TTSError, match="Non-commercial"): + tts.resolve_tts_backend("auto") + # Explicit local is allowed without a cloud key. + assert tts.resolve_tts_backend("local") == "local" + + +def test_resolve_model_explicit_env_default_error(monkeypatch): + monkeypatch.delenv("OPENAI_TTS_MODEL", raising=False) + with pytest.raises(tts.TTSError, match="No OpenAI TTS model specified.*OPENAI_TTS_MODEL"): + tts.resolve_tts_model("openai", None) + monkeypatch.setenv("OPENAI_TTS_MODEL", "tts-1-hd") + assert tts.resolve_tts_model("openai", None) == "tts-1-hd" + assert tts.resolve_tts_model("openai", "openai/tts-1") == "tts-1" + # local has a default_model + assert tts.resolve_tts_model("local", None) == "oute-default" + + +def test_resolve_model_cross_provider_leak_guard(monkeypatch): + """Regression: stale config defaults from another provider must not stick. + + Same class of bug as the --gemini run that died on a Claude config default + (llm._provider_model leak guard / CLAUDE.md failure history). + """ + monkeypatch.delenv("OPENAI_TTS_MODEL", raising=False) + # eleven_ prefix belongs to elevenlabs, not openai + with pytest.raises(tts.TTSError, match="No OpenAI TTS model specified"): + tts.resolve_tts_model("openai", "eleven_multilingual_v2") + monkeypatch.setenv("OPENAI_TTS_MODEL", "tts-1") + assert tts.resolve_tts_model("openai", "eleven_multilingual_v2") == "tts-1" + + +def test_resolve_voice_default_and_env(monkeypatch): + monkeypatch.delenv("OPENAI_TTS_VOICE", raising=False) + assert tts.resolve_tts_voice("openai", None) == "alloy" + monkeypatch.setenv("OPENAI_TTS_VOICE", "nova") + assert tts.resolve_tts_voice("openai", None) == "nova" + assert tts.resolve_tts_voice("openai", "echo") == "echo" + + +def test_check_tts_sdk_absent(monkeypatch): + monkeypatch.setitem(sys.modules, "elevenlabs", None) + + def boom(name): + raise ModuleNotFoundError("No module named 'elevenlabs'", name="elevenlabs") + + monkeypatch.setattr(tts.importlib, "import_module", boom) + with pytest.raises(tts.TTSError, match=r"elevenlabs is not installed"): + tts.check_tts_sdk("elevenlabs") + + +def test_check_tts_sdk_too_old(monkeypatch): + monkeypatch.setitem(sys.modules, "openai", types.ModuleType("openai")) + with pytest.raises(tts.TTSError, match="too old"): + tts.check_tts_sdk("openai") + + +def test_check_tts_sdk_broken_import(monkeypatch): + def boom(module): + raise ModuleNotFoundError("No module named 'httpx'", name="httpx") + + monkeypatch.setattr(tts.importlib, "import_module", boom) + with pytest.raises(tts.TTSError, match="failed to import.*httpx"): + tts.check_tts_sdk("openai") + + +def test_check_tts_sdk_noop_for_local(): + tts.check_tts_sdk("local") # no sdk + + +def test_check_tts_model_noop_unknown_backend(monkeypatch): + monkeypatch.delenv("OPENAI_TTS_MODEL", raising=False) + tts.check_tts_model("not-a-backend", None) # no-op + + +def test_check_tts_model_gates_when_unspecified(monkeypatch): + monkeypatch.delenv("OPENAI_TTS_MODEL", raising=False) + with pytest.raises(tts.TTSError, match="No OpenAI TTS model specified"): + tts.check_tts_model("openai", None)