# ROADMAP — eqtui

> A terminal-native audio effects processor for PipeWire, inspired by EasyEffects and powered by AutoEQ.

---

## 1. Vision

**EasyEffects** is a comprehensive Linux audio effects suite. However, it is integrated with the Qt6/QML and GTK desktop ecosystems. `eqtui` aims to provide a lightweight, keyboard-driven alternative for terminal environments and tiled window managers.

**eqtui** is that toolbox. Same PipeWire pipeline architecture, same effect chain, but rendered entirely in the terminal with `ratatui`. No Qt, no GTK, no DE dependency. Add AutoEQ integration as a first-class feature (import headphone correction profiles with one keypress).

### Principles

- **Keyboard-first** — every action reachable without a mouse
- **Lightweight** — binary under 10MB, sub-100MB RAM at idle
- **PipeWire-native** — no PulseAudio compatibility layer, no JACK bridge
- **AutoEQ-native** — import, preview, and apply correction profiles directly
- **Composable** — effect chains are just TOML/JSON files

---

## 2. Architecture Overview

```
┌─────────────────────────────────────────────┐
│                  TUI Layer                    │
│  ratatui + crossterm                         │
│  ┌─────────┐ ┌──────────┐ ┌──────────────┐  │
│  │ Node    │ │ Effect   │ │ Spectrum /   │  │
│  │ Picker  │ │ Chain    │ │ Level Meters │  │
│  └─────────┘ └──────────┘ └──────────────┘  │
├─────────────────────────────────────────────┤
│              Application Core                │
│  ┌──────────────┐ ┌──────────────────────┐  │
│  │ Preset Store  │ │  AutoEQ Importer     │  │
│  │ (TOML/JSON)  │ │  (parse PEQ output)  │  │
│  └──────────────┘ └──────────────────────┘  │
│  ┌──────────────────────────────────────┐   │
│  │         Effect Engine                 │   │
│  │  EQ │ Compressor │ Gate │ Reverb ...  │   │
│  │  (trait-based plugin system)          │   │
│  └──────────────────────────────────────┘   │
├─────────────────────────────────────────────┤
│           PipeWire Integration               │
│  pipewire crate (v0.9.x)                    │
│  ┌──────────┐ ┌──────────┐ ┌────────────┐  │
│  │ pw_main  │ │ pw_filter│ │ pw_context │  │
│  │ _loop    │ │ (per fx) │ │ (nodes)    │  │
│  └──────────┘ └──────────┘ └────────────┘  │
└─────────────────────────────────────────────┘
```

### Crate Stack

| Crate | Version | Phase | Purpose |
|-------|---------|-------|---------|
| `pipewire` | 0.9.x | 0 | PipeWire client bindings |
| `ratatui` | 0.30 | 0 | Terminal UI framework |
| `crossterm` | 0.29 | 0 | Terminal input/raw mode (with `event-stream` feature) |
| `color-eyre` | 0.6 | 0 | Error reporting |
| `dasp` | 0.11 | 1 | DSP primitives (biquad filters, envelopes) |
| `serde` + `serde_json` | 1.x | 1 | Config deserialization, preset serialization |
| `toml` | 0.8 | 1 | Config/preset file format |
| `dirs` | 6 | 1 | XDG config directory (`~/.config/eqtui/`) |
| `tui-input` | 0.15 | 1 | Text input widget for value editing in Insert mode |
| `csv` | 1.3 | 3 | Parse frequency response CSVs (Path B: auto-fit) |
| `rustfft` | 6.x | 4 | FFT for spectrum analyzer |
| `clap` | 4.x | 4 | CLI argument parsing |

**Linting:** Enable `clippy::pedantic` from Phase 1 (pattern: bluetui). Catch issues early.

### Plugin Trait (analogous to EasyEffects `PluginBase`)

```rust
trait EffectPlugin {
    fn name(&self) -> &str;
    fn setup(&mut self, rate: u32, channels: u16);
    fn process(&mut self, input: &[f32], output: &mut [f32]);
    fn bypass(&self) -> bool;
    fn set_bypass(&mut self, bypass: bool);
    fn latency_samples(&self) -> u32;
    fn reset(&mut self);
}
```

### Key Architectural Difference from EasyEffects

EasyEffects uses a **flat map of individually-wrapped PipeWire filters**, each getting connected as a separate PW node. For eqtui, we have two options:

1. **Mirror EasyEffects** — one `pw_filter` per effect. Pros: modular, matches upstream architecture. Cons: more PW overhead, sync complexity.

2. **Single filter, internal chain** — one `pw_filter`, chain effects internally. Pros: simpler, lower latency. Cons: harder to reorder effects at runtime.

**Decision: Start with Option 2** (single filter, internal chain). This approach is simpler to implement and debug. Transitioning to Option 1 can occur as required.

---

## 3. Phased Roadmap

### Phase 0 — Hello PipeWire [x]

**Goal:** Prove the stack works. Connect to PipeWire, list nodes, display in TUI.

**Tasks:**

- [x] Add `pipewire` and `libspa` crate dependencies
- [x] Initialize PW main loop in a background thread
- [x] Enumerate audio sink/source nodes
- [x] Build TUI screen with:
  - Node list (main panel)
  - Detail panel (selected node info)
  - Status bar (PW connection state, node count)
- [x] Handle terminal resize and graceful shutdown

**Deliverable:** Binary that opens a TUI, lists your speakers and microphone, exits cleanly on `q`.

**Files:** `src/main.rs`, `src/pw.rs`, `src/state.rs`, `src/tui.rs`

**Notes:**

- Architecture: PW thread communicates with TUI thread via `pipewire::channel` (TUI→PW) + `std::sync::mpsc` (PW→TUI)
- Uses `MainLoopRc`/`ContextRc`/`CoreRc` (reference-counted) for closure-friendly ownership
- Node hotplug (add/remove) support is scaffolded in `PwEvent` enum but not yet wired

---

### Phase 1 — Your First Effect (Equalizer) ✅

**Goal:** Create a working audio pipeline with one effect. The equalizer is the killer feature — it directly enables AutoEQ import.

**New dependencies:** `dasp`, `serde`, `serde_json`, `toml`, `dirs`, `tui-input`

**Status:** Complete. 20 files, 0 warnings, 20 passing tests.

**Architecture (adopted):**

- **Event-driven main loop** — `EventHandler` merges crossterm events + tick timer into single `Event` enum
- **`FocusedBlock` dispatching** — Devices | Pipeline | CommandBar, routes keys per-panel
- **Panic hook** — restores terminal on crash
- **Centered layout** — max 140 columns with margin, focused panel gets thick green border (bluetui pattern)
- **Table-based panels** — both Devices and EQ use `Table` + `TableState` for uniform look
- **Column navigation** — `h/l` or `←/→` to select Freq/Gain/Q/Type column
- **Text-based value editing** — Insert mode uses `tui-input` crate; type exact values, Enter to commit and clamp, Esc to cancel
- **Context-sensitive status bar** — per-mode keybinding hints, command input display
- **FFI bindings** — `pw_filter` C API (~110 lines unsafe), `pw_filter_ffi.rs`
- **SPA format negotiation** — F32LE, 48000Hz, 2ch via PodSerializer, passed to `pw_filter_connect()`
- **State logging** — filter state transitions printed to stderr (UNCONNECTED → CONNECTING → STREAMING)

**File structure:**

```
src/
├── lib.rs             — module declarations, AppResult<T>
├── main.rs            — event loop, Pipeline creation, TUI init
├── app.rs             — App state (focused_block, mode, eq_bands, cell_input, pipeline)
├── config.rs          — TOML config (~/.config/eqtui/config.toml)
├── event.rs           — Event enum + EventHandler (crossterm + tick thread)
├── pipeline.rs        — Pipeline { eq, bypass(AtomicBool) }
├── pw.rs              — PW thread + pw_filter integration + SPA format negotiation
├── pw_filter_ffi.rs   — FFI bindings for pw_filter C API
├── state.rs           — NodeInfo, EqBand, FilterType, PwEvent/PwCommand
├── effects/
│   ├── mod.rs         — EffectPlugin trait
│   └── equalizer.rs   — RBJ biquad EQ (Peak, LowShelf, HighShelf), 7 tests
├── handler/
│   ├── mod.rs         — dispatcher: Mode → sub-handler
│   ├── normal.rs      — j/k row, h/l col, a add, dd delete, g g, i insert, v visual, : command, b bypass, r/R reset, 2 tests
│   ├── insert.rs      — tui-input text entry, Enter commit+clamp, Esc cancel, 6 tests
│   ├── visual.rs      — j/k extend selection, d delete batch
│   └── command.rs     — :q, :flat, :add, :bypass
└── tui/
    ├── mod.rs         — Tui struct + centered render routing (Max 140)
    ├── devices.rs     — device Table with focused/unfocused borders
    ├── eq_table.rs    — band Table with column highlighting + cell_input display
    └── status.rs      — centered per-mode keybinding hints
```

**Tasks (all complete):**

- [x] Write FFI bindings for pw_filter
- [x] EventHandler pattern + Event enum
- [x] Tui struct with panic hook
- [x] Config system (TOML)
- [x] Filter integration: pw_filter with stereo ports, SPA format negotiation, RT_PROCESS
- [x] EffectPlugin trait + RBJ biquad EQ (3 filter types)
- [x] Pipeline chain with AtomicBool bypass
- [x] EQ band Table with column selection + focused/unfocused border styling
- [x] Centered layout (Max 140) with margin
- [x] Per-mode handlers (Normal, Insert with tui-input, Visual, Command)
- [x] Status bar with per-mode context hints
- [x] FocusedBlock toggle (Tab cycles Devices → Pipeline → back)
- [x] Pipeline wired to TUI: band edits sync to biquad coefficients in real time
- [x] 20 unit tests (7 EQ + 3 pipeline + 2 normal + 6 insert + 1 app + 1 init)
- [x] EQ curve graph deferred to Phase 4

---

### Phase 2 — Virtual Sink + Daemon + Simple PEQ Import

**Goal:** Make eqtui appear as a selectable output device in system settings, split into daemon/client architecture so EQ persists when TUI is closed, then add simple PEQ file import.

**New dependencies:** none

**Phase 2A — Virtual Null Sink:** ✅

Currently the pw_filter processes audio but is invisible to users — they can't select it as an output device. We solve this by creating a **null audio sink** that appears in GNOME/KDE sound settings as "eqtui Equalizer".

```
[Apps] → PipeWire → [eqtui Null Sink] → [our pw_filter] → [Real Speakers]
                        ↑
                 Selectable in system settings
```

- [x] Load PipeWire `module-null-sink` at startup to create virtual sink
- [x] Name it "eqtui Equalizer" (appears in volume control)
- [x] Set properties: `media.class=Audio/Sink`, `node.description=eqtui Equalizer`
- [x] Connect pw_filter to the null sink (capture from monitor, output to default sink)
- [x] Auto-remove null sink on shutdown

**How it works for users:**

1. Open GNOME/KDE sound settings
2. Select "eqtui Equalizer" as output
3. All system audio routes through eqtui's EQ

**Phase 2B — Daemon Architecture:**

Currently eqtui runs as a single process — closing the TUI destroys the PipeWire filter and EQ stops. Phase 2B splits the binary into daemon and client modes using a Unix socket for IPC.

```
┌─────────────────────────────────────────────────────────┐
│  eqtui daemon  (headless, owns PipeWire + EQ engine)    │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐   │
│  │ Unix socket  │  │ PW Thread    │  │ EQ Pipeline  │   │
│  │ listener     │  │ (null sink + │  │ (biquad DSP) │   │
│  │              │  │  pw_filter)  │  │              │   │
│  └──────┬───────┘  └──────────────┘  └──────────────┘   │
└─────────┼───────────────────────────────────────────────┘
          │ $XDG_RUNTIME_DIR/eqtui.sock
          │ JSON-line protocol
     ┌────┴────┐
     │         │
┌────┴───┐ ┌───┴──────┐
│eqtui   │ │eqtui load│
│attach  │ │peq.txt   │
│(TUI)   │ │(CLI)     │
└────────┘ └──────────┘
```

**Design decisions:**

- **Same binary, different mode** — `eqtui daemon` starts the background process, `eqtui attach` starts the TUI client. No separate daemon binary needed.
- **Auto-launch on attach** — if no daemon is running, `eqtui attach` spawns one automatically (fork + exec).
- **Unix socket at `$XDG_RUNTIME_DIR/eqtui.sock`** — standard location, auto-cleaned on logout.
- **JSON-line protocol** — each message is a JSON object on one line, easy to debug with `nc -U`:
  ```json
  {"cmd":"set_bands","bands":[{"freq":1000,"gain":6.0,"q":1.0,"type":"Peak"}]}
  {"cmd":"get_status"}
  {"cmd":"set_preamp","gain":-6.0}
  {"cmd":"set_bypass","bypass":true}
  ```
- **Stateless protocol** — daemon is the source of truth. Clients query state, send commands.
- **Concurrent clients** — multiple TUI/CLI clients can connect simultaneously (last write wins for conflicting changes).
- **No async runtime needed** — `std::os::unix::net::UnixListener` with one thread per client is sufficient given low concurrency (1-2 clients typical).

**Tasks:**

- [ ] Add subcommand dispatch to `main.rs` (`daemon` | `attach` | `load` | `status` | `stop`)
- [ ] Extract PipeWire thread + Pipeline into `Daemon` struct that runs without TUI
- [ ] Implement Unix socket listener in daemon (`std::os::unix::net::UnixListener`)
- [ ] Define JSON protocol messages (`src/protocol.rs`): `set_bands`, `set_preamp`, `set_bypass`, `get_status`, `connect_device`, etc.
- [ ] Implement client-side Unix socket connection for TUI (`eqtui attach`)
- [ ] Auto-launch daemon from TUI if not running (fork + exec `$0 daemon`)
- [ ] CLI command: `eqtui status` — query daemon for current EQ state
- [ ] CLI command: `eqtui stop` — tell daemon to shut down gracefully
- [ ] Graceful shutdown: daemon cleans up null sink + pw_filter on SIGTERM/SIGINT
- [ ] Lock file at `$XDG_RUNTIME_DIR/eqtui.lock` to prevent duplicate daemons

**Deliverable:** Start `eqtui daemon`, then `eqtui attach` → TUI opens, EQ works, close TUI → audio stays EQ'd. Re-attach later → see same state.

**Files:** `src/daemon.rs`, `src/protocol.rs`, `src/cli.rs`

---

**Phase 2C — Simple PEQ Import:**

User provides a pre-computed PEQ file (from AutoEQ CLI, Squiglink export, etc.). eqtui parses it and populates the EQ band list. No subprocess calls, no headphone database, no fuzzy search — the user is responsible for obtaining the file.

**Supported format** (standard AutoEQ PEQ output):
```
Preamp: -6.0 dB
Filter 1: ON PK Fc 32 Hz Gain 2.5 dB Q 0.71
Filter 2: ON LSC Fc 105 Hz Gain 5.5 dB Q 0.71
Filter 3: ON HSC Fc 10000 Hz Gain -2.0 dB Q 0.70
```

**Tasks:**

- [ ] PEQ file parser (`src/autoeq/parser.rs`) — regex/string-based, no external deps
- [ ] Map parsed filters to `EqBand` structs (PK→Peak, LSC→LowShelf, HSC→HighShelf)
- [ ] `:load <path>` command in TUI (sends PEQ to daemon via Unix socket)
- [ ] `eqtui load <path>` CLI command (connects to daemon, pushes PEQ)
- [ ] Handle malformed files gracefully (error notification to user)
- [ ] Export: `:save <path>` writes current bands as PEQ file

**Deliverable:** Download a PEQ file from Squiglink, run `eqtui load hd600.txt`, EQ applied immediately.

**Files:** `src/autoeq/mod.rs`, `src/autoeq/parser.rs`

**Deferred (Phase 3):**
- AutoEQ browser TUI (`Ctrl+a`) with fuzzy-search by headphone name
- AutoEQ subprocess integration (auto-fit from FR curve)
- CSV loader for frequency response curves
- Headphone/IEM model database

---

### Phase 3 — Preset System + More Effects

**Goal:** Complete the effect roster and make configurations persistent.

**Tasks:**

- [ ] Preset system (TOML files in `~/.config/eqtui/presets/`)
  - Per-effect presets
  - Full pipeline presets (chain of multiple effects)
  - Import/export to EasyEffects JSON format for compatibility
- [ ] **AutoEQ Browser** — `Ctrl+a` opens fuzzy-search picker for headphone/IEM targets
  - Scan `../AutoEq/results/` directory for available targets
  - Fuzzy-search by model name
  - Preview EQ curve before applying
  - AutoEQ subprocess integration for auto-fit from FR curve
  - Headphone/IEM model database (deferred from Phase 2C)
- [ ] **Notification system** (bluetui pattern) — transient status messages with TTL-based expiry
  - "Preset saved", "AutoEQ imported", "Error: no PW connection", etc.
- [ ] **Spinner widget** (bluetui pattern) — visual feedback during async operations
  - AutoEQ subprocess, preset loading, FFT computation
- [ ] **Compressor** — threshold, ratio, attack, release, knee, makeup gain
- [ ] **Gate / Expander** — threshold, range, attack, release
- [ ] **Bass Enhancer** — harmonic synthesis
- [ ] **Stereo Tools** — balance, width, phase invert
- [ ] **Delay** — time, feedback, mix
- [ ] **Reverb** — room size, damping, width, mix (basic Schroeder reverberator)
- [ ] Reorderable effect chain in TUI (`Ctrl+Up`/`Ctrl+Down` to move effects)
- [ ] Per-effect bypass toggle and solo

**Deliverable:** Multiple effects in a reorderable chain, saved/loaded as presets, with status notifications.

**Files:** `src/notification.rs`, `src/spinner.rs`, `src/presets.rs`, per-effect files under `src/effects/`

---

### Phase 4 — Visualization, Polish & Power-User Features

**Goal:** Make it look and feel like a professional audio tool. Text-mode graphs, spectrum, CLI control.

**New dependencies:** `rustfft` (FFT), `clap` (CLI)

**Visualization suite:**

- [ ] **EQ curve graph** — real-time text graph of the EQ frequency response

  ```
  +10 ┤         ▄▄▄
   +5 ┤  ▄▄▄▄▄▄▄ ▀▀▀▀▄▄▄▄         ▄▄▄▄▄▄
    0 ┤▀▀▀▄▄▄▄▄▄▄▄▄▄▄▄▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
   -5 ┤                   ▀▀▀▀
  -10 ┤
       ├──┼──┼──┼──┼──┼──┼──┼──┼──┼──
       32  64 125 250 500 1k  2k  4k  8k 16k
  ```

  - Unicode block characters (▀▄█▌) for smooth-looking curve
  - Updates live as band parameters change
  - Dual overlay: pre-EQ vs post-EQ (when spectrum added)

- [ ] **Spectrum analyzer** — real-time FFT of audio signal
  - Pre- and post-EQ spectrum (dual color overlay)
  - Configurable smoothing, decay, bar/line/block mode
  - Frequency axis labels (log scale)

- [ ] **Level meters** — per-effect input/output

  ```
  Output L ████████░░ -12 dB
  Output R ████████░░ -14 dB
  ```

  - Peak hold with configurable decay
  - Color gradient: green → yellow → red

- [ ] **Graphic EQ mode** — switch between parametric and graphic EQ
  - 15/31 fixed-frequency bands mapped from imported CSV curve
  - Toggle: `:graphic-eq` / `:parametric-eq`

**Power-user features:**

- [ ] **Limiter** — brickwall peak protection (sits last in chain)
- [ ] **Loudness normalization** — EBU R128 / ITU-R BS.1770
- [ ] **Noise reduction** — basic spectral gate (simpler than RNNoise)
- [ ] **CLI mode** — apply preset from command line (`eqtui apply hd600 --pipeline output`)
- [ ] **Daemon mode** — run headless, controlled via CLI or Unix socket
- [ ] Config file — `~/.config/eqtui/config.toml`
  - Theme (colors, graph style, block vs line characters)
  - Default pipeline, default AutoEQ target directory
  - Keybinding overrides

**Files:** `src/visuals/`, `src/visuals/curve.rs`, `src/visuals/spectrum.rs`, `src/visuals/meters.rs`, `src/cli.rs`, `src/daemon.rs`

---

### Phase 5 — Packaging & Distribution

**Goal:** Get it into users' hands.

**Tasks:**

- [ ] CI/CD — GitHub Actions for build + test + lint
- [ ] Binary releases — `.tar.gz` for Linux x86_64
- [ ] AUR package — `eqtui-bin` and `eqtui-git`
- [ ] Nix flake
- [ ] Cargo crate publish
- [ ] Man page
- [ ] README with screenshots and quickstart
- [ ] Shell completions (bash, zsh, fish)

---

## 4. Technical Decisions

### Why Rust (not C++, not Python)

| Factor | C++ (EasyEffects) | Python | Rust |
|--------|-------------------|--------|------|
| DSP performance | Excellent | Poor (GIL, interpreted) | Excellent |
| PW bindings | Native headers | ctypes/ffi | First-class crate |
| TUI ecosystem | ncurses (painful) | Textual/rich | ratatui (best-in-class) |
| Binary distribution | Shared lib hell | Needs Python + deps | Single static binary |
| Safety | Manual memory mgmt | GC'd but slow | Borrow checker |

Rust gives us C++-level performance with Python-level ergonomics for the high-level parts, and a single static binary for distribution.

### Why ratatui (not tui-rs, not Textual)

`ratatui` is the community-maintained fork of `tui-rs` and the only actively developed Rust TUI framework. It has:

- Immediate-mode rendering (no retained widget tree)
- Flexbox-like layout system
- Styling via `Span`/`Line` combinators
- Active ecosystem (crossterm, termion, termwiz backends)

### What Is Not Being Ported (and Why)

| EasyEffects Feature | Reason to Skip |
|---------------------|----------------|
| LV2 plugin host | High complexity. Native DSP implementation is more portable. |
| RNNoise / DeepFilterNet | ML inference engines are outside the current scope. |
| Convolution reverb | Requires impulse response management and partitioned convolution. |
| QML/Kirigami UI | Primary goal is to avoid graphical toolkit dependencies. |
| DBus API | Headless control is provided via CLI and Unix sockets. |
| Flatpak sandboxing | Standard package managers (AUR/Nix/Cargo) are prioritized. |

---

## 5. Risk Log

| Risk | Impact | Mitigation |
|------|--------|------------|
| `pipewire` crate is incomplete | Connection failure | Early testing (Phase 0). Fallback: raw FFI bindings for required subsets. |
| DSP implementation issues | Audio quality | Implementation of standard algorithms (biquad EQ). Use of validated DSP primitives. Alignment with industry-standard DSP parameters. |
| Terminal rendering limitations | Visual quality | Use of block characters (▄▀█) for spectrum and curves within terminal constraints. |
| PipeWire callback latency | Audio artifacts | Maintenance of lock-free `process()` callbacks and use of ring buffers. Profiling with `perf`. |
| Feature creep | Project delay | Adherence to phased development. Prioritization of core functionality. |

---

## 6. Current State

- **Rust toolchain:** 1.95.0 (2026-04-14)
- **Phase 0:** ✅ Complete — PipeWire connection, node enumeration, TUI display
- **Phase 1:** ✅ Complete — EQ engine + vim-mode TUI, 20 tests, centered layout, column nav, tui-input, SPA format negotiation
- **Phase 2A:** ✅ Complete — Virtual null-audio-sink with media.class=Audio/Sink for wiremix compatibility, proxy bound listener, filter wired to null sink monitor ports, correct teardown order
- **Crates in use:** ratatui 0.30, crossterm 0.29, pipewire 0.10, pipewire-sys 0.10, libspa-sys 0.10, color-eyre 0.6, serde 1.x, toml 1.1, dirs 6, tui-input 0.15
- **Next action:** Phase 2B — Daemon architecture (Unix socket IPC, daemon/client split)

---

*Last updated: 2026-05-21*
