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 crossfade + mixed overlap between tracks

Adjacent tracks now blend for a configurable window. Two modes:
- Crossfade: cosine/sine curves (outgoing^2 + incoming^2 = 1) — perceived
loudness stays constant across the overlap.
- Mixed: no curves, both tracks at full volume — sums additively for a
louder overlap window (DJ-style mix).

The SymphoniaPlayer worker now runs two decoders in parallel during an
overlap. Each track has its own cpal Stream + ring buffer; the OS mixes
them at the audio layer. Fade envelopes are applied per-frame in the
sample push path (using this track's produced_output_frames as the
progress clock).

Data
- fin-config: CrossfadeMode (Off/Crossfade/Mixed) + CrossfadeSettings
(mode + duration_secs, default 5s). #[serde(default)] on the Config
field keeps older configs loading.
- fin-player: crossfade module with fade_at() + FadePair, 6 unit tests
covering endpoint values, midpoint equal-power, sum-of-squares
invariant, and clamping.

Audio path
- Track gets `ended: bool`, `overlap_incoming/outgoing: Option<OverlapContext>`.
On EOF the current track marks itself ended but doesn't advance the queue
— that's the promotion step's job.
- Two ways an overlap starts:
1. End-approach: worker sees decoder-position + duration <= xf duration,
peeks the next queue item, loads it as `next` with fade_in, marks
current with fade_out.
2. User Play/enqueue-jump: while a track's playing and crossfade is on,
the incoming track loads as `next` instead of hard-cutting.
- Promotion fires when either the fade-in completes OR the current track's
fade-out reaches full length OR current ended prematurely.
- Manual Next snap-promotes any pending crossfade for instant response.
- SetReplayGain propagates to both tracks so a mid-overlap RG change hits
both sides.

Renderer + config
- PlaybackState.crossfade mirrored so the TUI can render without a
separate query path.
- Renderer::set_crossfade with a no-op default (Chromecast/UPnP each
handle their own transitions).
- main.rs applies cfg.crossfade on startup before playback begins.

TUI
- `f` cycles Off → Crossfade → Mixed → Off, persisted to config.toml.
Duration edit stays in config.toml.
- Player bar shows ⋈ Ns (Crossfade) or ≈ Ns (Mixed) when active.
- Settings screen row: mode + duration + hint.
- Help text updated.

Verified live: debug log confirms two crossfades executed with
length_frames=240000 (5s at 48 kHz), promoted after exactly 5 s;
Mixed mode swaps to the ≈ glyph. mpv still never spawns for audio.
82 workspace tests pass.

+612 -70
+62
crates/fin-config/src/lib.rs
··· 28 28 pub client: ClientInfo, 29 29 #[serde(default)] 30 30 pub replaygain: ReplayGainSettings, 31 + #[serde(default)] 32 + pub crossfade: CrossfadeSettings, 31 33 } 32 34 33 35 #[derive(Debug, Clone, Serialize, Deserialize)] ··· 164 166 165 167 fn default_replaygain_prevent_clip() -> bool { 166 168 true 169 + } 170 + 171 + /// How to blend adjacent tracks in the playback queue. 172 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] 173 + #[serde(rename_all = "lowercase")] 174 + pub enum CrossfadeMode { 175 + #[default] 176 + Off, 177 + /// Cosine/sine fade curves — outgoing fades out while incoming fades 178 + /// in. Perceived loudness stays constant across the overlap. 179 + Crossfade, 180 + /// No fade — both tracks play at full volume during the overlap and 181 + /// sum additively. Louder in the overlap window; sounds like a DJ mix. 182 + Mixed, 183 + } 184 + 185 + impl CrossfadeMode { 186 + pub fn label(&self) -> &'static str { 187 + match self { 188 + Self::Off => "off", 189 + Self::Crossfade => "crossfade", 190 + Self::Mixed => "mixed", 191 + } 192 + } 193 + 194 + pub fn next(self) -> Self { 195 + match self { 196 + Self::Off => Self::Crossfade, 197 + Self::Crossfade => Self::Mixed, 198 + Self::Mixed => Self::Off, 199 + } 200 + } 201 + 202 + pub fn is_active(&self) -> bool { 203 + !matches!(self, Self::Off) 204 + } 205 + } 206 + 207 + /// Config-facing crossfade settings. Behavior (dual-track decode + mixing) 208 + /// lives in fin-player; this is just data. 209 + #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] 210 + pub struct CrossfadeSettings { 211 + #[serde(default)] 212 + pub mode: CrossfadeMode, 213 + /// Overlap duration in seconds. Only meaningful when `mode` is active. 214 + #[serde(default = "default_crossfade_secs")] 215 + pub duration_secs: f32, 216 + } 217 + 218 + impl Default for CrossfadeSettings { 219 + fn default() -> Self { 220 + Self { 221 + mode: CrossfadeMode::Off, 222 + duration_secs: default_crossfade_secs(), 223 + } 224 + } 225 + } 226 + 227 + fn default_crossfade_secs() -> f32 { 228 + 5.0 167 229 } 168 230 169 231 #[derive(Debug, Clone, Serialize, Deserialize)]
+111
crates/fin-player/src/crossfade.rs
··· 1 + //! Cross-track blending helpers. 2 + //! 3 + //! The mixer keeps two decode pipelines active during the overlap window. 4 + //! For each output frame it asks this module for: 5 + //! - the fade-out multiplier applied to the outgoing track's sample, and 6 + //! - the fade-in multiplier applied to the incoming track's sample. 7 + //! 8 + //! The two samples are then summed and pushed to the ring buffer. 9 + 10 + pub use fin_config::{CrossfadeMode, CrossfadeSettings}; 11 + 12 + /// The pair of multipliers to apply to the outgoing and incoming samples at 13 + /// a given point in the overlap. `progress` is in `[0.0, 1.0]` — 0 at the 14 + /// start of the overlap, 1 at the end. 15 + #[derive(Debug, Clone, Copy)] 16 + pub struct FadePair { 17 + pub out: f32, 18 + pub incoming: f32, 19 + } 20 + 21 + impl FadePair { 22 + /// Full outgoing, no incoming — used when the fade hasn't started. 23 + pub const OUTGOING_ONLY: Self = Self { out: 1.0, incoming: 0.0 }; 24 + /// No outgoing, full incoming — used after the fade completes. 25 + pub const INCOMING_ONLY: Self = Self { out: 0.0, incoming: 1.0 }; 26 + } 27 + 28 + /// Evaluate the fade curve at `progress`. `Mixed` mode returns 1.0/1.0 — 29 + /// both samples pass through, and the sum can exceed unity. 30 + pub fn fade_at(mode: CrossfadeMode, progress: f32) -> FadePair { 31 + let p = progress.clamp(0.0, 1.0); 32 + match mode { 33 + CrossfadeMode::Off => FadePair::OUTGOING_ONLY, 34 + CrossfadeMode::Mixed => FadePair { 35 + out: 1.0, 36 + incoming: 1.0, 37 + }, 38 + CrossfadeMode::Crossfade => { 39 + // Equal-power (cosine/sine) curves so the sum-of-squares stays 40 + // constant — perceptually smooth on music. 41 + let half_pi = std::f32::consts::FRAC_PI_2; 42 + FadePair { 43 + out: (p * half_pi).cos(), 44 + incoming: (p * half_pi).sin(), 45 + } 46 + } 47 + } 48 + } 49 + 50 + #[cfg(test)] 51 + mod tests { 52 + use super::*; 53 + 54 + fn approx(a: f32, b: f32) -> bool { 55 + (a - b).abs() < 1e-4 56 + } 57 + 58 + #[test] 59 + fn off_mode_is_outgoing_only_regardless_of_progress() { 60 + for p in [0.0, 0.25, 0.5, 0.75, 1.0] { 61 + let pair = fade_at(CrossfadeMode::Off, p); 62 + assert!(approx(pair.out, 1.0)); 63 + assert!(approx(pair.incoming, 0.0)); 64 + } 65 + } 66 + 67 + #[test] 68 + fn mixed_mode_is_unity_on_both_sides() { 69 + for p in [0.0, 0.5, 1.0] { 70 + let pair = fade_at(CrossfadeMode::Mixed, p); 71 + assert!(approx(pair.out, 1.0)); 72 + assert!(approx(pair.incoming, 1.0)); 73 + } 74 + } 75 + 76 + #[test] 77 + fn crossfade_endpoints_are_pure_outgoing_or_pure_incoming() { 78 + let start = fade_at(CrossfadeMode::Crossfade, 0.0); 79 + assert!(approx(start.out, 1.0)); 80 + assert!(approx(start.incoming, 0.0)); 81 + 82 + let end = fade_at(CrossfadeMode::Crossfade, 1.0); 83 + assert!(approx(end.out, 0.0)); 84 + assert!(approx(end.incoming, 1.0)); 85 + } 86 + 87 + #[test] 88 + fn crossfade_midpoint_is_equal_power() { 89 + // At 50% progress, both are cos(pi/4) = sin(pi/4) ≈ 0.7071. 90 + let mid = fade_at(CrossfadeMode::Crossfade, 0.5); 91 + assert!(approx(mid.out, std::f32::consts::FRAC_1_SQRT_2)); 92 + assert!(approx(mid.incoming, std::f32::consts::FRAC_1_SQRT_2)); 93 + } 94 + 95 + #[test] 96 + fn crossfade_sum_of_squares_is_constant() { 97 + for p in [0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0] { 98 + let pair = fade_at(CrossfadeMode::Crossfade, p); 99 + let sos = pair.out * pair.out + pair.incoming * pair.incoming; 100 + assert!(approx(sos, 1.0), "sum of squares at p={} was {}", p, sos); 101 + } 102 + } 103 + 104 + #[test] 105 + fn progress_clamped_outside_unit_interval() { 106 + let neg = fade_at(CrossfadeMode::Crossfade, -0.5); 107 + let over = fade_at(CrossfadeMode::Crossfade, 1.5); 108 + assert!(approx(neg.out, 1.0)); 109 + assert!(approx(over.out, 0.0)); 110 + } 111 + }
+2
crates/fin-player/src/lib.rs
··· 1 1 pub mod cast; 2 + pub mod crossfade; 2 3 pub mod discovery; 3 4 pub mod local; 4 5 pub mod mpv; ··· 10 11 pub mod upnp; 11 12 12 13 pub use cast::ChromecastRenderer; 14 + pub use crossfade::{CrossfadeMode, CrossfadeSettings}; 13 15 pub use discovery::{discover_chromecasts, CastDevice}; 14 16 pub use local::LocalRenderer; 15 17 pub use mpv::MpvRenderer;
+6
crates/fin-player/src/local.rs
··· 14 14 use tracing::debug; 15 15 16 16 use crate::mpv::MpvRenderer; 17 + use crate::crossfade::CrossfadeSettings; 17 18 use crate::persist::PersistedQueue; 18 19 use crate::queue::{QueueItem, RepeatMode}; 19 20 use crate::renderer::{PlaybackState, PlaybackStatus, Renderer, RendererKind}; ··· 279 280 // ReplayGain is applied in the audio decode path — mpv has its own 280 281 // separate volume model for video that we don't touch here. 281 282 self.audio.set_replaygain(settings).await 283 + } 284 + 285 + async fn set_crossfade(&self, settings: CrossfadeSettings) -> Result<()> { 286 + // Only audio-side has the dual-track crossfade wiring. 287 + self.audio.set_crossfade(settings).await 282 288 } 283 289 284 290 fn state(&self) -> PlaybackState {
+17
crates/fin-player/src/queue.rs
··· 146 146 prev 147 147 } 148 148 149 + /// What would `advance()` land on, without mutating anything. Used by 150 + /// the crossfade path to know which item to preload while the current 151 + /// one is still winding down. 152 + pub fn peek_next_item(&self) -> Option<QueueItem> { 153 + let g = self.inner.read(); 154 + if g.items.is_empty() { 155 + return None; 156 + } 157 + match (g.repeat, g.index) { 158 + (RepeatMode::One, Some(i)) => g.items.get(i).cloned(), 159 + (_, Some(i)) if i + 1 < g.items.len() => g.items.get(i + 1).cloned(), 160 + (RepeatMode::All, Some(_)) => g.items.first().cloned(), 161 + (_, None) => g.items.first().cloned(), 162 + _ => None, 163 + } 164 + } 165 + 149 166 pub fn shuffle_enabled(&self) -> bool { 150 167 self.inner.read().shuffle 151 168 }
+11
crates/fin-player/src/renderer.rs
··· 1 1 use async_trait::async_trait; 2 2 use serde::{Deserialize, Serialize}; 3 3 4 + use crate::crossfade::CrossfadeSettings; 4 5 use crate::persist::PersistedQueue; 5 6 use crate::queue::{QueueItem, RepeatMode}; 6 7 use crate::replaygain::ReplayGainSettings; ··· 33 34 /// query path. 34 35 #[serde(default)] 35 36 pub replaygain: ReplayGainSettings, 37 + #[serde(default)] 38 + pub crossfade: CrossfadeSettings, 36 39 } 37 40 38 41 impl Default for PlaybackState { ··· 48 51 shuffle: false, 49 52 repeat: RepeatMode::Off, 50 53 replaygain: ReplayGainSettings::default(), 54 + crossfade: CrossfadeSettings::default(), 51 55 } 52 56 } 53 57 } ··· 125 129 /// renderers currently no-op — device-side receivers apply their own 126 130 /// loudness normalization. 127 131 async fn set_replaygain(&self, _settings: ReplayGainSettings) -> anyhow::Result<()> { 132 + Ok(()) 133 + } 134 + 135 + /// Update crossfade settings (mode + duration). Only the local 136 + /// SymphoniaPlayer implements this; Chromecast + UPnP receivers each 137 + /// manage their own track transitions. 138 + async fn set_crossfade(&self, _settings: CrossfadeSettings) -> anyhow::Result<()> { 128 139 Ok(()) 129 140 } 130 141
+341 -68
crates/fin-player/src/symphonia_player.rs
··· 27 27 use tracing::{debug, error, warn}; 28 28 29 29 use crate::queue::{PlaybackQueue, QueueItem}; 30 + use crate::crossfade::{fade_at, CrossfadeMode, CrossfadeSettings}; 30 31 use crate::persist::{PersistedQueue, Persister}; 31 32 use crate::renderer::{PlaybackState, PlaybackStatus, Renderer, RendererKind}; 32 33 use crate::replaygain::{ReplayGainInfo, ReplayGainSettings}; ··· 66 67 Restore(PersistedQueue), 67 68 RemoveAt(usize), 68 69 SetReplayGain(ReplayGainSettings), 70 + SetCrossfade(CrossfadeSettings), 69 71 Quit, 70 72 } 71 73 ··· 185 187 186 188 async fn set_replaygain(&self, settings: ReplayGainSettings) -> Result<()> { 187 189 self.send(PlayerCommand::SetReplayGain(settings)) 190 + } 191 + 192 + async fn set_crossfade(&self, settings: CrossfadeSettings) -> Result<()> { 193 + self.send(PlayerCommand::SetCrossfade(settings)) 188 194 } 189 195 190 196 fn state(&self) -> PlaybackState { ··· 423 429 // currently-active settings resolve to. Recomputed on SetReplayGain. 424 430 replaygain_info: ReplayGainInfo, 425 431 replaygain_linear: f32, 432 + // True once the decoder has returned EndOfStream (no more packets). 433 + // Used by the crossfade path — we can't advance the queue on EOF 434 + // anymore because that's the promotion step's job. 435 + ended: bool, 436 + // Set when this track is the "incoming" side of a crossfade. The push 437 + // loop scales samples by fade_in(progress); progress is measured off 438 + // this track's own produced_output_frames counter (which starts at 0). 439 + // `Some(OverlapContext)` means "I'm fading in". None means "full gain". 440 + overlap_incoming: Option<OverlapContext>, 441 + // Same for the outgoing side. `start_frame` records 442 + // `produced_output_frames` at overlap start so we can compute 443 + // progress = (produced - start) / length. 444 + overlap_outgoing: Option<OverlapContext>, 445 + } 446 + 447 + /// Per-frame fade envelope parameters. Baked in `push_samples_with_volume`. 448 + #[derive(Debug, Clone, Copy)] 449 + struct OverlapContext { 450 + mode: CrossfadeMode, 451 + length_frames: u64, 452 + start_frame: u64, 426 453 } 427 454 428 455 impl Track { ··· 585 612 let mut last_position_persist = Instant::now(); 586 613 // Current ReplayGain settings — applied to each track we load. 587 614 let mut rg_settings = ReplayGainSettings::default(); 615 + let mut xf_settings = CrossfadeSettings::default(); 616 + // The "incoming" track being preloaded during a crossfade overlap. 617 + // `Some` only during an active overlap. When the fade-in completes, 618 + // this promotes to `track` and the previous `track` is dropped. 619 + let mut next: Option<Track> = None; 588 620 589 621 'main: loop { 590 622 // Consume commands. If nothing's playing, block; otherwise poll. ··· 604 636 if let Some(cmd) = cmd { 605 637 match cmd { 606 638 PlayerCommand::Play { items, start_index } => { 607 - stop_current(&mut track); 639 + // If crossfade is active and something's playing, load 640 + // the new track as an incoming next and fade the old 641 + // one out — same feel as end-of-track crossfade. 642 + let can_crossfade = xf_settings.mode.is_active() 643 + && track 644 + .as_ref() 645 + .map(|t| !t.ended && !t.paused.load(Ordering::Relaxed)) 646 + .unwrap_or(false); 647 + if let Some(nt) = next.take() { 648 + drop(nt); 649 + } 608 650 queue.replace(items, start_index); 609 651 sync_queue_meta(&state, &queue); 610 652 paused.store(false, Ordering::Relaxed); 611 - if let Some(item) = queue.current() { 612 - track = load_and_maybe_seek( 613 - &host, 614 - item, 615 - &volume, 616 - &paused, 617 - &state, 618 - &queue, 619 - &mut pending_seek, 620 - rg_settings, 621 - ); 622 - } else { 623 - mark_idle(&state); 653 + pending_seek = None; 654 + 655 + match (can_crossfade, queue.current()) { 656 + (true, Some(item)) => { 657 + match load_track( 658 + &host, 659 + item.clone(), 660 + volume.clone(), 661 + paused.clone(), 662 + rg_settings, 663 + ) { 664 + Ok(mut nt) => { 665 + let length_frames = (xf_settings.duration_secs as f64 666 + * nt.output_sr as f64) 667 + as u64; 668 + let mode = xf_settings.mode; 669 + let ct = track.as_mut().unwrap(); 670 + nt.overlap_incoming = Some(OverlapContext { 671 + mode, 672 + length_frames, 673 + start_frame: 0, 674 + }); 675 + ct.overlap_outgoing = Some(OverlapContext { 676 + mode, 677 + length_frames, 678 + start_frame: ct.produced_output_frames, 679 + }); 680 + debug!( 681 + mode = ?mode, 682 + length_frames, 683 + title = %item.title, 684 + "crossfade on user Play" 685 + ); 686 + next = Some(nt); 687 + } 688 + Err(e) => { 689 + warn!(?e, "crossfade Play preload failed; hard-cutting"); 690 + stop_current(&mut track); 691 + track = load_and_maybe_seek( 692 + &host, 693 + item, 694 + &volume, 695 + &paused, 696 + &state, 697 + &queue, 698 + &mut pending_seek, 699 + rg_settings, 700 + ); 701 + } 702 + } 703 + } 704 + (false, Some(item)) => { 705 + stop_current(&mut track); 706 + track = load_and_maybe_seek( 707 + &host, 708 + item, 709 + &volume, 710 + &paused, 711 + &state, 712 + &queue, 713 + &mut pending_seek, 714 + rg_settings, 715 + ); 716 + } 717 + (_, None) => { 718 + stop_current(&mut track); 719 + mark_idle(&state); 720 + } 624 721 } 625 722 persist_now(&persister, &queue, &state); 626 723 } ··· 699 796 } 700 797 PlayerCommand::Stop => { 701 798 stop_current(&mut track); 799 + if let Some(nt) = next.take() { 800 + drop(nt); 801 + } 702 802 queue.clear(); 703 803 pending_seek = None; 704 804 let mut s = state.lock(); ··· 712 812 persist_now(&persister, &queue, &state); 713 813 } 714 814 PlayerCommand::Next => { 815 + // If a crossfade is already ramping in, snap-promote it 816 + // instead of loading fresh. Feels responsive, and it 817 + // preserves the buffered next-track audio. 715 818 stop_current(&mut track); 819 + if let Some(mut nt) = next.take() { 820 + nt.overlap_incoming = None; 821 + nt.overlap_outgoing = None; 822 + track = Some(nt); 823 + queue.advance(); 824 + sync_queue_meta(&state, &queue); 825 + persist_now(&persister, &queue, &state); 826 + continue; 827 + } 716 828 queue.advance(); 717 829 sync_queue_meta(&state, &queue); 718 830 if let Some(item) = queue.current() { ··· 733 845 } 734 846 PlayerCommand::Previous => { 735 847 stop_current(&mut track); 848 + if let Some(nt) = next.take() { 849 + drop(nt); 850 + } 736 851 queue.back(); 737 852 sync_queue_meta(&state, &queue); 738 853 if let Some(item) = queue.current() { ··· 820 935 if let Some(ref mut t) = track { 821 936 t.replaygain_linear = t.replaygain_info.linear_gain(settings); 822 937 } 938 + if let Some(ref mut nt) = next { 939 + nt.replaygain_linear = nt.replaygain_info.linear_gain(settings); 940 + } 823 941 state.lock().replaygain = settings; 824 942 persist_now(&persister, &queue, &state); 825 943 } 944 + PlayerCommand::SetCrossfade(settings) => { 945 + xf_settings = settings; 946 + // If the mode was turned OFF mid-overlap, kill the 947 + // pending next track so we stop mixing. The current 948 + // track keeps playing normally. 949 + if !settings.mode.is_active() { 950 + if let Some(nt) = next.take() { 951 + drop(nt); 952 + } 953 + if let Some(ref mut t) = track { 954 + t.overlap_outgoing = None; 955 + t.overlap_incoming = None; 956 + } 957 + } 958 + state.lock().crossfade = settings; 959 + } 826 960 PlayerCommand::RemoveAt(idx) => { 827 961 let cur = queue.current_index(); 828 962 let removing_current = cur == Some(idx); ··· 864 998 last_position_persist = Instant::now(); 865 999 } 866 1000 867 - // Decode one packet's worth of audio if we can. 1001 + // Decode current — same shape as before, but doesn't advance on 1002 + // EOF anymore. That's now the promotion step's job (below). 868 1003 if let Some(ref mut t) = track { 869 1004 if t.paused.load(Ordering::Relaxed) { 870 1005 thread::sleep(Duration::from_millis(25)); ··· 872 1007 continue; 873 1008 } 874 1009 875 - // Backpressure: if the ring buffer is nearly full, don't decode. 876 - // We need at least one MAX_FRAMES_PER_PACKET * channels slots free. 877 1010 let free = t.ring_free_slots(); 878 - if free < 8192 { 879 - thread::sleep(Duration::from_millis(5)); 880 - update_position(&state, t); 881 - continue; 1011 + if free >= 8192 && !t.ended { 1012 + match decode_one_packet(t) { 1013 + DecodeStep::Pushed => update_position(&state, t), 1014 + DecodeStep::EndOfStream => { 1015 + debug!(item = %t.item.title, "current track ended"); 1016 + t.ended = true; 1017 + } 1018 + DecodeStep::Error(e) => { 1019 + warn!(error = %e, item = %t.item.title, "current decode error"); 1020 + t.ended = true; 1021 + } 1022 + } 882 1023 } 1024 + } 883 1025 884 - match decode_one_packet(t) { 885 - DecodeStep::Pushed => { 886 - update_position(&state, t); 887 - } 888 - DecodeStep::EndOfStream => { 889 - debug!(item = %t.item.title, "track ended"); 890 - drain_ring(t); 891 - stop_current(&mut track); 892 - queue.advance(); 893 - sync_queue_meta(&state, &queue); 894 - if let Some(item) = queue.current() { 895 - track = load_and_maybe_seek( 896 - &host, 897 - item, 898 - &volume, 899 - &paused, 900 - &state, 901 - &queue, 902 - &mut pending_seek, 903 - rg_settings, 904 - ); 905 - } else { 906 - mark_idle(&state); 1026 + // Decode next in parallel — it has its own ring buffer, so no 1027 + // backpressure conflict with current. 1028 + if let Some(ref mut nt) = next { 1029 + let free = nt.ring_free_slots(); 1030 + if free >= 8192 && !nt.ended { 1031 + match decode_one_packet(nt) { 1032 + DecodeStep::Pushed => {} 1033 + DecodeStep::EndOfStream => nt.ended = true, 1034 + DecodeStep::Error(e) => { 1035 + warn!(error = %e, "crossfade next decode error"); 1036 + nt.ended = true; 907 1037 } 908 - persist_now(&persister, &queue, &state); 909 1038 } 910 - DecodeStep::Error(e) => { 911 - warn!(error = %e, item = %t.item.title, "decode error, skipping track"); 912 - stop_current(&mut track); 913 - queue.advance(); 914 - sync_queue_meta(&state, &queue); 915 - if let Some(item) = queue.current() { 916 - track = load_and_maybe_seek( 1039 + } 1040 + } 1041 + 1042 + // Should we trigger a crossfade preload? Only when: 1043 + // - a mode is selected 1044 + // - we don't already have a next track 1045 + // - the current track has a known duration 1046 + // - the remaining decoder-time is within the overlap window 1047 + if next.is_none() && xf_settings.mode.is_active() { 1048 + if let Some(ref mut ct) = track { 1049 + let duration = ct.duration_secs(); 1050 + let decoded_secs = ct.produced_output_frames as f64 / ct.output_sr as f64; 1051 + let remaining = duration - decoded_secs; 1052 + if duration > 0.0 && remaining <= xf_settings.duration_secs as f64 && !ct.ended { 1053 + if let Some(next_item) = queue.peek_next_item() { 1054 + // Attempt to load next. On failure we silently skip 1055 + // crossfade for this transition; the current track's 1056 + // natural EOF path will handle end-of-stream. 1057 + match load_track( 917 1058 &host, 918 - item, 919 - &volume, 920 - &paused, 921 - &state, 922 - &queue, 923 - &mut pending_seek, 1059 + next_item.clone(), 1060 + volume.clone(), 1061 + paused.clone(), 924 1062 rg_settings, 925 - ); 926 - } else { 927 - mark_idle(&state); 1063 + ) { 1064 + Ok(mut nt) => { 1065 + let length_frames = (xf_settings.duration_secs as f64 1066 + * nt.output_sr as f64) 1067 + as u64; 1068 + let ctx = OverlapContext { 1069 + mode: xf_settings.mode, 1070 + length_frames, 1071 + start_frame: 0, 1072 + }; 1073 + nt.overlap_incoming = Some(ctx); 1074 + ct.overlap_outgoing = Some(OverlapContext { 1075 + mode: xf_settings.mode, 1076 + length_frames, 1077 + start_frame: ct.produced_output_frames, 1078 + }); 1079 + debug!( 1080 + mode = ?xf_settings.mode, 1081 + length_frames, 1082 + next = %next_item.title, 1083 + "crossfade triggered" 1084 + ); 1085 + next = Some(nt); 1086 + } 1087 + Err(e) => { 1088 + warn!(?e, title = %next_item.title, "crossfade preload failed"); 1089 + } 1090 + } 928 1091 } 929 - persist_now(&persister, &queue, &state); 930 1092 } 931 1093 } 932 1094 } 1095 + 1096 + // Promote next → current when the fade-in has completed OR when the 1097 + // outgoing track's fade-out has run its full length. 1098 + let should_promote = match (&track, &next) { 1099 + (Some(ct), Some(nt)) => { 1100 + let nt_progress_done = nt 1101 + .overlap_incoming 1102 + .map(|ctx| nt.produced_output_frames >= ctx.length_frames) 1103 + .unwrap_or(false); 1104 + let ct_faded_out = ct 1105 + .overlap_outgoing 1106 + .map(|ctx| { 1107 + ct.produced_output_frames >= ctx.start_frame + ctx.length_frames 1108 + }) 1109 + .unwrap_or(false); 1110 + nt_progress_done || ct_faded_out || ct.ended 1111 + } 1112 + _ => false, 1113 + }; 1114 + if should_promote { 1115 + debug!("crossfade complete, promoting next → current"); 1116 + stop_current(&mut track); 1117 + if let Some(mut nt) = next.take() { 1118 + nt.overlap_incoming = None; 1119 + nt.overlap_outgoing = None; 1120 + track = Some(nt); 1121 + queue.advance(); 1122 + sync_queue_meta(&state, &queue); 1123 + persist_now(&persister, &queue, &state); 1124 + } 1125 + } 1126 + 1127 + // Current ended and there's no next to promote — advance the queue 1128 + // and load the next item straight (no crossfade). 1129 + let current_dry = track 1130 + .as_ref() 1131 + .map(|t| t.ended && t.ring_pending_frames() == 0) 1132 + .unwrap_or(false); 1133 + if current_dry && next.is_none() { 1134 + debug!("current track drained, advancing queue"); 1135 + stop_current(&mut track); 1136 + queue.advance(); 1137 + sync_queue_meta(&state, &queue); 1138 + if let Some(item) = queue.current() { 1139 + track = load_and_maybe_seek( 1140 + &host, 1141 + item, 1142 + &volume, 1143 + &paused, 1144 + &state, 1145 + &queue, 1146 + &mut pending_seek, 1147 + rg_settings, 1148 + ); 1149 + } else { 1150 + mark_idle(&state); 1151 + } 1152 + persist_now(&persister, &queue, &state); 1153 + } 1154 + 1155 + // If we didn't decode anything this tick, take a short nap to 1156 + // avoid pegging the CPU. 1157 + let idle = track.as_ref().map(|t| t.ended).unwrap_or(true) 1158 + || track 1159 + .as_ref() 1160 + .map(|t| t.ring_free_slots() < 8192) 1161 + .unwrap_or(true); 1162 + if idle { 1163 + thread::sleep(Duration::from_millis(5)); 1164 + } 933 1165 } 934 1166 935 1167 stop_current(&mut track); ··· 984 1216 } 985 1217 986 1218 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. 990 - let vol = f32::from_bits(t.volume.load(Ordering::Relaxed)); 991 - let effective = vol * t.replaygain_linear; 1219 + // Effective scale = user volume × ReplayGain linear multiplier × 1220 + // per-frame fade envelope. Volume + RG are constant across the batch; 1221 + // the fade multiplier walks per output frame using this track's own 1222 + // produced_output_frames counter. 1223 + let base = f32::from_bits(t.volume.load(Ordering::Relaxed)) * t.replaygain_linear; 1224 + let ch = t.output_channels.max(1); 992 1225 let mut offset = 0; 993 1226 while offset < samples.len() { 994 1227 if t.paused.load(Ordering::Relaxed) { ··· 996 1229 continue; 997 1230 } 998 1231 let end = std::cmp::min(offset + 4096, samples.len()); 999 - let chunk: Vec<f32> = samples[offset..end].iter().map(|s| s * effective).collect(); 1232 + let src = &samples[offset..end]; 1233 + let chunk: Vec<f32> = if t.overlap_outgoing.is_none() && t.overlap_incoming.is_none() { 1234 + // Fast path: no fade active. 1235 + src.iter().map(|s| s * base).collect() 1236 + } else { 1237 + let frames_in_chunk = src.len() / ch; 1238 + // frame index in the OUTPUT-frame timeline this chunk starts at. 1239 + let start_frame = 1240 + t.produced_output_frames + (offset / ch) as u64; 1241 + let mut out = Vec::with_capacity(src.len()); 1242 + for f in 0..frames_in_chunk { 1243 + let global = start_frame + f as u64; 1244 + let fade_mult = fade_multiplier(t, global); 1245 + let mul = base * fade_mult; 1246 + for c in 0..ch { 1247 + out.push(src[f * ch + c] * mul); 1248 + } 1249 + } 1250 + out 1251 + }; 1000 1252 match t.producer.write(&chunk) { 1001 1253 Ok(n) => offset += n, 1002 1254 Err(_) => thread::sleep(Duration::from_millis(5)), 1003 1255 } 1004 1256 } 1257 + } 1258 + 1259 + /// Compute the fade multiplier this track should apply at output-frame 1260 + /// `global`. Combines both directions when set — a track shouldn't normally 1261 + /// have both simultaneously, but if it does they compose multiplicatively. 1262 + fn fade_multiplier(t: &Track, global: u64) -> f32 { 1263 + let mut m = 1.0; 1264 + if let Some(ctx) = t.overlap_outgoing { 1265 + let relative = global.saturating_sub(ctx.start_frame); 1266 + let progress = relative as f32 / ctx.length_frames.max(1) as f32; 1267 + m *= fade_at(ctx.mode, progress).out; 1268 + } 1269 + if let Some(ctx) = t.overlap_incoming { 1270 + let relative = global.saturating_sub(ctx.start_frame); 1271 + let progress = relative as f32 / ctx.length_frames.max(1) as f32; 1272 + m *= fade_at(ctx.mode, progress).incoming; 1273 + } 1274 + m 1005 1275 } 1006 1276 1007 1277 fn seek_track(t: &mut Track, pos_secs: f64) { ··· 1317 1587 _started: Instant::now(), 1318 1588 replaygain_info, 1319 1589 replaygain_linear, 1590 + ended: false, 1591 + overlap_incoming: None, 1592 + overlap_outgoing: None, 1320 1593 }; 1321 1594 let first_out = resampler.process(&first_samples); 1322 1595 if !first_out.is_empty() {
+40 -2
crates/fin-tui/src/app.rs
··· 1077 1077 app.set_status(format!("ReplayGain: {}", new_settings.mode.label())); 1078 1078 } 1079 1079 } 1080 + // Crossfade: `f` cycles off → crossfade → mixed → off. Duration 1081 + // is edited in config.toml. 1082 + (KeyCode::Char('f'), _) => { 1083 + let mut new_settings = { 1084 + let cfg = app.config.lock(); 1085 + cfg.crossfade 1086 + }; 1087 + new_settings.mode = new_settings.mode.next(); 1088 + let renderer = app.renderer.lock().clone(); 1089 + if let Err(e) = renderer.set_crossfade(new_settings).await { 1090 + app.set_status(format!("crossfade: {}", e)); 1091 + } else { 1092 + let mut cfg = app.config.lock(); 1093 + cfg.crossfade = new_settings; 1094 + let _ = cfg.save(); 1095 + drop(cfg); 1096 + app.set_status(if new_settings.mode.is_active() { 1097 + format!( 1098 + "Crossfade: {} ({:.1}s)", 1099 + new_settings.mode.label(), 1100 + new_settings.duration_secs 1101 + ) 1102 + } else { 1103 + "Crossfade: off".to_string() 1104 + }); 1105 + } 1106 + } 1080 1107 _ => {} 1081 1108 } 1082 1109 Ok(()) ··· 1445 1472 1446 1473 let rows = Layout::default() 1447 1474 .direction(Direction::Vertical) 1448 - .constraints([Constraint::Length(8), Constraint::Min(4)]) 1475 + .constraints([Constraint::Length(9), Constraint::Min(4)]) 1449 1476 .split(area); 1450 1477 1451 1478 // Top card — global settings. ··· 1478 1505 } else { 1479 1506 "off" 1480 1507 } 1508 + ), 1509 + muted_style(), 1510 + ), 1511 + ]), 1512 + Line::from(vec![ 1513 + Span::styled(" Crossfade ", title_style()), 1514 + Span::styled(cfg_snapshot.crossfade.mode.label(), accent_style()), 1515 + Span::styled( 1516 + format!( 1517 + " duration {:.1} s (press f to cycle; duration in config.toml)", 1518 + cfg_snapshot.crossfade.duration_secs 1481 1519 ), 1482 1520 muted_style(), 1483 1521 ), ··· 1591 1629 None => None, 1592 1630 } 1593 1631 }; 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 "; 1632 + let help = " tab: screen ↑↓/jk: nav enter: play/drill x: play all a: queue n: next z: shuffle R: repeat g: replaygain f: crossfade space: pause s: stop d: rm/C: clear (queue) </>: skip +/-: vol m: local t: server /: search esc: back q: quit "; 1595 1633 // Errors/warnings pop in warn-red; other status messages use the primary 1596 1634 // teal so they stand out from the muted help text. 1597 1635 let (text, style) = match msg {
+19
crates/fin-tui/src/widgets/player_bar.rs
··· 138 138 } else { 139 139 Span::raw("") 140 140 }; 141 + let xf_span = if self.state.crossfade.mode.is_active() { 142 + let glyph = match self.state.crossfade.mode { 143 + fin_player::CrossfadeMode::Crossfade => "⋈", 144 + fin_player::CrossfadeMode::Mixed => "≈", 145 + fin_player::CrossfadeMode::Off => unreachable!(), 146 + }; 147 + Span::styled( 148 + format!( 149 + "{} {:.0}s ", 150 + glyph, self.state.crossfade.duration_secs 151 + ), 152 + Style::default() 153 + .fg(Palette::HIGHLIGHT) 154 + .add_modifier(Modifier::BOLD), 155 + ) 156 + } else { 157 + Span::raw("") 158 + }; 141 159 let right_line = Line::from(vec![ 142 160 shuffle_span, 143 161 repeat_span, 144 162 rg_span, 163 + xf_span, 145 164 Span::styled( 146 165 format!("♪ {}% ", (self.state.volume * 100.0) as i32), 147 166 Style::default().fg(Palette::HIGHLIGHT),
+3
crates/fin/src/main.rs
··· 194 194 if let Err(e) = r.set_replaygain(cfg.replaygain).await { 195 195 tracing::warn!(?e, "replaygain apply failed"); 196 196 } 197 + if let Err(e) = r.set_crossfade(cfg.crossfade).await { 198 + tracing::warn!(?e, "crossfade apply failed"); 199 + } 197 200 if let Some(snap) = saved { 198 201 if !snap.items.is_empty() { 199 202 let items_n = snap.items.len();