# Architecture
How eqtui works — from typing `eqtui daemon` to equalized audio.
---
## Process Model (Daemon + Client)
eqtui runs as two processes communicating over a Unix socket:
```
┌──────────────────── Daemon Process ────────────────────────────────┐
│ $XDG_RUNTIME_DIR/eqtui.sock │
│ ┌──────────────────┐ ┌────────────────────────────────────────┐ │
│ │ Accept Thread │ │ PW Thread (owns PipeWire + EQ) │ │
│ │ + Client │ │ ┌──────────┐ ┌──────────┐ │ │
│ │ Handlers │ │ │ Null Sink│→ │ pw_filter│→ output │ │
│ └────────┬─────────┘ │ └──────────┘ └──────────┘ │ │
│ │ │ Bridge Thread: PW events → JSON push │ │
│ │ │ Peak Thread: 15fps broadcast │ │
│ │ └────────────────────────────────────────┘ │
└───────────┼────────────────────────────────────────────────────────┘
│ JSON-line protocol
┌───────┴───────┐
│ │
┌───┴───┐ ┌─────┴──────┐
│ eqtui │ │ eqtui stop │
│attach │ │ (CLI) │
│ (TUI) │ └────────────┘
└───────┘
```
- **Daemon** (`eqtui daemon`) — background process, owns PipeWire + EQ engine. Runs headless.
- **TUI** (`eqtui attach`) — terminal client, connects via Unix socket. Closing the TUI does **not** stop the EQ.
- **CLI** (`eqtui stop`, `eqtui load`) — fire-and-forget commands via the same socket.
The daemon is the single source of truth. All EQ state lives there. Clients are thin — they send commands, receive responses, and display results.
---
## Daemon Threads
Inside the daemon process:
```
┌─ Accept Thread ───────────┐
│ UnixListener::accept() │──→ spawns one handler per client
│ │
├─ PW Thread ───────────────┤
│ MainLoop, null sink, │──mpsc──→ Bridge Thread
│ pw_filter, DSP pipeline │◄──cmd── Client handlers
│ │
├─ Bridge Thread ───────────┤
│ mpsc::recv(PwEvent) │──→ DaemonState.handle_pw_event()
│ │──→ broadcast PushEvent to clients
│ │
├─ Peak Thread ─────────────┤
│ pipeline.peaks() @ 15fps │──→ broadcast PeakUpdate to clients
│ │
├─ Client Handler × N ──────┤
│ BufReader::read_line() │──→ dispatch Request → DaemonState
│ JSON request/response │──→ send PwCommand to PW thread
└───────────────────────────┘
```
State is in `DaemonState` (one `Arc`, mutex-per-field):
| `pipeline` | `Arc<Pipeline>` | DSP chain (shared with PW callback) |
| `nodes` | `Mutex<Vec<NodeInfo>>` | Audio device list |
| `eq_bands` | `Mutex<Vec<EqBand>>` | Canonical EQ band list |
| `bypass` | `Mutex<bool>` | Bypass state |
| `filter_node_id` | `Mutex<Option<u32>>` | Filter's PW node ID (for link commands) |
| `clients` | `Mutex<Vec<ClientHandle>>` | Connected client channels for push events |
---
## TUI Threads
Inside the TUI client process (unchanged from single-process days):
```
┌─ Main Thread ─────────────┐
│ Keyboard → event router │──→ handler::dispatch(key, &app)
│ ratatui renderer │──→ tui::render(&app, frame)
│ │
│ drain_events() each frame │◄── Unix socket (non-blocking)
│ send commands via socket │──→ Unix socket (blocking)
│ │
├─ Event Thread ────────────┤
│ crossterm poll @ 30fps │──→ Tick / Key / Resize → main thread
└───────────────────────────┘
```
Threads communicate through `DaemonClient`:
| TUI → Daemon | `client.request(req)` → socket write + blocking read | `SetBands`, `ConnectDevice`, `GetStatus`, ... |
| Daemon → TUI | `client.try_read_event()` → non-blocking socket read | `PeakUpdate`, `NodeList`, `FilterReady`, ... |
---
## Startup Sequence
```
Terminal 1: Terminal 2:
eqtui daemon eqtui attach
│ │
├─ Lock file check ├─ Connect to $XDG_RUNTIME_DIR/eqtui.sock
├─ Pipeline::new(SAMPLE_RATE) │ ├─ Connected → get_status() → populate App
├─ DaemonState::new() │ └─ Connection refused
├─ Bind UnixListener │ ├─ Spawn eqtui daemon (fork+exec)
├─ Spawn PW thread │ └─ Retry connect (3s timeout)
│ └─ null sink → pw_filter │
├─ Spawn bridge thread ├─ full_sync() → pull daemon state
├─ Spawn peak thread ├─ TUI init (raw mode, alt screen)
└─ Accept loop (blocking) └─ Main loop
```
Auto-launch: if `eqtui attach` finds no daemon, it spawns `eqtui daemon` in the background and retries.
---
## Main Loop (TUI)
Every iteration does three things:
```
1. DRAIN push events from daemon (non-blocking):
client.try_read_event() → app.handle_push_event(event)
Event → Handler response
─────────────────────────────────────
PeakUpdate → store raw peaks for tick()
NodeList → update device table
FilterReady → store filter_node_id (enables C key)
NullSinkCreated → mark null sink loaded
SourceActive → update input monitor
StateChange → note state transition
Error → log to stderr
2. GET next TUI event (blocking with timeout):
- Tick @ 30fps → app.tick() → dBFS conversion + decay on cached peaks
- Key pressed → handler::dispatch(key, &mut app)
→ side effects sent to daemon via app.client().request(...)
- Resize → no-op
3. RENDER:
tui.draw(|frame| tui::render(&app, frame))
→ devices panel | EQ table | monitoring | status bar
```
Handler dispatch no longer returns commands — all mutations go directly through `App`'s daemon client:
| User action | Handler call | Daemon request |
|-------------|-------------|----------------|
| Edit band | `app.sync_bands()` | `Request::SetBands { bands }` |
| Toggle bypass | `app.sync_bypass()` | `Request::SetBypass { bypass }` |
| Press `C` on device | `app.toggle_device_connection(id)` | `Request::ConnectDevice { node_id }` |
---
## Audio Pipeline
```
[ Spotify ]
│ PipeWire routes audio to the selected output
▼
┌───────────────────┐
│ NULL SINK │ media.class = Audio/Sink
│ "eqtui Equalizer"│ PortConfig ✓ (wiremix monitors without errors)
│ │
│ Audio enters │ monitor.passthrough = true
│ → monitor port │ audio passes through silently
└────────┬──────────┘
│ captured from monitor port
▼
┌──────────────────┐
│ pw_filter │ no media.class (wiremix ignores — no PortConfig crash)
│ (hidden) │
│ │
│ process_cb() │ called by PipeWire real-time thread
│ → Pipeline:: │ stereo F32LE, 48 kHz, 1024 samples
│ process() │ → EQ chain → bypass check → peak detection
└────────┬─────────┘
│ equalized output (routed to one or more output devices via C key)
▼
┌───────────────┐
│ Output Device │ (user selects which devices receive equalized audio)
│ [ Speakers ] │
└───────┬───────┘
│
┌───────┴───────┐
│ Output Device │ (multiple devices can receive simultaneously)
│ [ Headphones ]│
└───────────────┘
```
### Why the null sink?
A `pw_filter` node does not support `PortConfig` parameter enumeration. Audio mixers like wiremix subscribe to `PortConfig` on every monitored node. If the filter had `media.class=Audio/Sink`, wiremix would bind to it and crash on the unsupported parameter query.
The null sink (created via `support.null-audio-sink` adapter factory) is a real PipeWire node with full parameter support. It appears as a selectable output, wiremix monitors it safely, and the filter processes audio invisibly behind it.
### Null sink properties
| Property | Value | Purpose |
|----------|-------|---------|
| `media.class` | `Audio/Sink` | Visible in system settings and wiremix |
| `node.name` | `eqtui` | Internal identifier |
| `node.description` | `eqtui Equalizer` | User-visible label |
| `monitor.passthrough` | `true` | Audio flows through unchanged |
| `priority.session` | `0` | Avoid stealing default-sink role |
### Link Management (`pw-link`)
The audio graph has two critical link sets:
```
┌──────────────────────────┐
│ Null Sink (node A) │
│ ┌─────────────┐ │
│ │ monitor_FL │──┐ │
│ │ monitor_FR │──┤ │
│ └─────────────┘ │ │
└───────────────────┘ │
│ pw-link A:monitor_FL B:input_FL (automatic)
│ pw-link A:monitor_FR B:input_FR (automatic)
┌───────────────────┐ │
│ pw_filter (B) │◄─────┘
│ ┌─────────────┐ │
│ │ input_FL │ │
│ │ input_FR │ │
│ │ output_FL │──┤──────────┬────────────────────────┐
│ │ output_FR │──┤ │ │
│ └─────────────┘ │ ┌──────┴──────┐ ┌──────┴──────┐
└───────────────────┘ │ Device 1 (C)│ │ Device 2 (D)│
│ playback_FL │ │ playback_FL │
│ playback_FR │ │ playback_FR │
└─────────────┘ └─────────────┘
pw-link B:output_FL C:playback_FL pw-link B:output_FL D:playback_FL
pw-link B:output_FR C:playback_FR pw-link B:output_FR D:playback_FR
(manual — press C on device) (manual — press C on device)
```
| Phase | Trigger | What happens |
|-------|---------|-------------|
| **Monitor links** (automatic) | `pw_filter` reaches PAUSED/STREAMING | Null sink monitor → filter input. Created automatically once at startup. |
| **Output links** (manual) | User presses `C` on a device in the TUI | Filter output → device playback. Each device toggled independently. |
**Why `pw-link` instead of the in-process API?** Spawning `pw-link` as an external process delegates link negotiation to PipeWire's own tested tool, avoiding intermittent failures with the in-process SPA link factory.
**Multi-device routing:** The filter is created once at startup. Pressing `C` triggers `Request::ConnectDevice` via Unix socket → daemon spawns `pw-link`. No filter teardown needed.
---
## EQ Engine
```
Client (TUI) Daemon
───────────── ──────
App.eq_bands: Vec<EqBand>
│
│ sync_bands() → client.set_bands(&bands)
│ → socket send {"cmd":"SetBands","bands":[...]}
│ │
│ ▼
│ DaemonState.apply_bands()
│ → Pipeline::set_bands()
│ → Equalizer::set_bands()
│
│ each band → biquad_coefficients() (RBJ Audio Cookbook)
│ w0 = 2π × freq / sample_rate
│ alpha = sin(w0) / (2 × Q)
│ → b0, b1, b2, a1, a2 (5 coefficients per band)
│
▼
Vec<BiquadCoeffs> + Vec<BiquadState> (per-channel state)
│
│ during audio callback (process_cb):
▼
process(left_in, right_in, left_out, right_out)
for each sample:
for each band:
y = b0·x[n] + b1·x[n-1] + b2·x[n-2] - a1·y[n-1] - a2·y[n-2]
```
### Filter types
| Type | What it does |
|:------|:-------------|
| `Peak` | Bell-shaped boost/cut at a center frequency |
| `LowShelf` | Boost/cut everything below a corner frequency |
| `HighShelf` | Boost/cut everything above a corner frequency |
### Thread safety
| Resource | Protected by | Access pattern |
|----------|-------------|---------------|
| Biquad coefficients | `RwLock<Vec<BiquadCoeffs>>` | Read-heavy (audio), write-rare (param changes) |
| Filter state (x1,x2,y1,y2) | `RwLock<Vec<BiquadState>>` | Write-only by audio callback |
| Bypass flag | `AtomicBool` | Read-heavy, write-rare |
| Peak values | `AtomicU32` (lock-free) | Write by audio callback, read by daemon peak thread |
| DaemonState fields | Per-field `Mutex` | Read/write by socket handlers and bridge thread |
---
## Peak Meters
Two-hop path in daemon mode:
```
Pipeline::process() — audio callback (PW RT thread):
max_l = max(max_l, abs(sample))
self.peak_l.store(max_l.to_bits(), Relaxed) ← lock-free atomic write
Daemon peak thread @ 15fps:
(l, r) = pipeline.peaks()
broadcast PushEvent::PeakUpdate { l, r } ← JSON to all clients
App::handle_push_event():
self.cached_peak_l = l ← raw linear value (0–1)
self.cached_peak_r = r
App::tick() — TUI main thread @ 30fps:
new_l = 20 * log10(cached_peak_l + ε) ← convert to dBFS
→ clamp(-60, 0)
→ attack: instant snap to higher peak
→ decay: 0.8 dB per tick (~24 dB/sec)
→ store in app.peak_l / app.peak_r
→ status.rs renders as LineGauge:
Output L ████████░░ -12 dB
Output R ████████░░ -14 dB
```
---
## TUI Layout
```
┌─ Devices Panel ─ ──────────────────────────────────────┐
│ Cls Name ID Conn │
│ ▶ Speakers 123 ✓ │
│ 🎧 Headphones 456 ✗ │
│ ⎳ eqtui Equalizer 789 — │
├─ EQ Table ─────── ─────────────────────────────────────┤
│ # Freq(Hz) Gain(dB) Q Type │
│ 1 1000.0 ▼ +6.0 ██ 1.00 Peak │
│ 2 200.0 +3.0 0.71 LowShelf │
│ 3 8000.0 -2.0 0.70 HighShelf │
├─ Monitoring ───────────────────────────────────────────┤
│ Core: Connected Output L ████████░░ -12 dB │
│ Source: active Output R ████████░░ -14 dB │
│ State: STREAMING │
│ Outputs: 1 │
│ Null Sink: Loaded (ID 123) │
├─ Status Bar ───────────────────────────────────────────┤
└────────────────────────────────────────────────────────┘
```
### Vim-like Modes
| **Normal** | Default | `j/k` navigate bands/devices, `h/l` navigate columns (Freq/Gain/Q/Type), `a` add band, `dd` delete, `b` toggle bypass, `r`/`R` reset, `C` toggle device connection |
| **Insert** | `i` | Type exact values, `Enter` commits with clamping, `Esc` cancels |
| **Visual** | `v` | `j/k` extend selection, `d` delete all selected |
| **Command** | `:` | `:w` save preset, `:flat` reset to 0dB, `:q` quit |
---
## Shutdown
### TUI disconnect (daemon keeps running)
```
q pressed in TUI:
1. app.running = false → main loop exits
2. tui.exit() → restore terminal
3. DaemonClient dropped → socket closed
4. Daemon handler thread exits, cleans up client slot
5. Daemon + PW thread + EQ keep running
```
### Daemon stop (`eqtui stop` or `Request::Shutdown`)
```
Client sends {"cmd":"Shutdown"}
1. Daemon sets shutting_down = true
2. Sends PwCommand::Terminate to PW thread
3. PW thread:
a) pw_filter_set_active(false)
b) pw_filter_disconnect()
c) pw_filter_destroy()
d) pw_proxy_destroy(null_sink)
e) mainloop.quit()
4. Accept loop breaks
5. Bridge + peak threads exit
6. pw_thread.join()
7. Remove socket file + lock file
8. Daemon process exits
```
---
## File Map
| File | Role |
|:------|:------|
| `main.rs` | Subcommand dispatch (`daemon` \| `attach` \| `stop`) |
| `daemon.rs` | Daemon process: `DaemonState`, Unix socket listener, client handlers, PW bridge |
| `protocol.rs` | IPC types: `Request`, `Response`, `PushEvent`, `DaemonStatus` (serde JSON-line) |
| `client.rs` | `DaemonClient` — Unix socket connection, request/response, push event polling |
| `app.rs` | TUI client state: UI fields + daemon-synced audio state |
| `pipeline.rs` | Audio chain: EQ → bypass → peak detection + `SAMPLE_RATE` constant |
| `state.rs` | Data types: `PwEvent`, `PwCommand`, `EqBand`, `NodeInfo`, `FilterType`, etc. |
| `pw/run.rs` | PipeWire thread: null sink, pw_filter, link management |
| `pw/filter.rs` | DSP filter FFI bindings, SPA format negotiation |
| `pw/null_sink.rs` | Virtual null-audio-sink creation and lifecycle |
| `pw/links.rs` | External `pw-link` process management |
| `pw/props.rs` | PipeWire properties RAII wrapper |
| `effects/equalizer.rs` | RBJ biquad filter implementation |
| `effects/mod.rs` | `EffectPlugin` trait |
| `event.rs` | Event thread: keyboard polling + 30fps tick timer |
| `handler/mod.rs` | Mode-based key dispatch |
| `handler/normal.rs` | Normal-mode key dispatch |
| `handler/insert.rs` | Insert-mode text entry + commit/clamp |
| `handler/visual.rs` | Visual-mode batch selection + delete |
| `handler/command.rs` | Command-mode colon commands |
| `tui/mod.rs` | Terminal init/exit, layout router |
| `tui/devices.rs` | Device list table rendering |
| `tui/eq_table.rs` | Equalizer band table rendering |
| `tui/status.rs` | Status bar: mode hints, peak meters, null sink status |
| `tui/graph.rs` | EQ frequency response curve |
| `config.rs` | TOML config file parsing |