a Jellyfin & Subsonic client for the terminal — powered by mpv, Chromecast and UPnP MediaRenderer
mpv chromecast mpris navidrome jellyfin upnp tui
0

Configure Feed

Select the types of activity you want to include in your feed.

Add 10-band Rockbox equalizer

Links rockbox-dsp 0.1 from crates.io — a Rust wrapper around the fixed-point
Rockbox DSP pipeline (10-band EQ + tone + resampler + more).

Config
- fin-config: eq_enabled + eq_band_settings at the top level, matching the
on-disk shape rockbox uses so presets round-trip. A fresh config is
auto-populated with the ISO-octave flat preset (32 Hz → 16 kHz, Q 7.0,
0 dB) via Config::ensure_eq_bands() on load, so the sliders always show
10 bands and the DSP has something meaningful to process. Old configs
with fewer bands get padded to 10 without touching what's there.

Audio path
- SymphoniaPlayer owns an Option<Dsp> singleton, lazy-initialised on the
first track load. When eq_enabled is true, the *current* track's post-
resample samples run through Dsp (f32 → i16 saturating → dsp_process →
i16 → f32) before volume + ReplayGain + fade multiply. Non-stereo output
bypasses (rockbox-dsp is stereo-only).
- The next track during a crossfade always bypasses — the Rockbox pipeline
is a process-wide singleton (CODEC_IDX_AUDIO), can't run two streams.
- New PlayerCommand::SetEq for live toggle + band updates without a
reload; SetReplayGain-style forwarding through LocalRenderer.
- main.rs applies cfg.eq_enabled + cfg.eq_band_settings on startup.

TUI
- New EqSliders widget: 10 vertical block-char slider columns with a
+N.N dB label above, "dB" unit hint, and a Hz/kHz cutoff label below.
Selected band gets highlighted; a ±24 dB axis label sits in the corner.
- Settings screen grew a 3-row layout — global config, EQ panel, servers.
- Keybindings: E toggles EQ. On Settings: [ / ] cycle bands, Shift+↑/↓
bump the selected band's gain by ±1 dB. Changes persist to config.toml
immediately and are reapplied to the running DSP.
- Player bar shows a compact "EQ" badge when active.
- Help hint + README table of contents + keybindings table updated with
a new "Equalizer" sub-section describing the on-disk format, keys, and
the licensing implication (linking Rockbox DSP makes the binary GPL).

Verified live: Settings screen renders 10 sliders at the ISO cutoffs; E
lights the panel border; ] moves to band 3 (125 Hz); five Shift+Up
presses take it to +5.0 dB with the status line and the slider fill
updating in step; config.toml persists gain = 50 on band 3.

+569 -20
+10
Cargo.lock
··· 660 660 "rand", 661 661 "rb", 662 662 "reqwest", 663 + "rockbox-dsp", 663 664 "rust_cast", 664 665 "serde", 665 666 "serde_json", ··· 1934 1935 "libc", 1935 1936 "untrusted", 1936 1937 "windows-sys 0.52.0", 1938 + ] 1939 + 1940 + [[package]] 1941 + name = "rockbox-dsp" 1942 + version = "0.1.1" 1943 + source = "registry+https://github.com/rust-lang/crates.io-index" 1944 + checksum = "71ce29fced42c85faa0b1e8df27bc4ad527c066ccb3b6d7bce1345479cd2a4a7" 1945 + dependencies = [ 1946 + "cc", 1937 1947 ] 1938 1948 1939 1949 [[package]]
+52 -9
README.md
··· 36 36 - [Repeat](#repeat) 37 37 - [ReplayGain](#replaygain) 38 38 - [Crossfade](#crossfade) 39 + - [Equalizer](#equalizer) 39 40 - [Queue persistence](#queue-persistence) 40 41 - [Remote-renderer queue](#remote-renderer-queue) 41 42 - [Streams & transcoding](#streams--transcoding) ··· 63 64 AVTransport (`SetAVTransportURI` / `Play` / `Pause` / `Stop` / `Seek`) 64 65 and volume via RenderingControl. Same auto-advancing queue. 65 66 - **Playback modes** — shuffle, repeat-off/all/one, [ReplayGain](#replaygain) 66 - (track / album), and [crossfade](#crossfade) between adjacent tracks 67 - (traditional cosine curves *or* additive DJ-mixed). 67 + (track / album), [crossfade](#crossfade) between adjacent tracks 68 + (traditional cosine curves *or* additive DJ-mixed), and a 69 + 10-band [equalizer](#equalizer) powered by the Rockbox DSP pipeline. 68 70 - **Queue persistence** — the audio queue, shuffle/repeat state, and the exact 69 71 playhead within the current track survive restarts. Restore lands paused; 70 72 `Space` picks up where you left off. ··· 258 260 Audio-side playback settings live under TOML sub-tables and are toggled from 259 261 the TUI (see [Playback modes & effects](#playback-modes--effects)): 260 262 261 - | TOML | Default | Notes | 262 - |-----------------------------------|---------|----------------------------------------| 263 - | `replaygain.mode` | `off` | `off` / `track` / `album` | 264 - | `replaygain.preamp_db` | `0.0` | additive in dB before clip guard | 265 - | `replaygain.prevent_clip` | `true` | caps gain so `linear * peak <= 1.0` | 266 - | `crossfade.mode` | `off` | `off` / `crossfade` / `mixed` | 267 - | `crossfade.duration_secs` | `5.0` | overlap window in seconds | 263 + | TOML | Default | Notes | 264 + |-----------------------------------|----------------|----------------------------------------------------------| 265 + | `replaygain.mode` | `off` | `off` / `track` / `album` | 266 + | `replaygain.preamp_db` | `0.0` | additive in dB before clip guard | 267 + | `replaygain.prevent_clip` | `true` | caps gain so `linear * peak <= 1.0` | 268 + | `crossfade.mode` | `off` | `off` / `crossfade` / `mixed` | 269 + | `crossfade.duration_secs` | `5.0` | overlap window in seconds | 270 + | `eq_enabled` | `false` | toggle the Rockbox 10-band EQ pipeline | 271 + | `[[eq_band_settings]]` | ISO octave | 10 bands (see [Equalizer](#equalizer)); Rockbox-compatible | 268 272 269 273 Find the on-disk config with `fin config --path`; print it with 270 274 `fin config --show`. ··· 333 337 | `Shift+R` | cycle repeat mode (off → all → one) | 334 338 | `g` | cycle ReplayGain (off → track → album) | 335 339 | `f` / `Shift+F` | cycle crossfade mode / cycle crossfade duration (3, 5, 8, 12 s) | 340 + | `Shift+E` | toggle 10-band Rockbox EQ | 341 + | `[` / `]` | (Settings) select previous / next EQ band | 342 + | `Shift+↑` / `Shift+↓` | (Settings) nudge the selected EQ band's gain by ±1 dB | 336 343 | `Space` or `p` | pause / resume | 337 344 | `s` | stop | 338 345 | `<` / `>` or `h` / `l` | previous / next track | ··· 389 396 overlap and the OS mixer sums them. The overlap kicks in both on natural 390 397 end-of-track transitions AND when you Play a new album or jump to a new 391 398 queue entry — so switching tracks manually still fades cleanly. 399 + 400 + ### Equalizer 401 + 402 + `fin` links the Rockbox DSP pipeline 403 + ([`rockbox-dsp`](https://crates.io/crates/rockbox-dsp)) for a fixed-point 404 + 10-band EQ with high-quality biquad filters — band 0 is a low shelf, band 9 405 + a high shelf, bands 1–8 are peaking filters. On the Settings screen you'll 406 + see 10 vertical sliders with `dB` labels above and cutoff frequency labels 407 + below. The controls: 408 + 409 + | Key | Action | 410 + |----------------|-----------------------------------------------| 411 + | `E` | toggle EQ on / off | 412 + | `[` / `]` | move the highlighted band left / right | 413 + | `Shift+↑` / `↓`| bump the highlighted band's gain by ±1 dB | 414 + 415 + Adjustments persist to `config.toml` immediately. Fresh installs get the 416 + ISO-octave flat preset (32 Hz, 63, 125, 250, 500, 1 kHz, 2, 4, 8, 16 kHz, 417 + Q 7.0, 0 dB across the board) so the DSP is a bit-exact bypass until you 418 + start tweaking. Values in `[[eq_band_settings]]` use Rockbox tenths — `q = 419 + 70` means Q 7.0, `gain = -125` means −12.5 dB — so a Rockbox preset drops 420 + in unchanged. 421 + 422 + **License note:** enabling EQ links the Rockbox DSP C sources (GPL-2.0-or-later), 423 + which makes the resulting `fin` binary GPL. The rest of `fin` remains MPL-2.0 424 + in source form. 425 + 426 + Two behavioral notes: 427 + 428 + - **EQ only applies to the currently-playing track**, not to the crossfade 429 + incoming track. The Rockbox DSP is a process-wide singleton (`CODEC_IDX_AUDIO`), 430 + so it can only process one stream at a time. During a crossfade the outgoing 431 + track is EQ'd; the incoming track becomes EQ'd only after it's promoted to 432 + current. 433 + - **Only local playback runs through EQ.** Chromecast and UPnP receivers each 434 + do their own DSP; the toggle is a no-op there. 392 435 393 436 ## Queue persistence 394 437
+59 -1
crates/fin-config/src/lib.rs
··· 30 30 pub replaygain: ReplayGainSettings, 31 31 #[serde(default)] 32 32 pub crossfade: CrossfadeSettings, 33 + /// Rockbox-style top-level equalizer on/off. Matches Rockbox 34 + /// `settings.toml` schema so users can share EQ presets. 35 + #[serde(default)] 36 + pub eq_enabled: bool, 37 + /// 10 EQ bands. Band 0 is a low shelf, band 9 a high shelf, the rest 38 + /// are peaking filters. `q` and `gain` are in Rockbox tenths (Q × 10, 39 + /// dB × 10); `cutoff` is plain Hz. Fresh configs get the ISO-octave 40 + /// flat preset (32 Hz…16 kHz, Q 7.0, 0 dB) via `#[serde(default = …)]`. 41 + #[serde(default = "default_eq_band_settings")] 42 + pub eq_band_settings: Vec<EqBand>, 43 + } 44 + 45 + /// The ISO-octave 10-band flat preset used when a fresh config has no 46 + /// `[[eq_band_settings]]` section. Cutoffs are the standard ISO center 47 + /// frequencies (32 / 63 / 125 / 250 / 500 / 1000 / 2000 / 4000 / 8000 / 48 + /// 16000 Hz); Q is 7.0 across the board; every gain is 0 dB, so the DSP 49 + /// output is bit-identical to bypass until the user starts tweaking. 50 + pub fn default_eq_band_settings() -> Vec<EqBand> { 51 + const CUTOFFS_HZ: [i32; 10] = [ 52 + 32, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000, 53 + ]; 54 + CUTOFFS_HZ 55 + .iter() 56 + .map(|&hz| EqBand { 57 + cutoff: hz, 58 + q: 70, 59 + gain: 0, 60 + }) 61 + .collect() 62 + } 63 + 64 + /// One EQ band, in the exact on-disk format Rockbox's `[[eq_band_settings]]` 65 + /// uses so presets round-trip losslessly between the two. 66 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 67 + pub struct EqBand { 68 + /// Cutoff frequency in Hz. 69 + pub cutoff: i32, 70 + /// Q × 10 (e.g. `70` = Q 7.0). 71 + pub q: i32, 72 + /// Gain × 10 in dB (e.g. `-125` = −12.5 dB). 73 + pub gain: i32, 33 74 } 34 75 35 76 #[derive(Debug, Clone, Serialize, Deserialize)] ··· 273 314 pub fn load() -> Result<Self> { 274 315 let path = config_path()?; 275 316 if !path.exists() { 276 - return Ok(Self::default()); 317 + let mut cfg = Self::default(); 318 + cfg.ensure_eq_bands(); 319 + return Ok(cfg); 277 320 } 278 321 let text = fs::read_to_string(&path) 279 322 .with_context(|| format!("reading config at {}", path.display()))?; 280 323 let mut cfg: Self = toml::from_str(&text).context("parsing config file")?; 281 324 cfg.migrate_legacy_server(); 325 + cfg.ensure_eq_bands(); 282 326 Ok(cfg) 327 + } 328 + 329 + /// Guarantee the EQ always has the full 10 bands. Fills missing slots 330 + /// from `default_eq_band_settings()` so the DSP has something to pass 331 + /// through and the TUI can render 10 sliders regardless of prior state. 332 + fn ensure_eq_bands(&mut self) { 333 + if self.eq_band_settings.is_empty() { 334 + self.eq_band_settings = default_eq_band_settings(); 335 + } else if self.eq_band_settings.len() < 10 { 336 + let defaults = default_eq_band_settings(); 337 + for i in self.eq_band_settings.len()..10 { 338 + self.eq_band_settings.push(defaults[i]); 339 + } 340 + } 283 341 } 284 342 285 343 /// Fold a leftover `[server]` block into the `servers` list. Idempotent.
+5
crates/fin-player/Cargo.toml
··· 33 33 # Shuffle uses Fisher-Yates via `rand` — pulled in as a transitive dep 34 34 # already, so this is a zero-cost explicit dep. 35 35 rand = "0.9" 36 + 37 + # Rockbox DSP pipeline — 10-band EQ, tone controls, resample, crossfeed, … 38 + # Note: the compiled C sources are GPL-2.0-or-later — linking makes the 39 + # binary GPL when EQ code is included. 40 + rockbox-dsp = "0.1"
+7
crates/fin-player/src/local.rs
··· 13 13 use parking_lot::Mutex; 14 14 use tracing::debug; 15 15 16 + use fin_config::EqBand; 17 + 16 18 use crate::crossfade::CrossfadeSettings; 17 19 use crate::mpv::MpvRenderer; 18 20 use crate::persist::PersistedQueue; ··· 300 302 async fn set_crossfade(&self, settings: CrossfadeSettings) -> Result<()> { 301 303 // Only audio-side has the dual-track crossfade wiring. 302 304 self.audio.set_crossfade(settings).await 305 + } 306 + 307 + async fn set_eq(&self, enabled: bool, bands: Vec<EqBand>) -> Result<()> { 308 + // EQ runs in the audio decode path — mpv-driven video is not affected. 309 + self.audio.set_eq(enabled, bands).await 303 310 } 304 311 305 312 fn state(&self) -> PlaybackState {
+18
crates/fin-player/src/renderer.rs
··· 1 1 use async_trait::async_trait; 2 2 use serde::{Deserialize, Serialize}; 3 3 4 + use fin_config::EqBand; 5 + 4 6 use crate::crossfade::CrossfadeSettings; 5 7 use crate::persist::PersistedQueue; 6 8 use crate::queue::{QueueItem, RepeatMode}; ··· 36 38 pub replaygain: ReplayGainSettings, 37 39 #[serde(default)] 38 40 pub crossfade: CrossfadeSettings, 41 + /// Whether the Rockbox EQ is currently active. Mirrored on state so the 42 + /// TUI can render an indicator without a separate query path. 43 + #[serde(default)] 44 + pub eq_enabled: bool, 45 + #[serde(default)] 46 + pub eq_band_count: usize, 39 47 } 40 48 41 49 impl Default for PlaybackState { ··· 52 60 repeat: RepeatMode::Off, 53 61 replaygain: ReplayGainSettings::default(), 54 62 crossfade: CrossfadeSettings::default(), 63 + eq_enabled: false, 64 + eq_band_count: 0, 55 65 } 56 66 } 57 67 } ··· 136 146 /// SymphoniaPlayer implements this; Chromecast + UPnP receivers each 137 147 /// manage their own track transitions. 138 148 async fn set_crossfade(&self, _settings: CrossfadeSettings) -> anyhow::Result<()> { 149 + Ok(()) 150 + } 151 + 152 + /// Enable/disable the Rockbox 10-band equalizer and load the band 153 + /// coefficients. `bands` is truncated to `EQ_NUM_BANDS`. Only the local 154 + /// SymphoniaPlayer implements this — non-local receivers each apply 155 + /// their own EQ (or none). 156 + async fn set_eq(&self, _enabled: bool, _bands: Vec<EqBand>) -> anyhow::Result<()> { 139 157 Ok(()) 140 158 } 141 159
+94 -4
crates/fin-player/src/symphonia_player.rs
··· 26 26 use symphonia::core::units::{Time, TimeBase}; 27 27 use tracing::{debug, error, warn}; 28 28 29 + use fin_config::EqBand; 30 + use rockbox_dsp::{eq_band_setting, Dsp, EQ_NUM_BANDS}; 31 + 29 32 use crate::crossfade::{fade_at, CrossfadeMode, CrossfadeSettings}; 30 33 use crate::persist::{PersistedQueue, Persister}; 31 34 use crate::queue::{PlaybackQueue, QueueItem}; ··· 68 71 RemoveAt(usize), 69 72 SetReplayGain(ReplayGainSettings), 70 73 SetCrossfade(CrossfadeSettings), 74 + SetEq { 75 + enabled: bool, 76 + bands: Vec<EqBand>, 77 + }, 71 78 Quit, 72 79 } 73 80 ··· 190 197 191 198 async fn set_crossfade(&self, settings: CrossfadeSettings) -> Result<()> { 192 199 self.send(PlayerCommand::SetCrossfade(settings)) 200 + } 201 + 202 + async fn set_eq(&self, enabled: bool, bands: Vec<EqBand>) -> Result<()> { 203 + self.send(PlayerCommand::SetEq { enabled, bands }) 193 204 } 194 205 195 206 fn state(&self) -> PlaybackState { ··· 610 621 // Current ReplayGain settings — applied to each track we load. 611 622 let mut rg_settings = ReplayGainSettings::default(); 612 623 let mut xf_settings = CrossfadeSettings::default(); 624 + // Rockbox DSP pipeline for the CURRENT track only. Initialised lazily 625 + // on the first track load (we need the device output rate). The 626 + // instance is a process-wide singleton (`CODEC_IDX_AUDIO`), so we 627 + // never own two at once — the crossfade `next` track bypasses it and 628 + // uses the plain resampler. 629 + let mut dsp: Option<Dsp> = None; 630 + let mut eq_enabled = false; 631 + let mut eq_bands: Vec<EqBand> = Vec::new(); 613 632 // The "incoming" track being preloaded during a crossfade overlap. 614 633 // `Some` only during an active overlap. When the fade-in completes, 615 634 // this promotes to `track` and the previous `track` is dropped. ··· 955 974 state.lock().replaygain = settings; 956 975 persist_now(&persister, &queue, &state); 957 976 } 977 + PlayerCommand::SetEq { enabled, bands } => { 978 + eq_enabled = enabled; 979 + eq_bands = bands; 980 + if let Some(ref mut d) = dsp { 981 + apply_eq_to_dsp(d, eq_enabled, &eq_bands); 982 + } 983 + { 984 + let mut s = state.lock(); 985 + s.eq_enabled = eq_enabled; 986 + s.eq_band_count = eq_bands.len().min(EQ_NUM_BANDS); 987 + } 988 + } 958 989 PlayerCommand::SetCrossfade(settings) => { 959 990 xf_settings = settings; 960 991 // If the mode was turned OFF mid-overlap, kill the ··· 1021 1052 continue; 1022 1053 } 1023 1054 1055 + // Lazy-init the Dsp singleton on the first track load — we need 1056 + // the device output rate, which comes from the track itself. 1057 + if dsp.is_none() { 1058 + let mut d = Dsp::new(t.output_sr); 1059 + // My Resampler already delivers samples at the device rate, 1060 + // so keep Dsp's input rate = output rate — no internal 1061 + // resample, just the EQ / tone stack. 1062 + d.set_input_frequency(t.output_sr); 1063 + apply_eq_to_dsp(&mut d, eq_enabled, &eq_bands); 1064 + dsp = Some(d); 1065 + } 1066 + 1024 1067 let free = t.ring_free_slots(); 1025 1068 if free >= 8192 && !t.ended { 1026 - match decode_one_packet(t) { 1069 + // EQ only routes for the CURRENT track — the singleton 1070 + // constraint means we can't run two tracks through Dsp 1071 + // at once. `next` (during crossfade) bypasses. 1072 + let dsp_arg = if eq_enabled { dsp.as_mut() } else { None }; 1073 + match decode_one_packet(t, dsp_arg) { 1027 1074 DecodeStep::Pushed => update_position(&state, t), 1028 1075 DecodeStep::EndOfStream => { 1029 1076 debug!(item = %t.item.title, "current track ended"); ··· 1042 1089 if let Some(ref mut nt) = next { 1043 1090 let free = nt.ring_free_slots(); 1044 1091 if free >= 8192 && !nt.ended { 1045 - match decode_one_packet(nt) { 1092 + match decode_one_packet(nt, None) { 1046 1093 DecodeStep::Pushed => {} 1047 1094 DecodeStep::EndOfStream => nt.ended = true, 1048 1095 DecodeStep::Error(e) => { ··· 1194 1241 Error(String), 1195 1242 } 1196 1243 1197 - fn decode_one_packet(t: &mut Track) -> DecodeStep { 1244 + fn decode_one_packet(t: &mut Track, dsp: Option<&mut Dsp>) -> DecodeStep { 1198 1245 let packet = match t.format.next_packet() { 1199 1246 Ok(p) => p, 1200 1247 Err(symphonia::core::errors::Error::IoError(e)) ··· 1223 1270 sample_buf.copy_interleaved_ref(audio_buf); 1224 1271 let src_samples = sample_buf.samples(); 1225 1272 // Convert channels + sample rate to what cpal is actually consuming. 1226 - let out_samples = t.resampler.process(src_samples); 1273 + let mut out_samples = t.resampler.process(src_samples); 1227 1274 if out_samples.is_empty() { 1228 1275 return DecodeStep::Pushed; 1229 1276 } 1277 + // Rockbox EQ post-processing — Dsp only handles interleaved stereo, so 1278 + // for non-stereo output we skip and let the samples through untouched. 1279 + if let Some(d) = dsp { 1280 + if t.output_channels == 2 { 1281 + out_samples = apply_dsp(d, &out_samples); 1282 + if out_samples.is_empty() { 1283 + return DecodeStep::Pushed; 1284 + } 1285 + } 1286 + } 1230 1287 let out_frames = out_samples.len() / t.output_channels.max(1); 1231 1288 push_samples_with_volume(t, &out_samples); 1232 1289 t.produced_output_frames += out_frames as u64; 1233 1290 DecodeStep::Pushed 1234 1291 } 1292 + 1293 + /// Route interleaved-stereo f32 samples through the Rockbox DSP pipeline 1294 + /// with input rate == output rate (no internal resample; my Resampler 1295 + /// already ran). Loudness scales aside, this is just the EQ + tone stack. 1296 + fn apply_dsp(dsp: &mut Dsp, samples: &[f32]) -> Vec<f32> { 1297 + // f32 → i16, saturating. 1298 + let mut input = Vec::with_capacity(samples.len()); 1299 + for &s in samples { 1300 + let v = (s * 32767.0).round().clamp(i16::MIN as f32, i16::MAX as f32) as i16; 1301 + input.push(v); 1302 + } 1303 + let mut out_i16: Vec<i16> = Vec::with_capacity(input.len()); 1304 + dsp.process(&input, &mut out_i16); 1305 + // i16 → f32. 1306 + out_i16.iter().map(|&s| s as f32 / 32768.0).collect() 1307 + } 1308 + 1309 + /// Apply an on/off toggle plus the current bands to the Dsp singleton. 1310 + /// Truncates to EQ_NUM_BANDS silently. 1311 + fn apply_eq_to_dsp(dsp: &mut Dsp, enabled: bool, bands: &[EqBand]) { 1312 + for (i, band) in bands.iter().take(EQ_NUM_BANDS).enumerate() { 1313 + dsp.set_eq_band_raw( 1314 + i, 1315 + eq_band_setting { 1316 + cutoff: band.cutoff, 1317 + q: band.q, 1318 + gain: band.gain, 1319 + }, 1320 + ); 1321 + } 1322 + dsp.eq_enable(enabled); 1323 + } 1324 + 1235 1325 1236 1326 fn push_samples_with_volume(t: &Track, samples: &[f32]) { 1237 1327 // Effective scale = user volume × ReplayGain linear multiplier ×
+139 -6
crates/fin-tui/src/app.rs
··· 72 72 use crate::event::{spawn_event_loop, AppEvent}; 73 73 use crate::screens::{item_row_line, RowLayout, Screen}; 74 74 use crate::theme::{accent_style, base_style, muted_style, title_style, Palette}; 75 - use crate::widgets::{neon_block, NeonTabs, PlayerBar}; 75 + use crate::widgets::{neon_block, EqSliders, NeonTabs, PlayerBar}; 76 76 77 77 /// Everything the render loop needs. 78 78 pub struct App { ··· 101 101 list_state: ListState, 102 102 status_message: Arc<Mutex<Option<(String, Instant)>>>, 103 103 playback_state: Arc<Mutex<PlaybackState>>, 104 + /// Which EQ band the user is currently editing (0..N-1). Only meaningful 105 + /// on the Settings screen. Nudged by `[` / `]`. 106 + eq_selected_band: usize, 104 107 should_quit: bool, 105 108 logo_pulse: u8, 106 109 } ··· 139 142 list_state, 140 143 status_message: Arc::new(Mutex::new(None)), 141 144 playback_state: Arc::new(Mutex::new(PlaybackState::default())), 145 + eq_selected_band: 0, 142 146 should_quit: false, 143 147 logo_pulse: 0, 144 148 } ··· 434 438 } 435 439 _ => Ok(vec![item]), 436 440 } 441 + } 442 + 443 + /// Adjust the selected EQ band's gain by `delta_tenths` (Rockbox 444 + /// tenths-of-dB units). Persists to config and reapplies to the DSP. 445 + async fn nudge_eq_band_gain(&mut self, delta_tenths: i32) { 446 + let (new_enabled, bands) = { 447 + let mut cfg = self.config.lock(); 448 + if cfg.eq_band_settings.is_empty() { 449 + self.set_status("No EQ bands — add [[eq_band_settings]] to config.toml"); 450 + return; 451 + } 452 + let idx = self 453 + .eq_selected_band 454 + .min(cfg.eq_band_settings.len() - 1); 455 + let band = &mut cfg.eq_band_settings[idx]; 456 + band.gain = (band.gain + delta_tenths).clamp(-240, 240); 457 + let hz = band.cutoff; 458 + let g = band.gain as f32 / 10.0; 459 + drop(cfg); 460 + self.set_status(format!( 461 + "band {}: {} Hz → {:+.1} dB", 462 + idx + 1, 463 + hz, 464 + g 465 + )); 466 + let cfg = self.config.lock(); 467 + let _ = cfg.save(); 468 + (cfg.eq_enabled, cfg.eq_band_settings.clone()) 469 + }; 470 + let renderer = self.renderer.lock().clone(); 471 + let _ = renderer.set_eq(new_enabled, bands).await; 437 472 } 438 473 439 474 /// Delete the highlighted entry from the queue. If it's the item being ··· 934 969 _ => {} 935 970 } 936 971 } 972 + // Shift+↑ / Shift+↓ on Settings nudge the highlighted EQ band's 973 + // gain by ±1 dB. Must run BEFORE the generic nav arms below or 974 + // they'd swallow the shifted variant. 975 + (KeyCode::Up, m) if app.screen == Screen::Settings && m.contains(KeyModifiers::SHIFT) => { 976 + app.nudge_eq_band_gain(10).await; 977 + } 978 + (KeyCode::Down, m) if app.screen == Screen::Settings && m.contains(KeyModifiers::SHIFT) => { 979 + app.nudge_eq_band_gain(-10).await; 980 + } 937 981 (KeyCode::Down, _) | (KeyCode::Char('j'), _) => { 938 982 let len = app.list_len(); 939 983 let i = app.list_state.selected().unwrap_or(0); ··· 1089 1133 app.set_status(format!("Crossfade duration: {:.1}s", next)); 1090 1134 } 1091 1135 } 1136 + // Equalizer: `E` toggles the Rockbox EQ pipeline. 1137 + (KeyCode::Char('E'), _) => { 1138 + let (new_enabled, bands) = { 1139 + let mut cfg = app.config.lock(); 1140 + cfg.eq_enabled = !cfg.eq_enabled; 1141 + let _ = cfg.save(); 1142 + (cfg.eq_enabled, cfg.eq_band_settings.clone()) 1143 + }; 1144 + let renderer = app.renderer.lock().clone(); 1145 + if let Err(e) = renderer.set_eq(new_enabled, bands).await { 1146 + app.set_status(format!("eq: {}", e)); 1147 + } else { 1148 + app.set_status(if new_enabled { "EQ: on" } else { "EQ: off" }); 1149 + } 1150 + } 1151 + // `[` / `]` move between EQ bands on the Settings screen. 1152 + (KeyCode::Char('['), _) if app.screen == Screen::Settings => { 1153 + let n = app.config.lock().eq_band_settings.len(); 1154 + if n > 0 { 1155 + app.eq_selected_band = 1156 + (app.eq_selected_band + n - 1) % n; 1157 + } 1158 + } 1159 + (KeyCode::Char(']'), _) if app.screen == Screen::Settings => { 1160 + let n = app.config.lock().eq_band_settings.len(); 1161 + if n > 0 { 1162 + app.eq_selected_band = (app.eq_selected_band + 1) % n; 1163 + } 1164 + } 1092 1165 // Crossfade: `f` cycles off → crossfade → mixed → off. Duration 1093 1166 // is edited via Shift+F or config.toml. 1094 1167 (KeyCode::Char('f'), _) => { ··· 1484 1557 1485 1558 let rows = Layout::default() 1486 1559 .direction(Direction::Vertical) 1487 - .constraints([Constraint::Length(9), Constraint::Min(4)]) 1560 + .constraints([ 1561 + Constraint::Length(9), 1562 + Constraint::Length(11), 1563 + Constraint::Min(4), 1564 + ]) 1488 1565 .split(area); 1489 1566 1490 1567 // Top card — global settings. ··· 1549 1626 inner.inner(Margin::new(2, 0)), 1550 1627 ); 1551 1628 1629 + // EQ card — 10-band sliders + status line + interactive hints. 1630 + let sel_band = if cfg_snapshot.eq_band_settings.is_empty() { 1631 + None 1632 + } else { 1633 + Some(app.eq_selected_band.min(cfg_snapshot.eq_band_settings.len() - 1)) 1634 + }; 1635 + let eq_title = if cfg_snapshot.eq_enabled { 1636 + format!( 1637 + " ▤ Equalizer ({} bands, on) ", 1638 + cfg_snapshot.eq_band_settings.len() 1639 + ) 1640 + } else { 1641 + format!( 1642 + " ▤ Equalizer ({} bands, off) ", 1643 + cfg_snapshot.eq_band_settings.len() 1644 + ) 1645 + }; 1646 + let eq_block = neon_block(&eq_title, cfg_snapshot.eq_enabled); 1647 + let eq_inner = eq_block.inner(rows[1]); 1648 + f.render_widget(eq_block, rows[1]); 1649 + 1650 + // Split the EQ card into a sliders row and a hint row. 1651 + let eq_rows = Layout::default() 1652 + .direction(Direction::Vertical) 1653 + .constraints([Constraint::Min(4), Constraint::Length(1)]) 1654 + .split(eq_inner.inner(Margin::new(1, 0))); 1655 + f.render_widget( 1656 + EqSliders { 1657 + bands: &cfg_snapshot.eq_band_settings, 1658 + enabled: cfg_snapshot.eq_enabled, 1659 + selected: sel_band, 1660 + range_db: 24, 1661 + }, 1662 + eq_rows[0], 1663 + ); 1664 + let sel_hint = sel_band 1665 + .and_then(|i| cfg_snapshot.eq_band_settings.get(i).map(|b| (i, b))) 1666 + .map(|(i, b)| { 1667 + format!( 1668 + " band {}: {} Hz Q {:.1} {:+.1} dB ", 1669 + i + 1, 1670 + b.cutoff, 1671 + b.q as f32 / 10.0, 1672 + b.gain as f32 / 10.0, 1673 + ) 1674 + }) 1675 + .unwrap_or_else(|| " no bands configured — add [[eq_band_settings]] to config.toml ".to_string()); 1676 + let hint = format!( 1677 + "{}E: on/off [ / ]: band Shift+↑/↓: ±1 dB", 1678 + sel_hint 1679 + ); 1680 + f.render_widget( 1681 + Paragraph::new(Span::styled(hint, muted_style())), 1682 + eq_rows[1], 1683 + ); 1684 + 1552 1685 // Bottom card — interactive server list. Enter switches, `t` cycles. 1553 1686 let current = cfg_snapshot.current_server.clone().unwrap_or_default(); 1554 1687 let items: Vec<ListItem> = cfg_snapshot ··· 1588 1721 1589 1722 if items.is_empty() { 1590 1723 let block = neon_block(&title, true); 1591 - let inner = block.inner(rows[1]); 1592 - f.render_widget(block, rows[1]); 1724 + let inner = block.inner(rows[2]); 1725 + f.render_widget(block, rows[2]); 1593 1726 f.render_widget( 1594 1727 Paragraph::new(Line::from(Span::styled( 1595 1728 " No servers yet — run `fin login <url>` to add one.", ··· 1609 1742 .add_modifier(Modifier::BOLD), 1610 1743 ) 1611 1744 .highlight_symbol(""); 1612 - f.render_stateful_widget(list, rows[1], &mut app.list_state); 1745 + f.render_stateful_widget(list, rows[2], &mut app.list_state); 1613 1746 } 1614 1747 1615 1748 fn draw_player_bar(f: &mut Frame<'_>, area: Rect, app: &App) { ··· 1641 1774 None => None, 1642 1775 } 1643 1776 }; 1644 - let help = " tab: screen ↑↓/jk: nav enter: play/drill x: play all a: queue n: next z: shuffle R: repeat g: replaygain f/F: crossfade/dur space: pause s: stop d: rm/C: clear (queue) </>: skip +/-: vol m: local t: server /: search esc: back q: quit "; 1777 + let help = " tab: screen ↑↓/jk: nav enter: play/drill x: play all a: queue n: next z: shuffle R: repeat g: replaygain f/F: crossfade/dur E: eq space: pause s: stop d: rm/C: clear (queue) </>: skip +/-: vol m: local t: server /: search esc: back q: quit "; 1645 1778 // Errors/warnings pop in warn-red; other status messages use the primary 1646 1779 // teal so they stand out from the muted help text. 1647 1780 let (text, style) = match msg {
+166
crates/fin-tui/src/widgets/eq.rs
··· 1 + //! 10-band EQ visualization — one vertical slider per band with a dB label 2 + //! above the bar, a Hz/kHz label below, and a highlight on the selected 3 + //! band. Used inside the Settings screen. 4 + 5 + use ratatui::buffer::Buffer; 6 + use ratatui::layout::Rect; 7 + use ratatui::style::{Modifier, Style}; 8 + use ratatui::text::{Line, Span}; 9 + use ratatui::widgets::{Paragraph, Widget}; 10 + 11 + use fin_config::EqBand; 12 + 13 + use crate::theme::{muted_style, Palette}; 14 + 15 + /// One EQ slider column drawn with vertical block characters. 16 + pub struct EqSliders<'a> { 17 + pub bands: &'a [EqBand], 18 + pub enabled: bool, 19 + pub selected: Option<usize>, 20 + /// Gain range in dB the vertical slider maps onto (± this many dB). 21 + pub range_db: i32, 22 + } 23 + 24 + impl<'a> Widget for EqSliders<'a> { 25 + fn render(self, area: Rect, buf: &mut Buffer) { 26 + if self.bands.is_empty() || area.width < 8 || area.height < 5 { 27 + let msg = "No EQ bands — add [[eq_band_settings]] to config.toml"; 28 + Paragraph::new(Span::styled(msg, muted_style())).render(area, buf); 29 + return; 30 + } 31 + 32 + let n = self.bands.len().min(10); 33 + let col_w = (area.width as usize / n).max(4) as u16; 34 + let center_row = area.y + area.height / 2; 35 + 36 + // Reserve top row for dB text, bottom row for Hz text; the middle 37 + // rows draw the bar. 38 + let bar_top = area.y + 1; 39 + let bar_bot = area.y + area.height.saturating_sub(3); 40 + let bar_h = bar_bot.saturating_sub(bar_top) as i32; 41 + if bar_h < 1 { 42 + return; 43 + } 44 + 45 + for (i, band) in self.bands.iter().take(n).enumerate() { 46 + let col_x = area.x + (i as u16) * col_w; 47 + if col_x >= area.right() { 48 + break; 49 + } 50 + 51 + // Layout inside this column: 4 char wide "gutter" for the bar, 52 + // centered in the column. 53 + let bar_x = col_x + col_w / 2; 54 + 55 + // Compute the vertical position for the band's gain. 56 + let gain_db = band.gain as f32 / 10.0; 57 + let clamped = gain_db.clamp(-(self.range_db as f32), self.range_db as f32); 58 + let ratio = clamped / self.range_db as f32; // −1..=+1 59 + let half_h = bar_h / 2; 60 + let offset = ((ratio * half_h as f32).round()) as i32; 61 + let bar_end_row = (center_row as i32 - offset).clamp(bar_top as i32, bar_bot as i32) as u16; 62 + 63 + // Bar style — muted when EQ off, highlighted on the selected band. 64 + let is_sel = self.selected == Some(i); 65 + let bar_color = if !self.enabled { 66 + Palette::MUTED 67 + } else if is_sel { 68 + Palette::HIGHLIGHT 69 + } else { 70 + Palette::PRIMARY 71 + }; 72 + let center_line_color = Palette::SURFACE; 73 + 74 + // Zero-dB axis across every column so the user can eyeball who's 75 + // pushed above / below flat. 76 + for r in bar_top..=bar_bot { 77 + let ch = if r == center_row { '─' } else { '│' }; 78 + let col = if r == center_row { 79 + center_line_color 80 + } else { 81 + Palette::SURFACE 82 + }; 83 + if let Some(cell) = buf.cell_mut((bar_x, r)) { 84 + cell.set_char(ch); 85 + cell.set_style(Style::default().fg(col)); 86 + } 87 + } 88 + 89 + // Draw the fill from center toward `bar_end_row`. 90 + let (from, to) = if bar_end_row < center_row { 91 + (bar_end_row, center_row) // gain > 0: fill upward 92 + } else { 93 + (center_row, bar_end_row) // gain < 0: fill downward 94 + }; 95 + for r in from..=to { 96 + if let Some(cell) = buf.cell_mut((bar_x, r)) { 97 + cell.set_char('█'); 98 + cell.set_style( 99 + Style::default() 100 + .fg(bar_color) 101 + .add_modifier(Modifier::BOLD), 102 + ); 103 + } 104 + } 105 + 106 + // dB label above the bar (row area.y). 107 + let db_style = if is_sel && self.enabled { 108 + Style::default() 109 + .fg(Palette::HIGHLIGHT) 110 + .add_modifier(Modifier::BOLD) 111 + } else if self.enabled { 112 + Style::default().fg(Palette::FG) 113 + } else { 114 + muted_style() 115 + }; 116 + let db_text = format!("{:+.1}", gain_db); 117 + let db_area = Rect::new(col_x, area.y, col_w, 1); 118 + Paragraph::new(Span::styled(db_text, db_style)) 119 + .alignment(ratatui::layout::Alignment::Center) 120 + .render(db_area, buf); 121 + 122 + // "dB" units row (small hint under the number). 123 + let unit_row = area.y + area.height.saturating_sub(2); 124 + let hz_row = area.y + area.height.saturating_sub(1); 125 + 126 + let hz_text = fmt_hz(band.cutoff); 127 + let hz_style = if is_sel && self.enabled { 128 + Style::default() 129 + .fg(Palette::HIGHLIGHT) 130 + .add_modifier(Modifier::BOLD) 131 + } else { 132 + muted_style() 133 + }; 134 + let unit_area = Rect::new(col_x, unit_row, col_w, 1); 135 + Paragraph::new(Span::styled("dB", muted_style())) 136 + .alignment(ratatui::layout::Alignment::Center) 137 + .render(unit_area, buf); 138 + let hz_area = Rect::new(col_x, hz_row, col_w, 1); 139 + Paragraph::new(Span::styled(hz_text, hz_style)) 140 + .alignment(ratatui::layout::Alignment::Center) 141 + .render(hz_area, buf); 142 + } 143 + 144 + // Top-right corner: axis label so the range is unambiguous. 145 + let axis_lbl = format!("±{} dB", self.range_db); 146 + let lbl_area = Rect::new( 147 + area.right().saturating_sub(axis_lbl.len() as u16), 148 + area.y, 149 + axis_lbl.len() as u16, 150 + 1, 151 + ); 152 + Paragraph::new(Line::from(Span::styled(axis_lbl, muted_style()))).render(lbl_area, buf); 153 + } 154 + } 155 + 156 + /// Format Hz as `60 Hz`, `1.2 kHz`, or `20 kHz`. Compact enough to fit under 157 + /// a narrow slider column. 158 + fn fmt_hz(hz: i32) -> String { 159 + if hz < 1000 { 160 + format!("{} Hz", hz) 161 + } else if hz % 1000 == 0 { 162 + format!("{} kHz", hz / 1000) 163 + } else { 164 + format!("{:.1} kHz", hz as f32 / 1000.0) 165 + } 166 + }
+2
crates/fin-tui/src/widgets/mod.rs
··· 1 + pub mod eq; 1 2 pub mod player_bar; 2 3 pub mod tabs; 3 4 5 + pub use eq::EqSliders; 4 6 pub use player_bar::PlayerBar; 5 7 pub use tabs::NeonTabs; 6 8
+11
crates/fin-tui/src/widgets/player_bar.rs
··· 153 153 } else { 154 154 Span::raw("") 155 155 }; 156 + let eq_span = if self.state.eq_enabled { 157 + Span::styled( 158 + "EQ ", 159 + Style::default() 160 + .fg(Palette::HIGHLIGHT) 161 + .add_modifier(Modifier::BOLD), 162 + ) 163 + } else { 164 + Span::raw("") 165 + }; 156 166 let right_line = Line::from(vec![ 157 167 shuffle_span, 158 168 repeat_span, 159 169 rg_span, 160 170 xf_span, 171 + eq_span, 161 172 Span::styled( 162 173 format!("♪ {}% ", (self.state.volume * 100.0) as i32), 163 174 Style::default().fg(Palette::HIGHLIGHT),
+6
crates/fin/src/main.rs
··· 197 197 if let Err(e) = r.set_crossfade(cfg.crossfade).await { 198 198 tracing::warn!(?e, "crossfade apply failed"); 199 199 } 200 + if let Err(e) = r 201 + .set_eq(cfg.eq_enabled, cfg.eq_band_settings.clone()) 202 + .await 203 + { 204 + tracing::warn!(?e, "eq apply failed"); 205 + } 200 206 if let Some(snap) = saved { 201 207 if !snap.items.is_empty() { 202 208 let items_n = snap.items.len();