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 ReplayGain support

Reads REPLAYGAIN_TRACK_GAIN / REPLAYGAIN_ALBUM_GAIN / REPLAYGAIN_*_PEAK
tags off decoded tracks and applies a linear multiplier to samples on
their way to cpal. User picks Off / Track / Album via `g` in the TUI.

Data
- fin-config: new ReplayGainMode enum and ReplayGainSettings struct
(mode, preamp_db, prevent_clip). Config gains a `replaygain` field
with #[serde(default)] so old on-disk configs keep loading. Pure
data, no fin-player dep required.
- fin-player: new replaygain module owns ReplayGainInfo (per-track
tag values) and the linear_gain math — dB→linear, preamp additive
in dB domain, peak-based clip prevention, fallback to the other
scope when the requested one is missing. 11 unit tests cover the
numeric edge cases (clip prevention, preamp, fallback, junk parse).

Audio path
- SymphoniaPlayer stores current ReplayGainSettings in the worker,
extracts ReplayGainInfo from the first packet's metadata on each
track load, and precomputes a linear gain that the sample push
loop folds into the same multiply as user volume.
- New PlayerCommand::SetReplayGain updates the settings live and
recomputes the current track's linear gain in place.
- Renderer trait gains set_replaygain with a no-op default (Chromecast
and UPnP do their own device-side normalization).

TUI
- `g` cycles Off → Track → Album → Off, saves to config, statuses
"ReplayGain: <mode>".
- Player bar shows a compact "RG:track" / "RG:album" badge only when
active; muted when Off (row stays balanced).
- Settings screen has a new row: mode + preamp dB + clip-guard flag
+ "press g to cycle" hint. Panel grew from 7→8 rows to fit.
- Help hint updated.

Verified live: on a track without RG tags, linear=1.0 is logged and the
badge lights up when cycled; config.toml grows a [replaygain] section
that survives restart; 76 workspace tests pass.

+491 -6
+1
Cargo.lock
··· 652 652 "anyhow", 653 653 "async-trait", 654 654 "cpal", 655 + "fin-config", 655 656 "fin-jellyfin", 656 657 "futures", 657 658 "mdns-sd",
+64
crates/fin-config/src/lib.rs
··· 26 26 pub last_upnp: Option<String>, 27 27 #[serde(default)] 28 28 pub client: ClientInfo, 29 + #[serde(default)] 30 + pub replaygain: ReplayGainSettings, 29 31 } 30 32 31 33 #[derive(Debug, Clone, Serialize, Deserialize)] ··· 100 102 Self::Upnp => "upnp", 101 103 } 102 104 } 105 + } 106 + 107 + /// Which scope of ReplayGain to honor at playback time. 108 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] 109 + #[serde(rename_all = "lowercase")] 110 + pub enum ReplayGainMode { 111 + #[default] 112 + Off, 113 + Track, 114 + Album, 115 + } 116 + 117 + impl ReplayGainMode { 118 + pub fn label(&self) -> &'static str { 119 + match self { 120 + Self::Off => "off", 121 + Self::Track => "track", 122 + Self::Album => "album", 123 + } 124 + } 125 + 126 + pub fn next(self) -> Self { 127 + match self { 128 + Self::Off => Self::Track, 129 + Self::Track => Self::Album, 130 + Self::Album => Self::Off, 131 + } 132 + } 133 + 134 + pub fn is_active(&self) -> bool { 135 + !matches!(self, Self::Off) 136 + } 137 + } 138 + 139 + /// Config-facing ReplayGain settings. Behavior (tag extraction + linear 140 + /// gain computation) lives in `fin_player::replaygain`; this is just data. 141 + #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] 142 + pub struct ReplayGainSettings { 143 + #[serde(default)] 144 + pub mode: ReplayGainMode, 145 + #[serde(default = "default_replaygain_preamp_db")] 146 + pub preamp_db: f32, 147 + #[serde(default = "default_replaygain_prevent_clip")] 148 + pub prevent_clip: bool, 149 + } 150 + 151 + impl Default for ReplayGainSettings { 152 + fn default() -> Self { 153 + Self { 154 + mode: ReplayGainMode::Off, 155 + preamp_db: default_replaygain_preamp_db(), 156 + prevent_clip: default_replaygain_prevent_clip(), 157 + } 158 + } 159 + } 160 + 161 + fn default_replaygain_preamp_db() -> f32 { 162 + 0.0 163 + } 164 + 165 + fn default_replaygain_prevent_clip() -> bool { 166 + true 103 167 } 104 168 105 169 #[derive(Debug, Clone, Serialize, Deserialize)]
+1
crates/fin-player/Cargo.toml
··· 22 22 reqwest = { workspace = true, features = ["stream", "blocking"] } 23 23 url.workspace = true 24 24 fin-jellyfin.workspace = true 25 + fin-config.workspace = true 25 26 26 27 # Local audio-only playback: fetch → symphonia decode → cpal output. 27 28 # mpv is only used for video; audio never touches mpv.
+2
crates/fin-player/src/lib.rs
··· 5 5 pub mod persist; 6 6 pub mod queue; 7 7 pub mod renderer; 8 + pub mod replaygain; 8 9 pub mod symphonia_player; 9 10 pub mod upnp; 10 11 ··· 15 16 pub use persist::{load as load_persisted_queue, PersistedQueue}; 16 17 pub use queue::{PlaybackQueue, QueueItem, RepeatMode}; 17 18 pub use renderer::{PlaybackState, PlaybackStatus, Renderer, RendererKind}; 19 + pub use replaygain::{ReplayGainInfo, ReplayGainMode, ReplayGainSettings}; 18 20 pub use symphonia_player::SymphoniaPlayer; 19 21 pub use upnp::{discover_upnp_renderers, UpnpDevice, UpnpRenderer};
+7
crates/fin-player/src/local.rs
··· 17 17 use crate::persist::PersistedQueue; 18 18 use crate::queue::{QueueItem, RepeatMode}; 19 19 use crate::renderer::{PlaybackState, PlaybackStatus, Renderer, RendererKind}; 20 + use crate::replaygain::ReplayGainSettings; 20 21 use crate::symphonia_player::SymphoniaPlayer; 21 22 22 23 /// Which backend is currently sourcing playback. ··· 272 273 Active::Video => self.video.remove_from_queue(index).await, 273 274 Active::None => Ok(()), 274 275 } 276 + } 277 + 278 + async fn set_replaygain(&self, settings: ReplayGainSettings) -> Result<()> { 279 + // ReplayGain is applied in the audio decode path — mpv has its own 280 + // separate volume model for video that we don't touch here. 281 + self.audio.set_replaygain(settings).await 275 282 } 276 283 277 284 fn state(&self) -> PlaybackState {
+14
crates/fin-player/src/renderer.rs
··· 3 3 4 4 use crate::persist::PersistedQueue; 5 5 use crate::queue::{QueueItem, RepeatMode}; 6 + use crate::replaygain::ReplayGainSettings; 6 7 7 8 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 8 9 #[serde(rename_all = "lowercase")] ··· 27 28 pub shuffle: bool, 28 29 #[serde(default)] 29 30 pub repeat: RepeatMode, 31 + /// Whatever ReplayGain settings the renderer is currently honoring — 32 + /// mirrored on state so the TUI can show a badge without a separate 33 + /// query path. 34 + #[serde(default)] 35 + pub replaygain: ReplayGainSettings, 30 36 } 31 37 32 38 impl Default for PlaybackState { ··· 41 47 current_index: None, 42 48 shuffle: false, 43 49 repeat: RepeatMode::Off, 50 + replaygain: ReplayGainSettings::default(), 44 51 } 45 52 } 46 53 } ··· 111 118 /// in which case playback advances to the next entry (or stops if the 112 119 /// queue is now empty). Default is a no-op. 113 120 async fn remove_from_queue(&self, _index: usize) -> anyhow::Result<()> { 121 + Ok(()) 122 + } 123 + 124 + /// Update ReplayGain settings (mode, preamp, clip prevention). Non-local 125 + /// renderers currently no-op — device-side receivers apply their own 126 + /// loudness normalization. 127 + async fn set_replaygain(&self, _settings: ReplayGainSettings) -> anyhow::Result<()> { 114 128 Ok(()) 115 129 } 116 130
+289
crates/fin-player/src/replaygain.rs
··· 1 + //! ReplayGain support — read `REPLAYGAIN_*` tags from decoded tracks and 2 + //! compute the linear gain multiplier to apply to samples before they go 3 + //! out to cpal. 4 + //! 5 + //! Reference: <https://en.wikipedia.org/wiki/ReplayGain> 6 + //! 7 + //! Applied gain formula: 8 + //! ```text 9 + //! gain_dB = base_gain_dB + preamp_dB 10 + //! linear = 10 ^ (gain_dB / 20) 11 + //! ``` 12 + //! 13 + //! If clip prevention is on and a peak is known, `linear` is reduced so 14 + //! `linear * peak <= 1.0` — this stops the ~0.5 % of tracks whose album 15 + //! gain would otherwise cause clipping. 16 + //! 17 + //! Falls back gracefully: if the requested mode's tags are missing, we try 18 + //! the other mode; if both are missing, gain is 1.0 (i.e. no adjustment). 19 + 20 + use symphonia::core::formats::FormatReader; 21 + use symphonia::core::meta::StandardTagKey; 22 + 23 + // Re-export the config-facing types so consumers of fin-player still see 24 + // them under `fin_player::ReplayGain*`. Definitions live in fin-config 25 + // because they're pure config data. 26 + pub use fin_config::{ReplayGainMode, ReplayGainSettings}; 27 + 28 + /// The four ReplayGain-related metadata values a track can carry. 29 + #[derive(Debug, Clone, Copy, Default, PartialEq)] 30 + pub struct ReplayGainInfo { 31 + pub track_gain_db: Option<f32>, 32 + pub album_gain_db: Option<f32>, 33 + pub track_peak: Option<f32>, 34 + pub album_peak: Option<f32>, 35 + } 36 + 37 + impl ReplayGainInfo { 38 + /// Pull whatever RG tags are present in the current metadata revision 39 + /// of `format`. Handles both Vorbis-comment style 40 + /// (`REPLAYGAIN_TRACK_GAIN`) and ID3v2 TXXX (`replaygain_track_gain`) 41 + /// by matching case-insensitively on the raw key, then also 42 + /// promotes `StandardTagKey::ReplayGain*` if symphonia populated it. 43 + pub fn extract_from(format: &mut Box<dyn FormatReader>) -> Self { 44 + let mut info = Self::default(); 45 + 46 + // Preferred path: the current metadata revision (post-probe). 47 + if let Some(rev) = format.metadata().current() { 48 + for tag in rev.tags() { 49 + info.absorb_tag(tag); 50 + } 51 + } 52 + // Some formats (FLAC/Ogg) put the ReplayGain tags in the initial 53 + // container metadata — MP3 tends to put them in ID3v2 which lands 54 + // in the same rev queue. The single scan above covers both. 55 + info 56 + } 57 + 58 + fn absorb_tag(&mut self, tag: &symphonia::core::meta::Tag) { 59 + let raw = tag.value.to_string(); 60 + let value = raw.trim(); 61 + // Prefer the std_key if symphonia identified it. 62 + if let Some(std_key) = tag.std_key { 63 + match std_key { 64 + StandardTagKey::ReplayGainTrackGain => { 65 + self.track_gain_db = self.track_gain_db.or(parse_db(value)); 66 + } 67 + StandardTagKey::ReplayGainAlbumGain => { 68 + self.album_gain_db = self.album_gain_db.or(parse_db(value)); 69 + } 70 + StandardTagKey::ReplayGainTrackPeak => { 71 + self.track_peak = self.track_peak.or(parse_peak(value)); 72 + } 73 + StandardTagKey::ReplayGainAlbumPeak => { 74 + self.album_peak = self.album_peak.or(parse_peak(value)); 75 + } 76 + _ => {} 77 + } 78 + return; 79 + } 80 + // Fallback: match the raw key case-insensitively. 81 + let key = tag.key.to_ascii_lowercase(); 82 + match key.as_str() { 83 + "replaygain_track_gain" => { 84 + self.track_gain_db = self.track_gain_db.or(parse_db(value)); 85 + } 86 + "replaygain_album_gain" => { 87 + self.album_gain_db = self.album_gain_db.or(parse_db(value)); 88 + } 89 + "replaygain_track_peak" => { 90 + self.track_peak = self.track_peak.or(parse_peak(value)); 91 + } 92 + "replaygain_album_peak" => { 93 + self.album_peak = self.album_peak.or(parse_peak(value)); 94 + } 95 + _ => {} 96 + } 97 + } 98 + 99 + /// Compute the linear multiplier to apply to samples. Returns 1.0 when 100 + /// the mode is `Off` or the requested tags aren't present (with a 101 + /// fall-through to the other mode's tags). 102 + pub fn linear_gain(&self, settings: ReplayGainSettings) -> f32 { 103 + if !settings.mode.is_active() { 104 + return 1.0; 105 + } 106 + // Pick gain + peak based on mode, falling back to the other scope 107 + // if the requested one is missing. 108 + let (gain_db, peak) = match settings.mode { 109 + ReplayGainMode::Album => ( 110 + self.album_gain_db.or(self.track_gain_db), 111 + self.album_peak.or(self.track_peak), 112 + ), 113 + ReplayGainMode::Track => ( 114 + self.track_gain_db.or(self.album_gain_db), 115 + self.track_peak.or(self.album_peak), 116 + ), 117 + ReplayGainMode::Off => unreachable!(), 118 + }; 119 + let Some(gain_db) = gain_db else { 120 + return 1.0; 121 + }; 122 + let total_db = gain_db + settings.preamp_db; 123 + let mut linear = db_to_linear(total_db); 124 + if settings.prevent_clip { 125 + if let Some(peak) = peak { 126 + if peak > 0.0 && linear * peak > 1.0 { 127 + linear = 1.0 / peak; 128 + } 129 + } 130 + } 131 + linear 132 + } 133 + } 134 + 135 + fn db_to_linear(db: f32) -> f32 { 136 + 10f32.powf(db / 20.0) 137 + } 138 + 139 + /// Parse an RG gain value like `"-6.5 dB"`, `"+3.14"`, `"0"`. Returns 140 + /// `None` for empty / unparseable strings. 141 + fn parse_db(s: &str) -> Option<f32> { 142 + let trimmed = s.trim(); 143 + if trimmed.is_empty() { 144 + return None; 145 + } 146 + // Strip a trailing " dB" (case-insensitive) if present. 147 + let num_part = trimmed 148 + .to_ascii_lowercase() 149 + .trim_end_matches(" db") 150 + .trim() 151 + .to_string(); 152 + num_part.parse::<f32>().ok() 153 + } 154 + 155 + /// Parse an RG peak value like `"1.0"` or `"0.988312"`. Same lenient rules 156 + /// as `parse_db` — negative or non-numeric input becomes `None`. 157 + fn parse_peak(s: &str) -> Option<f32> { 158 + let v: f32 = s.trim().parse().ok()?; 159 + if v.is_finite() && v >= 0.0 { 160 + Some(v) 161 + } else { 162 + None 163 + } 164 + } 165 + 166 + #[cfg(test)] 167 + mod tests { 168 + use super::*; 169 + 170 + #[test] 171 + fn parse_db_accepts_common_shapes() { 172 + assert_eq!(parse_db("-6.5 dB"), Some(-6.5)); 173 + assert_eq!(parse_db(" +3.14 "), Some(3.14)); 174 + assert_eq!(parse_db("0"), Some(0.0)); 175 + assert_eq!(parse_db("-6.5 DB"), Some(-6.5)); 176 + } 177 + 178 + #[test] 179 + fn parse_db_rejects_junk() { 180 + assert_eq!(parse_db(""), None); 181 + assert_eq!(parse_db("not a number"), None); 182 + } 183 + 184 + #[test] 185 + fn parse_peak_bounds() { 186 + assert_eq!(parse_peak("0.988"), Some(0.988)); 187 + assert_eq!(parse_peak("1.0"), Some(1.0)); 188 + assert_eq!(parse_peak(""), None); 189 + assert_eq!(parse_peak("-0.5"), None); 190 + assert_eq!(parse_peak("nan"), None); 191 + } 192 + 193 + #[test] 194 + fn db_to_linear_reference_values() { 195 + assert!((db_to_linear(0.0) - 1.0).abs() < 1e-6); 196 + // -6 dB ≈ 0.501187 197 + assert!((db_to_linear(-6.0) - 0.501187).abs() < 1e-4); 198 + // +6 dB ≈ 1.995262 199 + assert!((db_to_linear(6.0) - 1.995262).abs() < 1e-4); 200 + } 201 + 202 + fn info_with(track: Option<f32>, album: Option<f32>, peak: Option<f32>) -> ReplayGainInfo { 203 + ReplayGainInfo { 204 + track_gain_db: track, 205 + album_gain_db: album, 206 + track_peak: peak, 207 + album_peak: peak, 208 + } 209 + } 210 + 211 + #[test] 212 + fn linear_gain_off_is_unity() { 213 + let info = info_with(Some(-6.0), Some(-4.0), Some(0.9)); 214 + let s = ReplayGainSettings::default(); // Off 215 + assert!((info.linear_gain(s) - 1.0).abs() < 1e-6); 216 + } 217 + 218 + #[test] 219 + fn linear_gain_track_mode_uses_track_gain() { 220 + let info = info_with(Some(-6.0), Some(-3.0), None); 221 + let s = ReplayGainSettings { 222 + mode: ReplayGainMode::Track, 223 + preamp_db: 0.0, 224 + prevent_clip: false, 225 + }; 226 + // -6 dB → ~0.501 227 + assert!((info.linear_gain(s) - 0.501187).abs() < 1e-4); 228 + } 229 + 230 + #[test] 231 + fn linear_gain_falls_back_when_tag_missing() { 232 + // Album mode requested, but only track gain is tagged → use track. 233 + let info = info_with(Some(-6.0), None, None); 234 + let s = ReplayGainSettings { 235 + mode: ReplayGainMode::Album, 236 + preamp_db: 0.0, 237 + prevent_clip: false, 238 + }; 239 + assert!((info.linear_gain(s) - 0.501187).abs() < 1e-4); 240 + } 241 + 242 + #[test] 243 + fn linear_gain_returns_unity_when_all_tags_missing() { 244 + let info = ReplayGainInfo::default(); 245 + let s = ReplayGainSettings { 246 + mode: ReplayGainMode::Track, 247 + preamp_db: 0.0, 248 + prevent_clip: false, 249 + }; 250 + assert_eq!(info.linear_gain(s), 1.0); 251 + } 252 + 253 + #[test] 254 + fn preamp_is_additive_in_db_domain() { 255 + let info = info_with(Some(-6.0), None, None); 256 + let s = ReplayGainSettings { 257 + mode: ReplayGainMode::Track, 258 + preamp_db: 6.0, 259 + prevent_clip: false, 260 + }; 261 + // -6 + 6 = 0 dB → 1.0 linear 262 + assert!((info.linear_gain(s) - 1.0).abs() < 1e-6); 263 + } 264 + 265 + #[test] 266 + fn clip_prevention_caps_linear_when_peak_would_clip() { 267 + // +12 dB pushes linear well above 1.0. With peak=1.0 that clips — 268 + // prevent_clip should hold linear at 1/peak = 1.0. 269 + let info = info_with(Some(12.0), None, Some(1.0)); 270 + let s = ReplayGainSettings { 271 + mode: ReplayGainMode::Track, 272 + preamp_db: 0.0, 273 + prevent_clip: true, 274 + }; 275 + assert!((info.linear_gain(s) - 1.0).abs() < 1e-6); 276 + } 277 + 278 + #[test] 279 + fn clip_prevention_off_lets_gain_exceed_unity() { 280 + let info = info_with(Some(12.0), None, Some(1.0)); 281 + let s = ReplayGainSettings { 282 + mode: ReplayGainMode::Track, 283 + preamp_db: 0.0, 284 + prevent_clip: false, 285 + }; 286 + // +12 dB ≈ 3.981 287 + assert!((info.linear_gain(s) - 3.981).abs() < 1e-3); 288 + } 289 + }
+61 -4
crates/fin-player/src/symphonia_player.rs
··· 29 29 use crate::queue::{PlaybackQueue, QueueItem}; 30 30 use crate::persist::{PersistedQueue, Persister}; 31 31 use crate::renderer::{PlaybackState, PlaybackStatus, Renderer, RendererKind}; 32 + use crate::replaygain::{ReplayGainInfo, ReplayGainSettings}; 32 33 33 34 /// A local, audio-only renderer. Streams the HTTP body, decodes with symphonia, 34 35 /// and pushes float samples to the default cpal output device. ··· 64 65 /// snapshot. Doesn't start playback — the next Resume/Play does. 65 66 Restore(PersistedQueue), 66 67 RemoveAt(usize), 68 + SetReplayGain(ReplayGainSettings), 67 69 Quit, 68 70 } 69 71 ··· 179 181 180 182 async fn remove_from_queue(&self, index: usize) -> Result<()> { 181 183 self.send(PlayerCommand::RemoveAt(index)) 184 + } 185 + 186 + async fn set_replaygain(&self, settings: ReplayGainSettings) -> Result<()> { 187 + self.send(PlayerCommand::SetReplayGain(settings)) 182 188 } 183 189 184 190 fn state(&self) -> PlaybackState { ··· 413 419 paused: Arc<AtomicBool>, 414 420 // Instant we started the current track — used only as a tie-breaker in logs. 415 421 _started: Instant, 422 + // ReplayGain tags read from the track, plus the linear gain that 423 + // currently-active settings resolve to. Recomputed on SetReplayGain. 424 + replaygain_info: ReplayGainInfo, 425 + replaygain_linear: f32, 416 426 } 417 427 418 428 impl Track { ··· 573 583 let mut pending_seek: Option<f64> = None; 574 584 // Rate-limit position-only persist writes. 575 585 let mut last_position_persist = Instant::now(); 586 + // Current ReplayGain settings — applied to each track we load. 587 + let mut rg_settings = ReplayGainSettings::default(); 576 588 577 589 'main: loop { 578 590 // Consume commands. If nothing's playing, block; otherwise poll. ··· 605 617 &state, 606 618 &queue, 607 619 &mut pending_seek, 620 + rg_settings, 608 621 ); 609 622 } else { 610 623 mark_idle(&state); ··· 625 638 &state, 626 639 &queue, 627 640 &mut pending_seek, 641 + rg_settings, 628 642 ); 629 643 } 630 644 } ··· 644 658 &state, 645 659 &queue, 646 660 &mut pending_seek, 661 + rg_settings, 647 662 ); 648 663 } 649 664 } ··· 673 688 &state, 674 689 &queue, 675 690 &mut pending_seek, 691 + rg_settings, 676 692 ); 677 693 } 678 694 } ··· 708 724 &state, 709 725 &queue, 710 726 &mut pending_seek, 727 + rg_settings, 711 728 ); 712 729 } else { 713 730 mark_idle(&state); ··· 727 744 &state, 728 745 &queue, 729 746 &mut pending_seek, 747 + rg_settings, 730 748 ); 731 749 } else { 732 750 mark_idle(&state); ··· 797 815 drop(s); 798 816 // Do NOT overwrite the snapshot on restore itself. 799 817 } 818 + PlayerCommand::SetReplayGain(settings) => { 819 + rg_settings = settings; 820 + if let Some(ref mut t) = track { 821 + t.replaygain_linear = t.replaygain_info.linear_gain(settings); 822 + } 823 + state.lock().replaygain = settings; 824 + persist_now(&persister, &queue, &state); 825 + } 800 826 PlayerCommand::RemoveAt(idx) => { 801 827 let cur = queue.current_index(); 802 828 let removing_current = cur == Some(idx); ··· 816 842 &state, 817 843 &queue, 818 844 &mut pending_seek, 845 + rg_settings, 819 846 ); 820 847 } else { 821 848 mark_idle(&state); ··· 873 900 &state, 874 901 &queue, 875 902 &mut pending_seek, 903 + rg_settings, 876 904 ); 877 905 } else { 878 906 mark_idle(&state); ··· 893 921 &state, 894 922 &queue, 895 923 &mut pending_seek, 924 + rg_settings, 896 925 ); 897 926 } else { 898 927 mark_idle(&state); ··· 955 984 } 956 985 957 986 fn push_samples_with_volume(t: &Track, samples: &[f32]) { 987 + // Effective scale = user volume × ReplayGain linear multiplier. 988 + // Combining them here keeps the sample loop a single multiply, and 989 + // "volume" still behaves as a hard cap the user can always reach for. 958 990 let vol = f32::from_bits(t.volume.load(Ordering::Relaxed)); 991 + let effective = vol * t.replaygain_linear; 959 992 let mut offset = 0; 960 993 while offset < samples.len() { 961 994 if t.paused.load(Ordering::Relaxed) { ··· 963 996 continue; 964 997 } 965 998 let end = std::cmp::min(offset + 4096, samples.len()); 966 - let chunk: Vec<f32> = samples[offset..end].iter().map(|s| s * vol).collect(); 999 + let chunk: Vec<f32> = samples[offset..end].iter().map(|s| s * effective).collect(); 967 1000 match t.producer.write(&chunk) { 968 1001 Ok(n) => offset += n, 969 1002 Err(_) => thread::sleep(Duration::from_millis(5)), ··· 1082 1115 state: &Arc<Mutex<PlaybackState>>, 1083 1116 queue: &PlaybackQueue, 1084 1117 pending_seek: &mut Option<f64>, 1118 + rg_settings: ReplayGainSettings, 1085 1119 ) -> Option<Track> { 1086 - let mut track = try_load_track(host, item, volume, paused, state, queue)?; 1120 + let mut track = try_load_track(host, item, volume, paused, state, queue, rg_settings)?; 1087 1121 if let Some(secs) = pending_seek.take() { 1088 1122 if secs > 0.0 { 1089 1123 seek_track(&mut track, secs); ··· 1113 1147 paused: &Arc<AtomicBool>, 1114 1148 state: &Arc<Mutex<PlaybackState>>, 1115 1149 queue: &PlaybackQueue, 1150 + rg_settings: ReplayGainSettings, 1116 1151 ) -> Option<Track> { 1117 1152 { 1118 1153 let mut s = state.lock(); ··· 1121 1156 s.position_secs = 0.0; 1122 1157 s.duration_secs = item.duration_secs.map(|d| d as f64).unwrap_or(0.0); 1123 1158 } 1124 - match load_track(host, item.clone(), volume.clone(), paused.clone()) { 1159 + match load_track(host, item.clone(), volume.clone(), paused.clone(), rg_settings) { 1125 1160 Ok(t) => { 1126 1161 let mut s = state.lock(); 1127 1162 s.status = PlaybackStatus::Playing; ··· 1143 1178 mark_idle(state); 1144 1179 return None; 1145 1180 } 1146 - Some(next) => match load_track(host, next.clone(), volume.clone(), paused.clone()) { 1181 + Some(next) => match load_track( 1182 + host, 1183 + next.clone(), 1184 + volume.clone(), 1185 + paused.clone(), 1186 + rg_settings, 1187 + ) { 1147 1188 Ok(t) => { 1148 1189 let mut s = state.lock(); 1149 1190 s.status = PlaybackStatus::Playing; ··· 1164 1205 item: QueueItem, 1165 1206 volume: Arc<AtomicU32>, 1166 1207 paused: Arc<AtomicBool>, 1208 + rg_settings: ReplayGainSettings, 1167 1209 ) -> Result<Track> { 1168 1210 // 1. Kick off the HTTP fetch. 1169 1211 let source = spawn_streaming_source(&item.stream_url)?; ··· 1237 1279 1238 1280 let mut resampler = Resampler::new(source_sr, output_sr, source_channels, output_channels); 1239 1281 1282 + // 4b. Read whatever ReplayGain tags this track carries. Done AFTER the 1283 + // first-packet decode primer above — several formats only expose 1284 + // metadata once the first packet has flown by. 1285 + let replaygain_info = ReplayGainInfo::extract_from(&mut format); 1286 + let replaygain_linear = replaygain_info.linear_gain(rg_settings); 1287 + debug!( 1288 + title = %item.title, 1289 + track_gain = ?replaygain_info.track_gain_db, 1290 + album_gain = ?replaygain_info.album_gain_db, 1291 + linear = replaygain_linear, 1292 + "replaygain resolved" 1293 + ); 1294 + 1240 1295 // 5. Prime the ring with the first packet's samples so playback starts 1241 1296 // immediately instead of waiting a full worker tick. 1242 1297 let mut track = Track { ··· 1260 1315 volume, 1261 1316 paused, 1262 1317 _started: Instant::now(), 1318 + replaygain_info, 1319 + replaygain_linear, 1263 1320 }; 1264 1321 let first_out = resampler.process(&first_samples); 1265 1322 if !first_out.is_empty() {
+36 -2
crates/fin-tui/src/app.rs
··· 1059 1059 let _ = renderer.stop().await; 1060 1060 app.set_status("Queue cleared."); 1061 1061 } 1062 + // ReplayGain: `g` cycles off → track → album → off. 1063 + (KeyCode::Char('g'), _) => { 1064 + let mut new_settings = { 1065 + let cfg = app.config.lock(); 1066 + cfg.replaygain 1067 + }; 1068 + new_settings.mode = new_settings.mode.next(); 1069 + let renderer = app.renderer.lock().clone(); 1070 + if let Err(e) = renderer.set_replaygain(new_settings).await { 1071 + app.set_status(format!("replaygain: {}", e)); 1072 + } else { 1073 + let mut cfg = app.config.lock(); 1074 + cfg.replaygain = new_settings; 1075 + let _ = cfg.save(); 1076 + drop(cfg); 1077 + app.set_status(format!("ReplayGain: {}", new_settings.mode.label())); 1078 + } 1079 + } 1062 1080 _ => {} 1063 1081 } 1064 1082 Ok(()) ··· 1427 1445 1428 1446 let rows = Layout::default() 1429 1447 .direction(Direction::Vertical) 1430 - .constraints([Constraint::Length(7), Constraint::Min(4)]) 1448 + .constraints([Constraint::Length(8), Constraint::Min(4)]) 1431 1449 .split(area); 1432 1450 1433 1451 // Top card — global settings. ··· 1447 1465 Line::from(vec![ 1448 1466 Span::styled(" Last UPnP ", title_style()), 1449 1467 Span::styled(last_upnp, Style::default().fg(Palette::HIGHLIGHT)), 1468 + ]), 1469 + Line::from(vec![ 1470 + Span::styled(" ReplayGain ", title_style()), 1471 + Span::styled(cfg_snapshot.replaygain.mode.label(), accent_style()), 1472 + Span::styled( 1473 + format!( 1474 + " preamp {:+.1} dB clip-guard {} (press g to cycle)", 1475 + cfg_snapshot.replaygain.preamp_db, 1476 + if cfg_snapshot.replaygain.prevent_clip { 1477 + "on" 1478 + } else { 1479 + "off" 1480 + } 1481 + ), 1482 + muted_style(), 1483 + ), 1450 1484 ]), 1451 1485 Line::from(vec![ 1452 1486 Span::styled(" Config File ", title_style()), ··· 1557 1591 None => None, 1558 1592 } 1559 1593 }; 1560 - let help = " tab: screen ↑↓/jk: nav enter: play/drill x: play all a: queue n: next z: shuffle R: repeat space: pause s: stop d: rm/C: clear (queue) </>: skip +/-: vol m: local t: server /: search esc: back q: quit "; 1594 + let help = " tab: screen ↑↓/jk: nav enter: play/drill x: play all a: queue n: next z: shuffle R: repeat g: replaygain space: pause s: stop d: rm/C: clear (queue) </>: skip +/-: vol m: local t: server /: search esc: back q: quit "; 1561 1595 // Errors/warnings pop in warn-red; other status messages use the primary 1562 1596 // teal so they stand out from the muted help text. 1563 1597 let (text, style) = match msg {
+11
crates/fin-tui/src/widgets/player_bar.rs
··· 128 128 Style::default().fg(Palette::MUTED) 129 129 }, 130 130 ); 131 + let rg_span = if self.state.replaygain.mode.is_active() { 132 + Span::styled( 133 + format!("RG:{} ", self.state.replaygain.mode.label()), 134 + Style::default() 135 + .fg(Palette::HIGHLIGHT) 136 + .add_modifier(Modifier::BOLD), 137 + ) 138 + } else { 139 + Span::raw("") 140 + }; 131 141 let right_line = Line::from(vec![ 132 142 shuffle_span, 133 143 repeat_span, 144 + rg_span, 134 145 Span::styled( 135 146 format!("♪ {}% ", (self.state.volume * 100.0) as i32), 136 147 Style::default().fg(Palette::HIGHLIGHT),
+5
crates/fin/src/main.rs
··· 189 189 .as_ref() 190 190 .and_then(|p| fin_player::load_persisted_queue(p)); 191 191 let r = LocalRenderer::with_persist(queue_path); 192 + // Apply the user's ReplayGain preference before playback starts 193 + // so the very first track's samples get scaled correctly. 194 + if let Err(e) = r.set_replaygain(cfg.replaygain).await { 195 + tracing::warn!(?e, "replaygain apply failed"); 196 + } 192 197 if let Some(snap) = saved { 193 198 if !snap.items.is_empty() { 194 199 let items_n = snap.items.len();