# Inkhaven 1.2.17 — TTS Piper transition

**Status:** active scope (locked 2026-06-01)
**Cycle theme:** turn TTS from a macOS-only paper-cut into a
first-class cross-platform feature with auto-downloaded voices.

## Motivation

The 1.2.9 TTS surface (`Ctrl+B S` read-aloud, `Ctrl+B Shift+R`
save-as-audio, `ink.tts.speak` Bund word) is wired through
`/usr/bin/say` on macOS only.  Linux and Windows users
who try the chord get a friendly "TTS unavailable on this
platform" message.  Voice selection is whatever the OS has
already downloaded; no programmatic discovery, no project-
scoped consistency.

Piper ([rhasspy/piper](https://github.com/rhasspy/piper))
gives us a single neural TTS that runs the same on
Linux / macOS / Windows, with high-quality voice models
distributed via Hugging Face under permissive licenses.
The voice models are 25–100 MB ONNX files plus a small JSON
descriptor; both are deterministic on-disk artefacts that fit
inkhaven's project-as-files philosophy.

The 1.2.17 scope is narrow on purpose: one feature, done
properly, with cross-platform parity as a non-negotiable.

## Locked design decisions

These were resolved through brainstorming + question-and-
answer on 2026-06-01.  They constrain the rest of the plan;
re-opening them would require updating every phase below.

1. **`tts.engine` defaults to `"auto"`.**  Inkhaven detects
   the Piper binary at startup and prefers it; falls back to
   the 1.2.9 `system` backend if Piper is unresolvable.  A
   status-bar diagnostic chip surfaces the active engine so
   users can confirm without leaving the TUI.
2. **Piper binary lives at `~/.cache/inkhaven/piper-<plat>/piper`.**
   User-scoped cache (the binary is identical across every
   project, ~4–8 MB).  HJSON override via `tts.binary_path`.
3. **Voices live at `<project>/.inkhaven/voices/`.**  Per-
   project scope so a French-novel project's voices don't
   pollute a Russian project.  Cache size capped by
   `tts.cache_max_voices` (default 5) with LRU eviction.
4. **Voice picker modal binds `Ctrl+B Shift+V`.**  Matches
   the B-pane `Shift+letter` family (F filter words,
   G heatmap, H rhythm).  V was unused.
5. **`.gitignore` is auto-appended.**  On first voice
   download, inkhaven appends `.inkhaven/voices/` to
   `<project>/.gitignore` (creating it if absent).  Voices
   are large opaque blobs; checking them into git is
   universally wrong.  One-time, idempotent, atomic via
   `crate::io_atomic`.

## Architecture

### Engine abstraction

Today `App.tts_say: Say` is a struct holding a `say`-
subprocess handle.  Replace with:

```rust
pub enum TtsEngine {
    System(Say),         // 1.2.9 macOS-only backend, preserved
    Piper(PiperEngine),  // 1.2.17 cross-platform backend
    Disabled,            // master switch off OR no engine resolvable
}

impl TtsEngine {
    pub fn speak(&mut self, text: &str, voice: &str, wpm: Option<u16>) -> Result<()>;
    pub fn speak_to_file(&mut self, text: &str, voice: &str, path: &Path, wpm: Option<u16>) -> Result<()>;
    pub fn is_speaking(&self) -> bool;
    pub fn stop(&mut self);
    pub fn label(&self) -> &'static str;  // for the diagnostic chip
}
```

Existing call sites (`tts_speak_string`, `tts_speak_greeting`,
`tts_speak_goodbye_blocking`, `commit_tts_save_as_audio`,
`ink_tts_speak` Bund word) dispatch through the enum and stay
unchanged in shape.

### `tts.engine` resolution

```rust
fn resolve_engine(cfg: &TtsConfig, project_root: &Path) -> TtsEngine {
    if !cfg.enabled {
        return TtsEngine::Disabled;
    }
    match cfg.engine.as_str() {
        "system" => TtsEngine::System(Say::new()),
        "piper"  => PiperEngine::new(cfg, project_root).into_engine(),
        "auto"   => {
            // Try Piper first; fall back to System.
            if let Ok(piper) = PiperEngine::new(cfg, project_root) {
                TtsEngine::Piper(piper)
            } else if Say::available().is_ok() {
                TtsEngine::System(Say::new())
            } else {
                TtsEngine::Disabled
            }
        }
        other => {
            log::warn!("unknown tts.engine {other:?}; treating as auto");
            // ... same as "auto"
        }
    }
}
```

### HJSON shape (additive)

```hjson
{
  editor: {
    tts: {
      // 1.2.9+ (preserved)
      enabled: false
      voice: "en_US-lessac-medium"   // default flips with engine
      speed: 1.0
      greeting: ""
      goodbye: ""

      // 1.2.17+ (new)
      engine: "auto"                 // "auto" | "piper" | "system"
      voices_dir: ".inkhaven/voices" // relative to project root
      auto_download: true
      catalog_url: "https://huggingface.co/rhasspy/piper-voices/raw/main/voices.json"
      catalog_ttl_hours: 24
      binary_path: null              // null = autoresolve
      auto_download_binary: true
      cache_max_voices: 5
      play_command: null             // null = platform default
      sample_rate_hz: 22050
    }
  }
}
```

Default `voice` flips from `"Milena"` (1.2.9 default — a
macOS `say` voice) to `"en_US-lessac-medium"` (the Piper
catalog's canonical demo voice).  Existing 1.2.9 users who
have `tts.voice: "Milena"` and `tts.engine: "auto"` get the
existing macOS backend (since Piper has no "Milena"); the
T.8 migration prompts them to pick a Piper voice if they
want cross-platform parity.

### On-disk layout

```
<project>/
  .inkhaven/
    voices/
      voices.json                       # catalog cache, 24h TTL
      en_US-lessac-medium.onnx
      en_US-lessac-medium.onnx.json
      ru_RU-irina-medium.onnx
      ru_RU-irina-medium.onnx.json
      .lru                              # access-time index for eviction

~/.cache/inkhaven/
  piper-darwin-arm64/piper              # auto-downloaded binary
  piper-linux-x86_64/piper
  piper-windows-x86_64/piper.exe
```

### Playback dispatch

| Platform | Default `tts.play_command` |
|----------|----------------------------|
| macOS    | `afplay <path>`            |
| Linux    | `paplay <path>` (PulseAudio); fall back to `aplay <path>` (ALSA) |
| Windows  | `powershell -c "(New-Object Media.SoundPlayer '<path>').PlaySync()"` |

User override via `tts.play_command` if needed (mpv / ffplay
/ sox).  No new Rust audio dependency.

### Background work + safety invariants

* Downloads run in tokio tasks (1.2.15 health-monitor
  pattern).  Status-bar progress chip: `[tts: <voice> 73%]`
  using `ChipState::Info` from 1.2.16 P.1.
* SHA256 verification against the catalog manifest before
  the downloaded blob is moved into place.
* Atomic writes via `crate::io_atomic` for every voice
  artefact (.onnx, .onnx.json, voices.json, .lru, the
  binary).
* Path safety via `crate::path_safety::resolve_within` on
  every voice name (defensive against catalog poisoning +
  crafted voice names containing `..`).
* Lock-poisoning recovery on the catalog cache + LRU
  mutex.
* `tokio::process::Command` for Piper subprocess so the
  TUI loop isn't blocked.
* Hard timeout on synthesis (30 s default) so a misbehaving
  Piper invocation doesn't stall the TUI.

## Phase plan

Eight phases, each commit-shipped after green tests.  Phases
sized for one commit each; if a phase grows past ~400 lines
it splits.

### T.1 — Engine abstraction

Replace `App.tts_say: Say` with `App.tts: TtsEngine`.
Dispatch through the enum from all three speak paths +
save-as-audio + the Bund word.  `Piper` variant is a stub
that errors with "Piper backend not yet implemented in
T.1".  Add HJSON fields (with defaults) so the config
shape lands ahead of the implementation that uses them.

* `src/tui/say.rs` → keep, used by `System` variant.
* `src/tui/piper.rs` → new, stub `PiperEngine`.
* `src/tui/tts.rs` → new, `TtsEngine` enum + dispatch.
* `src/config.rs` → extend `TtsConfig`.
* `assert_send_sync<TtsEngine>()` invariant (1.2.16 P.4-pre
  pattern).

**Test budget:** 6–8 unit tests on dispatch + resolution.
No behavioural change vs. 1.2.16 expected on macOS.

### T.2 — Piper binary resolution + auto-download

`resolve_piper_binary(cfg, os, arch) -> Result<PathBuf>`:

1. If `cfg.binary_path` is set + the file is executable,
   use it.
2. Search `PATH` for `piper`.
3. Check `~/.cache/inkhaven/piper-<plat>/piper` for a
   previously-downloaded binary.
4. If `cfg.auto_download_binary`, fetch the latest GitHub
   release matching `(os, arch)`, extract, verify the
   SHA256 from the release notes, install atomically.
5. Else return `BinaryUnresolvable`.

Pure classifier `pick_piper_release_asset(release_json, os,
arch)` extracts the right tarball name for the platform.

* `src/tui/piper/binary.rs` → resolution logic.
* `src/tui/piper/download.rs` → `ureq` download +
  extraction (already in tree for typst).

**Test budget:** 10–12 tests; mock `ureq` for download
flows; real-filesystem tests for resolve order.

### T.3 — Voice catalog fetch + parse + cache

`Catalog::load(voices_dir, catalog_url, ttl) -> Result<Catalog>`:

1. If `voices_dir/voices.json` exists and is younger than
   `ttl`, parse + return.
2. Otherwise fetch from `catalog_url`, write atomically,
   parse + return.
3. On network failure with a cached copy present, return
   the stale copy + log a warning.  No-network must not
   block synthesis when the voice itself is already on
   disk.

Pure parsers `parse_voice_catalog(json) -> Catalog`,
`catalog_voice(catalog, name) -> Option<VoiceMeta>`,
`voice_languages(catalog) -> Vec<String>`.

**Test budget:** 8–10 tests on parse + TTL + stale-fallback.

### T.4 — Voice auto-download

`ensure_voice_downloaded(name, voices_dir, catalog, on_progress) -> Result<PathBuf>`:

1. If both `.onnx` and `.onnx.json` exist + checksums
   match, return.
2. Else stream-download both from the catalog's URLs.
3. Verify SHA256 against the catalog manifest.
4. Atomic-move into place via `io_atomic`.
5. Update `.lru` index.  Run LRU eviction if over
   `cache_max_voices`.
6. On first-ever download, append `.inkhaven/voices/` to
   `<project>/.gitignore` (idempotent).

Progress callback drives the status-bar chip.

* `src/tui/piper/voice.rs` → download + verification.
* `src/tui/piper/lru.rs` → access-time index +
  eviction policy.
* `src/tui/piper/gitignore.rs` → idempotent append.

**Test budget:** 14–16 tests; mock-HTTP for download
flows; property-test path-traversal defence on voice
names.

### T.5 — Piper synthesis + playback

`PiperEngine::speak(text, voice, wpm) -> Result<()>`:

1. Resolve binary (T.2).
2. Ensure voice downloaded (T.4).
3. Spawn piper, pipe text on stdin, capture WAV stdout
   to a temp file under `voices_dir/.synthesis-<uuid>.wav`.
4. Dispatch playback via `tts.play_command` or platform
   default.
5. Return immediately (background plays); next `speak`
   stops the current playback first.

`PiperEngine::speak_to_file(text, voice, path, wpm)`:

* As above but writes the WAV directly to `path` (or
  encodes via ffmpeg if `path` ends in `.mp3` / `.m4a`).
  Existing `commit_tts_save_as_audio` already invokes
  ffmpeg via subprocess; we reuse.

`is_speaking()` / `stop()` track the playback subprocess
PID.

**Test budget:** 8–10 tests; gated by `[ignore]` when
Piper binary isn't on the test runner, with one always-on
test for the WAV-temp-file path-safety check.

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

`Modal::TtsVoicePicker { catalog, downloaded, selected,
filter, scroll, status }`:

* Lists every voice in the catalog: `<name>  <language>
  <quality>  [downloaded ✓ | available ⬇]`.
* `/<text>` filters by language code or name substring.
* Enter on undownloaded voice → background download with
  progress chip; on downloaded voice → set `tts.voice` +
  persist HJSON.
* `d` on a downloaded voice → remove + update LRU.
* `Esc` closes.

* `src/tui/app/render/modals.rs` → `draw_tts_voice_picker`.
* `src/tui/app.rs` → `App::open_tts_voice_picker` +
  `handle_tts_voice_picker_key`.
* `src/tui/keybind.rs` → `Action::OpenTtsVoicePicker`
  rebindable as `tts.voice_picker`, default chord
  `Ctrl+B Shift+V`.

**Test budget:** 10–12 tests on filter + select + LRU
interaction.

### T.7 — `inkhaven tts` CLI surface

Subcommands:

```
inkhaven tts engine status            # which engine is active + why
inkhaven tts binary status            # piper binary path + checksum
inkhaven tts binary download          # explicit download (else implicit on first speak)
inkhaven tts voice list               # downloaded + available
inkhaven tts voice download <name>    # explicit download
inkhaven tts voice remove <name>      # delete + update LRU
inkhaven tts voice catalog refresh    # bypass TTL
inkhaven tts test "<phrase>" [--voice <name>]   # synth + play
```

Extends the 1.2.9 `inkhaven doctor --tts-test` so it
works against Piper, reporting binary location + voice
path + checksum.

* `src/cli/tts.rs` → new module.
* `src/cli/mod.rs` → wire `TtsCommand`.

**Test budget:** 12–14 tests on subcommand routing +
output shape.

### T.8 — Migration + docs + tutorial 56

* `inkhaven config` (the schema-aware HJSON editor)
  surfaces a one-time prompt when a project has
  `tts.voice: "Milena"` (or any other 1.2.9 default) AND
  `tts.engine: "auto"`: "Piper has no `Milena`.  Pick
  `en_US-lessac-medium` (en) / `ru_RU-irina-medium` (ru)
  / ...".  User can dismiss; we don't auto-overwrite.
* Tutorial 56 — `Documentation/Tutorials/56-tts-piper.md`.
* `Documentation/CONFIGURATION.md` § "1.2.17 — new HJSON
  blocks" with the full `tts.*` reference.
* `Documentation/KEYBINDING.md` row for `Shift+V`.
* `Documentation/KEYS_REASSIGNMENT.md` § "1.2.17 — new
  actions".
* `Documentation/RELEASE_NOTES/1.2.17.md` implementation
  log + closing.
* `README.md` "Latest release" section flipped to 1.2.17.

**Test budget:** light — most are docs.  Migration logic
gets 4–6 tests.

## Test budget total

Roughly 80–100 new tests across the eight phases.  The
1.2.16 cycle landed at 758 passing; 1.2.17 should land
around 840–860.

## Risk register

* **Hugging Face availability.**  The catalog and voice
  URLs are external.  Stale-copy fallback (T.3) handles
  network outages; the catalog itself going away would
  require a config knob `tts.catalog_url` swap to a
  community mirror.  Already configurable.
* **Piper releases change tarball naming.**  Pin the
  expected asset-name pattern in
  `pick_piper_release_asset` so a release-naming change
  fails loudly with a clear error rather than silently
  picking the wrong asset.  Test the patterns we
  currently know about.
* **Voice model size on slow connections.**  25–100 MB
  per voice can be painful on metered or slow links.
  The status-bar chip + cancellable downloads (via the
  picker modal's `Esc` while downloading) limit the
  user-impact.  Documented.
* **Project-dir voice cache grows large.**  Mitigated by
  `cache_max_voices: 5` LRU + the doctor `--scan` could
  flag voices that have outgrown LRU (deferred to T.8.b
  if any user reports it).
* **`.gitignore` auto-append surprise.**  Some users
  view `.gitignore` as sacrosanct.  Document the
  behaviour in Tutorial 56 + add `tts.auto_gitignore:
  true` HJSON knob to opt out.
* **Playback command not available on minimal Linux.**
  No `paplay` / `aplay` on truly minimal systems.  Fall-
  through gives a clear "no playback backend found —
  set `tts.play_command` manually" error.

## Out of scope for 1.2.17

These pair with TTS but don't fit the cycle:

* **Audiobook draft (.m4b chapter slicing)** — would need
  ffmpeg pipeline work + a "speak whole book" UX.
  Candidate for 1.2.18.
* **Per-character voice mapping** — assign a Piper voice
  per Characters book entry so dialogue auto-routes to
  that voice on `Ctrl+B S`.  Big UX surface; needs design.
* **Voice cloning / fine-tuning** — Piper supports it but
  the workflow is out-of-scope for an editor.

## Deferred to 1.2.17.b or 1.2.18

Carryovers from 1.2.16 stay where they are in
`Documentation/RELEASE_NOTES/1.2.17.md`; they may slip
again if the TTS scope absorbs the full cycle:

* I.1 — Performance pass.
* A.2.b dashboard sections (POV chip, rhythm gauge,
  filter-words flagged today, recent AI critique).
* A.6.b unresolved-tension detector.
* A.5.b worldbuilding pack additions.
* I.4.b/c Bund stdlib follow-ups.
* M1/M3 security hygiene.
* Language UX polish.
* Genre packs (mystery + historical).

The honest reality: TTS Piper is a substantial cycle on
its own, especially with the cross-platform constraint.
If it ships clean, that's a release.  Pulling more in
trades stability for surface.

## Closing

Single-feature 1.2.17 cycle.  Eight phases, ~80–100 tests,
zero new Rust deps (`ureq` already in tree; playback via
system commands).  Cross-platform parity is the
non-negotiable acceptance criterion: every chord that
works on macOS must work identically on Linux and
Windows.
