# Inkhaven 1.2.17 — TTS Piper transition

**Active scope locked 2026-06-01.**  This cycle
ships a single feature done properly: a full
TTS Piper transition with cross-platform parity,
auto-downloaded voices stored per project, and a
voice picker modal.

See `Documentation/PROPOSALS/1.2.17_PLAN.md` for
the eight-phase shape (T.1–T.8), test budget,
on-disk layout, locked design decisions, and
risk register.

The remainder of this file is the carryover
backlog — themes deferred to 1.2.17.b or 1.2.18
to keep TTS Piper as a single-feature release.

## Carryovers from 1.2.16

### I.1 — Performance pass

The 1.2.16 proposal scheduled `cargo flamegraph`
against a 10K-paragraph fixture + targeted fixes
(lazy tree row materialisation, background
embedding refresh, DuckDB write batching).
Pulled out late-cycle to keep 1.2.16 release-
ready on schedule; the work is more valuable
with its own design + benchmark-pinning
iteration here.

Concrete first steps:

* Fixture: build a synthetic 10K-paragraph
  project (script in `tools/`) with realistic
  prose distribution + thread + comment
  density.
* Baseline: capture startup time, first-render
  time, tree pane scroll cost, search round-
  trip, AI envelope assembly time.
* Profile: `cargo flamegraph` per scenario.
* Fix: one PR per hotspot, each tied to a
  benchmark threshold that fails CI on
  regression.

### A.2.b — Dashboard sections

Each adds one row to the existing `Ctrl+V
Shift+J` modal; no new infrastructure needed.

* **POV chip** — most-mentioned character in
  the active book (heuristic from
  `pov_tracker`).
* **Rhythm gauge** — sentence-rhythm CV across
  the project, paired with the per-paragraph
  `Ctrl+B Shift+H` modal.
* **Filter-words flagged today** — count of
  style-warning hits per the existing overlay
  toggled by `Ctrl+B Shift+F`.
* **Recent AI critique** — first ~120 chars of
  the most recent AI history entry, so the
  dashboard reminds the author of the last
  critique they got.

### A.6.b — Unresolved-tension detector

The fourth A.6 plot-mining class.  Deferred
because the heuristic needs real-manuscript
calibration before its false-positive rate is
predictable.

Candidate shape: detect an introduced conflict
/ goal / question that lacks a resolution
paragraph downstream.  Probably needs a small
LLM-assisted classifier ("does this paragraph
introduce or resolve a tension?") rather than
a pure regex / keyword approach.

### A.5.b — Worldbuilding pack additions

* **Numeric / spatial contradictions** —
  number-word extraction across prose +
  consistency check (`three days later` after
  `a week passed`; `200 leagues north` after
  `200 leagues south`).
* **Anachronism overlay** — flag period-
  inappropriate vocabulary against the
  project's declared setting (HJSON
  `project.setting_period`); render as a
  style-warning overlay toggled with the
  existing `Ctrl+B Shift+F` infrastructure.

Both need number-words extraction +
preposition parsing + editor render infra
that doesn't yet exist.

### I.4.b/c — Bund stdlib follow-ups

* `ink.thread.add_waypoint(thread_uuid,
  paragraph_uuid)` — append a waypoint to a
  thread programmatically.
* `ink.thread.link_paragraph(thread_uuid,
  paragraph_uuid)` — same but for the
  alternate paragraph-link relation.
* `ink.workflow.chain([prog_a, prog_b, ...])`
  — composability primitive so scripts can
  declare multi-step author workflows.

Each follows the I.4.a pattern (registered in
`src/scripting/stdlib/`, declared category in
`policy.rs`, contract tests for the policy
boundary).

## Carryovers from 1.2.15

### Phase G — Git / backup safety rails

The 1.2.15 proposal listed Phase G out of scope
"to keep 1.2.15 scope crisp"; 1.2.16 closed the
backup-freshness amber chip slice (P.1).  Still
outstanding:

* **Uncommitted-changes warning at TUI exit** —
  detect a dirty git working tree on exit;
  surface a status-line warning (and an opt-in
  confirm) so users don't leave their changes
  behind.
* **Disk-space pre-flight** — before save /
  assemble / backup operations, check free
  space and refuse to start under critical-low
  conditions instead of trapping a write error
  half-way through.

The disk-space check needs a `fs2` or `sysinfo`
dependency (no platform-portable disk-free in
std).  Worth the dep if the broader release
needs it; alone it isn't.

### M1 — Crash-report action-ring detail hygiene

Audit every `crash::context().push_action(...)`
call site and document the rule that `detail`
must carry structural metadata only (slug
names, counts, anchor IDs), never raw user
input.  Truncate `detail` to ~80 chars at
insertion as defence-in-depth.

Already documented in
`Documentation/SECURITY_WARNING.md` §3.5 +
§4.2; the code-side audit is the open work.

### M3 — Script-initiated `ink.input` modal prefix

Prefix modals opened by Bund scripts with a
visible `[script: <slug>]` tag so users can
distinguish them from inkhaven-internal
prompts.  Mitigation for the "phishing prompt"
class flagged in the 1.2.15 security audit.

## Carryovers from 1.2.13

### Language-book UX polish

The 1.2.16 cycle closed the export-format
carryovers + `define-rule`.  Still outstanding
from the 1.2.13 UX list:

* `Ctrl+B Shift+R` reverse-lookup picker (find
  dictionary entry by translation).
* `Ctrl+B Shift+W` word-of-the-day card +
  phonotactic generator.
* Card renderers for Dictionary / Grammar /
  Phonology paragraphs.
* Inline gloss-table renderer in the AI pane.
* Confidence-flag chip (LLM "dictionary
  missing for X" → one-key jump into
  `language add-word`).
* Inflection-aware status-bar chip.

## Themes under consideration

### TTS — Piper transition

The 1.2.9 `Ctrl+B S` TTS surface uses macOS
`say` / `AVSpeechSynthesizer`.  Plan: replace
with [Piper](https://github.com/rhasspy/piper)
so TTS works on Linux + Windows + macOS with
high-quality neural voices, no platform fork.

Sketch:

* Bundle `piper` binary + voice models in the
  release artefacts (`release.yml`).
* `tts.engine: "system" | "piper"` HJSON key
  (default `"system"` for compat in 1.2.17,
  flip to `"piper"` in 1.2.18 after a cycle of
  field testing).
* `tts.voice` field maps to a Piper voice name
  (`en_US-lessac-medium`, etc.).
* `inkhaven doctor --tts-test` extended to
  verify the Piper binary + voice model on
  disk.

### Genre packs

Two new project-template families were
deferred from 1.2.16 to keep the worldbuilding
pack focused:

* **Mystery / detective** scaffold + style-
  rule pack (clue-tracker chapter, "fair-play"
  audit detector, suspect-thread weave view).
* **Historical fiction** scaffold + style-
  rule pack (period-glossary chapter,
  anachronism overlay co-shipping with A.5.b,
  setting-period field in `project.hjson`).

### Other candidates

* **Sixth language for the supported set** —
  Italian / Portuguese / Japanese.
* **More diagnose → AI-fix pairs** — repeated-
  phrase detector → AI rewrite, weak-verb
  detector → AI replace.
* **Live theme preview in config TUI** — apply
  the candidate colour to the live editor
  while sliding.
* **GitHub Release object follow-up** —
  backfill Release UI objects for
  1.2.11/12/13/14/15/16 (tags exist but the
  Release pages don't yet).

## Implementation log

### Phase T.1 — Engine abstraction

The 1.2.9 TTS surface wired `App.tts_say: Say`
directly — a single struct wrapping
`/usr/bin/say`.  T.1 inserts a `TtsEngine` enum
dispatcher between the chord handlers (the
`Ctrl+B S` read-aloud, `Ctrl+B Shift+R` save-as-
audio, `tts.greeting` / `tts.goodbye`, and the
`ink.tts.speak` Bund word) and the variant-
specific backend state.

* `src/tui/say.rs` — preserved as the System
  variant's backend.
* `src/tui/piper.rs` (later promoted to a
  directory module) — `PiperEngine` stub that
  returns `Err(PiperUnavailable::NotImplemented)`
  on construction so `engine: "auto"` falls
  through to System on every host.  Removed in
  T.5.
* `src/tui/tts.rs` — `TtsEngine` enum with
  `System(Say)` / `Piper(PiperEngine)` /
  `Disabled(reason)` variants.  Resolver
  honours `tts.engine`: `"system"` forces
  System, `"piper"` forces Piper (errors fall
  into `Disabled`), `"auto"` tries Piper first
  + falls back to System on construction
  error.
* `src/config.rs` — 11 new fields on
  `TtsConfig` (engine, voices_dir,
  auto_download, catalog_url,
  catalog_ttl_hours, binary_path,
  auto_download_binary, cache_max_voices,
  play_command, sample_rate_hz,
  auto_gitignore).  All defaulted; no existing
  HJSON breaks.
* `Say::speak_to_file_blocking` lifted from
  the inline `say -o` block in
  `commit_tts_save_as_audio` so save-as-audio
  routes through the engine uniformly.
* `assert_send_sync<TtsEngine>()` compile-time
  invariant — guards the 1.2.16 P.4-pre
  cross-thread story.

15 new tests, 758 → 773 passing.  Behavioural
delta on macOS = zero; on Linux / Windows the
existing "TTS unavailable on this platform"
modal copy still fires.

### Phase T.2 — Binary resolution + auto-download library

Converts `tui::piper` from a single file into a
directory module with three submodules: `mod.rs`
(types + diagnostic messages),
`binary.rs` (resolution), `download.rs` (HTTP +
extract).

* **`PiperUnavailable`** expanded with seven
  variants: `BinaryNotFound`,
  `UnsupportedPlatform`, `AssetNotFound`,
  `DownloadFailed`, `ExtractFailed`,
  `ChecksumMismatch` (reserved for T.4.b),
  `VoicesDirInvalid` (reserved for T.4).  Each
  carries a user-facing message that surfaces
  the recovery action.
* **`Platform::detect`** maps
  `std::env::consts::{OS, ARCH}` to one of six
  supported `(PiperOs, PiperArch)` tuples
  (darwin / linux / windows × x86_64 /
  aarch64 / armv7-Linux-only).
* **`pick_piper_release_asset`** —
  priority-ordered matcher tolerant of
  upstream renaming (`piper_amd64.tar.gz` ↔
  `piper_linux_x86_64.tar.gz`).  Pure
  function; tested against a fixture covering
  every platform.
* **`resolve_piper_binary`** — five-step
  resolution order: explicit `tts.binary_path`
  → PATH lookup → user cache → auto-download
  closure → `BinaryNotFound`.  Refactored to
  `resolve_piper_binary_with` taking
  `path_env: Option<&OsStr>` so tests don't
  mutate the shared process env (cargo runs
  tests in parallel; `set_var` is racy).
* **`user_cache_root`** — XDG_CACHE_HOME →
  HOME/.cache → Windows LOCALAPPDATA →
  tempdir fallback.  Similarly testable via an
  injected env-lookup closure.
* **`download_piper_binary` orchestrator** —
  curl fetch (manifest + asset) + tar extract
  + atomic install via `crate::io_atomic`,
  fully injectable for tests.

Curl + tar as universal subprocess primitives
keeps the "zero new Rust deps" pledge intact —
the same shell-out pattern `tui::say` uses for
`/usr/bin/say`.

46 new tests, 773 → 819 passing.

### Phase T.3 — Voice catalog

`Catalog` + `VoiceMeta` + `CatalogFile`
modelled after Piper's
`voices.json` shape: top-level map of voice
key → metadata (language code / family / native
+ English name / quality tier / files map of
relative-path → `{size_bytes, md5_digest}`).

* **`parse_voice_catalog`** — pure parser,
  tolerant of missing optional fields, drops
  voices without `language.code` (we can't
  classify them).
* **`Catalog::load_with_clock`** implements the
  freshness-first policy:
    1. Fresh cache (mtime within ttl) → parse +
       return, no network.
    2. Else fetch via `fetch_json` closure,
       parse, write atomically via
       `crate::io_atomic`, return fresh.
    3. On network failure with a cached copy
       present, parse the stale copy + return
       with `stale: true` + a
       `tracing::warn!`.  Synthesis with
       already-downloaded voices must not
       break when the network goes away.
    4. On network failure with no cache,
       surface the original error.
* **`Catalog::voice`** does canonical-key
  lookup then alias fallback;
  **`languages()`** dedupes + sorts;
  **`voices_for_language`** sorts by quality
  tier descending then key ascending.
* **`VoiceMeta::onnx_url` / `onnx_json_url`**
  join the catalog's relative paths to the
  Hugging Face base URL so T.4 has clean
  download sources.

27 new tests, 819 → 846 passing.

### Phase T.4 — Voice auto-download

Two submodules:

* **`lru.rs`** — `LruIndex` with newest-first
  vec backing, plain-text `<voices_dir>/.lru`
  format (`<voice-key> <iso8601>`), tolerant
  load + atomic save via `crate::io_atomic`.
  `touch` inserts-or-moves to the front;
  `evict_beyond(max)` returns evicted keys;
  `max == 0` is a documented no-op so a typo
  can't wipe the cache.
* **`voice.rs`** — `ensure_voice_downloaded`
  orchestrates:
    1. `voice_files_present` — both `.onnx`
       and `.onnx.json` on disk + non-empty.
       A zero-byte file from an interrupted
       transfer does NOT count as present
       (defends against the old "curl killed,
       0-byte placeholder" failure mode).
    2. `fetch_to_file` twice into
       `<voice_key>.onnx.part` /
       `.onnx.json.part` staging files.  Second
       failure cleans up the first.
    3. Atomic install via
       `crate::io_atomic` for both files.
    4. LRU touch + `evict_beyond` — each
       evicted voice's files are removed.
    5. First-ever-download `.gitignore` append
       (`.inkhaven/voices/`), idempotent.

Path safety: `safe_voice_filename` rejects keys
containing `/`, `\`, `..`, NUL, empty, leading
`.`.  Tested against `../../etc/passwd` voice
keys.

MD5 verification deferred to T.4.b — catalog
field exposed via
`CatalogFile::md5_digest`; ONNX runtime + HTTPS
provide reasonable trust for cycle 1.

41 new tests, 846 → 887 passing.

### Phase T.5 — Synthesis + playback (engine works end-to-end)

`src/tui/piper/synth.rs` adds the synthesis
pipeline + playback dispatch:

* **`synth_to_wav`** spawns the resolved Piper
  binary with `--model` + `--output_file` +
  `--length_scale`, pipes text on stdin (no
  command-line escaping for non-ASCII), waits
  with a 30s default timeout, cleans up dest
  on failure.  Treats "exited 0 but no audio"
  as failure too.
* **`wpm_to_length_scale`** maps the existing
  1.2.9 wpm slider (180 default = scale 1.0)
  to Piper's `--length_scale`, clamped to
  `[0.5, 2.0]` for intelligibility.
* **`select_play_command` + `spawn_playback`**
  dispatch playback per platform: `afplay`
  (macOS), `paplay` → `aplay` fallback
  (Linux), PowerShell `Media.SoundPlayer`
  (Windows).  User overrides via
  `tts.play_command` with `{path}`
  substitution.

`PiperEngine` finally wires together:

* **`new`** resolves the binary via PATH + user
  cache at startup (no network — explicit
  download is via T.7's CLI or T.6's picker),
  voices dir validated under project root via
  `path_safety::resolve_within_str`.
* **`speak`** stops prior playback → cache-
  first voice resolution (offline users with
  downloaded voices keep working) → catalog
  lookup + T.4 download if needed → synth +
  spawn playback.
* **`speak_to_file_blocking`** for the save-as-
  audio chord.
* **`is_speaking` / `stop` / `Drop`** track +
  reap the playback subprocess.

The T.1–T.4 "auto falls through to System"
invariant relaxes here: on hosts where Piper
resolves, `auto` routes to Piper; on hosts
without piper installed, still falls through
to System (no regression).

30 net new tests, 887 → 917 passing.  Real
end-to-end synthesis verification is deferred
to T.7's `tts test` CLI behind a `#[ignore]`
gate keyed on a real Piper binary.

### Phase T.6 — Voice picker modal (Ctrl+B Shift+V)

Pure state model
(`src/tui/voice_picker.rs`) +
`Modal::TtsVoicePicker` variant + action
`tts.voice_picker` + default chord +
`open_tts_voice_picker` /
`handle_tts_voice_picker_key` on App + render.

* **`VoiceListEntry`** carries voice key,
  language code, English name, quality,
  downloaded flag, byte size, from-catalog
  flag.
* **`TtsVoicePickerState::from_catalog`**
  merges catalog with on-disk voices (catalog
  hits + disk-only stragglers from projects
  that swapped catalogs).
  **`from_dir_only`** powers the offline path.
* **Filter** is free-form, matches voice key /
  language code / English language name
  (case-insensitive).
* **Key handler**: `↑↓` / `Home` / `End` /
  `PgUp` / `PgDn` / `Backspace` / `Esc` /
  printable→filter / `Enter` commit / `d`
  remove (only when filter empty, so it
  doesn't collide with type-to-filter).
* **Commit**: downloaded → set runtime voice +
  close.  Undownloaded → blocking download
  via T.4 pipeline → refresh + set.
* **Remove**: deletes both files + rebuilds
  LRU.
* **Render**: green/amber header by catalog
  state (fresh / stale / dir-only), filter
  line with match count, table-style rows
  with `✓`/`⬇` chips + size in MB, picker-
  local status in amber.

21 new picker-state tests, 917 → 938 passing.
Background-download polish (currently blocks
the UI for the 5–30 s of a voice download)
deferred to T.6.b.

### Phase T.7 — CLI surface

`src/cli/tts.rs` ships the headless management
surface mirroring the picker + adding engine /
binary diagnostics:

```
$ inkhaven tts engine
$ inkhaven tts binary status | download
$ inkhaven tts voice list [--filter X] [--downloaded]
                | download <name> | remove <name>
$ inkhaven tts catalog refresh
$ inkhaven tts test "<phrase>" [--voice <name>] [--output PATH]
```

* **`engine_status`** reports master switch,
  requested engine string, resolved binary
  path, effective backend.  Pure
  `format_engine_status()` for tests.
* **`binary_status`** prints platform
  identifier, cache root, expected install
  path, override visibility, resolved
  location.
* **`binary_download`** delegates to
  `piper::download::download_piper_binary`
  with real `curl_get_json` +
  `curl_get_to_file` fetchers.
* **`voice_list`** — table with
  `[✓ downloaded]` / `[⬇ available]` chips,
  `--filter` (case-insensitive substring on
  key + lang code), `--downloaded` (only show
  what's on disk), offline fallback to
  disk-scan-only.
* **`voice_download` / `voice_remove`** wrap
  the T.4 pipeline + LRU rebuild.
* **`catalog_refresh`** deletes the cached
  `voices.json` + forces refetch.
* **`test_phrase`** routes through
  `TtsEngine::resolve` so the same backend the
  TUI uses synthesises (or writes to
  `--output PATH`); waits for playback in sync
  mode so callers see exit 0 only after audio
  completes.

Visibility lift: `TtsEngine` + `EngineKind` +
`Say` and the `tui::tts` / `tui::piper` modules
from `pub(super)` to `pub(crate)` so the CLI
module can construct + drive them.  The 1.2.9
`doctor --tts-test` path remains intact as the
macOS-specific engine-bypass diagnostic.

16 pure-format tests, 938 → 954 passing.

### Phase T.2.a — Whole-tree install bugfix

Discovered when actually exercising Piper
end-to-end: `install_binary` from T.2 copied
only the `piper` executable + dropped the rest
of the extracted tarball (`espeak-ng-data/`
with 400 phoneme tables, `piper_phonemize`
helper, `libtashkeel_model.ort`, etc.).  Piper
would launch but fail at first phonemization.

Replaced with **`install_tree(source_dir,
dest_dir)`** that wipes the destination first
(idempotent re-install) then recursive-copies
the entire directory tree.  `std::fs::copy`
preserves Unix mode bits; symlinks are skipped
(path-traversal defence; Piper tarballs don't
use them).  Staging directory moved from
`<target>/.staging/` to
`<cache_root>/.staging-<plat-subdir>/` because
the new install step wipes the target dir,
and sharing a parent with staging would
self-wipe the source.

2 new tests, 954 → 955 passing.  Real-runtime
exercise of T.5+ on a fresh checkout.

### Phase T.8 — Docs

* **Tutorial 56** —
  `Documentation/Tutorials/56-tts-piper.md`
  covers engine resolution, voice picker
  (`Ctrl+B Shift+V`), CLI surface, on-disk
  layout, playback dispatch, HJSON reference,
  troubleshooting, and the **known Apple
  Silicon limitation** (Piper's upstream
  `piper_macos_aarch64.tar.gz` ships x86_64
  code, not aarch64; macOS arm64 users need
  `engine: "system"`, a from-source build, or
  the documented Rosetta workaround).
* **`CONFIGURATION.md`** §"1.2.17 — new HJSON
  blocks" with the full 11-field `tts.*`
  reference + per-field defaults.
* **`KEYBINDING.md`** row for
  `Ctrl+B Shift+V`.
* **`KEYS_REASSIGNMENT.md`** §"1.2.17 — new
  actions" listing the action + chord + CLI
  surface + HJSON knob summary.
* **`Tutorials/README.md`** row 56.
* **This file** — implementation log.
* **`RELEASE_NOTES/README.md`** + top-level
  **`README.md`** — flipped to 1.2.17 at
  release-cut time.

The `inkhaven config` migration prompt (suggest
a Piper voice when a 1.2.9 `say` voice name is
detected) deferred to T.8.b — implementing it
in the schema-aware HJSON editor needs
non-trivial editor integration, and the Apple
Silicon limitation means premature migration
prompts on Mac would mislead users.

## Deferred to 1.2.17.b / 1.2.18

* **T.4.b — MD5 verification.**  Catalog
  exposes `md5_digest`; T.4 ships without
  verification.  Add once we settle on a
  hashing primitive that doesn't introduce a
  Cargo dep (probably `openssl md5` subprocess
  or hand-rolled MD5 in ~150 lines).
* **T.6.b — Background voice downloads.**
  Current `Enter` on an `⬇ available` row in
  the picker blocks the UI for the duration
  of the download.  Lift to a tokio task with
  a status-bar progress chip (mirrors the
  1.2.15 health monitor pattern).
* **T.8.b — `inkhaven config` migration
  prompt.**  Detect `say`-style voice names
  with `tts.engine: "auto"` and suggest a
  Piper alternative.
* **Apple Silicon Piper.**  Upstream
  packaging bug — track piper's GitHub for a
  real arm64 release; revisit `engine: "auto"`
  default on macOS once it lands.

## Closing

Single-feature 1.2.17 cycle.  Eight phases
shipped end-to-end across `T.1 → T.7` + the
`T.2.a` mid-cycle bugfix + `T.8` docs.  Zero
new Rust dependencies — curl + tar +
PowerShell-Media.SoundPlayer carry the
HTTP / extract / playback weight via the same
subprocess pattern `tui::say` uses.

Test growth: 758 (1.2.16 final) → 955.
Net +197 tests across the cycle, all green.

Behavioural promise held: on macOS without
Piper installed, `engine: "auto"` falls
through to the 1.2.9 System backend so the
chord-driven UX is unchanged.  On Linux +
Windows users now have a working TTS for the
first time.

Honest about the Apple Silicon Piper
limitation: upstream ships x86_64 code in the
arm64 tarball.  Documented in the tutorial +
in this file; the System backend fallback
keeps macOS arm64 users on a working stack.
