revision-engine 0.2.2__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.
- revision_engine/__init__.py +3 -0
- revision_engine/anchors.py +163 -0
- revision_engine/build.py +268 -0
- revision_engine/cli.py +159 -0
- revision_engine/config.py +159 -0
- revision_engine/diagrams.py +108 -0
- revision_engine/errors.py +18 -0
- revision_engine/highlight.py +62 -0
- revision_engine/intrinsics/__init__.py +34 -0
- revision_engine/intrinsics/arm.yaml +22 -0
- revision_engine/intrinsics/intel.yaml +42 -0
- revision_engine/intrinsics/riscv.yaml +28 -0
- revision_engine/lesson.py +64 -0
- revision_engine/markdown_ext.py +285 -0
- revision_engine/narration.py +276 -0
- revision_engine/render.py +119 -0
- revision_engine/serve.py +153 -0
- revision_engine/sources.py +61 -0
- revision_engine/static/app.js +292 -0
- revision_engine/static/styles.css +160 -0
- revision_engine/templates/lesson.html.j2 +89 -0
- revision_engine/templates/redirect.html.j2 +5 -0
- revision_engine-0.2.2.dist-info/METADATA +134 -0
- revision_engine-0.2.2.dist-info/RECORD +27 -0
- revision_engine-0.2.2.dist-info/WHEEL +4 -0
- revision_engine-0.2.2.dist-info/entry_points.txt +3 -0
- revision_engine-0.2.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Markdown rendering with the reVision extensions.
|
|
2
|
+
|
|
3
|
+
Extensions, all plain CommonMark syntax so other renderers degrade gracefully:
|
|
4
|
+
[phrase](@anchor) anchor phrase -> <span class="rv-anchor" data-anchor=...>
|
|
5
|
+
[name](!intrinsic) vendor link -> <a class="rv-intrinsic" href=...>
|
|
6
|
+
`name` (inline code) auto-linked when the name is in a vendor table, except inside another
|
|
7
|
+
link's text (Markdown or raw <a>), where it renders as plain <code>; an
|
|
8
|
+
anchor phrase is a <span>, so a span in one still links. Spans whose
|
|
9
|
+
name is in no table are recorded on RenderedLesson.unlinked_code
|
|
10
|
+
 inline pre-rendered SVG in a <figure class="rv-diagram">
|
|
11
|
+
```quiz ... ``` page-local knowledge check widget; code spans in its text render as
|
|
12
|
+
plain <code>, never vendor-linked; choices are shuffled in the browser
|
|
13
|
+
unless a question sets `shuffle: false` (data-fixed-order) or has
|
|
14
|
+
two choices; any key outside _QUESTION_KEYS (or _QUIZ_KEYS, at quiz
|
|
15
|
+
level) fails the build
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import html
|
|
21
|
+
import posixpath
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Callable
|
|
24
|
+
|
|
25
|
+
import yaml
|
|
26
|
+
from markdown_it import MarkdownIt
|
|
27
|
+
from markdown_it.common.utils import escapeHtml, isLinkClose, isLinkOpen
|
|
28
|
+
from markdown_it.rules_inline import backtick
|
|
29
|
+
from markdown_it.token import Token
|
|
30
|
+
|
|
31
|
+
from revision_engine.errors import BuildError, Report
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class RenderContext:
|
|
36
|
+
anchors: dict[str, str]
|
|
37
|
+
diagrams: dict[str, str]
|
|
38
|
+
lookup: Callable[[str], str | None]
|
|
39
|
+
report: Report
|
|
40
|
+
where: str
|
|
41
|
+
lesson_file: str
|
|
42
|
+
line_offset: int = 0 # lines preceding the body in lesson.md (front matter), for file-relative error lines
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class RenderedLesson:
|
|
47
|
+
html: str
|
|
48
|
+
used_anchors: list[str] = field(default_factory=list)
|
|
49
|
+
used_diagrams: list[str] = field(default_factory=list)
|
|
50
|
+
quiz_count: int = 0
|
|
51
|
+
unlinked_code: list[tuple[int, str]] = field(default_factory=list) # (file line, span text)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _vendor_attrs(url: str) -> dict[str, str]:
|
|
55
|
+
return {"class": "rv-intrinsic", "href": url, "target": "_blank", "rel": "noopener"}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _line(token: Token, fallback: int, ctx: RenderContext) -> int:
|
|
59
|
+
body_line = token.map[0] + 1 if token.map else fallback
|
|
60
|
+
return body_line + ctx.line_offset
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _link_text(children: list[Token], start: int) -> str:
|
|
64
|
+
parts: list[str] = []
|
|
65
|
+
for tok in children[start + 1 :]:
|
|
66
|
+
if tok.type == "link_close":
|
|
67
|
+
break
|
|
68
|
+
if tok.type in ("text", "code_inline"):
|
|
69
|
+
parts.append(tok.content)
|
|
70
|
+
return "".join(parts)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _close(children: list[Token], open_index: int) -> Token:
|
|
74
|
+
for tok in children[open_index + 1 :]:
|
|
75
|
+
if tok.type == "link_close":
|
|
76
|
+
return tok
|
|
77
|
+
raise AssertionError("link_open without link_close")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _rewrite_links(children: list[Token], line: int, ctx: RenderContext, out: RenderedLesson) -> None:
|
|
81
|
+
# A code span in another link's text must not render its own vendor <a> (no <a> inside an <a>):
|
|
82
|
+
# "code" renders it as plain <code>, "text" as its bare escaped text (the link itself became <code>).
|
|
83
|
+
# Markdown links don't nest. Raw inline <a ...> and </a> are separate html_inline tokens around the
|
|
84
|
+
# link text; an unclosed <a> runs to the end of the inline block (a browser would carry it further).
|
|
85
|
+
link_stamp: str | None = None
|
|
86
|
+
in_raw_a = False
|
|
87
|
+
for i, tok in enumerate(children):
|
|
88
|
+
if tok.type == "code_inline":
|
|
89
|
+
stamp = link_stamp or ("code" if in_raw_a else None)
|
|
90
|
+
if stamp:
|
|
91
|
+
tok.meta["rv_in_link"] = stamp
|
|
92
|
+
continue
|
|
93
|
+
if tok.type == "html_inline":
|
|
94
|
+
if isLinkOpen(tok.content):
|
|
95
|
+
in_raw_a = True
|
|
96
|
+
elif isLinkClose(tok.content):
|
|
97
|
+
in_raw_a = False
|
|
98
|
+
continue
|
|
99
|
+
if tok.type == "link_close":
|
|
100
|
+
link_stamp = None
|
|
101
|
+
continue
|
|
102
|
+
if tok.type != "link_open":
|
|
103
|
+
continue
|
|
104
|
+
link_stamp = "code"
|
|
105
|
+
href = tok.attrGet("href") or ""
|
|
106
|
+
if href.startswith("@"):
|
|
107
|
+
name = href[1:]
|
|
108
|
+
if name not in ctx.anchors:
|
|
109
|
+
raise BuildError(f"{ctx.where}:{line}: unknown anchor {name!r}")
|
|
110
|
+
if ctx.anchors[name] != ctx.lesson_file:
|
|
111
|
+
raise BuildError(
|
|
112
|
+
f"{ctx.where}:{line}: anchor {name!r} resolves in {ctx.anchors[name]!r} "
|
|
113
|
+
f"but this lesson shows {ctx.lesson_file!r}"
|
|
114
|
+
)
|
|
115
|
+
tok.tag = "span"
|
|
116
|
+
tok.attrs = {"class": "rv-anchor", "data-anchor": name, "tabindex": "0"}
|
|
117
|
+
if name not in out.used_anchors:
|
|
118
|
+
out.used_anchors.append(name)
|
|
119
|
+
_close(children, i).tag = "span"
|
|
120
|
+
link_stamp = None # an anchor phrase is a <span>, so a span in one still links
|
|
121
|
+
elif href == "!intrinsic":
|
|
122
|
+
name = _link_text(children, i)
|
|
123
|
+
url = ctx.lookup(name)
|
|
124
|
+
if url is None:
|
|
125
|
+
ctx.report.warn(f"{ctx.where}:{line}: intrinsic {name!r} is not in any vendor table")
|
|
126
|
+
tok.tag = "code"
|
|
127
|
+
tok.attrs = {}
|
|
128
|
+
_close(children, i).tag = "code"
|
|
129
|
+
link_stamp = "text"
|
|
130
|
+
else:
|
|
131
|
+
tok.attrs = _vendor_attrs(url)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# Quiz text accepts code spans only: raw HTML, emphasis, links, entities and backslash escapes show
|
|
135
|
+
# as written, and a code span is never a vendor link (an <a> inside a choice's <label> would take
|
|
136
|
+
# the click meant for the radio button).
|
|
137
|
+
_quiz_text = MarkdownIt("zero").enable("backticks").renderInline
|
|
138
|
+
|
|
139
|
+
# The keys _quiz_html reads; any other key fails the build, so a misspelled or misplaced
|
|
140
|
+
# `shuffle: false` cannot silently leave a question shuffled (#60).
|
|
141
|
+
_QUIZ_KEYS: tuple[str, ...] = ("questions",)
|
|
142
|
+
_QUESTION_KEYS: tuple[str, ...] = ("q", "choices", "answer", "shuffle")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _unknown_keys(mapping: dict, accepted: tuple[str, ...]) -> list:
|
|
146
|
+
# Source order; keys may be non-strings (YAML 1.1 loads `on:` as True).
|
|
147
|
+
return [k for k in mapping if k not in accepted]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _unknown_keys_message(what: str, keys: list, accepted: tuple[str, ...]) -> str:
|
|
151
|
+
noun = "unknown key" if len(keys) == 1 else "unknown keys"
|
|
152
|
+
return f"{what} has {noun} {', '.join(repr(k) for k in keys)} (accepted: {', '.join(accepted)})"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _quiz_html(source: str, line: int, ctx: RenderContext) -> str:
|
|
156
|
+
try:
|
|
157
|
+
data = yaml.safe_load(source) or {}
|
|
158
|
+
except yaml.YAMLError as e:
|
|
159
|
+
raise BuildError(f"{ctx.where}:{line}: quiz block is not valid YAML: {e}") from e
|
|
160
|
+
if isinstance(data, dict):
|
|
161
|
+
unknown = _unknown_keys(data, _QUIZ_KEYS)
|
|
162
|
+
if unknown:
|
|
163
|
+
# #57 adds `shuffle` to _QUIZ_KEYS, which drops this hint; #57 and #66 add their keys there.
|
|
164
|
+
hint = "; 'shuffle' goes on each question" if "shuffle" in unknown else ""
|
|
165
|
+
raise BuildError(f"{ctx.where}:{line}: {_unknown_keys_message('quiz block', unknown, _QUIZ_KEYS)}{hint}")
|
|
166
|
+
questions = data.get("questions") if isinstance(data, dict) else None
|
|
167
|
+
if not isinstance(questions, list) or not questions:
|
|
168
|
+
raise BuildError(f"{ctx.where}:{line}: quiz block needs a non-empty 'questions' list")
|
|
169
|
+
# Browsers otherwise restore checked answers (Firefox after a reload, all three engines after Back; #46); the quiz
|
|
170
|
+
# forgets by design. Fixed here, not by form.reset() in app.js: restore timing may vary by engine (unmeasured).
|
|
171
|
+
parts = ['<section class="rv-quiz">', "<h2>Knowledge check</h2>", '<form autocomplete="off">']
|
|
172
|
+
for qi, q in enumerate(questions):
|
|
173
|
+
n = qi + 1
|
|
174
|
+
if isinstance(q, dict):
|
|
175
|
+
unknown = _unknown_keys(q, _QUESTION_KEYS)
|
|
176
|
+
if unknown:
|
|
177
|
+
raise BuildError(f"{ctx.where}:{line}: {_unknown_keys_message(f'quiz question {n}', unknown, _QUESTION_KEYS)}")
|
|
178
|
+
if not isinstance(q, dict) or not isinstance(q.get("q"), str):
|
|
179
|
+
raise BuildError(f"{ctx.where}:{line}: quiz question {n} needs a 'q' string")
|
|
180
|
+
choices = q.get("choices")
|
|
181
|
+
if not isinstance(choices, list) or len(choices) < 2:
|
|
182
|
+
raise BuildError(f"{ctx.where}:{line}: quiz question {n} needs at least two choices")
|
|
183
|
+
answer = q.get("answer")
|
|
184
|
+
if not isinstance(answer, int) or isinstance(answer, bool) or not 0 <= answer < len(choices):
|
|
185
|
+
raise BuildError(f"{ctx.where}:{line}: quiz question {n} answer must be an index into choices")
|
|
186
|
+
shuffle = q.get("shuffle", True)
|
|
187
|
+
if not isinstance(shuffle, bool):
|
|
188
|
+
raise BuildError(f"{ctx.where}:{line}: quiz question {n} shuffle must be true or false")
|
|
189
|
+
# `< 3` mirrors app.js (`labels.length < 3`); test_quiz_two_choice_threshold_matches_app_js pins it.
|
|
190
|
+
if q.get("shuffle") is True and len(choices) < 3:
|
|
191
|
+
ctx.report.warn(
|
|
192
|
+
f"{ctx.where}:{line}: quiz question {n} has two choices, which always keep their order; "
|
|
193
|
+
"shuffle: true has no effect"
|
|
194
|
+
)
|
|
195
|
+
fixed = "" if shuffle else " data-fixed-order"
|
|
196
|
+
parts.append(f'<fieldset class="rv-q" data-answer="{answer}"{fixed}><legend>{n}. {_quiz_text(q["q"])}</legend>')
|
|
197
|
+
for ci, choice in enumerate(choices):
|
|
198
|
+
parts.append(f'<label><input type="radio" name="q{qi}" value="{ci}"> {_quiz_text(str(choice))}</label>')
|
|
199
|
+
parts.append('<p class="rv-q-feedback" hidden></p></fieldset>')
|
|
200
|
+
parts += [
|
|
201
|
+
'<button type="submit">Check answers</button>',
|
|
202
|
+
'<p class="rv-quiz-score" hidden></p>',
|
|
203
|
+
"</form>",
|
|
204
|
+
"</section>",
|
|
205
|
+
]
|
|
206
|
+
return "\n".join(parts) + "\n"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def render_markdown(body: str, ctx: RenderContext) -> RenderedLesson:
|
|
210
|
+
out = RenderedLesson(html="")
|
|
211
|
+
md = MarkdownIt("commonmark").enable("table")
|
|
212
|
+
|
|
213
|
+
# Stamp each code span with its line within the inline block, so the unlinked listing can
|
|
214
|
+
# report the span's own line (a span can sit several lines into a paragraph, or wrap).
|
|
215
|
+
def backticks(state, silent) -> bool:
|
|
216
|
+
pos, count = state.pos, len(state.tokens)
|
|
217
|
+
ok = backtick(state, silent)
|
|
218
|
+
if len(state.tokens) > count: # only a matched span pushes; push flushes pending text first
|
|
219
|
+
state.tokens[-1].meta["rv_line"] = state.src.count("\n", 0, pos)
|
|
220
|
+
return ok
|
|
221
|
+
|
|
222
|
+
md.inline.ruler.at("backticks", backticks)
|
|
223
|
+
|
|
224
|
+
def core_rewrite(state) -> None:
|
|
225
|
+
for block in state.tokens:
|
|
226
|
+
if block.type == "inline" and block.children:
|
|
227
|
+
line = _line(block, 1, ctx)
|
|
228
|
+
_rewrite_links(block.children, line, ctx, out)
|
|
229
|
+
# Top level only: alt-text tokens nest in image.children and render as plain text.
|
|
230
|
+
for child in block.children:
|
|
231
|
+
if child.type == "image":
|
|
232
|
+
child.map = block.map
|
|
233
|
+
elif child.type == "code_inline" and ctx.lookup(child.content) is None:
|
|
234
|
+
out.unlinked_code.append((line + child.meta.get("rv_line", 0), child.content))
|
|
235
|
+
|
|
236
|
+
md.core.ruler.push("rv_links", core_rewrite)
|
|
237
|
+
|
|
238
|
+
# add_render_rule binds each function as a method of the renderer, hence `self` first.
|
|
239
|
+
default_code_inline = md.renderer.rules.get("code_inline")
|
|
240
|
+
default_fence = md.renderer.rules.get("fence")
|
|
241
|
+
default_image = md.renderer.rules.get("image")
|
|
242
|
+
|
|
243
|
+
def code_inline(self, tokens, idx, options, env):
|
|
244
|
+
content = tokens[idx].content
|
|
245
|
+
in_link = tokens[idx].meta.get("rv_in_link")
|
|
246
|
+
if in_link == "text":
|
|
247
|
+
return escapeHtml(content)
|
|
248
|
+
code = default_code_inline(tokens, idx, options, env)
|
|
249
|
+
url = None if in_link else ctx.lookup(content)
|
|
250
|
+
if url is None:
|
|
251
|
+
return code
|
|
252
|
+
attrs = "".join(f' {k}="{escapeHtml(v)}"' for k, v in _vendor_attrs(url).items())
|
|
253
|
+
return f"<a{attrs}>{code}</a>"
|
|
254
|
+
|
|
255
|
+
def fence(self, tokens, idx, options, env):
|
|
256
|
+
tok = tokens[idx]
|
|
257
|
+
if tok.info.strip() == "quiz":
|
|
258
|
+
out.quiz_count += 1
|
|
259
|
+
return _quiz_html(tok.content, _line(tok, 1, ctx), ctx)
|
|
260
|
+
return default_fence(tokens, idx, options, env)
|
|
261
|
+
|
|
262
|
+
def image(self, tokens, idx, options, env):
|
|
263
|
+
tok = tokens[idx]
|
|
264
|
+
src = tok.attrGet("src") or ""
|
|
265
|
+
if src.endswith(".dot"):
|
|
266
|
+
stem = posixpath.splitext(posixpath.basename(src))[0]
|
|
267
|
+
line = _line(tok, 1, ctx)
|
|
268
|
+
if stem not in ctx.diagrams:
|
|
269
|
+
raise BuildError(f"{ctx.where}:{line}: diagram {stem!r} ({src}) not found")
|
|
270
|
+
if stem not in out.used_diagrams:
|
|
271
|
+
out.used_diagrams.append(stem)
|
|
272
|
+
alt = html.escape(tok.content)
|
|
273
|
+
return (
|
|
274
|
+
f'<figure class="rv-diagram" id="diagram-{stem}" data-diagram="{stem}">'
|
|
275
|
+
f"{ctx.diagrams[stem]}<figcaption>{alt}</figcaption></figure>"
|
|
276
|
+
)
|
|
277
|
+
return default_image(tokens, idx, options, env)
|
|
278
|
+
|
|
279
|
+
md.add_render_rule("code_inline", code_inline)
|
|
280
|
+
md.add_render_rule("fence", fence)
|
|
281
|
+
md.add_render_rule("image", image)
|
|
282
|
+
|
|
283
|
+
tokens = md.parse(body)
|
|
284
|
+
out.html = md.renderer.render(tokens, md.options, {})
|
|
285
|
+
return out
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Narration: authored segments -> synthesized audio + derived cue timings.
|
|
2
|
+
|
|
3
|
+
Authors write words, never timestamps. `narrate` derives start/end per segment from
|
|
4
|
+
the synthesized audio and records a hash of the words so `build` can detect drift.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import difflib
|
|
10
|
+
import hashlib
|
|
11
|
+
import io
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import wave
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Protocol
|
|
21
|
+
|
|
22
|
+
import yaml
|
|
23
|
+
|
|
24
|
+
from revision_engine.config import DEFAULT_VOICE, _load_yaml
|
|
25
|
+
from revision_engine.errors import BuildError
|
|
26
|
+
from revision_engine.lesson import load_lesson
|
|
27
|
+
|
|
28
|
+
DEFAULT_PAUSE = 0.4
|
|
29
|
+
# A bare voice name: <voice>.onnx directly in the voices folder. Use only via VOICE_NAME.fullmatch.
|
|
30
|
+
VOICE_NAME = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.-]*")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Segment:
|
|
35
|
+
text: str
|
|
36
|
+
show: tuple[str, ...]
|
|
37
|
+
diagram: str | None
|
|
38
|
+
node: str | None
|
|
39
|
+
pause: float
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Narration:
|
|
44
|
+
voice: str | None
|
|
45
|
+
segments: tuple[Segment, ...]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_narration(path: Path, where: str) -> Narration:
|
|
49
|
+
try:
|
|
50
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
51
|
+
except yaml.YAMLError as e:
|
|
52
|
+
raise BuildError(f"{where}: invalid YAML: {e}") from e
|
|
53
|
+
if not isinstance(data, dict):
|
|
54
|
+
raise BuildError(f"{where}: top level must be a mapping")
|
|
55
|
+
raw = data.get("segments")
|
|
56
|
+
if not isinstance(raw, list) or not raw:
|
|
57
|
+
raise BuildError(f"{where}: 'segments' must be a non-empty list")
|
|
58
|
+
voice = data.get("voice")
|
|
59
|
+
if voice is not None and not isinstance(voice, str):
|
|
60
|
+
raise BuildError(f"{where}: 'voice' must be a string")
|
|
61
|
+
segments: list[Segment] = []
|
|
62
|
+
for i, s in enumerate(raw, start=1):
|
|
63
|
+
if not isinstance(s, dict) or not isinstance(s.get("text"), str) or not s["text"].strip():
|
|
64
|
+
raise BuildError(f"{where}: segment {i} needs a 'text' string")
|
|
65
|
+
show = s.get("show", [])
|
|
66
|
+
if isinstance(show, str):
|
|
67
|
+
show = [show]
|
|
68
|
+
if not isinstance(show, list) or not all(isinstance(x, str) for x in show):
|
|
69
|
+
raise BuildError(f"{where}: segment {i}: 'show' must be an anchor name or a list of names")
|
|
70
|
+
for key in ("diagram", "node"):
|
|
71
|
+
if key in s and not isinstance(s[key], str):
|
|
72
|
+
raise BuildError(f"{where}: segment {i}: '{key}' must be a string")
|
|
73
|
+
pause = s.get("pause", DEFAULT_PAUSE)
|
|
74
|
+
if isinstance(pause, bool) or not isinstance(pause, (int, float)) or pause < 0:
|
|
75
|
+
raise BuildError(f"{where}: segment {i}: 'pause' must be a non-negative number of seconds")
|
|
76
|
+
segments.append(
|
|
77
|
+
Segment(
|
|
78
|
+
text=" ".join(s["text"].split()),
|
|
79
|
+
show=tuple(show),
|
|
80
|
+
diagram=s.get("diagram"),
|
|
81
|
+
node=s.get("node"),
|
|
82
|
+
pause=float(pause),
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
return Narration(voice=voice, segments=tuple(segments))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def text_hash(n: Narration) -> str:
|
|
89
|
+
payload = json.dumps([[s.text, s.pause] for s in n.segments], ensure_ascii=False, separators=(",", ":"))
|
|
90
|
+
return "sha256:" + hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Synthesizer(Protocol):
|
|
94
|
+
def synthesize(self, text: str) -> tuple[int, bytes]:
|
|
95
|
+
"""Return (sample_rate, 16-bit mono little-endian PCM) for one segment."""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class SilentSynthesizer:
|
|
99
|
+
"""Deterministic stand-in: silence whose length grows with the text. For tests and fixtures."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, rate: int = 8000, seconds_per_char: float = 0.05) -> None:
|
|
102
|
+
self.rate = rate
|
|
103
|
+
self.seconds_per_char = seconds_per_char
|
|
104
|
+
|
|
105
|
+
def synthesize(self, text: str) -> tuple[int, bytes]:
|
|
106
|
+
frames = int(round(len(text) * self.seconds_per_char * self.rate))
|
|
107
|
+
return self.rate, b"\x00\x00" * frames
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class PiperSynthesizer:
|
|
111
|
+
"""Runs the `piper` command with a voice model file; text on stdin, raw PCM on stdout."""
|
|
112
|
+
|
|
113
|
+
def __init__(self, model: Path) -> None:
|
|
114
|
+
if shutil.which("piper") is None:
|
|
115
|
+
raise BuildError("piper is not installed or not on PATH (pip install piper-tts)")
|
|
116
|
+
if not model.is_file():
|
|
117
|
+
raise BuildError(f"piper voice model not found: {model}")
|
|
118
|
+
self.model = model
|
|
119
|
+
self.rate = _piper_sample_rate(model)
|
|
120
|
+
|
|
121
|
+
def synthesize(self, text: str) -> tuple[int, bytes]:
|
|
122
|
+
proc = subprocess.run(
|
|
123
|
+
["piper", "--model", str(self.model), "--output-raw"],
|
|
124
|
+
input=text.encode("utf-8"),
|
|
125
|
+
capture_output=True,
|
|
126
|
+
check=False,
|
|
127
|
+
)
|
|
128
|
+
if proc.returncode != 0:
|
|
129
|
+
raise BuildError(f"piper failed: {proc.stderr.decode('utf-8', 'replace').strip()}")
|
|
130
|
+
return self.rate, proc.stdout
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _piper_sample_rate(model: Path) -> int:
|
|
134
|
+
cfg = model.with_suffix(model.suffix + ".json")
|
|
135
|
+
if not cfg.is_file():
|
|
136
|
+
raise BuildError(f"piper voice config not found: {cfg}")
|
|
137
|
+
data = json.loads(cfg.read_text(encoding="utf-8"))
|
|
138
|
+
return int(data["audio"]["sample_rate"])
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _write_wav(path: Path, rate: int, pcm: bytes) -> None:
|
|
142
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
buf = io.BytesIO()
|
|
144
|
+
with wave.open(buf, "wb") as w:
|
|
145
|
+
w.setnchannels(1)
|
|
146
|
+
w.setsampwidth(2)
|
|
147
|
+
w.setframerate(rate)
|
|
148
|
+
w.writeframes(pcm)
|
|
149
|
+
path.write_bytes(buf.getvalue())
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def narrate(lesson_dir: Path, synth: Synthesizer, voice: str, media_name: str = "narration.wav") -> dict:
|
|
153
|
+
where = f"{lesson_dir.name}/narration.yaml"
|
|
154
|
+
n = load_narration(lesson_dir / "narration.yaml", where)
|
|
155
|
+
rate: int | None = None
|
|
156
|
+
pcm = bytearray()
|
|
157
|
+
cues_segments: list[dict[str, Any]] = []
|
|
158
|
+
for seg in n.segments:
|
|
159
|
+
seg_rate, audio = synth.synthesize(seg.text)
|
|
160
|
+
if rate is None:
|
|
161
|
+
rate = seg_rate
|
|
162
|
+
elif seg_rate != rate:
|
|
163
|
+
raise BuildError(f"{where}: synthesizer changed sample rate mid-lesson ({rate} -> {seg_rate})")
|
|
164
|
+
pcm += b"\x00\x00" * int(round(seg.pause * rate))
|
|
165
|
+
start = len(pcm) / 2 / rate
|
|
166
|
+
pcm += audio
|
|
167
|
+
end = len(pcm) / 2 / rate
|
|
168
|
+
entry: dict[str, Any] = {"start": round(start, 3), "end": round(end, 3), "text": seg.text}
|
|
169
|
+
if seg.show:
|
|
170
|
+
entry["show"] = list(seg.show)
|
|
171
|
+
if seg.diagram:
|
|
172
|
+
entry["diagram"] = seg.diagram
|
|
173
|
+
if seg.node:
|
|
174
|
+
entry["node"] = seg.node
|
|
175
|
+
cues_segments.append(entry)
|
|
176
|
+
assert rate is not None
|
|
177
|
+
_write_wav(lesson_dir / "media" / media_name, rate, bytes(pcm))
|
|
178
|
+
cues = {
|
|
179
|
+
"media": f"media/{media_name}",
|
|
180
|
+
"voice": voice,
|
|
181
|
+
"text_hash": text_hash(n),
|
|
182
|
+
"segments": cues_segments,
|
|
183
|
+
}
|
|
184
|
+
(lesson_dir / "cues.json").write_text(
|
|
185
|
+
json.dumps(cues, indent=2, sort_keys=True) + "\n", encoding="utf-8", newline="\n"
|
|
186
|
+
)
|
|
187
|
+
return cues
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def load_cues(path: Path, where: str) -> dict:
|
|
191
|
+
try:
|
|
192
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
193
|
+
except json.JSONDecodeError as e:
|
|
194
|
+
raise BuildError(f"{where}: invalid JSON: {e}") from e
|
|
195
|
+
if not isinstance(data, dict):
|
|
196
|
+
raise BuildError(f"{where}: top level must be an object")
|
|
197
|
+
for key in ("media", "text_hash"):
|
|
198
|
+
if not isinstance(data.get(key), str):
|
|
199
|
+
raise BuildError(f"{where}: '{key}' must be a string")
|
|
200
|
+
if not isinstance(data.get("segments"), list):
|
|
201
|
+
raise BuildError(f"{where}: 'segments' must be a list")
|
|
202
|
+
for i, s in enumerate(data["segments"], start=1):
|
|
203
|
+
if not isinstance(s, dict) or not all(isinstance(s.get(k), (int, float)) for k in ("start", "end")):
|
|
204
|
+
raise BuildError(f"{where}: segment {i} needs numeric 'start' and 'end'")
|
|
205
|
+
if not isinstance(s.get("text"), str):
|
|
206
|
+
raise BuildError(f"{where}: segment {i} needs a 'text' string")
|
|
207
|
+
return data
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def check_cues(n: Narration, cues: dict, where: str) -> None:
|
|
211
|
+
if cues.get("text_hash") == text_hash(n):
|
|
212
|
+
return
|
|
213
|
+
old = [s["text"] for s in cues.get("segments", [])]
|
|
214
|
+
new = [s.text for s in n.segments]
|
|
215
|
+
diff = "\n".join(difflib.unified_diff(old, new, "cues.json", "narration.yaml", lineterm="", n=1))
|
|
216
|
+
raise BuildError(
|
|
217
|
+
f"{where}: narration.yaml changed since cues.json was generated; "
|
|
218
|
+
f"rerun 'rv2 narrate' for this lesson.\n{diff}"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _project_voice(lesson_dir: Path) -> tuple[str, Path] | None:
|
|
223
|
+
"""The ``narration.voice`` of the first revision.yaml found walking up from the lesson,
|
|
224
|
+
with the path of that revision.yaml."""
|
|
225
|
+
resolved = lesson_dir.resolve()
|
|
226
|
+
for parent in [resolved, *resolved.parents]:
|
|
227
|
+
cfg = parent / "revision.yaml"
|
|
228
|
+
if cfg.is_file():
|
|
229
|
+
data = _load_yaml(cfg)
|
|
230
|
+
narration = data.get("narration") if isinstance(data, dict) else None
|
|
231
|
+
voice = narration.get("voice") if isinstance(narration, dict) else None
|
|
232
|
+
return (voice, cfg) if isinstance(voice, str) else None
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def resolve_voice_model(lesson_dir: Path, cli_voice: str | None, cli_model: Path | None) -> tuple[str, Path]:
|
|
237
|
+
"""Pick the voice name and Piper model for ``rv2 narrate``.
|
|
238
|
+
|
|
239
|
+
Name: --voice, then narration.yaml's ``voice``, then the lesson's front matter, then the
|
|
240
|
+
project's narration.voice, then the default. Model: --model if given, else ``<voice>.onnx``
|
|
241
|
+
in $RV2_VOICES or ~/piper-voices. The returned name is what cues.json records.
|
|
242
|
+
|
|
243
|
+
The name must be a bare voice name — ASCII letters, digits, ``_``, ``-`` and ``.``, not
|
|
244
|
+
starting with ``-`` or ``.`` (VOICE_NAME); anything else is refused before it is joined into
|
|
245
|
+
a path.
|
|
246
|
+
"""
|
|
247
|
+
if cli_model is not None:
|
|
248
|
+
if not cli_model.is_file():
|
|
249
|
+
raise BuildError(f"piper voice model not found: {cli_model}")
|
|
250
|
+
return cli_voice or cli_model.name.removesuffix(".onnx"), cli_model
|
|
251
|
+
voice, where = cli_voice, "--voice"
|
|
252
|
+
if voice is None and (lesson_dir / "narration.yaml").is_file():
|
|
253
|
+
where = f"{lesson_dir.name}/narration.yaml"
|
|
254
|
+
voice = load_narration(lesson_dir / "narration.yaml", where).voice
|
|
255
|
+
if voice is None and (lesson_dir / "lesson.md").is_file():
|
|
256
|
+
where = f"{lesson_dir.name}/lesson.md"
|
|
257
|
+
voice = load_lesson(lesson_dir / "lesson.md", where).voice
|
|
258
|
+
if voice is None and (project := _project_voice(lesson_dir)) is not None:
|
|
259
|
+
voice, cfg = project
|
|
260
|
+
where = f"{cfg} (narration.voice)"
|
|
261
|
+
if voice is None:
|
|
262
|
+
voice, where = DEFAULT_VOICE, "default voice"
|
|
263
|
+
if not VOICE_NAME.fullmatch(voice):
|
|
264
|
+
raise BuildError(
|
|
265
|
+
f"{where}: voice {voice!r} is not a voice name: use only ASCII letters, digits, '_', '-' "
|
|
266
|
+
"and '.', not starting with '-' or '.'. Put <voice>.onnx directly in $RV2_VOICES (or "
|
|
267
|
+
"~/piper-voices), or pass --model to use a model file elsewhere"
|
|
268
|
+
)
|
|
269
|
+
voices_dir = Path(os.environ.get("RV2_VOICES") or Path.home() / "piper-voices")
|
|
270
|
+
model = voices_dir / f"{voice}.onnx"
|
|
271
|
+
if not model.is_file():
|
|
272
|
+
raise BuildError(
|
|
273
|
+
f"voice {voice!r}: no model at {model}. Download <voice>.onnx and .onnx.json from "
|
|
274
|
+
"https://huggingface.co/rhasspy/piper-voices into that folder, or pass --model."
|
|
275
|
+
)
|
|
276
|
+
return voice, model
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Turn per-lesson build products into HTML pages, hashed assets, and site.json."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from importlib import resources
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from jinja2 import Environment, PackageLoader, select_autoescape
|
|
13
|
+
|
|
14
|
+
from revision_engine.config import Curriculum, ProjectConfig
|
|
15
|
+
|
|
16
|
+
Assets = dict[str, str]
|
|
17
|
+
|
|
18
|
+
_env = Environment(
|
|
19
|
+
loader=PackageLoader("revision_engine", "templates"),
|
|
20
|
+
autoescape=select_autoescape(["html", "j2"]),
|
|
21
|
+
keep_trailing_newline=True,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class NavLesson:
|
|
27
|
+
slug: str
|
|
28
|
+
title: str
|
|
29
|
+
href: str
|
|
30
|
+
current: bool
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class NavChapter:
|
|
35
|
+
slug: str
|
|
36
|
+
title: str
|
|
37
|
+
lessons: tuple[NavLesson, ...]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Variant:
|
|
42
|
+
name: str
|
|
43
|
+
label: str
|
|
44
|
+
start: int
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class PageModel:
|
|
49
|
+
site_title: str
|
|
50
|
+
root: str
|
|
51
|
+
assets: Assets
|
|
52
|
+
chapter_title: str
|
|
53
|
+
lesson_title: str
|
|
54
|
+
summary: str
|
|
55
|
+
nav: tuple[NavChapter, ...]
|
|
56
|
+
prev_href: str | None
|
|
57
|
+
next_href: str | None
|
|
58
|
+
file_path: str
|
|
59
|
+
commit_short: str
|
|
60
|
+
file_url: str
|
|
61
|
+
code_html: str
|
|
62
|
+
variants: tuple[Variant, ...]
|
|
63
|
+
lesson_html: str
|
|
64
|
+
media_url: str | None
|
|
65
|
+
media_kind: str | None
|
|
66
|
+
cues: list[dict[str, Any]] | None
|
|
67
|
+
rv_data: dict[str, Any]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _hashed_name(name: str, content: bytes) -> str:
|
|
71
|
+
stem, ext = name.rsplit(".", 1)
|
|
72
|
+
return f"{stem}.{hashlib.sha256(content).hexdigest()[:8]}.{ext}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def write_assets(out: Path, token_css: str) -> Assets:
|
|
76
|
+
assets_dir = out / "assets"
|
|
77
|
+
assets_dir.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
static = resources.files("revision_engine").joinpath("static")
|
|
79
|
+
files = {
|
|
80
|
+
"styles.css": static.joinpath("styles.css").read_bytes(),
|
|
81
|
+
"app.js": static.joinpath("app.js").read_bytes(),
|
|
82
|
+
"tokens.css": token_css.encode("utf-8"),
|
|
83
|
+
}
|
|
84
|
+
result: Assets = {}
|
|
85
|
+
for name, content in files.items():
|
|
86
|
+
hashed = _hashed_name(name, content)
|
|
87
|
+
(assets_dir / hashed).write_bytes(content)
|
|
88
|
+
result[name] = f"assets/{hashed}"
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def json_for_script(data: Any) -> str:
|
|
93
|
+
"""JSON safe to embed inside <script type="application/json">."""
|
|
94
|
+
return json.dumps(data, sort_keys=True, separators=(",", ":")).replace("</", "<\\/")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def render_page(model: PageModel) -> str:
|
|
98
|
+
template = _env.get_template("lesson.html.j2")
|
|
99
|
+
return template.render(m=model, rv_data_json=json_for_script(model.rv_data))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def render_redirect(target_href: str, title: str) -> str:
|
|
103
|
+
return _env.get_template("redirect.html.j2").render(target=target_href, title=title)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def site_index(cfg: ProjectConfig, curriculum: Curriculum) -> dict:
|
|
107
|
+
return {
|
|
108
|
+
"title": cfg.site.title,
|
|
109
|
+
"chapters": [
|
|
110
|
+
{
|
|
111
|
+
"slug": ch.slug,
|
|
112
|
+
"title": ch.title,
|
|
113
|
+
"lessons": [
|
|
114
|
+
{"slug": le.slug, "title": le.title, "href": f"{ch.slug}/{le.slug}/"} for le in ch.lessons
|
|
115
|
+
],
|
|
116
|
+
}
|
|
117
|
+
for ch in curriculum.chapters
|
|
118
|
+
],
|
|
119
|
+
}
|