# 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):

| Field | Type | Purpose |
|-------|------|---------|
| `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`:

| Direction | Mechanism | Carries |
|-----------|-----------|---------|
| 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 ───────────────────────────────────────────┤
│ NORMAL │ j/k,↕ Row | h/l,↔ Col | +/- Bump | i Edit |  │
│   a Add | dd Del | C Connect | Tab Focus | q Quit      │
└────────────────────────────────────────────────────────┘
```

### Vim-like Modes

| Mode | Trigger | What you can do |
|------|---------|----------------|
| **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 |
