steppy-dev 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. steppy/CHANGELOG.md +862 -0
  2. steppy/__init__.py +9 -0
  3. steppy/__main__.py +13 -0
  4. steppy/_cli_common.py +183 -0
  5. steppy/_docker/Dockerfile.installed +53 -0
  6. steppy/_parallel/__init__.py +7 -0
  7. steppy/_parallel/compose.py +349 -0
  8. steppy/_parallel/event_bus.py +418 -0
  9. steppy/_parallel/failures.py +267 -0
  10. steppy/_parallel/fingerprint.py +141 -0
  11. steppy/_parallel/image.py +380 -0
  12. steppy/_parallel/lastfailed.py +125 -0
  13. steppy/_parallel/lifecycle.py +90 -0
  14. steppy/_parallel/preauth.py +109 -0
  15. steppy/_parallel/progress.py +101 -0
  16. steppy/_parallel/queue.py +385 -0
  17. steppy/_parallel/renderer.py +489 -0
  18. steppy/_parallel/retry.py +316 -0
  19. steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
  20. steppy/_player_bundle/__init__.py +100 -0
  21. steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
  22. steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
  23. steppy/_player_bundle/steppy-player.js +5419 -0
  24. steppy/_player_bundle/steppy-player.js.map +1 -0
  25. steppy/_player_bundle/steppy-video.js +472 -0
  26. steppy/_player_bundle/steppy-video.js.map +1 -0
  27. steppy/_templating.py +117 -0
  28. steppy/assertions.py +251 -0
  29. steppy/auth.py +493 -0
  30. steppy/build.py +2798 -0
  31. steppy/build_models.py +669 -0
  32. steppy/buildid.py +134 -0
  33. steppy/bundle/__init__.py +47 -0
  34. steppy/bundle/archive.py +239 -0
  35. steppy/bundle/classify.py +329 -0
  36. steppy/bundle/create.py +490 -0
  37. steppy/bundle/hashing.py +28 -0
  38. steppy/bundle/manifest.py +334 -0
  39. steppy/bundle/results.py +546 -0
  40. steppy/bundle_cli.py +455 -0
  41. steppy/captures.py +207 -0
  42. steppy/cli.py +2489 -0
  43. steppy/cloud/__init__.py +58 -0
  44. steppy/cloud/client.py +267 -0
  45. steppy/cloud/upload.py +497 -0
  46. steppy/compat.py +109 -0
  47. steppy/config.py +231 -0
  48. steppy/config_schema.py +87 -0
  49. steppy/contracts/__init__.py +54 -0
  50. steppy/contracts/bundle.py +215 -0
  51. steppy/contracts/report.py +346 -0
  52. steppy/contracts/tours.py +505 -0
  53. steppy/coordinator.py +1352 -0
  54. steppy/coverage/__init__.py +9 -0
  55. steppy/coverage/gaps.py +190 -0
  56. steppy/coverage/manifest.py +193 -0
  57. steppy/coverage/merge.py +363 -0
  58. steppy/coverage/normalize.py +174 -0
  59. steppy/coverage/report.py +178 -0
  60. steppy/coverage/sourcemap.py +219 -0
  61. steppy/coverage/store.py +473 -0
  62. steppy/coverage/triage.py +151 -0
  63. steppy/coverage_cli.py +265 -0
  64. steppy/durations.py +48 -0
  65. steppy/exclusive.py +178 -0
  66. steppy/flaky_cli.py +169 -0
  67. steppy/generator.py +633 -0
  68. steppy/guide.py +1467 -0
  69. steppy/history.py +352 -0
  70. steppy/init_cli.py +330 -0
  71. steppy/locales.py +282 -0
  72. steppy/migrate/__init__.py +35 -0
  73. steppy/migrate/apply.py +194 -0
  74. steppy/migrate/plan.py +199 -0
  75. steppy/migrate/rules.py +465 -0
  76. steppy/migrate/verify.py +163 -0
  77. steppy/migrate_cli.py +152 -0
  78. steppy/models.py +2539 -0
  79. steppy/naming.py +37 -0
  80. steppy/parser.py +475 -0
  81. steppy/pdf.py +193 -0
  82. steppy/presets.py +284 -0
  83. steppy/record_cli.py +796 -0
  84. steppy/report/__init__.py +37 -0
  85. steppy/report/assets.py +197 -0
  86. steppy/report/build.py +261 -0
  87. steppy/report/coverage_source.py +277 -0
  88. steppy/report/model.py +423 -0
  89. steppy/report/render.py +303 -0
  90. steppy/report/source.py +238 -0
  91. steppy/report_cli.py +173 -0
  92. steppy/runmeta.py +114 -0
  93. steppy/selection.py +183 -0
  94. steppy/sitemap.py +205 -0
  95. steppy/skills/steppy-app-tours/SKILL.md +448 -0
  96. steppy/skills/steppy-ci-report/SKILL.md +567 -0
  97. steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
  98. steppy/skills/steppy-template/SKILL.md +676 -0
  99. steppy/templates/_steppy_events.py +291 -0
  100. steppy/templates/_steppy_tours.py +404 -0
  101. steppy/templates/angular/component-interactive.ts.j2 +109 -0
  102. steppy/templates/angular/component-video.ts.j2 +112 -0
  103. steppy/templates/angular/component.ts.j2 +229 -0
  104. steppy/templates/angular/content.ts.j2 +31 -0
  105. steppy/templates/angular/gitignore.j2 +3 -0
  106. steppy/templates/angular/group.component.ts.j2 +39 -0
  107. steppy/templates/angular/onboarding.component.ts.j2 +43 -0
  108. steppy/templates/angular/routes.ts.j2 +34 -0
  109. steppy/templates/ci/github-actions.yml +284 -0
  110. steppy/templates/ci/gitlab-ci.yml +178 -0
  111. steppy/templates/conftest.py.j2 +2405 -0
  112. steppy/templates/guide.book.html.j2 +145 -0
  113. steppy/templates/guide.fragment.html.j2 +257 -0
  114. steppy/templates/guide.html.j2 +301 -0
  115. steppy/templates/guide.md.j2 +18 -0
  116. steppy/templates/init/config.yaml +124 -0
  117. steppy/templates/init/credentials.yaml.example +30 -0
  118. steppy/templates/init/scenarios/example--first-flow.md +53 -0
  119. steppy/templates/report/_head.html.j2 +14 -0
  120. steppy/templates/report/_mark.html.j2 +3 -0
  121. steppy/templates/report/coverage.html.j2 +124 -0
  122. steppy/templates/report/index.html.j2 +203 -0
  123. steppy/templates/report/report.css +401 -0
  124. steppy/templates/report/scenario.html.j2 +137 -0
  125. steppy/templates/static/_headers.j2 +30 -0
  126. steppy/templates/static/_mode_links.html.j2 +22 -0
  127. steppy/templates/static/_seo_head.html.j2 +37 -0
  128. steppy/templates/static/cursor.js.j2 +10 -0
  129. steppy/templates/static/group.html.j2 +78 -0
  130. steppy/templates/static/guides.css +678 -0
  131. steppy/templates/static/index.html.j2 +79 -0
  132. steppy/templates/static/interactive.html.j2 +100 -0
  133. steppy/templates/static/onboarding.html.j2 +64 -0
  134. steppy/templates/static/page.html.j2 +145 -0
  135. steppy/templates/static/robots.txt.j2 +7 -0
  136. steppy/templates/static/video.html.j2 +135 -0
  137. steppy/templates/test_scenario.py.j2 +767 -0
  138. steppy/testids.py +166 -0
  139. steppy/totp.py +26 -0
  140. steppy/tour_spec.py +163 -0
  141. steppy/tours/__init__.py +22 -0
  142. steppy/tours/actions.py +402 -0
  143. steppy/tours/compiler.py +144 -0
  144. steppy/tours/diagnostics.py +137 -0
  145. steppy/tours/emit.py +239 -0
  146. steppy/tours/ids.py +40 -0
  147. steppy/tours/inference.py +399 -0
  148. steppy/tours/model.py +120 -0
  149. steppy/tours/paths.py +126 -0
  150. steppy/tours/pipeline.py +277 -0
  151. steppy/tours/publish.py +124 -0
  152. steppy/tours/replay.py +234 -0
  153. steppy/tours/routes.py +175 -0
  154. steppy/tours/settings.py +164 -0
  155. steppy/tours/text.py +237 -0
  156. steppy/tours_cli.py +219 -0
  157. steppy/tts/__init__.py +280 -0
  158. steppy/tts/cache.py +185 -0
  159. steppy/tts/presets.py +125 -0
  160. steppy/tts/providers/__init__.py +6 -0
  161. steppy/tts/providers/http.py +184 -0
  162. steppy/tts/providers/local.py +207 -0
  163. steppy/tts/providers/null.py +28 -0
  164. steppy/tts/registry.py +201 -0
  165. steppy/tts/track.py +158 -0
  166. steppy/tts/types.py +94 -0
  167. steppy/update_cli.py +460 -0
  168. steppy/video.py +730 -0
  169. steppy/video_cache.py +239 -0
  170. steppy/video_cli.py +271 -0
  171. steppy/worker.py +301 -0
  172. steppy_dev-0.3.0.dist-info/METADATA +1335 -0
  173. steppy_dev-0.3.0.dist-info/RECORD +177 -0
  174. steppy_dev-0.3.0.dist-info/WHEEL +4 -0
  175. steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
  176. steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
  177. steppy_dev-0.3.0.dist-info/licenses/NOTICE +13 -0
@@ -0,0 +1,184 @@
1
+ """Generic HTTP text-to-speech provider — the engine every vendor preset runs on.
2
+
3
+ One class serves ElevenLabs, OpenAI, Azure, Google, and any vendor that has not
4
+ shipped yet, because the differences between them are data (``tts/presets.py``)
5
+ rather than code. A user facing an unsupported API fills in ``video.tts.http``
6
+ and is done — no fork, no plugin, no release of Steppy.
7
+
8
+ ``httpx`` is imported **inside** :meth:`synthesize`. It is already a Steppy
9
+ dependency, but ``tests/test_no_playwright_import_on_default_path.py`` forbids
10
+ it transitively from ``import steppy.cli``, and ``TtsConfig`` lives in
11
+ ``models.py`` on the eager path.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import hashlib
18
+ import json
19
+ import re
20
+ from typing import Any
21
+
22
+ from ..types import SpeechClip, SpeechRequest, TtsError, check_resolved
23
+
24
+ # Only whole ``{name}`` tokens are substituted; ``{{`` / ``}}`` escape a literal
25
+ # brace. Applied per string leaf, never to serialized JSON, so narration text
26
+ # containing braces cannot alter the request structure.
27
+ _TOKEN = re.compile(r"\{\{|\}\}|\{(\w+)\}")
28
+
29
+
30
+ def substitute(value: Any, context: dict[str, str]) -> Any:
31
+ """Fill ``{placeholders}`` in every string leaf of a parsed structure."""
32
+ if isinstance(value, dict):
33
+ return {k: substitute(v, context) for k, v in value.items()}
34
+ if isinstance(value, list):
35
+ return [substitute(v, context) for v in value]
36
+ if not isinstance(value, str):
37
+ return value
38
+
39
+ def _sub(match: "re.Match") -> str:
40
+ token = match.group(0)
41
+ if token == "{{":
42
+ return "{"
43
+ if token == "}}":
44
+ return "}"
45
+ return context.get(match.group(1), "")
46
+
47
+ return _TOKEN.sub(_sub, value)
48
+
49
+
50
+ def dig(payload: Any, path: str) -> Any:
51
+ """Follow a dotted path into a decoded JSON response."""
52
+ current = payload
53
+ for part in path.split("."):
54
+ if not isinstance(current, dict) or part not in current:
55
+ raise TtsError(f"TTS response has no field '{path}' (looking for '{part}')")
56
+ current = current[part]
57
+ return current
58
+
59
+
60
+ class HttpTtsProvider:
61
+ """Config-driven REST provider.
62
+
63
+ ``spec`` is a merged preset + user override (see ``registry.resolve_spec``),
64
+ so this class never knows which vendor it is talking to.
65
+ """
66
+
67
+ def __init__(self, name: str, spec: dict, cfg) -> None:
68
+ self.name = name
69
+ self.spec = spec
70
+ self.cfg = cfg
71
+
72
+ def version(self) -> str:
73
+ """Model name plus a hash of the request shape.
74
+
75
+ A remote voice has nothing local to fingerprint, so the model id is the
76
+ only version there is — but the *request* is configuration too. Editing
77
+ ``video.tts.http.json`` (ElevenLabs ``stability``/``style``, Azure SSML
78
+ prosody, a different ``format``) changes the audio, and without the spec
79
+ in the key every existing clip would stay a hit forever.
80
+
81
+ The spec is hashed **pre-substitution**, so it holds ``{api_key}``
82
+ placeholders rather than the secret — safe to fold in, and rotating a
83
+ key does not churn the cache.
84
+ """
85
+ shape = hashlib.sha256(
86
+ json.dumps(self.spec, sort_keys=True, ensure_ascii=False, default=str).encode("utf-8")
87
+ ).hexdigest()[:12]
88
+ return f"{self.spec.get('model') or self.cfg.model or ''}:{shape}"
89
+
90
+ def _context(self, request: SpeechRequest) -> dict[str, str]:
91
+ api_key = check_resolved(self.cfg.api_key, "video.tts.api_key")
92
+ # Fall back to config so the provider is usable on its own, not only
93
+ # through synthesize_steps() — the request carries per-utterance intent,
94
+ # the config carries the project's defaults.
95
+ voice = request.voice or self.cfg.voice_for(request.locale) or ""
96
+ model = request.model or self.spec.get("model") or self.cfg.model or ""
97
+ context: dict[str, str] = {
98
+ "text": request.text,
99
+ "locale": request.locale,
100
+ "voice": voice,
101
+ "model": model,
102
+ "speed": _format_number(request.speed),
103
+ "api_key": api_key or "",
104
+ "region": self.cfg.region or "",
105
+ "base_url": (self.cfg.base_url or self.spec.get("base_url") or "").rstrip("/"),
106
+ }
107
+ # Provider-specific extras last, so a project can override anything above.
108
+ for key, value in (request.options or {}).items():
109
+ context[str(key)] = "" if value is None else str(value)
110
+ return context
111
+
112
+ def _require(self, context: dict[str, str]) -> None:
113
+ missing = [f for f in self.spec.get("requires", []) if not context.get(f)]
114
+ if not missing:
115
+ return
116
+ hint = self.spec.get("docs")
117
+ raise TtsError(
118
+ f"TTS provider '{self.name}' needs {', '.join('video.tts.' + m for m in missing)}."
119
+ + (f" See {hint}" if hint else "")
120
+ )
121
+
122
+ def synthesize(self, request: SpeechRequest) -> SpeechClip:
123
+ import httpx # lazy: see the module docstring
124
+
125
+ context = self._context(request)
126
+ self._require(context)
127
+
128
+ url = substitute(self.spec.get("url") or "", context)
129
+ if not url:
130
+ raise TtsError(
131
+ f"TTS provider '{self.name}' has no URL. Set video.tts.http.url, or pick a "
132
+ "provider preset that supplies one."
133
+ )
134
+
135
+ headers = substitute(self.spec.get("headers") or {}, context)
136
+ json_body = self.spec.get("json")
137
+ raw_body = self.spec.get("body")
138
+ kwargs: dict[str, Any] = {"headers": headers}
139
+ if json_body is not None:
140
+ kwargs["json"] = substitute(json_body, context)
141
+ elif raw_body is not None:
142
+ kwargs["content"] = substitute(raw_body, context).encode("utf-8")
143
+
144
+ timeout = self.cfg.timeout / 1000
145
+ try:
146
+ response = httpx.request(self.spec.get("method", "POST"), url, timeout=timeout, **kwargs)
147
+ except Exception as exc:
148
+ raise TtsError(f"TTS request to '{self.name}' failed: {exc}") from exc
149
+
150
+ if response.status_code >= 400:
151
+ # Never echo the headers back — they carry the API key.
152
+ detail = response.text[:300].strip()
153
+ raise TtsError(f"TTS provider '{self.name}' returned HTTP {response.status_code}: {detail}")
154
+
155
+ audio = self._extract(response)
156
+ if not audio:
157
+ raise TtsError(f"TTS provider '{self.name}' returned no audio for a {len(request.text)}-char utterance")
158
+ return SpeechClip(audio=audio, fmt=self._format())
159
+
160
+ def _extract(self, response) -> bytes:
161
+ if self.spec.get("response") == "json":
162
+ payload = response.json()
163
+ value = dig(payload, self.spec.get("audio_path") or "")
164
+ if self.spec.get("encoding") == "base64":
165
+ return base64.b64decode(value)
166
+ return value.encode("utf-8") if isinstance(value, str) else bytes(value)
167
+ return response.content
168
+
169
+ def _format(self) -> str:
170
+ return str(self.cfg.format or self.spec.get("format") or "wav")
171
+
172
+
173
+ def _format_number(value: float) -> str:
174
+ """1.0 renders as "1", not "1.0" — some APIs reject the float form."""
175
+ if float(value).is_integer():
176
+ return str(int(value))
177
+ return str(value)
178
+
179
+
180
+ def build(name: str, spec: dict, cfg) -> HttpTtsProvider:
181
+ return HttpTtsProvider(name, spec, cfg)
182
+
183
+
184
+ __all__ = ["HttpTtsProvider", "build", "dig", "substitute"]
@@ -0,0 +1,207 @@
1
+ """Offline text-to-speech providers: Piper (neural) and espeak-ng (formant).
2
+
3
+ Both are driven as **subprocesses**, never imported. For espeak that is simply
4
+ how it ships; for Piper it is a licensing boundary worth stating plainly:
5
+ `piper1-gpl <https://github.com/OHF-Voice/piper1-gpl>`_ is GPL-3.0, and Steppy
6
+ is headed for a permissive license. Running ``python -m piper`` is arms-length
7
+ use of a tool the user installed — the same posture already taken with ffmpeg —
8
+ whereas ``from piper import PiperVoice`` would make Steppy a derivative work.
9
+ For the same reason Piper is deliberately **not** a ``pyproject.toml`` extra:
10
+ users install it themselves with ``pip install piper-tts``.
11
+
12
+ (The archived ``rhasspy/piper`` repo is not this. Development moved to
13
+ OHF-Voice under the Open Home Foundation in late 2025; voice names and the
14
+ ``download_voices`` module below are that project's.)
15
+
16
+ Both engines emit WAV, which is the format the narration assembler wants — so
17
+ the free path never pays a transcode.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import shutil
23
+ import subprocess
24
+ import sys
25
+ import tempfile
26
+ from functools import lru_cache
27
+ from pathlib import Path
28
+ from typing import Optional
29
+
30
+ from ..types import SpeechClip, SpeechRequest, TtsError
31
+
32
+
33
+ class PiperProvider:
34
+ """Free, offline, neural. Needs a per-language voice model on disk."""
35
+
36
+ name = "piper"
37
+
38
+ def __init__(self, cfg) -> None:
39
+ self.cfg = cfg
40
+ self.python = cfg.piper.python or sys.executable
41
+ self.data_dir = Path(cfg.piper.data_dir)
42
+
43
+ def version(self) -> str:
44
+ return _piper_version(self.python)
45
+
46
+ def synthesize(self, request: SpeechRequest) -> SpeechClip:
47
+ voice = request.voice
48
+ if not voice:
49
+ raise TtsError(
50
+ "video.tts.voice is required for the piper provider. Voices are "
51
+ "per-language, so use the map form:\n"
52
+ " voice: { fr: fr_FR-siwis-medium, nl: nl_NL-mls-medium }\n"
53
+ "Browse them at https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md"
54
+ )
55
+ self._ensure_voice(voice)
56
+
57
+ with tempfile.TemporaryDirectory() as tmp:
58
+ out = Path(tmp) / "clip.wav"
59
+ args = [
60
+ self.python,
61
+ "-m",
62
+ "piper",
63
+ "-m",
64
+ voice,
65
+ "--data-dir",
66
+ str(self.data_dir),
67
+ "-f",
68
+ str(out),
69
+ ]
70
+ # Piper's length_scale is inverse to speed: slower speech is longer.
71
+ if request.speed and request.speed != 1.0:
72
+ args += ["--length-scale", f"{1.0 / request.speed:.4f}"]
73
+ for flag in ("sentence_silence", "volume"):
74
+ value = (request.options or {}).get(flag)
75
+ if value is not None:
76
+ args += [f"--{flag.replace('_', '-')}", str(value)]
77
+ args += ["--", request.text]
78
+
79
+ _run(args, what="piper", hint=_PIPER_HINT, timeout=self.cfg.timeout / 1000)
80
+ if not out.is_file():
81
+ raise TtsError("piper produced no audio file")
82
+ return SpeechClip(audio=out.read_bytes(), fmt="wav")
83
+
84
+ def _ensure_voice(self, voice: str) -> None:
85
+ if (self.data_dir / f"{voice}.onnx").is_file():
86
+ return
87
+ if not self.cfg.piper.download:
88
+ raise TtsError(
89
+ f"piper voice '{voice}' is not in {self.data_dir} and "
90
+ "video.tts.piper.download is false. Fetch it with:\n"
91
+ f" {self.python} -m piper.download_voices --data-dir {self.data_dir} {voice}"
92
+ )
93
+ self.data_dir.mkdir(parents=True, exist_ok=True)
94
+ _run(
95
+ [self.python, "-m", "piper.download_voices", "--data-dir", str(self.data_dir), voice],
96
+ what="piper.download_voices",
97
+ hint=_PIPER_HINT,
98
+ # A voice model is tens of MB; the per-clip budget is far too small.
99
+ timeout=600,
100
+ )
101
+
102
+
103
+ class EspeakProvider:
104
+ """Free, offline, zero-download — and audibly robotic.
105
+
106
+ Ships as the honest fallback for a machine that cannot spare a voice model:
107
+ fine for a CI smoke test, not for a guide a customer watches.
108
+ """
109
+
110
+ name = "espeak"
111
+
112
+ def __init__(self, cfg) -> None:
113
+ self.cfg = cfg
114
+
115
+ def version(self) -> str:
116
+ return _espeak_version()
117
+
118
+ def synthesize(self, request: SpeechRequest) -> SpeechClip:
119
+ binary = shutil.which("espeak-ng") or shutil.which("espeak")
120
+ if not binary:
121
+ raise TtsError(
122
+ "espeak-ng is not installed. Install it with:\n"
123
+ " Windows: winget install espeak-ng.espeak-ng\n"
124
+ " macOS: brew install espeak-ng\n"
125
+ " Debian: sudo apt-get install espeak-ng"
126
+ )
127
+ with tempfile.TemporaryDirectory() as tmp:
128
+ out = Path(tmp) / "clip.wav"
129
+ # espeak's default is ~175 wpm; scale it rather than exposing raw wpm.
130
+ args = [binary, "-w", str(out), "-s", str(int(175 * request.speed))]
131
+ voice = request.voice or request.locale
132
+ if voice:
133
+ args += ["-v", voice]
134
+ args += [request.text]
135
+ _run(args, what="espeak-ng", hint="", timeout=self.cfg.timeout / 1000)
136
+ if not out.is_file():
137
+ raise TtsError("espeak-ng produced no audio file")
138
+ return SpeechClip(audio=out.read_bytes(), fmt="wav")
139
+
140
+
141
+ _PIPER_HINT = (
142
+ "Install it with:\n"
143
+ " pip install piper-tts\n"
144
+ "It is not a Steppy dependency on purpose: piper1-gpl is GPL-3.0, so Steppy "
145
+ "runs it as a separate program rather than linking it."
146
+ )
147
+
148
+
149
+ def _run(args: list[str], *, what: str, hint: str, timeout: float) -> None:
150
+ try:
151
+ proc = subprocess.run(args, capture_output=True, timeout=max(10.0, timeout))
152
+ except FileNotFoundError as exc:
153
+ raise TtsError(f"{what} is not available: {exc}.\n{hint}".rstrip()) from exc
154
+ except subprocess.TimeoutExpired as exc:
155
+ raise TtsError(f"{what} timed out after {exc.timeout:.0f}s") from exc
156
+ if proc.returncode != 0:
157
+ tail = (proc.stderr or b"").decode("utf-8", "replace").strip().splitlines()[-5:]
158
+ raise TtsError(f"{what} exited {proc.returncode}: " + " / ".join(tail))
159
+
160
+
161
+ @lru_cache(maxsize=4)
162
+ def _piper_version(python: str) -> str:
163
+ """Fold the engine version into the cache key.
164
+
165
+ Different Piper builds produce different audio, so clip durations differ,
166
+ so the whole video timeline shifts — with no other signal that anything
167
+ changed.
168
+ """
169
+ try:
170
+ proc = subprocess.run(
171
+ [python, "-c", "import piper,sys; sys.stdout.write(getattr(piper,'__version__','?'))"],
172
+ capture_output=True,
173
+ timeout=30,
174
+ )
175
+ return proc.stdout.decode("utf-8", "replace").strip() or "?"
176
+ except Exception:
177
+ return "?"
178
+
179
+
180
+ @lru_cache(maxsize=1)
181
+ def _espeak_version() -> str:
182
+ binary = shutil.which("espeak-ng") or shutil.which("espeak")
183
+ if not binary:
184
+ return "?"
185
+ try:
186
+ proc = subprocess.run([binary, "--version"], capture_output=True, timeout=20)
187
+ return proc.stdout.decode("utf-8", "replace").strip()[:80] or "?"
188
+ except Exception:
189
+ return "?"
190
+
191
+
192
+ def build_piper(cfg) -> PiperProvider:
193
+ return PiperProvider(cfg)
194
+
195
+
196
+ def build_espeak(cfg) -> EspeakProvider:
197
+ return EspeakProvider(cfg)
198
+
199
+
200
+ def probe(provider: str, cfg) -> Optional[str]:
201
+ """One-line readiness report for ``steppy video check``."""
202
+ if provider == "piper":
203
+ version = _piper_version(cfg.piper.python or sys.executable)
204
+ return None if version != "?" else "piper is not installed (pip install piper-tts)"
205
+ if provider == "espeak":
206
+ return None if _espeak_version() != "?" else "espeak-ng is not on PATH"
207
+ return None
@@ -0,0 +1,28 @@
1
+ """The ``none`` provider — captions, no voiceover.
2
+
3
+ Steppy's shipped default. A video renders with burned-in captions and a silent
4
+ track, with no install, no network and no bill; adding a voice is one config
5
+ line. That ordering is deliberate: the free path has to work out of the box,
6
+ and a default that silently starts charging per character would not.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from ..types import SpeechClip, SpeechRequest
12
+
13
+
14
+ class NullTtsProvider:
15
+ name = "none"
16
+
17
+ def __init__(self, cfg=None) -> None:
18
+ self.cfg = cfg
19
+
20
+ def version(self) -> str:
21
+ return "none"
22
+
23
+ def synthesize(self, request: SpeechRequest) -> SpeechClip:
24
+ return SpeechClip(audio=b"", fmt="wav")
25
+
26
+
27
+ def build(cfg=None) -> NullTtsProvider:
28
+ return NullTtsProvider(cfg)
steppy/tts/registry.py ADDED
@@ -0,0 +1,201 @@
1
+ """Provider resolution — the seam that makes ``video.tts.provider`` a config knob.
2
+
3
+ Four sources, in priority order:
4
+
5
+ 1. **Entry points** (``steppy.tts_providers``) — a third party ships a package,
6
+ declares one entry point, and their engine is nameable in ``config.yaml``
7
+ with no change to Steppy. This is why ``TtsConfig.provider`` is a plain
8
+ ``str`` and not a ``Literal``.
9
+ 2. **Built-in code providers** — ``none``, ``piper``, ``espeak``, ``http``.
10
+ 3. **Vendor presets** (``tts/presets.py``) — ``elevenlabs``, ``openai``,
11
+ ``azure``, ``google``, each a data row over the generic HTTP engine, with any
12
+ explicitly-set ``video.tts.http.*`` field layered on top.
13
+ 4. Anything else is an error that lists what *is* available.
14
+
15
+ Layering in (3) follows the same rule as the UI-kit presets in ``models.py``:
16
+ what the user set explicitly always wins, what they left alone gets filled in.
17
+
18
+ Structured as a priority cascade returning a tagged result, like
19
+ ``_parallel/image.py:resolve_build_inputs`` — the caller can report which branch
20
+ won, which is most of what ``steppy video check`` needs to be useful.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass
26
+ from functools import lru_cache
27
+ from typing import Any, Optional
28
+
29
+ from .presets import TTS_PRESETS
30
+ from .types import TtsError, TtsProvider
31
+
32
+ ENTRY_POINT_GROUP = "steppy.tts_providers"
33
+
34
+ _BUILTINS = ("none", "piper", "espeak", "http")
35
+
36
+ # Fields a preset may supply and the user may override. ``defaults`` is handled
37
+ # separately because it fills TtsConfig, not the request spec.
38
+ _SPEC_FIELDS = (
39
+ "url",
40
+ "method",
41
+ "headers",
42
+ "json",
43
+ "body",
44
+ "response",
45
+ "audio_path",
46
+ "encoding",
47
+ "format",
48
+ )
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Resolved:
53
+ """A provider plus the branch that produced it."""
54
+
55
+ provider: TtsProvider
56
+ name: str
57
+ # entry_point | builtin | preset
58
+ source: str
59
+ spec: dict[str, Any]
60
+
61
+
62
+ @lru_cache(maxsize=1)
63
+ def _entry_point_providers() -> dict[str, Any]:
64
+ """Discover third-party providers.
65
+
66
+ Cached and called lazily: ``entry_points()`` walks the metadata of every
67
+ installed distribution, which is not a cost the default CLI path should pay.
68
+ """
69
+ from importlib.metadata import entry_points
70
+
71
+ found: dict[str, Any] = {}
72
+ try:
73
+ for entry in entry_points(group=ENTRY_POINT_GROUP):
74
+ found[entry.name] = entry
75
+ except Exception: # pragma: no cover - broken metadata must not break a build
76
+ return {}
77
+ return found
78
+
79
+
80
+ def available_providers() -> list[str]:
81
+ return sorted({*_BUILTINS, *TTS_PRESETS, *_entry_point_providers()})
82
+
83
+
84
+ def resolve_spec(name: str, cfg) -> dict[str, Any]:
85
+ """Merge a vendor preset with the user's ``video.tts.http`` overrides.
86
+
87
+ Explicit wins: any field the user actually set in config takes precedence
88
+ over the preset's value, exactly like ``_PresetModel._apply_preset``.
89
+ """
90
+ preset = dict(TTS_PRESETS.get(name, {}))
91
+ spec: dict[str, Any] = {k: v for k, v in preset.items() if k in _SPEC_FIELDS}
92
+ spec["requires"] = list(preset.get("requires", []))
93
+ spec["docs"] = preset.get("docs")
94
+ for key, value in (preset.get("defaults") or {}).items():
95
+ spec.setdefault(key, value)
96
+
97
+ http = cfg.http
98
+ explicit = http.model_fields_set
99
+ overrides = {
100
+ "url": http.url,
101
+ "method": http.method,
102
+ "headers": http.headers,
103
+ "json": http.json_body,
104
+ "body": http.body,
105
+ "response": http.response,
106
+ "audio_path": http.audio_path,
107
+ "encoding": http.encoding,
108
+ "format": http.format,
109
+ }
110
+ for field_name, value in overrides.items():
111
+ # `json_body` carries the alias `json` on the model.
112
+ set_name = "json_body" if field_name == "json" else field_name
113
+ if set_name in explicit and value is not None:
114
+ spec[field_name] = value
115
+
116
+ # A user-set model/voice always beats the preset's default.
117
+ if cfg.model:
118
+ spec["model"] = cfg.model
119
+ if cfg.base_url:
120
+ spec["base_url"] = cfg.base_url
121
+ return spec
122
+
123
+
124
+ def resolve(cfg) -> Resolved:
125
+ """Resolve ``video.tts.provider`` into something that can speak."""
126
+ name = (cfg.provider or "none").strip()
127
+
128
+ entry = _entry_point_providers().get(name)
129
+ if entry is not None:
130
+ try:
131
+ factory = entry.load()
132
+ except Exception as exc:
133
+ raise TtsError(f"TTS provider '{name}' is registered but failed to load: {exc}") from exc
134
+ try:
135
+ provider = factory(cfg)
136
+ except Exception as exc:
137
+ raise TtsError(f"TTS provider '{name}' failed to initialise: {exc}") from exc
138
+ return Resolved(provider=provider, name=name, source="entry_point", spec={})
139
+
140
+ if name in _BUILTINS:
141
+ return Resolved(provider=_build_builtin(name, cfg), name=name, source="builtin", spec=resolve_spec(name, cfg))
142
+
143
+ if name in TTS_PRESETS:
144
+ from .providers.http import build as build_http
145
+
146
+ spec = resolve_spec(name, cfg)
147
+ return Resolved(provider=build_http(name, spec, cfg), name=name, source="preset", spec=spec)
148
+
149
+ raise TtsError(
150
+ f"Unknown TTS provider '{name}'. Available: {', '.join(available_providers())}.\n"
151
+ "Set video.tts.provider to one of those, or describe an unsupported API "
152
+ "directly under video.tts.http (url / headers / json / response)."
153
+ )
154
+
155
+
156
+ def _build_builtin(name: str, cfg):
157
+ if name == "none":
158
+ from .providers.null import build
159
+
160
+ return build(cfg)
161
+ if name == "http":
162
+ from .providers.http import build as build_http
163
+
164
+ return build_http("http", resolve_spec("http", cfg), cfg)
165
+ from .providers.local import build_espeak, build_piper
166
+
167
+ return build_piper(cfg) if name == "piper" else build_espeak(cfg)
168
+
169
+
170
+ def describe(cfg) -> str:
171
+ """Human-readable one-liner for ``steppy video check``."""
172
+ try:
173
+ resolved = resolve(cfg)
174
+ except TtsError as exc:
175
+ return f"unusable — {exc}"
176
+ detail = {"entry_point": "third-party plugin", "builtin": "built in", "preset": "vendor preset"}[resolved.source]
177
+ if resolved.name == "none":
178
+ return "none (captions only, no audio)"
179
+ return f"{resolved.name} ({detail})"
180
+
181
+
182
+ def readiness(cfg) -> Optional[str]:
183
+ """Return a problem description, or None when the provider looks usable."""
184
+ try:
185
+ resolved = resolve(cfg)
186
+ except TtsError as exc:
187
+ return str(exc)
188
+ if resolved.source == "builtin" and resolved.name in ("piper", "espeak"):
189
+ from .providers.local import probe
190
+
191
+ return probe(resolved.name, cfg)
192
+ if resolved.name != "none" and resolved.spec.get("requires"):
193
+ from .types import check_resolved
194
+
195
+ try:
196
+ check_resolved(cfg.api_key, "video.tts.api_key")
197
+ except TtsError as exc:
198
+ return str(exc)
199
+ if "api_key" in resolved.spec["requires"] and not cfg.api_key:
200
+ return f"video.tts.api_key is not set (required by '{resolved.name}')"
201
+ return None