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

Configure Feed

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

Apply ReplayGain via rockbox-dsp 0.2.0 PGA stage

Bump rockbox-dsp to 0.2.0 and move gain application into the Rockbox
DSP's pre-gain (PGA) stage, the same fixed-point pipeline as EQ and
tone controls. Tag extraction stays in fin; the f32 linear multiplier
survives only as a fallback for paths the PGA can't reach (the
crossfade-incoming track routed through the voice config, non-stereo
output, and the first primed packet).

Tsiry Sandratraina (Jul 5, 2026, 9:59 PM +0300) f9a850cf 35207c5a

+142 -34
+10
CHANGELOG.md
··· 5 5 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 6 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 7 8 + ## [Unreleased] 9 + 10 + ### Changed 11 + - **ReplayGain now runs in the Rockbox DSP** — upgraded `rockbox-dsp` to 12 + 0.2.0 and moved gain application into its pre-gain (PGA) stage, the same 13 + fixed-point pipeline as the EQ and tone controls. Tag extraction stays in 14 + fin; the old f32 multiplier survives only as a fallback for paths the PGA 15 + can't reach (crossfade-incoming track, non-stereo output, first primed 16 + packet). 17 + 8 18 ## [0.3.0] - 2026-07-05 9 19 10 20 ### Added
+2 -2
Cargo.lock
··· 1984 1984 1985 1985 [[package]] 1986 1986 name = "rockbox-dsp" 1987 - version = "0.1.1" 1987 + version = "0.2.0" 1988 1988 source = "registry+https://github.com/rust-lang/crates.io-index" 1989 - checksum = "71ce29fced42c85faa0b1e8df27bc4ad527c066ccb3b6d7bce1345479cd2a4a7" 1989 + checksum = "4310fa190f0ffdd5dd2c49578dbe5111d8a068b4a51f8baa6d2e5f94d1dec80a" 1990 1990 dependencies = [ 1991 1991 "cc", 1992 1992 ]
+4 -4
crates/fin-player/Cargo.toml
··· 34 34 # already, so this is a zero-cost explicit dep. 35 35 rand = "0.9" 36 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" 37 + # Rockbox DSP pipeline — 10-band EQ, tone controls, ReplayGain pre-gain, 38 + # resample, crossfeed, … Note: the compiled C sources are 39 + # GPL-2.0-or-later — linking makes the binary GPL when EQ code is included. 40 + rockbox-dsp = "0.2"
+17 -7
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. 1 + //! ReplayGain support — read `REPLAYGAIN_*` tags from decoded tracks. 4 2 //! 5 3 //! Reference: <https://en.wikipedia.org/wiki/ReplayGain> 6 4 //! 7 - //! Applied gain formula: 5 + //! The gain itself is applied by the Rockbox DSP's pre-gain (PGA) stage 6 + //! (`rockbox_dsp::Dsp::set_replaygain` + `set_replaygain_gains`), in 7 + //! fixed point, as part of the same pipeline that runs the EQ and tone 8 + //! controls. This module only extracts the tags and computes the f32 9 + //! fallback multiplier ([`ReplayGainInfo::linear_gain`]) for the paths 10 + //! the PGA can't cover: the crossfade-incoming track (routed through the 11 + //! voice DSP config, which Rockbox gives no PGA stage), non-stereo 12 + //! output (the DSP is skipped entirely), and the first primed packet. 13 + //! 14 + //! Fallback gain formula (mirrors Rockbox's `dsp_replaygain_update`): 8 15 //! ```text 9 16 //! gain_dB = base_gain_dB + preamp_dB 10 17 //! linear = 10 ^ (gain_dB / 20) ··· 16 23 //! 17 24 //! Falls back gracefully: if the requested mode's tags are missing, we try 18 25 //! the other mode; if both are missing, gain is 1.0 (i.e. no adjustment). 26 + //! Rockbox's PGA does the same track ↔ album fallback internally, so the 27 + //! two paths resolve the same gain. 19 28 20 29 use symphonia::core::formats::FormatReader; 21 30 use symphonia::core::meta::StandardTagKey; ··· 96 105 } 97 106 } 98 107 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). 108 + /// Compute the f32 fallback multiplier for samples that bypass the 109 + /// Rockbox PGA stage (see module docs). Returns 1.0 when the mode is 110 + /// `Off` or the requested tags aren't present (with a fall-through to 111 + /// the other mode's tags). 102 112 pub fn linear_gain(&self, settings: ReplayGainSettings) -> f32 { 103 113 if !settings.mode.is_active() { 104 114 return 1.0;
+109 -21
crates/fin-player/src/symphonia_player.rs
··· 27 27 use tracing::{debug, error, warn}; 28 28 29 29 use fin_config::EqBand; 30 - use rockbox_dsp::{eq_band_setting, Dsp, EQ_NUM_BANDS}; 30 + use rockbox_dsp::{ 31 + eq_band_setting, Dsp, EQ_NUM_BANDS, REPLAYGAIN_ALBUM, REPLAYGAIN_OFF, REPLAYGAIN_TRACK, 32 + }; 31 33 32 34 use crate::voice_dsp::VoiceDsp; 33 35 ··· 54 56 use crate::persist::{PersistedQueue, Persister}; 55 57 use crate::queue::{PlaybackQueue, QueueItem}; 56 58 use crate::renderer::{PlaybackState, PlaybackStatus, Renderer, RendererKind}; 57 - use crate::replaygain::{ReplayGainInfo, ReplayGainSettings}; 59 + use crate::replaygain::{ReplayGainInfo, ReplayGainMode, ReplayGainSettings}; 58 60 59 61 /// A local, audio-only renderer. Streams the HTTP body, decodes with symphonia, 60 62 /// and pushes float samples to the default cpal output device. ··· 471 473 paused: Arc<AtomicBool>, 472 474 // Instant we started the current track — used only as a tie-breaker in logs. 473 475 _started: Instant, 474 - // ReplayGain tags read from the track, plus the linear gain that 475 - // currently-active settings resolve to. Recomputed on SetReplayGain. 476 + // ReplayGain tags read from the track. The gain itself is applied by 477 + // the Rockbox DSP's pre-gain (PGA) stage — these tags are handed to 478 + // the audio DSP config once this track becomes current (see 479 + // `rg_gains_pushed`). 476 480 replaygain_info: ReplayGainInfo, 481 + // Fallback linear multiplier for samples the Rockbox PGA can't touch: 482 + // the crossfade-incoming path (routed through the voice DSP config, 483 + // which has no PGA stage), non-stereo output (DSP skipped entirely), 484 + // and the first primed packet. Recomputed on SetReplayGain. 477 485 replaygain_linear: f32, 486 + // True once this track's RG tags have been pushed into the audio DSP. 487 + // Reset per-track so promotion / track changes re-push. 488 + rg_gains_pushed: bool, 478 489 // True once the decoder has returned EndOfStream (no more packets). 479 490 // Used by the crossfade path — we can't advance the queue on EOF 480 491 // anymore because that's the promotion step's job. ··· 667 678 // on the first track load (we need the device output rate). The 668 679 // instance is a process-wide singleton (`CODEC_IDX_AUDIO`), so we 669 680 // never own two at once — the crossfade `next` track bypasses it and 670 - // uses the plain resampler. 681 + // uses the plain resampler. Besides EQ/tone this config also owns the 682 + // PGA stage, which applies ReplayGain in fixed point. 671 683 let mut dsp: Option<Dsp> = None; 672 684 // Second DSP instance bound to Rockbox's `CODEC_IDX_VOICE`. Rockbox 673 685 // shares EQ / tone coefficients across configs but keeps biquad delay ··· 1023 1035 } 1024 1036 PlayerCommand::SetReplayGain(settings) => { 1025 1037 rg_settings = settings; 1038 + // The DSP stashes per-track gains internally, so a 1039 + // settings change alone recomputes the pre-gain — no 1040 + // need to re-push the current track's tags. 1041 + if let Some(ref mut d) = dsp { 1042 + apply_replaygain_to_dsp(d, settings); 1043 + } 1044 + // Refresh the f32 fallback multipliers (crossfade 1045 + // incoming / non-stereo paths). 1026 1046 if let Some(ref mut t) = track { 1027 1047 t.replaygain_linear = t.replaygain_info.linear_gain(settings); 1028 1048 } ··· 1149 1169 tone_bass_cutoff_hz, 1150 1170 tone_treble_cutoff_hz, 1151 1171 ); 1172 + apply_replaygain_to_dsp(&mut d, rg_settings); 1152 1173 dsp = Some(d); 1153 1174 // Spin up the second config now too — cheap, and it means 1154 1175 // the crossfade preload path never blocks on init. ··· 1159 1180 } 1160 1181 } 1161 1182 1183 + // Hand this track's RG tags to the audio DSP's PGA stage the 1184 + // first time it decodes as current — covers fresh loads and 1185 + // crossfade promotions alike. Pushed even with RG off so a 1186 + // later mode toggle picks them up without a reload. 1187 + if !t.rg_gains_pushed { 1188 + if let Some(ref mut d) = dsp { 1189 + apply_replaygain_gains_to_dsp(d, &t.replaygain_info); 1190 + t.rg_gains_pushed = true; 1191 + } 1192 + } 1193 + 1162 1194 let free = t.ring_free_slots(); 1163 1195 if free >= 8192 && !t.ended { 1164 1196 // Current track always uses the AUDIO Rockbox config; the ··· 1166 1198 // below. Coefficients are global, so the EQ curve matches; 1167 1199 // only the biquad delay lines differ. Skip both configs 1168 1200 // when no stage is doing anything to avoid the f32↔i16 1169 - // conversion hops. 1170 - let dsp_active = eq_enabled || tone_bass_db != 0 || tone_treble_db != 0; 1201 + // conversion hops. ReplayGain counts as a stage here — it 1202 + // runs in the audio config's PGA. 1203 + let dsp_active = eq_enabled 1204 + || tone_bass_db != 0 1205 + || tone_treble_db != 0 1206 + || rg_settings.mode.is_active(); 1171 1207 let dsp_arg: Option<&mut dyn DspProcess> = if dsp_active { 1172 1208 dsp.as_mut().map(|d| d as &mut dyn DspProcess) 1173 1209 } else { 1174 1210 None 1175 1211 }; 1176 - match decode_one_packet(t, dsp_arg) { 1212 + match decode_one_packet(t, dsp_arg, true) { 1177 1213 DecodeStep::Pushed => update_position(&state, t), 1178 1214 DecodeStep::EndOfStream => { 1179 1215 debug!(item = %t.item.title, "current track ended"); ··· 1193 1229 // AUDIO config's biquad delay lines are undisturbed — that's what 1194 1230 // caused the "small noises" during overlap in the previous 1195 1231 // shared-DSP version. Coefficients are still global, so the EQ 1196 - // curve is identical on both sides. 1232 + // curve is identical on both sides. The voice config has NO PGA 1233 + // stage (Rockbox hardwires ReplayGain to the audio config), so 1234 + // this side gets its RG via the f32 fallback multiplier instead 1235 + // — `rg_in_dsp = false` below. 1197 1236 if let Some(ref mut nt) = next { 1198 1237 let free = nt.ring_free_slots(); 1199 1238 if free >= 8192 && !nt.ended { ··· 1203 1242 } else { 1204 1243 None 1205 1244 }; 1206 - match decode_one_packet(nt, dsp_arg) { 1245 + match decode_one_packet(nt, dsp_arg, false) { 1207 1246 DecodeStep::Pushed => {} 1208 1247 DecodeStep::EndOfStream => nt.ended = true, 1209 1248 DecodeStep::Error(e) => { ··· 1375 1414 Error(String), 1376 1415 } 1377 1416 1378 - fn decode_one_packet(t: &mut Track, dsp: Option<&mut dyn DspProcess>) -> DecodeStep { 1417 + /// Decode one packet, run it through the given DSP config (if any) and 1418 + /// push it to the ring. `rg_in_dsp` says whether that config applies 1419 + /// ReplayGain in its PGA stage (true only for the AUDIO config) — when it 1420 + /// does, the f32 fallback multiplier is skipped so gain isn't applied 1421 + /// twice. 1422 + fn decode_one_packet( 1423 + t: &mut Track, 1424 + dsp: Option<&mut dyn DspProcess>, 1425 + rg_in_dsp: bool, 1426 + ) -> DecodeStep { 1379 1427 let packet = match t.format.next_packet() { 1380 1428 Ok(p) => p, 1381 1429 Err(symphonia::core::errors::Error::IoError(e)) ··· 1408 1456 if out_samples.is_empty() { 1409 1457 return DecodeStep::Pushed; 1410 1458 } 1411 - // Rockbox EQ post-processing — Dsp only handles interleaved stereo, so 1412 - // for non-stereo output we skip and let the samples through untouched. 1459 + // Rockbox DSP post-processing — Dsp only handles interleaved stereo, 1460 + // so for non-stereo output we skip it (and fall back to the f32 1461 + // ReplayGain multiplier below). 1462 + let mut rg_mult = t.replaygain_linear; 1413 1463 if let Some(d) = dsp { 1414 1464 if t.output_channels == 2 { 1415 1465 out_samples = apply_dsp(d, &out_samples); 1466 + if rg_in_dsp { 1467 + // The audio config's PGA stage already applied ReplayGain 1468 + // in fixed point. 1469 + rg_mult = 1.0; 1470 + } 1416 1471 if out_samples.is_empty() { 1417 1472 return DecodeStep::Pushed; 1418 1473 } 1419 1474 } 1420 1475 } 1421 1476 let out_frames = out_samples.len() / t.output_channels.max(1); 1422 - push_samples_with_volume(t, &out_samples); 1477 + push_samples_with_volume(t, &out_samples, rg_mult); 1423 1478 t.produced_output_frames += out_frames as u64; 1424 1479 DecodeStep::Pushed 1425 1480 } ··· 1458 1513 dsp.set_tone(bass_db, treble_db); 1459 1514 } 1460 1515 1516 + /// Map fin's ReplayGain settings onto the Rockbox PGA stage: mode, clip 1517 + /// prevention, preamp. The per-track gains are pushed separately via 1518 + /// [`apply_replaygain_gains_to_dsp`]; the DSP stashes both and recomputes 1519 + /// the pre-gain whenever either changes. 1520 + fn apply_replaygain_to_dsp(dsp: &mut Dsp, settings: ReplayGainSettings) { 1521 + let mode = match settings.mode { 1522 + ReplayGainMode::Off => REPLAYGAIN_OFF, 1523 + ReplayGainMode::Track => REPLAYGAIN_TRACK, 1524 + ReplayGainMode::Album => REPLAYGAIN_ALBUM, 1525 + }; 1526 + dsp.set_replaygain(mode, settings.prevent_clip, settings.preamp_db); 1527 + } 1528 + 1529 + /// Hand a track's RG tags to the audio DSP config. Rockbox falls back 1530 + /// track ↔ album internally when the requested scope's tag is absent, 1531 + /// matching the old fin behavior. 1532 + fn apply_replaygain_gains_to_dsp(dsp: &mut Dsp, info: &ReplayGainInfo) { 1533 + dsp.set_replaygain_gains( 1534 + info.track_gain_db, 1535 + info.album_gain_db, 1536 + info.track_peak, 1537 + info.album_peak, 1538 + ); 1539 + } 1540 + 1461 1541 /// Apply an on/off toggle plus the current bands to the Dsp singleton. 1462 1542 /// Truncates to EQ_NUM_BANDS silently. 1463 1543 fn apply_eq_to_dsp(dsp: &mut Dsp, enabled: bool, bands: &[EqBand]) { ··· 1474 1554 dsp.eq_enable(enabled); 1475 1555 } 1476 1556 1477 - fn push_samples_with_volume(t: &Track, samples: &[f32]) { 1478 - // Effective scale = user volume × ReplayGain linear multiplier × 1479 - // per-frame fade envelope. Volume + RG are constant across the batch; 1480 - // the fade multiplier walks per output frame using this track's own 1557 + fn push_samples_with_volume(t: &Track, samples: &[f32], rg_mult: f32) { 1558 + // Effective scale = user volume × ReplayGain fallback multiplier × 1559 + // per-frame fade envelope. `rg_mult` is 1.0 when the Rockbox PGA 1560 + // stage already gained these samples; otherwise it's the track's f32 1561 + // fallback. Volume + RG are constant across the batch; the fade 1562 + // multiplier walks per output frame using this track's own 1481 1563 // produced_output_frames counter. 1482 - let base = f32::from_bits(t.volume.load(Ordering::Relaxed)) * t.replaygain_linear; 1564 + let base = f32::from_bits(t.volume.load(Ordering::Relaxed)) * rg_mult; 1483 1565 let ch = t.output_channels.max(1); 1484 1566 let mut offset = 0; 1485 1567 while offset < samples.len() { ··· 1797 1879 1798 1880 // 4b. Read whatever ReplayGain tags this track carries. Done AFTER the 1799 1881 // first-packet decode primer above — several formats only expose 1800 - // metadata once the first packet has flown by. 1882 + // metadata once the first packet has flown by. The tags are pushed 1883 + // into the Rockbox PGA when this track starts decoding as current; 1884 + // the linear value is the f32 fallback for DSP-bypassing paths. 1801 1885 let replaygain_info = ReplayGainInfo::extract_from(&mut format); 1802 1886 let replaygain_linear = replaygain_info.linear_gain(rg_settings); 1803 1887 debug!( ··· 1832 1916 _started: Instant::now(), 1833 1917 replaygain_info, 1834 1918 replaygain_linear, 1919 + rg_gains_pushed: false, 1835 1920 ended: false, 1836 1921 overlap_incoming: None, 1837 1922 overlap_outgoing: None, ··· 1839 1924 let first_out = resampler.process(&first_samples); 1840 1925 if !first_out.is_empty() { 1841 1926 let frames = first_out.len() / output_channels.max(1); 1842 - push_samples_with_volume(&track, &first_out); 1927 + // The primed packet never routes through the DSP, so it takes the 1928 + // f32 ReplayGain fallback — same magnitude as the PGA gain the 1929 + // following packets get, so there's no audible seam. 1930 + push_samples_with_volume(&track, &first_out, track.replaygain_linear); 1843 1931 track.produced_output_frames += frames as u64; 1844 1932 } 1845 1933 // Adopt the primed resampler state.