# 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
| `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:**
- [ ] 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)
| 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)
| 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
| `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*