japanese-learning-avatar / docs /VOICEVOX-SETUP.md
WolfDavid's picture
docs(01-04): record the measured cold-start cost of the lazy runtime fetch
84bdf64
|
Raw
History Blame
24.3 kB
# VOICEVOX setup (VOIC-01)
Everything in this document was **executed**, not inferred. `01-RESEARCH.md`'s code sample was
marked `[A]`; every name below was confirmed either against the release's own materials or by
running it against an installed `voicevox_core` 0.17.0 on Python 3.12.12.
Reconnaissance date: **2026-08-27**.
---
## Confirmed API surface — voicevox_core 0.17.0
Import root: `from voicevox_core.blocking import Onnxruntime, OpenJtalk, Synthesizer, VoiceModelFile`
(`voicevox_core.asyncio` mirrors it; we use `blocking` because synthesis is CPU-bound and
`Synthesizer` is internally mutex-guarded anyway).
| Call | Confirmed signature | Source |
|---|---|---|
| `Onnxruntime.load_once(filename=...)` | `load_once(*, filename: str = LIB_RECOMMENDED_VERSIONED_FILENAME) -> Onnxruntime` | [`docs/guide/user/usage.md` @ tag 0.17.0](https://github.com/VOICEVOX/voicevox_core/blob/0.17.0/docs/guide/user/usage.md) + `dir(Onnxruntime)` on the installed wheel |
| `OpenJtalk(dict_dir)` | `OpenJtalk(open_jtalk_dict_dir: str \| PathLike)` | same `usage.md`; executed |
| `Synthesizer(onnxruntime, open_jtalk)` | `Synthesizer(Onnxruntime, OpenJtalk, *, acceleration_mode=..., cpu_num_threads=...)` | same `usage.md`; executed |
| `VoiceModelFile.open(path)` | classmethod, returns a context manager; `.id`, `.metas` | same `usage.md`; executed |
| `synthesizer.load_voice_model(model)` | `load_voice_model(model, *, on_existing=...)` — `on_existing` is new in 0.17.0 | [release 0.17.0 body](https://github.com/VOICEVOX/voicevox_core/releases/tag/0.17.0); executed |
| `synthesizer.create_audio_query(text, style_id)` | `-> voicevox_core.AudioQuery` | same `usage.md`; executed |
| `synthesizer.synthesis(audio_query, style_id)` | `-> bytes` (RIFF WAV) | same `usage.md`; executed |
| `Onnxruntime.LIB_RECOMMENDED_VERSION` | `"1.23.2"` | executed against the installed wheel |
| `Onnxruntime.LIB_RECOMMENDED_NAME` | `"voicevox_onnxruntime"` | executed |
| `Onnxruntime.LIB_RECOMMENDED_VERSIONED_FILENAME` | `"voicevox_onnxruntime.dll"` on Windows; platform-dependent | executed |
`AudioQuery`, `AccentPhrase` and `Mora` are **real Python `dataclasses`** (`dataclasses.is_dataclass`
returns `True` for all three), which is what makes the plain-dict conversion in `tts.py` exact
rather than hand-transcribed.
| Type | `dataclasses.fields()` | Source |
|---|---|---|
| `AudioQuery` | `accent_phrases, speed_scale, pitch_scale, intonation_scale, volume_scale, pre_phoneme_length, post_phoneme_length, output_sampling_rate, output_stereo, kana` | executed |
| `AccentPhrase` | `moras, accent, pause_mora, is_interrogative` | executed |
| `Mora` | `text, vowel, vowel_length, pitch, consonant, consonant_length` | executed |
---
## Corrections to 01-RESEARCH.md
Five differences. All five matter to a later plan.
**1. `voicevox_vvm` is on 0.17.0, not 0.16.4 — and the version-track mismatch does not exist.**
RESEARCH warned that "core is on 0.17.0 but the vvm repo's latest release is 0.16.4 (2026-04-30)"
and told this plan to confirm the pairing. There is in fact a
[`voicevox_vvm` 0.17.0 release](https://github.com/VOICEVOX/voicevox_vvm/releases/tag/0.17.0)
published **2026-08-12**, one day *before* core 0.17.0. The core 0.17.0 release notes link to it
directly. We use the matched pair. See "Version pairing" below for why this is not optional.
**2. `AudioQuery` has no `.json()` method.** The plan proposed `json.loads(query.json())` to reach
a plain dict. That attribute does not exist in 0.17.0 (`hasattr(q, "json")` is `False`; so is
`to_json`). The supported conversion is `dataclasses.asdict(query)`, which returns snake_case keys.
`tts.py` therefore converts to the VOICEVOX **ENGINE** JSON schema explicitly — see "AudioQuery
serialisation" below.
**3. `AudioQuery` has no `pauseLength` / `pauseLengthScale`.** RESEARCH's `<interfaces>` schema and
its timing-pipeline step 3 both reference them. They are **not** fields of `voicevox_core`
0.17.0's `AudioQuery` (they exist in the separate VOICEVOX *ENGINE* HTTP product, not in CORE).
**Consequence for plan 01-06:** pipeline step 3 (`pauseLength` override, then `pauseLengthScale`
multiply) is a **no-op on this stack**. Do not implement it against a key that will never be
present; if it is implemented defensively, it must tolerate the key being absent.
**4. `SpeakerMeta` is `CharacterMeta`.** `usage.md`'s pasted `pprint` output still shows
`SpeakerMeta(...)`, but 0.17.0 exports `CharacterMeta`. The `.speaker_uuid` *attribute* is
unchanged. Cosmetic, but it will bite anyone who imports the name.
**5. The Open JTalk dictionary's copyright holder is Nara Institute of Science and Technology,
not Nagoya Institute of Technology.** See "Open JTalk dictionary licence" below — the two
institutions are genuinely both involved and RESEARCH conflated them.
Everything else RESEARCH inferred was correct, including the whole `Onnxruntime` → `OpenJtalk` →
`Synthesizer` → `VoiceModelFile` construction order and the exact Linux wheel filename.
**6. `speedScale` is applied to the frame count, not to the phoneme length.** This is the most
consequential correction in this document and it has its own section below.
---
## Frame quantisation — read this before writing `visemes.py`
**Plan 01-06 depends on this section.** 01-RESEARCH.md's `build_timeline` gets the order wrong,
and the error is invisible at normal speed, which is exactly what makes it dangerous.
RESEARCH's pipeline divides each phoneme length by `speedScale` and *then* quantises:
```python
frames = round(length / speed_scale * 93.75) # WRONG
```
VOICEVOX CORE 0.17.0 actually quantises **first**, at speed 1.0, and then divides the resulting
**frame count** and rounds again:
```python
frames = round(round(length * 93.75) / speed_scale) # CORRECT
```
At `speedScale == 1.0` the two are identical, so RESEARCH's version looks verified. They diverge
everywhere else.
Measured: 4 sentences × 6 speed values (1.0, 0.9, 0.75, 0.5, 1.25, 1.5), predicted total frames
compared against the true frame count of the synthesised WAV (`getnframes() // 256`):
| Formula | Correct |
|---|---|
| `round(length / speed * 93.75)` (01-RESEARCH.md) | **8 / 24** |
| `round(round(length * 93.75) / speed)` | **24 / 24** |
Worst observed error from the wrong formula: **5 frames ≈ 53 ms** on the long sentence at
`speedScale = 1.25`. That is well past the ±1 frame (10.667 ms) tolerance `test_no_drift_long_utterance`
is specified with, and it is exactly the symptom `01-RESEARCH.md` Pitfall 5 describes — "normal
speed syncs, slow speed drifts" — reached from the other direction.
Everything else about the pipeline is confirmed and should be implemented as RESEARCH describes:
- Flatten each accent phrase's `moras`, then its `pause_mora` if present — pause **after**.
- Wrap the whole sequence in `prePhonemeLength` / `postPhonemeLength` silence moras, and scale
those by `speedScale` like any other phoneme. They are not exempt.
- Emit the consonant (mouth closed) before the vowel within a mora.
- Accumulate the quantised frame counts, never the raw floats. Per-phoneme rounding error is up to
±0.5 frame ≈ ±5.3 ms, and the long fixture has 61 phonemes.
- Use banker's rounding. Python's built-in `round()` already is; JavaScript's `Math.round()` is
round-half-up and would disagree, which is an independent reason the timeline is built in Python
and the browser only plays it.
- Steps involving `pauseLength` / `pauseLengthScale` are no-ops — those fields do not exist here.
The committed fixtures are the proof: `tests/fixtures/make_synth_fixtures.py` asserts the correct
formula reproduces each fixture's true frame count before it will write anything, so the fixture
set cannot silently drift away from the engine.
| Fixture | speedScale | Frames | Duration (s) |
|---|---|---|---|
| `short` | 1.0 | 99 | 1.056 |
| `long` | 1.0 | 516 | 5.504 |
| `slow` | 0.75 | 692 | 7.381333333333333 |
Note that `slow / long = 1.341085`, **not** exactly `1/0.75 = 1.333333`. Re-quantisation after
scaling means the realised ratio lands near the requested one, not on it. A test that asserts
"exactly 1/0.75× longer" against *durations* will fail; assert the timeline matches the WAV
instead, which is the property that actually matters for lip-sync.
---
## AudioQuery serialisation
`SynthResult.audio_query` is a plain dict in the **VOICEVOX ENGINE schema**, which is
deliberately mixed-case: nested structures keep snake_case, top-level scalar parameters are
camelCase. This is not a style choice — it is the wire format every VOICEVOX consumer expects,
and it is what the committed fixtures record.
| `voicevox_core` dataclass field | ENGINE JSON key |
|---|---|
| `accent_phrases` | `accent_phrases` (unchanged) |
| `speed_scale` | `speedScale` |
| `pitch_scale` | `pitchScale` |
| `intonation_scale` | `intonationScale` |
| `volume_scale` | `volumeScale` |
| `pre_phoneme_length` | `prePhonemeLength` |
| `post_phoneme_length` | `postPhonemeLength` |
| `output_sampling_rate` | `outputSamplingRate` |
| `output_stereo` | `outputStereo` |
| `kana` | `kana` (unchanged) |
Mora keys (`text`, `consonant`, `consonant_length`, `vowel`, `vowel_length`, `pitch`) and accent
phrase keys (`moras`, `accent`, `pause_mora`, `is_interrogative`) pass through unchanged.
`consonant` / `consonant_length` are `None` for vowel-only moras and for `pau`.
---
## Runtime assets
| Asset | Path in repo | Size | Acquired from | Committed via LFS? |
|---|---|---|---|---|
| Open JTalk dict | `voicevox/open_jtalk_dic_utf_8-1.11/` | 107,304,813 B total (`sys.dic` alone 103,073,776 B) | `https://downloads.sourceforge.net/project/open-jtalk/Dictionary/open_jtalk_dic-1.11/open_jtalk_dic_utf_8-1.11.tar.gz` | **yes** — via `voicevox/.gitattributes` (`*.dic`, `*.bin`) |
| Voice model | `voicevox/model/zundamon.vvm` | 59,308,488 B | `voicevox_vvm` 0.17.0 asset, original filename **`0.vvm`** | **yes** — root `.gitattributes` `*.vvm` |
| VOICEVOX ONNX Runtime | *not in the repo* — fetched to `voicevox_runtime/` (gitignored) | 8.2 MB (linux-x64 CPU `.tgz`) | `github.com/VOICEVOX/onnxruntime-builder` release `voicevox_onnxruntime-1.23.2` | **NO — never vendored** (option **c**, below) |
```text
SHA256 zundamon.vvm: ecd35374d4182cd883cba5040376f7f888cc6ba248b1c2f4cea07cdb34bb1318
speaker_uuid: 388f246b-8c41-4ac1-8e2d-5d79f3ff56d9
style_id (ノーマル): 3
```
`style_id` **3** is the integer constant `SPEAKER_STYLE_ID` in `src/japanese_avatar/voice/tts.py`.
`0.vvm` also carries 四国めたん (2/0/6/4), 春日部つむぎ (8) and 雨晴はう (10). Only ずんだもん is
credited and used; the others are present because the model file is packaged that way upstream.
Confirmed against the `metas.json` inside the downloaded file and against the character↔style
table in the release's own `README.txt`.
### Why only one `.vvm`
The `voicevox_vvm` 0.17.0 release ships 29 assets — 25 talk models `0.vvm`..`24.vvm`, `n0.vvm`
(Nemo), `s0.vvm` (song), plus `README.txt` and `TERMS.txt`. Each talk model is 57-67 MB. The
character↔file index lives in `README.txt` (section 音声モデル(.vvm)ファイルと声…の対応表), which
resolves ずんだもん ノーマル to **`0.vvm`, style ID 3**. Only that one file was downloaded.
---
## ONNX Runtime acquisition — **option (c)**, lazy first-use download
Chosen: **(c) a lazy first-use download, guarded so it never happens at import time.**
Reasons, in the plan's stated order of preference:
- **(a) a pip-installable companion distribution — does not exist.** Verified: `pypi.org/pypi/voicevox-onnxruntime/json`,
`voicevox_onnxruntime` and `voicevox-core` all return **HTTP 404**. The runtime is published
only as `.tgz` / `.zip` archives on
`github.com/VOICEVOX/onnxruntime-builder/releases/tag/voicevox_onnxruntime-1.23.2`.
There is nothing that could be added to `requirements.txt`.
- **(b) the official downloader binary run at build time — not available on this platform.** A
Hugging Face **Gradio-SDK** Space has no arbitrary build hook; its build phase runs pip against
`requirements.txt` (optionally `pre-requirements.txt`) and apt against `packages.txt`. Neither can
execute `./download`. Option (b) remains the correct choice for local development and for any
future Docker-SDK variant, and it is what a contributor may use instead of the automatic path.
- **(c) chosen.** `get_synthesizer()` resolves the runtime in this order: `VOICEVOX_ORT_PATH` env
var → an existing copy under `voicevox_runtime/` → download the official archive for the current
platform → fall back to `Onnxruntime.load_once()`'s own library search path. The linux-x64 CPU
archive is **8.2 MB**, so a cold wake pays roughly a second of network, once, and only inside
the lazily-constructed synthesizer — never at module import. Plan 01-08's `warmup()` call pays it
during app startup so the first visitor does not.
This keeps the repo compliant with the VOICEVOX ソフトウェア利用規約 禁止事項 (unauthorised
redistribution of the software is forbidden): the runtime and the wheel are always *referenced*
from their official release URLs, never copied into this repository. The asymmetry with the voice
model is deliberate and is spelled out under "Licence compliance" below.
---
## Version pairing
**voicevox_core 0.17.0 + voicevox_vvm 0.17.0 — verified compatible, executed.**
The pairing question RESEARCH raised is real but resolves the other way round from what it
assumed. `voicevox_vvm` 0.17.0's `0.vvm` reports `vvm_format_version: 2` in its internal
`manifest.json`, and every style in it has `type: "streaming_talk"`. Both the format version and
the `StyleType::StreamingTalk` variant were **introduced in core 0.17.0**. So:
- core 0.17.0 + vvm **0.17.0** → verified working (audio synthesised, see below).
- core 0.17.0 + vvm 0.16.4 → the old `vvm_format_version` is still readable by 0.17.0, but there is
no reason to take it; 0.16.4 predates the format the current core is built around.
- core **0.16.x** + vvm 0.17.0 → would **not** work: an older core cannot read format 2.
Evidence, executed: loading `voicevox/model/zundamon.vvm` into a 0.17.0 `Synthesizer` succeeded and
`create_audio_query("こんにちは", 3)` → `synthesis(...)` returned a 50,732-byte RIFF WAV at
24000 Hz / mono / 16-bit, duration 1.056 s.
Pin both to **0.17.0**. If either is bumped, regenerate the fixtures with
`tests/fixtures/make_synth_fixtures.py` — `tests/test_tts_contract.py::test_fixtures_match_current_engine`
exists to make that failure loud rather than mysterious.
---
## Cold-start posture
Import-time work is **zero**: `import japanese_avatar.voice.tts` constructs nothing, touches no
file and opens no socket, so `app.py` stays importable on a machine with no wheel installed. All
loading happens inside `get_synthesizer()`, which is `functools.lru_cache(maxsize=1)`-wrapped, and
`warmup()` exists purely so plan 01-08 can pay that cost during startup.
Measured on Windows / Python 3.12.12, warm disk, runtime already present:
| Stage | Time |
|---|---|
| `import japanese_avatar.voice.tts` | 0.110 s (pure Python import; no file or socket touched) |
| `Onnxruntime.load_once(...)` | 0.093 s |
| `OpenJtalk(dict_dir)` | 0.001 s |
| `VoiceModelFile.open` + `load_voice_model` | 1.742 s |
| **Total import-to-ready (`warmup()`)** | **1.73 s** |
| Second `warmup()` call (cache hit) | 0.000001 s |
Per-turn, once warm: `create_audio_query` **1.2 ms**, `synthesis` **1215 ms** for 「こんにちは」.
Both are recorded through `TurnTimings`, so plan 01-08's latency harness gets them for free.
**The lazy runtime download is exercised, not assumed.** Pointing `VOICEVOX_ORT_DIR` at an empty
directory and calling `warmup()` fetched, unpacked and loaded the runtime and then synthesised
successfully: **2.66 s** total, versus 1.73 s warm — so the download costs about **0.9 s** on a
cold container. That is the entire cold-start penalty this design carries.
The ~1.7 s is model deserialisation, not I/O, so it is paid on every process start regardless of
caching. The one thing that *would* have dominated cold start — pulling ~166 MB of dictionary and
voice model on each 48 h wake onto ephemeral Space disk — is eliminated by committing both through
Git LFS, so they arrive with the clone. Only the 8.2 MB runtime is fetched, and only once per
container.
---
## Licence compliance (voice assets)
The VRM's equivalent lives in `docs/ASSETS.md`, owned by plan 01-01. Plan 01-09 merges both into
`LICENSES.md`.
- **Software** — VOICEVOX ソフトウェア利用規約, <https://voicevox.hiroshiba.jp/term/>.
Commercial and non-commercial use permitted. 禁止事項 forbids redistributing the software in
whole or part without authorisation, so `voicevox_core` is installed from the official release
URL pinned in `requirements.txt` and **neither the wheel nor the ONNX Runtime binary is
vendored into this repository**.
- **Voice model** — VOICEVOX 音声モデル 利用規約, published in the
[`voicevox_vvm` README](https://github.com/VOICEVOX/voicevox_vvm/blob/main/README.md) and shipped
as `README.txt` / `TERMS.txt` alongside the release assets. Clause 2 reads
「アプリケーションに組み込んで再配布することができます」 — **embedded redistribution is explicitly
permitted**, which is what licenses committing `zundamon.vvm` to this repo.
- **Character** — ずんだもん, SSS LLC, <https://zunko.jp/con_ongen_kiyaku.html>. One document covers
nine characters. Commercial and non-commercial use permitted *with credit*; without credit,
per-character licensing is ¥400,000 (+ tax).
### The credit string
```text
VOICEVOX:ずんだもん
```
Exact, including the ASCII colon and no spaces. Later plans render it; this document is where the
string is defined.
**Placement requirement** (character terms clause 2):
「アプリなどでの利用の場合は、アプリの紹介画面などに記載をお願いします。(少し探せばわかる場所に)」
— an app introduction / about screen, in a place findable with a little looking. Phase 1 satisfies
this with a persistently visible footer line next to the avatar **plus** an About/Credits surface.
**Flow-down obligation** (software clause 3 / voice-model clause 4): when audio generated here is
made available to others, those others must be bound to the same terms. Anywhere audio is
user-obtainable (playback, replay, download), display a terms notice to the effect of *"Synthesised
audio is provided under the VOICEVOX and VOICEVOX:ずんだもん terms of use; by using it you agree to
comply with them"*, linking both URLs above.
**The credit obligation survives an engine swap.** The official Q&A answers
「音声の中間表現(AudioQuery/FrameAudioQuery)を VOICEVOX 以外の音声合成に利用した場合はクレジット記載が
必要ですか?」with 「必要です。」 Using the `AudioQuery` — which is precisely what the viseme
timeline consumes — triggers the credit requirement on its own.
### There is no "ask the rights holder" step, ever
SSS LLC 免責条項 2: 「本ガイドラインに該当するかどうかのご質問については、原則お答えしておりません。
特に無償利用の範囲についての問い合わせについては回答いたしかねます。…できるかぎりガイドラインを自身で
読み込んだ上での判断をお願いいたします。」 They decline "does my use qualify?" questions as a matter
of policy. The compliance posture is: read the guideline (done, above), comply visibly, document
here. Recorded so it is never re-litigated in a later phase.
### Pre-cleared swap-in: VOICEVOX Nemo
If the character terms ever become inconvenient, **VOICEVOX Nemo** is the drop-in replacement and
the swap is a two-line change (`SPEAKER_STYLE_ID` plus the model path):
- Model file: `n0.vvm` from the same `voicevox_vvm` 0.17.0 release; nine voices, style IDs
10000-10008 (男声1-3, 女声1-6), all ノーマル.
- Credit string is just `VOICEVOX Nemo` — no character name, no third-party rights holder.
- Terms: <https://voicevox.hiroshiba.jp/nemo/term/>, restated in the `voicevox_vvm` README.
- No ¥400,000 credit-omission clause, because there is no separate character licensor.
Cost of the swap: the loss of a recognisable character identity. Recommendation remains ずんだもん.
### Open JTalk dictionary licence
BSD-3-Clause. The shipped `voicevox/open_jtalk_dic_utf_8-1.11/COPYING` reads verbatim:
> Copyright (c) 2009, **Nara Institute of Science and Technology**, Japan.
> All rights reserved.
>
> Redistribution and use in source and binary forms, with or without modification, are permitted
> provided that the following conditions are met: Redistributions of source code must retain the
> above copyright notice, this list of conditions and the following disclaimer. Redistributions in
> binary form must reproduce the above copyright notice, this list of conditions and the following
> disclaimer in the documentation and/or other materials provided with the distribution. Neither
> the name of the Nara Institute of Science and Technology (NAIST) nor the names of its
> contributors may be used to endorse or promote products derived from this software without
> specific prior written permission.
**Correction to 01-RESEARCH.md:** RESEARCH attributes this dictionary to "Nagoya Institute of
Technology / HTS Working Group". Both institutions are genuinely in the picture and RESEARCH
conflated them:
- The **dictionary data** (`open_jtalk_dic_utf_8-1.11`, derived from the NAIST Japanese
Dictionary / IPAdic lineage) is © 2009 **Nara** Institute of Science and Technology, BSD-3-Clause
— this is the notice that must be reproduced for the files committed here.
- **Open JTalk itself** — the engine whose dictionary format this is, and whose code is linked
inside `voicevox_core` — is from the **Nagoya Institute of Technology** and the HTS Working
Group, also under a modified BSD licence.
`LICENSES.md` (plan 01-09) must carry **both** attributions; reproducing only Nagoya would fail to
satisfy the BSD notice on the files actually redistributed in this repo.
---
## Local development
The Space consumes `requirements.txt`, which pins the **Linux** wheel. Local development on
Windows needs the `win_amd64` asset from the same release. It is wired into `pyproject.toml` as
the `voice` extra with marker-differentiated `[tool.uv.sources]`, so one lockfile serves both:
```console
uv sync --extra dev --extra voice
```
Never put a Windows wheel in `requirements.txt` — that file is consumed by a Linux builder.
To obtain the ONNX Runtime explicitly rather than letting `get_synthesizer()` fetch it, use the
official downloader (option **b**) and point `VOICEVOX_ORT_PATH` at the result:
```console
curl -sSfL https://github.com/VOICEVOX/voicevox_core/releases/download/0.17.0/download-linux-x64 -o download
chmod +x download
./download --only onnxruntime -o ./voicevox_runtime
```
`voicevox_runtime/` is gitignored.
### Environment variables
| Variable | Default | Purpose |
|---|---|---|
| `VOICEVOX_DICT_DIR` | `voicevox/open_jtalk_dic_utf_8-1.11` | Open JTalk dictionary directory |
| `VOICEVOX_VVM_PATH` | `voicevox/model/zundamon.vvm` | Voice model file |
| `VOICEVOX_ORT_PATH` | *(auto-resolved)* | Explicit path to the VOICEVOX ONNX Runtime shared library |
| `VOICEVOX_ORT_DIR` | `voicevox_runtime` | Where the runtime is cached / downloaded to |
| `VOICEVOX_ORT_NO_DOWNLOAD` | unset | Set to `1` to forbid the lazy download entirely |
---
## Zero GPU, permanently
Nothing in `src/japanese_avatar/voice/` imports `spaces` or `torch`, and no `@spaces.GPU`
decorator exists anywhere on the synthesis path. Synthesis is CPU-only by design — that is the
whole reason this engine was chosen, since it means speech output consumes **none** of a visitor's
daily ZeroGPU quota. `tests/test_tts_contract.py::test_no_gpu_imports_on_synthesis_path` walks the
package with `ast` and fails if that ever changes; plan 01-08 adds the wider turn-path scan.