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
steppy/tts/__init__.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Text-to-speech for video narration — provider-agnostic by construction.
|
|
2
|
+
|
|
3
|
+
The whole point of this package is that ``video.tts.provider`` is the only thing
|
|
4
|
+
that has to change to swap engines: ``none`` (the default: captions, no audio),
|
|
5
|
+
``piper`` or ``espeak`` (free, offline), a vendor preset (``elevenlabs``,
|
|
6
|
+
``openai``, ``azure``, ``google``), a hand-written REST shape under
|
|
7
|
+
``video.tts.http``, or a third-party plugin registered through the
|
|
8
|
+
``steppy.tts_providers`` entry point. No code path branches on vendor.
|
|
9
|
+
|
|
10
|
+
Narration is synthesized **before** the browser opens, because the video
|
|
11
|
+
timeline needs each clip's duration to know how long to hold its step. So this
|
|
12
|
+
package never imports ``steppy.video``, and ``steppy.video`` never imports this
|
|
13
|
+
one — they meet at a dict of durations and a finished WAV.
|
|
14
|
+
|
|
15
|
+
Synthesis is **per step**, and the cache is keyed on the text rather than on the
|
|
16
|
+
step's position, so editing one sentence re-voices one sentence: unchanged steps
|
|
17
|
+
hit, reordered steps hit, and the same sentence in two guides shares one clip.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Callable, Optional, Sequence
|
|
25
|
+
|
|
26
|
+
from .cache import ClipCache, cache_key
|
|
27
|
+
from .registry import available_providers, describe, readiness, resolve
|
|
28
|
+
from .types import SpeechClip, SpeechRequest, TtsError, TtsProvider
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"ClipCache",
|
|
32
|
+
"SpeechClip",
|
|
33
|
+
"SpeechRequest",
|
|
34
|
+
"TtsError",
|
|
35
|
+
"TtsProvider",
|
|
36
|
+
"Utterance",
|
|
37
|
+
"Narration",
|
|
38
|
+
"assemble_track",
|
|
39
|
+
"available_providers",
|
|
40
|
+
"cache_key",
|
|
41
|
+
"describe",
|
|
42
|
+
"normalize_narration",
|
|
43
|
+
"readiness",
|
|
44
|
+
"resolve",
|
|
45
|
+
"synthesize_steps",
|
|
46
|
+
"voice_jobs",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class Utterance:
|
|
52
|
+
"""One step's narration."""
|
|
53
|
+
|
|
54
|
+
step_id: str
|
|
55
|
+
text: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Narration:
|
|
60
|
+
"""Per-step clips and durations, ready for the timeline and the muxer."""
|
|
61
|
+
|
|
62
|
+
durations_ms: dict[str, int]
|
|
63
|
+
clips: dict[str, Path]
|
|
64
|
+
provider: str
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def silent(self) -> bool:
|
|
68
|
+
return not self.clips
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def normalize_narration(text: Optional[str]) -> str:
|
|
72
|
+
"""Collapse authoring whitespace, so re-wrapping a line is not a re-bill.
|
|
73
|
+
|
|
74
|
+
``guide-text`` is YAML, and a hard wrap moved by one word changes the string
|
|
75
|
+
without changing a syllable of the audio. Normalizing the *request* rather
|
|
76
|
+
than the *key* is deliberate: were only the key normalized, two differently
|
|
77
|
+
wrapped texts would collide on whichever clip synthesized first, and the
|
|
78
|
+
audio would no longer correspond to the text that was sent.
|
|
79
|
+
"""
|
|
80
|
+
return " ".join((text or "").split())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def synthesize_steps(
|
|
84
|
+
utterances: Sequence[Utterance],
|
|
85
|
+
cfg,
|
|
86
|
+
*,
|
|
87
|
+
locale: str,
|
|
88
|
+
work_dir: Path,
|
|
89
|
+
ffmpeg: str,
|
|
90
|
+
cache_root: Optional[Path] = None,
|
|
91
|
+
memo: Optional[dict[str, tuple[Path, int]]] = None,
|
|
92
|
+
on_warning: Optional[Callable[[str], None]] = None,
|
|
93
|
+
) -> Narration:
|
|
94
|
+
"""Synthesize every step's narration for one locale.
|
|
95
|
+
|
|
96
|
+
Returns durations keyed by step id — which is what the video timeline needs
|
|
97
|
+
up front, to stretch each step so its voiceover is never cut off.
|
|
98
|
+
|
|
99
|
+
``cache_root`` overrides ``cfg.cache_dir`` and should be an **absolute**
|
|
100
|
+
path resolved against the project root; the bare config value is relative to
|
|
101
|
+
the CWD, so a build launched from a subdirectory would otherwise miss a warm
|
|
102
|
+
cache and re-bill for it. ``memo`` is a process-local key→path map shared
|
|
103
|
+
across a batch, so N layouts of one guide synthesize once even when the
|
|
104
|
+
on-disk cache is disabled.
|
|
105
|
+
|
|
106
|
+
A provider failure is reported and downgraded to silence rather than raised:
|
|
107
|
+
losing the voiceover is a bad outcome, losing the release is a worse one.
|
|
108
|
+
"""
|
|
109
|
+
from .track import normalize, wav_duration_ms
|
|
110
|
+
|
|
111
|
+
resolved = resolve(cfg)
|
|
112
|
+
if resolved.name == "none":
|
|
113
|
+
return Narration(durations_ms={}, clips={}, provider="none")
|
|
114
|
+
|
|
115
|
+
cache = ClipCache(Path(cache_root or cfg.cache_dir), enabled=cfg.cache)
|
|
116
|
+
version = _safe_version(resolved.provider)
|
|
117
|
+
durations: dict[str, int] = {}
|
|
118
|
+
clips: dict[str, Path] = {}
|
|
119
|
+
|
|
120
|
+
for utterance in utterances:
|
|
121
|
+
text = normalize_narration(utterance.text)
|
|
122
|
+
if not text:
|
|
123
|
+
continue
|
|
124
|
+
request = SpeechRequest(
|
|
125
|
+
text=text,
|
|
126
|
+
locale=locale,
|
|
127
|
+
voice=cfg.voice_for(locale),
|
|
128
|
+
model=cfg.model,
|
|
129
|
+
speed=cfg.speed,
|
|
130
|
+
fmt=cfg.format,
|
|
131
|
+
options=dict(cfg.options or {}),
|
|
132
|
+
)
|
|
133
|
+
key = cache_key(request, resolved.name, version)
|
|
134
|
+
try:
|
|
135
|
+
wav, duration = _clip_for(request, key, cache, resolved, work_dir, ffmpeg, normalize, memo)
|
|
136
|
+
except TtsError as exc:
|
|
137
|
+
if on_warning:
|
|
138
|
+
on_warning(f"narration for '{utterance.step_id}' ({locale}) failed: {exc}")
|
|
139
|
+
continue
|
|
140
|
+
if wav is None:
|
|
141
|
+
continue
|
|
142
|
+
if duration is None:
|
|
143
|
+
try:
|
|
144
|
+
duration = wav_duration_ms(wav)
|
|
145
|
+
except Exception as exc: # a clip we just produced and cannot read
|
|
146
|
+
if on_warning:
|
|
147
|
+
on_warning(f"narration for '{utterance.step_id}' ({locale}) is unreadable: {exc}")
|
|
148
|
+
continue
|
|
149
|
+
cache.set_duration(key, "wav", duration)
|
|
150
|
+
durations[utterance.step_id] = duration
|
|
151
|
+
clips[utterance.step_id] = wav
|
|
152
|
+
if memo is not None:
|
|
153
|
+
memo[key] = (wav, duration)
|
|
154
|
+
|
|
155
|
+
return Narration(durations_ms=durations, clips=clips, provider=resolved.name)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _clip_for(request, key, cache, resolved, work_dir, ffmpeg, normalize, memo) -> tuple[Optional[Path], Optional[int]]:
|
|
159
|
+
"""Fetch from the memo, then the cache, else synthesize.
|
|
160
|
+
|
|
161
|
+
Always returns normalized WAV, plus the clip's duration when it was already
|
|
162
|
+
known. A cached clip that will not open is treated as a **miss**: the entry
|
|
163
|
+
is dropped and re-synthesized, because a truncated WAV left by a killed run
|
|
164
|
+
is otherwise a permanent hit whose duration no longer matches the timeline
|
|
165
|
+
the video was rendered against.
|
|
166
|
+
"""
|
|
167
|
+
from .track import is_intact_wav, wav_duration_ms
|
|
168
|
+
|
|
169
|
+
if memo is not None:
|
|
170
|
+
remembered = memo.get(key)
|
|
171
|
+
if remembered is not None and remembered[0].is_file():
|
|
172
|
+
return remembered
|
|
173
|
+
|
|
174
|
+
hit = cache.get(key, "wav")
|
|
175
|
+
if hit is not None:
|
|
176
|
+
if is_intact_wav(hit.path):
|
|
177
|
+
return hit.path, hit.duration_ms or wav_duration_ms(hit.path)
|
|
178
|
+
cache.discard(key, "wav")
|
|
179
|
+
|
|
180
|
+
clip = resolved.provider.synthesize(request)
|
|
181
|
+
if clip.empty:
|
|
182
|
+
return None, None
|
|
183
|
+
|
|
184
|
+
# ``.raw.`` keeps the pre-transcode container from ever colliding with the
|
|
185
|
+
# normalized artifact, which matters when the provider already returned WAV.
|
|
186
|
+
work_dir.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
raw = work_dir / f"{key}.raw.{clip.fmt}"
|
|
188
|
+
raw.write_bytes(clip.audio)
|
|
189
|
+
try:
|
|
190
|
+
if not cache.enabled:
|
|
191
|
+
return normalize(raw, work_dir / f"{key}.wav", ffmpeg), None
|
|
192
|
+
staging = cache.staging_path(key, "wav")
|
|
193
|
+
try:
|
|
194
|
+
normalize(raw, staging, ffmpeg)
|
|
195
|
+
return cache.commit(key, staging, "wav"), None
|
|
196
|
+
except BaseException:
|
|
197
|
+
staging.unlink(missing_ok=True)
|
|
198
|
+
raise
|
|
199
|
+
finally:
|
|
200
|
+
# The normalized WAV is the artifact; the raw container is scratch, and
|
|
201
|
+
# leaving it behind is what made ``tts-cache/work/`` grow forever.
|
|
202
|
+
raw.unlink(missing_ok=True)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _safe_version(provider) -> str:
|
|
206
|
+
try:
|
|
207
|
+
return str(provider.version())
|
|
208
|
+
except Exception: # pragma: no cover - a provider must not break on this
|
|
209
|
+
return "?"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ── Job-level helpers, shared by ``steppy build`` and ``steppy guide`` ───────
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def voice_jobs(
|
|
216
|
+
jobs: Sequence,
|
|
217
|
+
cfg,
|
|
218
|
+
*,
|
|
219
|
+
ffmpeg: str,
|
|
220
|
+
cache_root: Path,
|
|
221
|
+
work_dir: Path,
|
|
222
|
+
on_warning: Optional[Callable[[str], None]] = None,
|
|
223
|
+
) -> list:
|
|
224
|
+
"""Voice every job's steps before any browser opens.
|
|
225
|
+
|
|
226
|
+
The timeline needs each clip's *duration* up front, to stretch its step so
|
|
227
|
+
the voiceover is never cut off — which is why this cannot wait until render
|
|
228
|
+
time. Where each clip is *placed* is a separate question, decided by the
|
|
229
|
+
timeline and answered by :func:`assemble_track`.
|
|
230
|
+
|
|
231
|
+
One home for both entry points: ``steppy build --video`` and
|
|
232
|
+
``steppy guide --video`` had byte-identical copies of this, and they had
|
|
233
|
+
already drifted apart on cache-path resolution. The batch shares one memo,
|
|
234
|
+
so the desktop and mobile jobs of one guide voice its steps once.
|
|
235
|
+
|
|
236
|
+
With ``tts.provider: none`` (the shipped default) this is a no-op.
|
|
237
|
+
"""
|
|
238
|
+
from dataclasses import replace
|
|
239
|
+
|
|
240
|
+
if cfg.provider == "none":
|
|
241
|
+
return list(jobs)
|
|
242
|
+
|
|
243
|
+
memo: dict[str, tuple[Path, int]] = {}
|
|
244
|
+
voiced: list = []
|
|
245
|
+
for job in jobs:
|
|
246
|
+
narration = synthesize_steps(
|
|
247
|
+
[
|
|
248
|
+
Utterance(step_id=step["id"], text=(step.get("text") or {}).get(job.locale) or "")
|
|
249
|
+
for step in job.manifest["steps"]
|
|
250
|
+
],
|
|
251
|
+
cfg,
|
|
252
|
+
locale=job.locale,
|
|
253
|
+
work_dir=work_dir,
|
|
254
|
+
ffmpeg=ffmpeg,
|
|
255
|
+
cache_root=cache_root,
|
|
256
|
+
memo=memo,
|
|
257
|
+
on_warning=on_warning,
|
|
258
|
+
)
|
|
259
|
+
voiced.append(
|
|
260
|
+
job
|
|
261
|
+
if narration.silent
|
|
262
|
+
else replace(job, narration_ms=narration.durations_ms, narration_clips=narration.clips)
|
|
263
|
+
)
|
|
264
|
+
return voiced
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def assemble_track(job, cues: list, total_ms: int, dest: Path) -> Optional[Path]:
|
|
268
|
+
"""Lay each step's clip at the offset the timeline gave that step.
|
|
269
|
+
|
|
270
|
+
Injected into ``render_videos`` rather than called from it: ``video.py`` is a
|
|
271
|
+
leaf module that must not import ``steppy.tts``, and the placement rule
|
|
272
|
+
genuinely belongs to whoever owns the narration.
|
|
273
|
+
"""
|
|
274
|
+
if not job.narration_clips:
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
from .track import assemble
|
|
278
|
+
|
|
279
|
+
placed = [(int(cue["startMs"]), job.narration_clips.get(cue["stepId"])) for cue in cues]
|
|
280
|
+
return assemble(placed, total_ms=total_ms, dest=dest)
|
steppy/tts/cache.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Content-addressed clip cache.
|
|
2
|
+
|
|
3
|
+
Not an optimisation — a correctness-of-cost feature. A cloud provider bills per
|
|
4
|
+
character, and a guide corpus re-synthesized on every CI run would bill for
|
|
5
|
+
text that has not changed since the last release. With the cache, only edited
|
|
6
|
+
narration costs anything.
|
|
7
|
+
|
|
8
|
+
Lives under ``.steppy/``, never ``output/``, for the same reason ``coverage.db``
|
|
9
|
+
and ``history.jsonl`` do: ``steppy build`` wipes its output directory on every
|
|
10
|
+
run, so a cache there would never survive to be a hit.
|
|
11
|
+
|
|
12
|
+
**Every write lands atomically.** A clip is written to a sibling temp file and
|
|
13
|
+
``os.replace``d into position, because the failure mode otherwise is permanent:
|
|
14
|
+
a Ctrl-C or a full disk leaves a truncated WAV *at the final key path*, and a
|
|
15
|
+
lookup that only asks ``is_file()`` will serve it forever.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import tempfile
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Optional
|
|
27
|
+
|
|
28
|
+
from .types import SpeechClip, SpeechRequest
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class CachedClip:
|
|
33
|
+
path: Path
|
|
34
|
+
fmt: str
|
|
35
|
+
duration_ms: Optional[int]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cache_key(request: SpeechRequest, provider: str, version: str) -> str:
|
|
39
|
+
"""Everything that can change the audio, and nothing that cannot.
|
|
40
|
+
|
|
41
|
+
``version`` is load-bearing for the local engines: espeak-ng and Piper
|
|
42
|
+
produce different audio across builds, so an unversioned key would serve a
|
|
43
|
+
stale clip whose duration no longer matches the timeline the video was
|
|
44
|
+
rendered against. For the HTTP engine it also carries a hash of the request
|
|
45
|
+
shape, so editing ``video.tts.http`` re-synthesizes rather than serving
|
|
46
|
+
audio the old body produced.
|
|
47
|
+
|
|
48
|
+
``request.text`` is already whitespace-normalized by ``synthesize_steps``,
|
|
49
|
+
so re-wrapping a YAML block scalar is not a cache miss.
|
|
50
|
+
"""
|
|
51
|
+
payload = {
|
|
52
|
+
"provider": provider,
|
|
53
|
+
"version": version,
|
|
54
|
+
"text": request.text,
|
|
55
|
+
"locale": request.locale,
|
|
56
|
+
"voice": request.voice or "",
|
|
57
|
+
"model": request.model or "",
|
|
58
|
+
"speed": round(float(request.speed), 4),
|
|
59
|
+
"fmt": request.fmt or "",
|
|
60
|
+
"options": request.options or {},
|
|
61
|
+
}
|
|
62
|
+
blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)
|
|
63
|
+
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def atomic_replace(source: Path, dest: Path) -> Path:
|
|
67
|
+
"""Move *source* onto *dest* in one indivisible step.
|
|
68
|
+
|
|
69
|
+
``os.replace`` is atomic on NTFS and POSIX alike **as long as both paths sit
|
|
70
|
+
on the same filesystem** — which is why callers stage their temp file in
|
|
71
|
+
``dest.parent`` rather than the system temp dir.
|
|
72
|
+
"""
|
|
73
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
os.replace(str(source), str(dest))
|
|
75
|
+
return dest
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def atomic_write_bytes(dest: Path, data: bytes) -> Path:
|
|
79
|
+
return _atomic_write(dest, lambda handle: handle.write(data))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def atomic_write_text(dest: Path, text: str) -> Path:
|
|
83
|
+
return _atomic_write(dest, lambda handle: handle.write(text.encode("utf-8")))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _atomic_write(dest: Path, write) -> Path:
|
|
87
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
fd, name = tempfile.mkstemp(dir=str(dest.parent), prefix=f".{dest.name}.", suffix=".tmp")
|
|
89
|
+
try:
|
|
90
|
+
with os.fdopen(fd, "wb") as handle:
|
|
91
|
+
write(handle)
|
|
92
|
+
return atomic_replace(Path(name), dest)
|
|
93
|
+
except BaseException:
|
|
94
|
+
Path(name).unlink(missing_ok=True)
|
|
95
|
+
raise
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _touch(path: Path) -> None:
|
|
99
|
+
"""Mark an entry as used, so LRU eviction means what it says.
|
|
100
|
+
|
|
101
|
+
Without this a clip reused daily for a year looks exactly as stale as one
|
|
102
|
+
abandoned on day two. Best-effort: a read-only cache is still a usable one.
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
path.touch()
|
|
106
|
+
except OSError:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ClipCache:
|
|
111
|
+
def __init__(self, root: Path, enabled: bool = True) -> None:
|
|
112
|
+
self.root = Path(root)
|
|
113
|
+
self.enabled = enabled
|
|
114
|
+
|
|
115
|
+
def _paths(self, key: str, fmt: str) -> tuple[Path, Path]:
|
|
116
|
+
return self.root / f"{key}.{fmt}", self.root / f"{key}.json"
|
|
117
|
+
|
|
118
|
+
def get(self, key: str, fmt: str) -> Optional[CachedClip]:
|
|
119
|
+
if not self.enabled:
|
|
120
|
+
return None
|
|
121
|
+
audio, meta = self._paths(key, fmt)
|
|
122
|
+
if not audio.is_file():
|
|
123
|
+
return None
|
|
124
|
+
duration = None
|
|
125
|
+
if meta.is_file():
|
|
126
|
+
try:
|
|
127
|
+
duration = json.loads(meta.read_text(encoding="utf-8")).get("duration_ms")
|
|
128
|
+
except (ValueError, OSError):
|
|
129
|
+
# A torn sidecar just means we re-measure; the audio is still good.
|
|
130
|
+
duration = None
|
|
131
|
+
_touch(meta)
|
|
132
|
+
_touch(audio)
|
|
133
|
+
return CachedClip(path=audio, fmt=fmt, duration_ms=duration)
|
|
134
|
+
|
|
135
|
+
def put(self, key: str, clip: SpeechClip, duration_ms: Optional[int] = None) -> Optional[CachedClip]:
|
|
136
|
+
"""Store raw provider bytes. Returns None when the cache is disabled —
|
|
137
|
+
there is no path to hand back, because nothing was written."""
|
|
138
|
+
if not self.enabled:
|
|
139
|
+
return None
|
|
140
|
+
audio, _ = self._paths(key, clip.fmt)
|
|
141
|
+
atomic_write_bytes(audio, clip.audio)
|
|
142
|
+
self.set_duration(key, clip.fmt, duration_ms)
|
|
143
|
+
return CachedClip(path=audio, fmt=clip.fmt, duration_ms=duration_ms)
|
|
144
|
+
|
|
145
|
+
def commit(self, key: str, source: Path, fmt: str = "wav") -> Path:
|
|
146
|
+
"""Adopt an already-produced file as this key's cache entry.
|
|
147
|
+
|
|
148
|
+
The normalize step writes into the cache directory directly (so the
|
|
149
|
+
transcode is not paid twice), then hands the temp file here to be moved
|
|
150
|
+
into position — the move being the only moment the entry becomes
|
|
151
|
+
visible to a lookup.
|
|
152
|
+
"""
|
|
153
|
+
if not self.enabled:
|
|
154
|
+
return source
|
|
155
|
+
audio, _ = self._paths(key, fmt)
|
|
156
|
+
if source == audio:
|
|
157
|
+
return audio
|
|
158
|
+
return atomic_replace(source, audio)
|
|
159
|
+
|
|
160
|
+
def set_duration(self, key: str, fmt: str, duration_ms: Optional[int]) -> None:
|
|
161
|
+
if not self.enabled:
|
|
162
|
+
return
|
|
163
|
+
_, meta = self._paths(key, fmt)
|
|
164
|
+
atomic_write_text(
|
|
165
|
+
meta,
|
|
166
|
+
json.dumps({"duration_ms": duration_ms, "format": fmt}, ensure_ascii=False) + "\n",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def discard(self, key: str, fmt: str) -> None:
|
|
170
|
+
"""Drop a poisoned entry so the next lookup is an honest miss."""
|
|
171
|
+
for path in self._paths(key, fmt):
|
|
172
|
+
try:
|
|
173
|
+
path.unlink(missing_ok=True)
|
|
174
|
+
except OSError:
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
def staging_path(self, key: str, fmt: str) -> Path:
|
|
178
|
+
"""Where a producer should write before calling :meth:`commit`.
|
|
179
|
+
|
|
180
|
+
Same directory as the final entry, so ``os.replace`` stays atomic; and
|
|
181
|
+
pid-suffixed, so two concurrent builds sharing one cache stage into
|
|
182
|
+
different files and the loser's replace is merely redundant.
|
|
183
|
+
"""
|
|
184
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
185
|
+
return self.root / f".{key}.{fmt}.{os.getpid()}.tmp"
|
steppy/tts/presets.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Vendor TTS presets — pure data, no logic.
|
|
2
|
+
|
|
3
|
+
Every cloud text-to-speech API is the same shape: POST some JSON, get audio
|
|
4
|
+
back. So Steppy has one generic HTTP provider and this table of vendor shapes,
|
|
5
|
+
rather than a class per vendor. Adding ElevenLabs' next competitor is a dict
|
|
6
|
+
entry; adding one Steppy has never heard of needs no code at all, just
|
|
7
|
+
``video.tts.http`` filled in directly. That is what makes the promise —
|
|
8
|
+
"change the provider by changing config" — literally true.
|
|
9
|
+
|
|
10
|
+
Mirrors ``steppy/presets.py`` (the UI-kit selector packs) in both spirit and
|
|
11
|
+
mechanics: a data registry plus a resolver that lets explicitly-set fields win.
|
|
12
|
+
|
|
13
|
+
**Keep this module free of ``httpx``.** It sits on an import path that reaches
|
|
14
|
+
the default CLI path, and the negative-import guard enforces it.
|
|
15
|
+
|
|
16
|
+
Placeholders available in any string leaf of ``headers`` / ``json`` / ``body``:
|
|
17
|
+
|
|
18
|
+
{text} {voice} {locale} {model} {speed} {api_key} {region}
|
|
19
|
+
|
|
20
|
+
plus every key of ``video.tts.options``. Substitution happens on the parsed
|
|
21
|
+
structure, never on serialized JSON, so narration containing a brace is safe.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
# ``format`` is what the provider returns, not what the user asks for; the audio
|
|
29
|
+
# assembler transcodes to WAV as needed. ``defaults`` fills fields the user left
|
|
30
|
+
# unset — an explicit ``video.tts.model`` always wins.
|
|
31
|
+
TTS_PRESETS: dict[str, dict[str, Any]] = {
|
|
32
|
+
"elevenlabs": {
|
|
33
|
+
"url": "https://api.elevenlabs.io/v1/text-to-speech/{voice}",
|
|
34
|
+
"method": "POST",
|
|
35
|
+
"headers": {
|
|
36
|
+
"xi-api-key": "{api_key}",
|
|
37
|
+
"accept": "audio/mpeg",
|
|
38
|
+
"content-type": "application/json",
|
|
39
|
+
},
|
|
40
|
+
"json": {
|
|
41
|
+
"text": "{text}",
|
|
42
|
+
"model_id": "{model}",
|
|
43
|
+
},
|
|
44
|
+
"response": "binary",
|
|
45
|
+
"encoding": "raw",
|
|
46
|
+
"format": "mp3",
|
|
47
|
+
"defaults": {
|
|
48
|
+
# Multilingual by default: a per-locale guide is the normal case here.
|
|
49
|
+
"model": "eleven_multilingual_v2",
|
|
50
|
+
},
|
|
51
|
+
"requires": ["api_key", "voice"],
|
|
52
|
+
"docs": "https://elevenlabs.io/docs/api-reference/text-to-speech",
|
|
53
|
+
},
|
|
54
|
+
"openai": {
|
|
55
|
+
"url": "{base_url}/audio/speech",
|
|
56
|
+
"method": "POST",
|
|
57
|
+
"headers": {
|
|
58
|
+
"Authorization": "Bearer {api_key}",
|
|
59
|
+
"content-type": "application/json",
|
|
60
|
+
},
|
|
61
|
+
"json": {
|
|
62
|
+
"model": "{model}",
|
|
63
|
+
"input": "{text}",
|
|
64
|
+
"voice": "{voice}",
|
|
65
|
+
"speed": "{speed}",
|
|
66
|
+
# WAV keeps the assembler on the stdlib path (no transcode).
|
|
67
|
+
"response_format": "wav",
|
|
68
|
+
},
|
|
69
|
+
"response": "binary",
|
|
70
|
+
"encoding": "raw",
|
|
71
|
+
"format": "wav",
|
|
72
|
+
"defaults": {
|
|
73
|
+
"model": "gpt-4o-mini-tts",
|
|
74
|
+
"voice": "alloy",
|
|
75
|
+
"base_url": "https://api.openai.com/v1",
|
|
76
|
+
},
|
|
77
|
+
"requires": ["api_key"],
|
|
78
|
+
"docs": "https://platform.openai.com/docs/api-reference/audio/createSpeech",
|
|
79
|
+
},
|
|
80
|
+
"azure": {
|
|
81
|
+
"url": "https://{region}.tts.speech.microsoft.com/cognitiveservices/v1",
|
|
82
|
+
"method": "POST",
|
|
83
|
+
"headers": {
|
|
84
|
+
"Ocp-Apim-Subscription-Key": "{api_key}",
|
|
85
|
+
"Content-Type": "application/ssml+xml",
|
|
86
|
+
"X-Microsoft-OutputFormat": "riff-24khz-16bit-mono-pcm",
|
|
87
|
+
},
|
|
88
|
+
# Azure speaks SSML, not JSON — which is exactly why the engine supports
|
|
89
|
+
# a raw body as well as a JSON one.
|
|
90
|
+
"body": (
|
|
91
|
+
"<speak version='1.0' xml:lang='{locale}'>"
|
|
92
|
+
"<voice xml:lang='{locale}' name='{voice}'>"
|
|
93
|
+
"<prosody rate='{speed}'>{text}</prosody>"
|
|
94
|
+
"</voice></speak>"
|
|
95
|
+
),
|
|
96
|
+
"response": "binary",
|
|
97
|
+
"encoding": "raw",
|
|
98
|
+
"format": "wav",
|
|
99
|
+
"defaults": {},
|
|
100
|
+
"requires": ["api_key", "region", "voice"],
|
|
101
|
+
"docs": "https://learn.microsoft.com/azure/ai-services/speech-service/rest-text-to-speech",
|
|
102
|
+
},
|
|
103
|
+
"google": {
|
|
104
|
+
"url": "https://texttospeech.googleapis.com/v1/text:synthesize?key={api_key}",
|
|
105
|
+
"method": "POST",
|
|
106
|
+
"headers": {"content-type": "application/json"},
|
|
107
|
+
"json": {
|
|
108
|
+
"input": {"text": "{text}"},
|
|
109
|
+
"voice": {"languageCode": "{locale}", "name": "{voice}"},
|
|
110
|
+
"audioConfig": {"audioEncoding": "LINEAR16", "speakingRate": "{speed}"},
|
|
111
|
+
},
|
|
112
|
+
# Google wraps the audio in JSON as base64 rather than returning it raw.
|
|
113
|
+
"response": "json",
|
|
114
|
+
"audio_path": "audioContent",
|
|
115
|
+
"encoding": "base64",
|
|
116
|
+
"format": "wav",
|
|
117
|
+
"defaults": {},
|
|
118
|
+
"requires": ["api_key", "voice"],
|
|
119
|
+
"docs": "https://cloud.google.com/text-to-speech/docs/reference/rest/v1/text/synthesize",
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def available_presets() -> list[str]:
|
|
125
|
+
return sorted(TTS_PRESETS)
|