Skip to content

tts: declarative TTS_PROVIDERS table + resolution and preflight validation (pure data, no audio — slice 1 of #113) #193

Description

@alphacrack

User story

As a contributor picking up the audio milestone, I want the TTS provider matrix (env keys, SDK names, model/voice defaults, licensing) to already exist as a tested data table with resolution rules, so that whoever wires real synthesis writes audio code instead of re-litigating env-var and model-name policy that llm.py already settled.

What

First slice of #113. One new module + one new test file. No audio is produced, no network is touched, no dependency is added. The module is deliberately unwired — nothing imports it when this lands.

In scope — src/readme2demo/tts.py:

  1. TTSProviderSpec — frozen dataclass, mirroring ProviderSpec (llm.py:93-116):
    name, title, key_env, model_env, voice_env, model_prefixes, sdk (import module, pip name, sentinel attribute), extra, default_model, default_voice, models_url, noncommercial: bool = False.
  2. TTS_PROVIDERS: dict[str, TTSProviderSpec]openai, elevenlabs, local (the three named in podcast/narration: TTS backend abstraction (BYOK cloud first, optional local engines) #113).
  3. resolve_tts_backend(name: str | None) -> str — mirrors llm.resolve_backend (llm.py:221-244): an explicit name whose key_env is unset is a loud error naming the variable; "auto" picks the first provider (deterministic table order) whose key_env is set; nothing available is a loud error listing the options.
  4. resolve_tts_model / resolve_tts_voice — mirror _provider_model (llm.py:333-355). Order: explicit value (litellm-style <provider>/ prefix stripped) → model_env/voice_envspec.default_model/spec.default_voice → loud error naming the flag, the env var, and models_url.
  5. check_tts_sdk(backend) / check_tts_model(backend, model) — preflight-shaped, same three-case structure as check_sdk (llm.py:160-195): absent → install hint; importable but raises → quote the real ImportError; importable but missing the sentinel attribute → upgrade hint. check_tts_model is a no-op for backends with no spec, exactly like check_model (llm.py:198-208).
  6. tests/test_tts_providers.py covering all of the above against fixtures/monkeypatched env.

Two policy decisions to encode (both are the point of the issue, not incidental):

  • No hardcoded default_model for the cloud providers. llm.PROVIDERS sets default_model only for Anthropic; the comment at llm.py:110-113 explains why: "Google and OpenAI retire model names with hard 404s, so guessing is a loud error instead (a --gemini run once died in ingest on a stale hardcoded default)." Same rule here. default_voice is set per provider — a voice is a taste default the operator should not have to supply.
  • "auto" must never select a noncommercial=True backend, even when its runtime imports fine. podcast/narration: TTS backend abstraction (BYOK cloud first, optional local engines) #113 records that OuteTTS 0.2 is CC-BY-NC; a licensing constraint belongs in the table as data, not in a contributor's memory. Selecting local requires naming it explicitly.

Out of scope — do not include in this PR:

  • synthesize(), audio bytes, files, chunking, stitching, ffmpeg
  • any network call or live API call
  • any change to pyproject.toml (no audio extra, no new dependency — spec.extra is a string used to build the install hint; the extra gets declared by the slice that actually needs the SDK)
  • any Config field or CLI flag (Config is extra="forbid" at config.py:22, so adding a field is a public-surface change that belongs with the feature that uses it)
  • wiring into cli.py preflight, prompts, templates, docs, README

Why

Grounding implication: none. This slice touches no grounding-path module (distill.py, tutorial.py, normalize.py, engines/, prompts/, templates/) and adds no code path that can write tutorial.md, step_by_step.md, commands.sh, or demo.tape. It is a leaf module with no importers. Reviewers should confirm that stays true — a TTS module has no business reading or writing a published artifact.

Pointers

All line numbers verified by reading the files at 64a3cbd.

  • src/readme2demo/llm.py:93-116ProviderSpec, the dataclass to mirror. Field comment at :108: model_prefixes: tuple[str, ...] # names recognized as this provider's models.
  • src/readme2demo/llm.py:118-140 — the PROVIDERS table itself (3 entries). Note llm.py:138: default_model="claude-sonnet-5", # keep in sync with Config.model — Anthropic is the only entry with a default.
  • src/readme2demo/llm.py:144-148_BACKEND_KEY_ENVS, the "which backends need a key at resolve time" map, with the comment "fail in preflight, not mid-run".
  • src/readme2demo/llm.py:154-157_BACKEND_SDKS, {backend: (import name, pip name, sentinel attribute)}. The TTS spec should carry this per-entry rather than as a side table (there are only 2-3 entries and the side table adds a lookup that can drift).
  • src/readme2demo/llm.py:160-195check_sdk, the three-branch structure to copy verbatim in shape.
  • src/readme2demo/llm.py:198-208check_model; the body is just spec = PROVIDERS.get(backend) then _provider_model(spec, model) when non-None.
  • src/readme2demo/llm.py:333-355_provider_model. Two behaviors to port: prefix stripping at :345-346, and the config-default leak guard at :347if not name or (spec.name != "anthropic" and name.startswith("claude")):. The TTS analog is broader: a name matching another spec's model_prefixes counts as "not specified", because the same leak will happen the moment a shared config field feeds both providers.
  • src/readme2demo/cli.py:64-66 — the current consumer of model_prefixes (deciding whether the token after a bare --gemini is a model name or a positional). Worth knowing: in this slice model_prefixes has no CLI consumer yet; its only in-slice consumer is the cross-provider leak guard above. That is fine and intentional — the flag arrives with the feature.
  • src/readme2demo/cli.py:565-574 — where LLM preflight runs (llm.check_sdk(backend) / llm.check_model(backend, cfg.model)), preceded by the comment explaining that "a --openai run once burned its ingest stage on an ImportError instead." This is where a future slice hooks check_tts_*; do not add the hook here.
  • tests/test_llm_backend.py:467-501 — the exact test patterns to mirror: monkeypatch.setitem(sys.modules, "openai", ModuleType("openai")) for too-old, monkeypatch.setattr("readme2demo.llm.importlib.import_module", boom) for broken-import, monkeypatch.delenv/setenv for the model-resolution cases.
  • pyproject.toml:46-52 — the optional-extras block (gemini, openai). Read for context; leave unchanged.

Acceptance criteria

  • src/readme2demo/tts.py exists with TTSProviderSpec (frozen dataclass), TTS_PROVIDERS, resolve_tts_backend, resolve_tts_model, resolve_tts_voice, check_tts_sdk, check_tts_model, and a module-level TTSError.
  • TTS_PROVIDERS has entries for openai, elevenlabs, and local; local is marked noncommercial=True with its license named in the spec.
  • Neither cloud provider carries a default_model; both carry a default_voice.
  • resolve_tts_backend("auto") returns the first key-bearing cloud provider and never returns a noncommercial=True backend; a test asserts this with every key set.
  • resolve_tts_backend("elevenlabs") with ELEVENLABS_API_KEY unset raises TTSError naming the variable.
  • Model resolution honors explicit → env → spec default → error, strips a <provider>/ prefix, and treats a name matching a different provider's model_prefixes as unspecified.
  • Every "not specified" error names the env var and, when models_url is set, links it — matching llm.py:349-354.
  • check_tts_sdk distinguishes absent / broken-import / too-old with three different messages; the install hint is built from spec.extra, never hardcoded.
  • check_tts_model is a no-op for an unknown backend.
  • tests/test_tts_providers.py covers all of the above; the tests for the cross-provider leak guard and the auto-selection licensing rule carry a """Regression: ...""" docstring citing the --gemini stale-default incident and podcast/narration: TTS backend abstraction (BYOK cloud first, optional local engines) #113's CC-BY-NC note respectively.
  • No new entry in pyproject.toml; no Config field; no CLI flag; no import of tts.py from any other module.
  • Public functions have type hints and docstrings; ruff check clean; full suite green.

Notes for contributors

This is a genuinely self-contained first issue. Pure Python, pure data. No Docker, no API key, no network, no LLM call — nothing in this slice can reach a provider even if you wanted it to. You do not need to understand the sandbox, the agent, or the grounding pipeline to land it.

pip install -e ".[dev]"
python -m pytest tests/ -q     # whole suite, well under 2s — no Docker, no network, no API key
ruff check src/ tests/

The single most useful preparation is to open src/readme2demo/llm.py and read lines 93-208 and 333-355 end to end before writing anything. This issue is deliberately a mirror of that code; if your module reads like a sibling of llm.py, it is right. Where you find yourself inventing a new pattern, prefer copying the existing one and note the divergence in your PR description.

Two things reviewers will look for: that the error messages are actionable (name the flag AND the env var — see llm.py:351-354 for the tone), and that no default model name for a cloud provider is hardcoded anywhere.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or improvementgood first issueSmall, self-contained, newcomer-friendlyhelp wantedMaintainer would welcome a PRmulti-formatNew output formats (podcast/video/social) derived from a verified run

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions