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.
- steppy/CHANGELOG.md +862 -0
- steppy/__init__.py +9 -0
- steppy/__main__.py +13 -0
- steppy/_cli_common.py +183 -0
- steppy/_docker/Dockerfile.installed +53 -0
- steppy/_parallel/__init__.py +7 -0
- steppy/_parallel/compose.py +349 -0
- steppy/_parallel/event_bus.py +418 -0
- steppy/_parallel/failures.py +267 -0
- steppy/_parallel/fingerprint.py +141 -0
- steppy/_parallel/image.py +380 -0
- steppy/_parallel/lastfailed.py +125 -0
- steppy/_parallel/lifecycle.py +90 -0
- steppy/_parallel/preauth.py +109 -0
- steppy/_parallel/progress.py +101 -0
- steppy/_parallel/queue.py +385 -0
- steppy/_parallel/renderer.py +489 -0
- steppy/_parallel/retry.py +316 -0
- steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
- steppy/_player_bundle/__init__.py +100 -0
- steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
- steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
- steppy/_player_bundle/steppy-player.js +5419 -0
- steppy/_player_bundle/steppy-player.js.map +1 -0
- steppy/_player_bundle/steppy-video.js +472 -0
- steppy/_player_bundle/steppy-video.js.map +1 -0
- steppy/_templating.py +117 -0
- steppy/assertions.py +251 -0
- steppy/auth.py +493 -0
- steppy/build.py +2798 -0
- steppy/build_models.py +669 -0
- steppy/buildid.py +134 -0
- steppy/bundle/__init__.py +47 -0
- steppy/bundle/archive.py +239 -0
- steppy/bundle/classify.py +329 -0
- steppy/bundle/create.py +490 -0
- steppy/bundle/hashing.py +28 -0
- steppy/bundle/manifest.py +334 -0
- steppy/bundle/results.py +546 -0
- steppy/bundle_cli.py +455 -0
- steppy/captures.py +207 -0
- steppy/cli.py +2489 -0
- steppy/cloud/__init__.py +58 -0
- steppy/cloud/client.py +267 -0
- steppy/cloud/upload.py +497 -0
- steppy/compat.py +109 -0
- steppy/config.py +231 -0
- steppy/config_schema.py +87 -0
- steppy/contracts/__init__.py +54 -0
- steppy/contracts/bundle.py +215 -0
- steppy/contracts/report.py +346 -0
- steppy/contracts/tours.py +505 -0
- steppy/coordinator.py +1352 -0
- steppy/coverage/__init__.py +9 -0
- steppy/coverage/gaps.py +190 -0
- steppy/coverage/manifest.py +193 -0
- steppy/coverage/merge.py +363 -0
- steppy/coverage/normalize.py +174 -0
- steppy/coverage/report.py +178 -0
- steppy/coverage/sourcemap.py +219 -0
- steppy/coverage/store.py +473 -0
- steppy/coverage/triage.py +151 -0
- steppy/coverage_cli.py +265 -0
- steppy/durations.py +48 -0
- steppy/exclusive.py +178 -0
- steppy/flaky_cli.py +169 -0
- steppy/generator.py +633 -0
- steppy/guide.py +1467 -0
- steppy/history.py +352 -0
- steppy/init_cli.py +330 -0
- steppy/locales.py +282 -0
- steppy/migrate/__init__.py +35 -0
- steppy/migrate/apply.py +194 -0
- steppy/migrate/plan.py +199 -0
- steppy/migrate/rules.py +465 -0
- steppy/migrate/verify.py +163 -0
- steppy/migrate_cli.py +152 -0
- steppy/models.py +2539 -0
- steppy/naming.py +37 -0
- steppy/parser.py +475 -0
- steppy/pdf.py +193 -0
- steppy/presets.py +284 -0
- steppy/record_cli.py +796 -0
- steppy/report/__init__.py +37 -0
- steppy/report/assets.py +197 -0
- steppy/report/build.py +261 -0
- steppy/report/coverage_source.py +277 -0
- steppy/report/model.py +423 -0
- steppy/report/render.py +303 -0
- steppy/report/source.py +238 -0
- steppy/report_cli.py +173 -0
- steppy/runmeta.py +114 -0
- steppy/selection.py +183 -0
- steppy/sitemap.py +205 -0
- steppy/skills/steppy-app-tours/SKILL.md +448 -0
- steppy/skills/steppy-ci-report/SKILL.md +567 -0
- steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
- steppy/skills/steppy-template/SKILL.md +676 -0
- steppy/templates/_steppy_events.py +291 -0
- steppy/templates/_steppy_tours.py +404 -0
- steppy/templates/angular/component-interactive.ts.j2 +109 -0
- steppy/templates/angular/component-video.ts.j2 +112 -0
- steppy/templates/angular/component.ts.j2 +229 -0
- steppy/templates/angular/content.ts.j2 +31 -0
- steppy/templates/angular/gitignore.j2 +3 -0
- steppy/templates/angular/group.component.ts.j2 +39 -0
- steppy/templates/angular/onboarding.component.ts.j2 +43 -0
- steppy/templates/angular/routes.ts.j2 +34 -0
- steppy/templates/ci/github-actions.yml +284 -0
- steppy/templates/ci/gitlab-ci.yml +178 -0
- steppy/templates/conftest.py.j2 +2405 -0
- steppy/templates/guide.book.html.j2 +145 -0
- steppy/templates/guide.fragment.html.j2 +257 -0
- steppy/templates/guide.html.j2 +301 -0
- steppy/templates/guide.md.j2 +18 -0
- steppy/templates/init/config.yaml +124 -0
- steppy/templates/init/credentials.yaml.example +30 -0
- steppy/templates/init/scenarios/example--first-flow.md +53 -0
- steppy/templates/report/_head.html.j2 +14 -0
- steppy/templates/report/_mark.html.j2 +3 -0
- steppy/templates/report/coverage.html.j2 +124 -0
- steppy/templates/report/index.html.j2 +203 -0
- steppy/templates/report/report.css +401 -0
- steppy/templates/report/scenario.html.j2 +137 -0
- steppy/templates/static/_headers.j2 +30 -0
- steppy/templates/static/_mode_links.html.j2 +22 -0
- steppy/templates/static/_seo_head.html.j2 +37 -0
- steppy/templates/static/cursor.js.j2 +10 -0
- steppy/templates/static/group.html.j2 +78 -0
- steppy/templates/static/guides.css +678 -0
- steppy/templates/static/index.html.j2 +79 -0
- steppy/templates/static/interactive.html.j2 +100 -0
- steppy/templates/static/onboarding.html.j2 +64 -0
- steppy/templates/static/page.html.j2 +145 -0
- steppy/templates/static/robots.txt.j2 +7 -0
- steppy/templates/static/video.html.j2 +135 -0
- steppy/templates/test_scenario.py.j2 +767 -0
- steppy/testids.py +166 -0
- steppy/totp.py +26 -0
- steppy/tour_spec.py +163 -0
- steppy/tours/__init__.py +22 -0
- steppy/tours/actions.py +402 -0
- steppy/tours/compiler.py +144 -0
- steppy/tours/diagnostics.py +137 -0
- steppy/tours/emit.py +239 -0
- steppy/tours/ids.py +40 -0
- steppy/tours/inference.py +399 -0
- steppy/tours/model.py +120 -0
- steppy/tours/paths.py +126 -0
- steppy/tours/pipeline.py +277 -0
- steppy/tours/publish.py +124 -0
- steppy/tours/replay.py +234 -0
- steppy/tours/routes.py +175 -0
- steppy/tours/settings.py +164 -0
- steppy/tours/text.py +237 -0
- steppy/tours_cli.py +219 -0
- steppy/tts/__init__.py +280 -0
- steppy/tts/cache.py +185 -0
- steppy/tts/presets.py +125 -0
- steppy/tts/providers/__init__.py +6 -0
- steppy/tts/providers/http.py +184 -0
- steppy/tts/providers/local.py +207 -0
- steppy/tts/providers/null.py +28 -0
- steppy/tts/registry.py +201 -0
- steppy/tts/track.py +158 -0
- steppy/tts/types.py +94 -0
- steppy/update_cli.py +460 -0
- steppy/video.py +730 -0
- steppy/video_cache.py +239 -0
- steppy/video_cli.py +271 -0
- steppy/worker.py +301 -0
- steppy_dev-0.3.0.dist-info/METADATA +1335 -0
- steppy_dev-0.3.0.dist-info/RECORD +177 -0
- steppy_dev-0.3.0.dist-info/WHEEL +4 -0
- steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
- steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
- 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
|