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

Configure Feed

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

Fix EQ+crossfade noise + expand unit tests

Two changes in one commit — they share the tree touched by the
recent EQ / tone / crossfade work.

DSP transients during overlap
- The previous shared-DSP approach routed both tracks through the same
`CODEC_IDX_AUDIO` config every tick. Rockbox biquad delay lines are
per-config, so alternating between the two tracks corrupted the state
and produced the "small noises" the user reported.
- Add a `VoiceDsp` wrapper around Rockbox's second config
(`CODEC_IDX_VOICE`). Coefficients (`dsp_set_eq_coefs`, `tone_*`) are
global — same EQ curve on both configs — but the delay lines are
independent, so the two tracks stop stepping on each other's state.
- A new `DspProcess` trait lets `decode_one_packet` and `apply_dsp`
accept either config without duplicating the packet loop.
- Both configs are flushed at crossfade preload start and at promotion
so stale delay-line state doesn't leak between tracks.

Unit tests (+52 across the workspace, 73→125)
- fin-player::queue: RepeatMode label/next cycle, advance()/back()
under Off/One/All, peek_next_item across all repeat modes, shuffle
preserves the current item, reshuffle_all pins current at index 0.
- fin-player::persist: PersistedQueue round-trip via write_atomically
+ load, malformed / missing file returns None, legacy JSON with
missing fields loads with #[serde(default)] filled in, no `.tmp`
sibling left behind.
- fin-config: RepeatMode / ReplayGainMode / CrossfadeMode label + cycle
+ is_active helpers; default_eq_band_settings has 10 monotonic ISO
bands; ensure_eq_bands fills empty and pads short lists, leaves full
lists alone.
- fin-tui::screens: Screen::ALL matches tab order, next/prev wrap and
are inverses; icon/label lookups; fmt_dur across the H:MM:SS
threshold; RowLayout sums to total width, hides subtitle on narrow
terminals, gives title ~55% of the middle; truncate honors width
budgets and produces bare "…" at width 1.

Tsiry Sandratraina (Jul 5, 2026, 4:04 PM +0300) af476f62 10c51fd6

+725 -16
+125
crates/fin-config/src/lib.rs
··· 593 593 assert!(cfg.find_server("prod").is_some()); 594 594 assert!(cfg.find_server("nope").is_none()); 595 595 } 596 + 597 + // ------------------------------------------------------------------ 598 + // Playback-mode enum helpers 599 + // ------------------------------------------------------------------ 600 + 601 + #[test] 602 + fn replaygain_mode_labels_and_cycle() { 603 + assert_eq!(ReplayGainMode::Off.label(), "off"); 604 + assert_eq!(ReplayGainMode::Track.label(), "track"); 605 + assert_eq!(ReplayGainMode::Album.label(), "album"); 606 + assert_eq!(ReplayGainMode::Off.next(), ReplayGainMode::Track); 607 + assert_eq!(ReplayGainMode::Track.next(), ReplayGainMode::Album); 608 + assert_eq!(ReplayGainMode::Album.next(), ReplayGainMode::Off); 609 + assert!(!ReplayGainMode::Off.is_active()); 610 + assert!(ReplayGainMode::Track.is_active()); 611 + assert!(ReplayGainMode::Album.is_active()); 612 + } 613 + 614 + #[test] 615 + fn crossfade_mode_labels_and_cycle() { 616 + assert_eq!(CrossfadeMode::Off.label(), "off"); 617 + assert_eq!(CrossfadeMode::Crossfade.label(), "crossfade"); 618 + assert_eq!(CrossfadeMode::Mixed.label(), "mixed"); 619 + assert_eq!(CrossfadeMode::Off.next(), CrossfadeMode::Crossfade); 620 + assert_eq!(CrossfadeMode::Crossfade.next(), CrossfadeMode::Mixed); 621 + assert_eq!(CrossfadeMode::Mixed.next(), CrossfadeMode::Off); 622 + assert!(!CrossfadeMode::Off.is_active()); 623 + assert!(CrossfadeMode::Crossfade.is_active()); 624 + assert!(CrossfadeMode::Mixed.is_active()); 625 + } 626 + 627 + #[test] 628 + fn crossfade_default_duration_is_five_seconds() { 629 + // The public contract: fresh installs get a 5 s overlap when the 630 + // mode is turned on, matching the README + Settings hint. 631 + let s = CrossfadeSettings::default(); 632 + assert!((s.duration_secs - 5.0).abs() < 1e-6); 633 + assert_eq!(s.mode, CrossfadeMode::Off); 634 + } 635 + 636 + #[test] 637 + fn replaygain_default_has_clip_guard_on() { 638 + // Clip prevention on by default — safer for typical listeners. 639 + let s = ReplayGainSettings::default(); 640 + assert!(s.prevent_clip); 641 + assert!((s.preamp_db - 0.0).abs() < 1e-6); 642 + assert_eq!(s.mode, ReplayGainMode::Off); 643 + } 644 + 645 + // ------------------------------------------------------------------ 646 + // Default EQ preset 647 + // ------------------------------------------------------------------ 648 + 649 + #[test] 650 + fn default_eq_band_settings_has_ten_iso_octave_bands() { 651 + let bands = default_eq_band_settings(); 652 + assert_eq!(bands.len(), 10); 653 + // First = 32 Hz low shelf, last = 16 kHz high shelf. 654 + assert_eq!(bands[0].cutoff, 32); 655 + assert_eq!(bands[9].cutoff, 16_000); 656 + // Every band flat + Q 7.0. 657 + for b in &bands { 658 + assert_eq!(b.q, 70); 659 + assert_eq!(b.gain, 0); 660 + } 661 + // Strictly ascending cutoffs — a monotonic sweep across the audible 662 + // range so users get a meaningful sliders layout out of the box. 663 + for w in bands.windows(2) { 664 + assert!(w[0].cutoff < w[1].cutoff); 665 + } 666 + } 667 + 668 + #[test] 669 + fn ensure_eq_bands_fills_empty_with_defaults() { 670 + let mut cfg = Config::default(); 671 + assert!(cfg.eq_band_settings.is_empty()); 672 + cfg.ensure_eq_bands(); 673 + assert_eq!(cfg.eq_band_settings.len(), 10); 674 + assert_eq!(cfg.eq_band_settings[0].cutoff, 32); 675 + } 676 + 677 + #[test] 678 + fn ensure_eq_bands_pads_short_lists_to_ten() { 679 + let mut cfg = Config::default(); 680 + // Simulate a truncated preset with three custom bands. 681 + cfg.eq_band_settings = vec![ 682 + EqBand { 683 + cutoff: 100, 684 + q: 70, 685 + gain: 30, 686 + }, 687 + EqBand { 688 + cutoff: 300, 689 + q: 70, 690 + gain: 15, 691 + }, 692 + EqBand { 693 + cutoff: 900, 694 + q: 70, 695 + gain: -20, 696 + }, 697 + ]; 698 + cfg.ensure_eq_bands(); 699 + assert_eq!(cfg.eq_band_settings.len(), 10); 700 + // First three preserved verbatim. 701 + assert_eq!(cfg.eq_band_settings[0].cutoff, 100); 702 + assert_eq!(cfg.eq_band_settings[1].cutoff, 300); 703 + assert_eq!(cfg.eq_band_settings[2].cutoff, 900); 704 + // Slots 3..9 filled from the ISO default at their respective indices. 705 + let defaults = default_eq_band_settings(); 706 + for i in 3..10 { 707 + assert_eq!(cfg.eq_band_settings[i], defaults[i]); 708 + } 709 + } 710 + 711 + #[test] 712 + fn ensure_eq_bands_leaves_full_lists_alone() { 713 + let mut cfg = Config::default(); 714 + let mut custom = default_eq_band_settings(); 715 + // Tweak one so we can spot mutation. 716 + custom[5].gain = 87; 717 + cfg.eq_band_settings = custom.clone(); 718 + cfg.ensure_eq_bands(); 719 + assert_eq!(cfg.eq_band_settings, custom); 720 + } 596 721 }
+1
crates/fin-player/src/lib.rs
··· 9 9 pub mod replaygain; 10 10 pub mod symphonia_player; 11 11 pub mod upnp; 12 + pub mod voice_dsp; 12 13 13 14 pub use cast::ChromecastRenderer; 14 15 pub use crossfade::{CrossfadeMode, CrossfadeSettings};
+107
crates/fin-player/src/persist.rs
··· 97 97 std::fs::rename(&tmp, path)?; 98 98 Ok(()) 99 99 } 100 + 101 + #[cfg(test)] 102 + mod tests { 103 + use super::*; 104 + use crate::queue::{QueueItem, RepeatMode}; 105 + use std::sync::atomic::{AtomicU64, Ordering}; 106 + 107 + fn item(id: &str) -> QueueItem { 108 + QueueItem { 109 + id: id.into(), 110 + title: id.into(), 111 + subtitle: String::new(), 112 + stream_url: format!("http://example/{id}"), 113 + image_url: None, 114 + duration_secs: Some(180), 115 + is_video: false, 116 + content_type: "audio/flac".into(), 117 + } 118 + } 119 + 120 + fn tmp_path(name: &str) -> std::path::PathBuf { 121 + // Unique-per-call so parallel tests don't step on each other. 122 + static COUNTER: AtomicU64 = AtomicU64::new(0); 123 + let n = COUNTER.fetch_add(1, Ordering::Relaxed); 124 + std::env::temp_dir() 125 + .join(format!("fin-persist-test-{name}-{}-{n}.json", std::process::id())) 126 + } 127 + 128 + // ------------------------------------------------------------------ 129 + // Load edge cases 130 + // ------------------------------------------------------------------ 131 + 132 + #[test] 133 + fn load_returns_none_when_path_missing() { 134 + let path = tmp_path("missing"); 135 + // Sanity: file truly doesn't exist. 136 + assert!(!path.exists()); 137 + assert!(load(&path).is_none()); 138 + } 139 + 140 + #[test] 141 + fn load_returns_none_on_malformed_json() { 142 + let path = tmp_path("bad-json"); 143 + std::fs::write(&path, b"this is not JSON at all").unwrap(); 144 + assert!(load(&path).is_none()); 145 + let _ = std::fs::remove_file(&path); 146 + } 147 + 148 + // ------------------------------------------------------------------ 149 + // Serde round-trip through write_atomically + load 150 + // ------------------------------------------------------------------ 151 + 152 + #[test] 153 + fn write_then_load_round_trips_every_field() { 154 + let path = tmp_path("round-trip"); 155 + let snap = PersistedQueue { 156 + items: vec![item("a"), item("b")], 157 + current_index: Some(1), 158 + shuffle: true, 159 + repeat: RepeatMode::All, 160 + position_secs: 42.5, 161 + }; 162 + write_atomically(&path, &snap).expect("write"); 163 + let restored = load(&path).expect("load"); 164 + assert_eq!(restored.items.len(), 2); 165 + assert_eq!(restored.items[1].id, "b"); 166 + assert_eq!(restored.current_index, Some(1)); 167 + assert!(restored.shuffle); 168 + assert_eq!(restored.repeat, RepeatMode::All); 169 + assert!((restored.position_secs - 42.5).abs() < 1e-9); 170 + let _ = std::fs::remove_file(&path); 171 + } 172 + 173 + // ------------------------------------------------------------------ 174 + // Backward compatibility: older writers didn't emit every field 175 + // ------------------------------------------------------------------ 176 + 177 + #[test] 178 + fn load_accepts_partial_json_with_defaults() { 179 + // Emulate an older on-disk shape that predates the shuffle / repeat 180 + // / position_secs additions. Every new field has #[serde(default)], 181 + // so this must still parse into a zero-value snapshot. 182 + let path = tmp_path("legacy"); 183 + std::fs::write(&path, br#"{"items": []}"#).unwrap(); 184 + let snap = load(&path).expect("load"); 185 + assert!(snap.items.is_empty()); 186 + assert_eq!(snap.current_index, None); 187 + assert!(!snap.shuffle); 188 + assert_eq!(snap.repeat, RepeatMode::Off); 189 + assert_eq!(snap.position_secs, 0.0); 190 + let _ = std::fs::remove_file(&path); 191 + } 192 + 193 + // ------------------------------------------------------------------ 194 + // Write is atomic — no stray .tmp left behind 195 + // ------------------------------------------------------------------ 196 + 197 + #[test] 198 + fn write_atomically_removes_the_temp_file() { 199 + let path = tmp_path("atomic"); 200 + write_atomically(&path, &PersistedQueue::default()).expect("write"); 201 + // The .tmp sibling MUST be gone after the rename step. 202 + assert!(!path.with_extension("json.tmp").exists()); 203 + assert!(path.exists()); 204 + let _ = std::fs::remove_file(&path); 205 + } 206 + }
+168
crates/fin-player/src/queue.rs
··· 392 392 assert!(q.is_empty()); 393 393 assert_eq!(q.current_index(), None); 394 394 } 395 + 396 + // ------------------------------------------------------------------ 397 + // RepeatMode enum helpers 398 + // ------------------------------------------------------------------ 399 + 400 + #[test] 401 + fn repeat_mode_labels_and_cycle() { 402 + assert_eq!(RepeatMode::Off.label(), "off"); 403 + assert_eq!(RepeatMode::All.label(), "all"); 404 + assert_eq!(RepeatMode::One.label(), "one"); 405 + // Cycle: off → all → one → off 406 + assert_eq!(RepeatMode::Off.next(), RepeatMode::All); 407 + assert_eq!(RepeatMode::All.next(), RepeatMode::One); 408 + assert_eq!(RepeatMode::One.next(), RepeatMode::Off); 409 + } 410 + 411 + // ------------------------------------------------------------------ 412 + // advance() under each RepeatMode 413 + // ------------------------------------------------------------------ 414 + 415 + #[test] 416 + fn advance_repeat_one_sticks_on_current() { 417 + let q = PlaybackQueue::new(); 418 + q.replace(vec![item("a"), item("b")], 1); 419 + q.set_repeat(RepeatMode::One); 420 + assert_eq!(q.advance(), Some(1)); 421 + assert_eq!(q.advance(), Some(1)); 422 + assert_eq!(q.current().unwrap().id, "b"); 423 + } 424 + 425 + #[test] 426 + fn advance_repeat_all_wraps_to_zero_past_end() { 427 + let q = PlaybackQueue::new(); 428 + q.replace(vec![item("a"), item("b"), item("c")], 2); 429 + q.set_repeat(RepeatMode::All); 430 + assert_eq!(q.advance(), Some(0)); 431 + assert_eq!(q.current().unwrap().id, "a"); 432 + } 433 + 434 + #[test] 435 + fn advance_repeat_off_still_stops_at_end() { 436 + let q = PlaybackQueue::new(); 437 + q.replace(vec![item("a"), item("b")], 1); 438 + q.set_repeat(RepeatMode::Off); 439 + assert_eq!(q.advance(), None); 440 + assert_eq!(q.current_index(), None); 441 + } 442 + 443 + // ------------------------------------------------------------------ 444 + // back() under each RepeatMode 445 + // ------------------------------------------------------------------ 446 + 447 + #[test] 448 + fn back_repeat_one_sticks_on_current() { 449 + let q = PlaybackQueue::new(); 450 + q.replace(vec![item("a"), item("b"), item("c")], 1); 451 + q.set_repeat(RepeatMode::One); 452 + assert_eq!(q.back(), Some(1)); 453 + assert_eq!(q.current().unwrap().id, "b"); 454 + } 455 + 456 + #[test] 457 + fn back_repeat_all_wraps_to_last_from_index_zero() { 458 + let q = PlaybackQueue::new(); 459 + q.replace(vec![item("a"), item("b"), item("c")], 0); 460 + q.set_repeat(RepeatMode::All); 461 + assert_eq!(q.back(), Some(2)); 462 + assert_eq!(q.current().unwrap().id, "c"); 463 + } 464 + 465 + #[test] 466 + fn back_repeat_off_stops_at_zero() { 467 + let q = PlaybackQueue::new(); 468 + q.replace(vec![item("a"), item("b")], 0); 469 + q.set_repeat(RepeatMode::Off); 470 + assert_eq!(q.back(), Some(0)); 471 + } 472 + 473 + // ------------------------------------------------------------------ 474 + // peek_next_item — used by the crossfade preload path 475 + // ------------------------------------------------------------------ 476 + 477 + #[test] 478 + fn peek_next_returns_next_in_queue_by_default() { 479 + let q = PlaybackQueue::new(); 480 + q.replace(vec![item("a"), item("b"), item("c")], 0); 481 + assert_eq!(q.peek_next_item().unwrap().id, "b"); 482 + } 483 + 484 + #[test] 485 + fn peek_next_at_end_returns_none_when_repeat_off() { 486 + let q = PlaybackQueue::new(); 487 + q.replace(vec![item("a"), item("b")], 1); 488 + assert!(q.peek_next_item().is_none()); 489 + } 490 + 491 + #[test] 492 + fn peek_next_repeat_all_wraps_to_first() { 493 + let q = PlaybackQueue::new(); 494 + q.replace(vec![item("a"), item("b")], 1); 495 + q.set_repeat(RepeatMode::All); 496 + assert_eq!(q.peek_next_item().unwrap().id, "a"); 497 + } 498 + 499 + #[test] 500 + fn peek_next_repeat_one_returns_current_item() { 501 + let q = PlaybackQueue::new(); 502 + q.replace(vec![item("a"), item("b")], 0); 503 + q.set_repeat(RepeatMode::One); 504 + assert_eq!(q.peek_next_item().unwrap().id, "a"); 505 + } 506 + 507 + #[test] 508 + fn peek_next_on_empty_returns_none() { 509 + let q = PlaybackQueue::new(); 510 + assert!(q.peek_next_item().is_none()); 511 + } 512 + 513 + // ------------------------------------------------------------------ 514 + // Shuffle 515 + // ------------------------------------------------------------------ 516 + 517 + #[test] 518 + fn set_shuffle_keeps_currently_playing_item_in_place() { 519 + let q = PlaybackQueue::new(); 520 + q.replace(vec![item("a"), item("b"), item("c"), item("d")], 1); 521 + // Fisher-Yates over items[2..], so items[0..=1] must be untouched. 522 + q.set_shuffle(true); 523 + let items = q.items(); 524 + assert_eq!(items[0].id, "a"); 525 + assert_eq!(items[1].id, "b"); 526 + // items[2..=3] are some permutation of {"c", "d"}. 527 + let tail: Vec<&str> = items[2..].iter().map(|i| i.id.as_str()).collect(); 528 + assert!(tail.contains(&"c")); 529 + assert!(tail.contains(&"d")); 530 + assert!(q.shuffle_enabled()); 531 + } 532 + 533 + #[test] 534 + fn set_shuffle_false_flips_flag_without_reordering() { 535 + let q = PlaybackQueue::new(); 536 + q.replace(vec![item("a"), item("b"), item("c")], 0); 537 + q.set_shuffle(true); 538 + let after_on = ids(&q); 539 + q.set_shuffle(false); 540 + // Turning shuffle off is documented as not re-sorting. 541 + assert_eq!(ids(&q), after_on); 542 + assert!(!q.shuffle_enabled()); 543 + } 544 + 545 + #[test] 546 + fn reshuffle_all_pins_current_to_index_zero() { 547 + let q = PlaybackQueue::new(); 548 + q.replace(vec![item("a"), item("b"), item("c"), item("d")], 2); 549 + q.reshuffle_all(); 550 + assert_eq!(q.current_index(), Some(0)); 551 + // Contents preserved as a permutation. 552 + let mut got: Vec<String> = q.items().into_iter().map(|i| i.id).collect(); 553 + got.sort(); 554 + assert_eq!(got, vec!["a", "b", "c", "d"]); 555 + } 556 + 557 + #[test] 558 + fn reshuffle_all_on_empty_is_noop() { 559 + let q = PlaybackQueue::new(); 560 + q.reshuffle_all(); 561 + assert!(q.is_empty()); 562 + } 395 563 }
+80 -16
crates/fin-player/src/symphonia_player.rs
··· 29 29 use fin_config::EqBand; 30 30 use rockbox_dsp::{eq_band_setting, Dsp, EQ_NUM_BANDS}; 31 31 32 + use crate::voice_dsp::VoiceDsp; 33 + 34 + /// Uniform "process interleaved stereo i16 → interleaved stereo i16" 35 + /// interface so `decode_one_packet` can accept either the process-wide 36 + /// audio DSP or the crossfade-only voice DSP without duplicating logic. 37 + trait DspProcess { 38 + fn process_stereo(&mut self, input: &[i16], out: &mut Vec<i16>); 39 + } 40 + 41 + impl DspProcess for Dsp { 42 + fn process_stereo(&mut self, input: &[i16], out: &mut Vec<i16>) { 43 + self.process(input, out); 44 + } 45 + } 46 + 47 + impl DspProcess for VoiceDsp { 48 + fn process_stereo(&mut self, input: &[i16], out: &mut Vec<i16>) { 49 + self.process(input, out); 50 + } 51 + } 52 + 32 53 use crate::crossfade::{fade_at, CrossfadeMode, CrossfadeSettings}; 33 54 use crate::persist::{PersistedQueue, Persister}; 34 55 use crate::queue::{PlaybackQueue, QueueItem}; ··· 648 669 // never own two at once — the crossfade `next` track bypasses it and 649 670 // uses the plain resampler. 650 671 let mut dsp: Option<Dsp> = None; 672 + // Second DSP instance bound to Rockbox's `CODEC_IDX_VOICE`. Rockbox 673 + // shares EQ / tone coefficients across configs but keeps biquad delay 674 + // lines per-config — so running the crossfade next track through here 675 + // gives it the same filter curve as `dsp` without churning `dsp`'s 676 + // delay lines (the source of the "small noises" during overlap). 677 + let mut voice_dsp: Option<VoiceDsp> = None; 651 678 let mut eq_enabled = false; 652 679 let mut eq_bands: Vec<EqBand> = Vec::new(); 653 680 // Tone (bass/treble) shelf gains in whole dB, plus optional cutoff ··· 738 765 // current_index at the incoming track, 739 766 // so promotion must NOT advance it. 740 767 promote_advances_queue = false; 768 + if let Some(vd) = voice_dsp.as_mut() { 769 + vd.flush(); 770 + } 741 771 next = Some(nt); 742 772 } 743 773 Err(e) => { ··· 1120 1150 tone_treble_cutoff_hz, 1121 1151 ); 1122 1152 dsp = Some(d); 1153 + // Spin up the second config now too — cheap, and it means 1154 + // the crossfade preload path never blocks on init. 1155 + if voice_dsp.is_none() { 1156 + let mut vd = VoiceDsp::new(t.output_sr); 1157 + vd.set_input_frequency(t.output_sr); 1158 + voice_dsp = Some(vd); 1159 + } 1123 1160 } 1124 1161 1125 1162 let free = t.ring_free_slots(); 1126 1163 if free >= 8192 && !t.ended { 1127 - // EQ / tone only route for the CURRENT track — the DSP is 1128 - // a process-wide singleton (`CODEC_IDX_AUDIO`), so we 1129 - // can't run two tracks through it at once. `next` (during 1130 - // crossfade) bypasses. We also skip the DSP entirely when 1131 - // no stage is doing anything, to avoid the f32↔i16 hops. 1164 + // Current track always uses the AUDIO Rockbox config; the 1165 + // next track (during crossfade) uses the VOICE config 1166 + // below. Coefficients are global, so the EQ curve matches; 1167 + // only the biquad delay lines differ. Skip both configs 1168 + // when no stage is doing anything to avoid the f32↔i16 1169 + // conversion hops. 1132 1170 let dsp_active = 1133 1171 eq_enabled || tone_bass_db != 0 || tone_treble_db != 0; 1134 - let dsp_arg = if dsp_active { dsp.as_mut() } else { None }; 1172 + let dsp_arg: Option<&mut dyn DspProcess> = if dsp_active { 1173 + dsp.as_mut().map(|d| d as &mut dyn DspProcess) 1174 + } else { 1175 + None 1176 + }; 1135 1177 match decode_one_packet(t, dsp_arg) { 1136 1178 DecodeStep::Pushed => update_position(&state, t), 1137 1179 DecodeStep::EndOfStream => { ··· 1147 1189 } 1148 1190 1149 1191 // Decode next in parallel — it has its own ring buffer, so no 1150 - // backpressure conflict with current. During crossfade we want the 1151 - // same EQ / tone curve on the incoming track too, so we route it 1152 - // through the same Dsp singleton. Rockbox's biquad delay-line 1153 - // state gets briefly stirred at the switch (~sub-1 ms transient 1154 - // at 48 kHz), inaudible in practice for a 5–12 s overlap. 1192 + // backpressure conflict with current. During crossfade the next 1193 + // track routes through the dedicated VOICE DSP config so the 1194 + // AUDIO config's biquad delay lines are undisturbed — that's what 1195 + // caused the "small noises" during overlap in the previous 1196 + // shared-DSP version. Coefficients are still global, so the EQ 1197 + // curve is identical on both sides. 1155 1198 if let Some(ref mut nt) = next { 1156 1199 let free = nt.ring_free_slots(); 1157 1200 if free >= 8192 && !nt.ended { 1158 1201 let dsp_active = 1159 1202 eq_enabled || tone_bass_db != 0 || tone_treble_db != 0; 1160 - let dsp_arg = if dsp_active { dsp.as_mut() } else { None }; 1203 + let dsp_arg: Option<&mut dyn DspProcess> = if dsp_active { 1204 + voice_dsp.as_mut().map(|d| d as &mut dyn DspProcess) 1205 + } else { 1206 + None 1207 + }; 1161 1208 match decode_one_packet(nt, dsp_arg) { 1162 1209 DecodeStep::Pushed => {} 1163 1210 DecodeStep::EndOfStream => nt.ended = true, ··· 1215 1262 // NOT yet the queue's current, so promotion 1216 1263 // must advance. 1217 1264 promote_advances_queue = true; 1265 + // Fresh biquad state for the incoming track 1266 + // — no delay-line pollution from a prior 1267 + // preload that never got promoted. 1268 + if let Some(vd) = voice_dsp.as_mut() { 1269 + vd.flush(); 1270 + } 1218 1271 next = Some(nt); 1219 1272 } 1220 1273 Err(e) => { ··· 1254 1307 } 1255 1308 sync_queue_meta(&state, &queue); 1256 1309 persist_now(&persister, &queue, &state); 1310 + // The AUDIO DSP was tuned to the OUTGOING track's samples. 1311 + // The newly-promoted track will now route through it, so 1312 + // reset both configs' delay lines to zero — cheaper and 1313 + // cleaner than letting stale state resonate through the 1314 + // first packet or two. 1315 + if let Some(d) = dsp.as_mut() { 1316 + d.flush(); 1317 + } 1318 + if let Some(vd) = voice_dsp.as_mut() { 1319 + vd.flush(); 1320 + } 1257 1321 } 1258 1322 promote_advances_queue = false; 1259 1323 } ··· 1310 1374 Error(String), 1311 1375 } 1312 1376 1313 - fn decode_one_packet(t: &mut Track, dsp: Option<&mut Dsp>) -> DecodeStep { 1377 + fn decode_one_packet(t: &mut Track, dsp: Option<&mut dyn DspProcess>) -> DecodeStep { 1314 1378 let packet = match t.format.next_packet() { 1315 1379 Ok(p) => p, 1316 1380 Err(symphonia::core::errors::Error::IoError(e)) ··· 1359 1423 DecodeStep::Pushed 1360 1424 } 1361 1425 1362 - /// Route interleaved-stereo f32 samples through the Rockbox DSP pipeline 1426 + /// Route interleaved-stereo f32 samples through a Rockbox DSP pipeline 1363 1427 /// with input rate == output rate (no internal resample; my Resampler 1364 1428 /// already ran). Loudness scales aside, this is just the EQ + tone stack. 1365 - fn apply_dsp(dsp: &mut Dsp, samples: &[f32]) -> Vec<f32> { 1429 + fn apply_dsp(dsp: &mut dyn DspProcess, samples: &[f32]) -> Vec<f32> { 1366 1430 // f32 → i16, saturating. 1367 1431 let mut input = Vec::with_capacity(samples.len()); 1368 1432 for &s in samples { ··· 1370 1434 input.push(v); 1371 1435 } 1372 1436 let mut out_i16: Vec<i16> = Vec::with_capacity(input.len()); 1373 - dsp.process(&input, &mut out_i16); 1437 + dsp.process_stereo(&input, &mut out_i16); 1374 1438 // i16 → f32. 1375 1439 out_i16.iter().map(|&s| s as f32 / 32768.0).collect() 1376 1440 }
+99
crates/fin-player/src/voice_dsp.rs
··· 1 + //! Second Rockbox DSP instance bound to `CODEC_IDX_VOICE`. 2 + //! 3 + //! The safe [`rockbox_dsp::Dsp`] wrapper is hardcoded to the audio config 4 + //! (`CODEC_IDX_AUDIO`). During a crossfade we need a *second* independent 5 + //! pipeline for the incoming track so its biquad delay lines don't share 6 + //! state with the outgoing track's. Rockbox exposes exactly two configs 7 + //! for that purpose (audio + voice) — coefficients set via 8 + //! `dsp_set_eq_coefs` / `tone_set_*` are global (both configs share the 9 + //! same EQ curve, exactly what we want), and only the internal delay 10 + //! lines are per-config. 11 + //! 12 + //! This module wraps `CODEC_IDX_VOICE` with a `process()` shaped like the 13 + //! safe wrapper's so `decode_one_packet` can plug it in interchangeably. 14 + 15 + use std::os::raw::{c_int, c_void}; 16 + 17 + use rockbox_dsp::{ 18 + dsp_buffer, dsp_buffer_count, dsp_buffer_ptrs, dsp_config, dsp_configure, dsp_get_config, 19 + dsp_process, sample_format, CODEC_IDX_VOICE, DSP_FLUSH, DSP_RESET, DSP_SET_FREQUENCY, 20 + DSP_SET_OUT_FREQUENCY, DSP_SET_SAMPLE_DEPTH, DSP_SET_STEREO_MODE, STEREO_INTERLEAVED, 21 + }; 22 + 23 + /// Minimal wrapper around Rockbox's voice DSP config. 24 + /// 25 + /// Uses the same `dsp_init` singleton as [`rockbox_dsp::Dsp`], so an audio 26 + /// DSP must have been constructed at least once (or `dsp_init` called 27 + /// externally) before this is created. `SymphoniaPlayer` always creates 28 + /// the audio-side DSP first. 29 + pub struct VoiceDsp { 30 + cfg: *mut dsp_config, 31 + } 32 + 33 + impl VoiceDsp { 34 + /// Initialize the voice config for interleaved S16LE stereo at 35 + /// `sample_rate`. Assumes `dsp_init()` has already been called by the 36 + /// safe [`rockbox_dsp::Dsp::new`] constructor. 37 + pub fn new(sample_rate: u32) -> Self { 38 + let cfg = unsafe { dsp_get_config(CODEC_IDX_VOICE) }; 39 + assert!(!cfg.is_null(), "dsp_get_config(voice) returned null"); 40 + unsafe { 41 + dsp_configure(cfg, DSP_RESET, 0); 42 + dsp_configure(cfg, DSP_SET_OUT_FREQUENCY, sample_rate as isize); 43 + dsp_configure(cfg, DSP_SET_FREQUENCY, sample_rate as isize); 44 + dsp_configure(cfg, DSP_SET_SAMPLE_DEPTH, 16); 45 + dsp_configure(cfg, DSP_SET_STEREO_MODE, STEREO_INTERLEAVED); 46 + } 47 + Self { cfg } 48 + } 49 + 50 + pub fn set_input_frequency(&mut self, hz: u32) { 51 + unsafe { dsp_configure(self.cfg, DSP_SET_FREQUENCY, hz as isize) }; 52 + } 53 + 54 + pub fn flush(&mut self) { 55 + unsafe { dsp_configure(self.cfg, DSP_FLUSH, 0) }; 56 + } 57 + 58 + /// Run `input` (interleaved stereo S16) through the voice pipeline, 59 + /// appending processed samples to `out`. Mirrors the shape of 60 + /// [`rockbox_dsp::Dsp::process`] so it can be swapped in unchanged. 61 + pub fn process(&mut self, input: &[i16], out: &mut Vec<i16>) -> usize { 62 + assert!(input.len() % 2 == 0, "input must be interleaved stereo"); 63 + let mut produced = 0usize; 64 + let mut chunk = [0i16; 8192]; // 4096 frames per dsp_process call 65 + 66 + let mut src = dsp_buffer { 67 + remcount: (input.len() / 2) as i32, 68 + ptrs: dsp_buffer_ptrs { 69 + pin: [input.as_ptr() as *const c_void; 2], 70 + }, 71 + count: dsp_buffer_count { proc_mask: 0 }, 72 + format: sample_format::default(), 73 + }; 74 + 75 + loop { 76 + let mut dst = dsp_buffer { 77 + remcount: 0, 78 + ptrs: dsp_buffer_ptrs { 79 + p16out: chunk.as_mut_ptr(), 80 + }, 81 + count: dsp_buffer_count { 82 + bufcount: (chunk.len() / 2) as c_int, 83 + }, 84 + format: sample_format::default(), 85 + }; 86 + unsafe { dsp_process(self.cfg, &mut src, &mut dst, false) }; 87 + let frames = dst.remcount as usize; 88 + if frames == 0 && src.remcount <= 0 { 89 + break; 90 + } 91 + out.extend_from_slice(&chunk[..frames * 2]); 92 + produced += frames; 93 + if src.remcount <= 0 && frames < chunk.len() / 2 { 94 + break; 95 + } 96 + } 97 + produced 98 + } 99 + }
+145
crates/fin-tui/src/screens/mod.rs
··· 217 217 format!("{}{}", s, " ".repeat(cols - w)) 218 218 } 219 219 } 220 + 221 + #[cfg(test)] 222 + mod tests { 223 + use super::*; 224 + 225 + // ------------------------------------------------------------------ 226 + // Screen tab navigation 227 + // ------------------------------------------------------------------ 228 + 229 + #[test] 230 + fn screen_all_matches_declared_tab_order() { 231 + // The header row in the TUI walks Screen::ALL in this order; if it 232 + // drifts, the `1`…`7` shortcuts silently jump to the wrong tab. 233 + assert_eq!( 234 + Screen::ALL, 235 + &[ 236 + Screen::Music, 237 + Screen::Videos, 238 + Screen::Playlists, 239 + Screen::Queue, 240 + Screen::Search, 241 + Screen::Devices, 242 + Screen::Settings, 243 + ] 244 + ); 245 + } 246 + 247 + #[test] 248 + fn screen_next_wraps_around_at_end() { 249 + assert_eq!(Screen::Music.next(), Screen::Videos); 250 + assert_eq!(Screen::Videos.next(), Screen::Playlists); 251 + // Last tab wraps back to the first. 252 + assert_eq!(Screen::Settings.next(), Screen::Music); 253 + } 254 + 255 + #[test] 256 + fn screen_prev_wraps_around_at_start() { 257 + assert_eq!(Screen::Music.prev(), Screen::Settings); 258 + assert_eq!(Screen::Videos.prev(), Screen::Music); 259 + assert_eq!(Screen::Settings.prev(), Screen::Devices); 260 + } 261 + 262 + #[test] 263 + fn screen_next_prev_are_inverses() { 264 + for &s in Screen::ALL { 265 + assert_eq!(s.next().prev(), s); 266 + assert_eq!(s.prev().next(), s); 267 + } 268 + } 269 + 270 + #[test] 271 + fn screen_icon_and_label_lookup_by_variant() { 272 + // Guards against a rename that would blank out the header UI. 273 + assert_eq!(Screen::Music.icon(), "♪"); 274 + assert_eq!(Screen::Music.label(), "Music"); 275 + assert_eq!(Screen::Queue.icon(), "≡"); 276 + assert_eq!(Screen::Settings.label(), "Settings"); 277 + } 278 + 279 + // ------------------------------------------------------------------ 280 + // Duration formatting 281 + // ------------------------------------------------------------------ 282 + 283 + #[test] 284 + fn fmt_dur_zero() { 285 + assert_eq!(fmt_dur(0), "0:00"); 286 + } 287 + 288 + #[test] 289 + fn fmt_dur_under_an_hour() { 290 + assert_eq!(fmt_dur(59), "0:59"); 291 + assert_eq!(fmt_dur(60), "1:00"); 292 + assert_eq!(fmt_dur(3599), "59:59"); 293 + } 294 + 295 + #[test] 296 + fn fmt_dur_hour_and_over() { 297 + assert_eq!(fmt_dur(3600), "1:00:00"); 298 + assert_eq!(fmt_dur(3661), "1:01:01"); 299 + assert_eq!(fmt_dur(36_000), "10:00:00"); 300 + } 301 + 302 + // ------------------------------------------------------------------ 303 + // RowLayout column widths 304 + // ------------------------------------------------------------------ 305 + 306 + #[test] 307 + fn row_layout_sums_to_total_width() { 308 + for total in [40u16, 80, 120, 200] { 309 + let l = RowLayout::compute(total); 310 + let sum = l.icon_col + l.title_col + l.gap1 + l.sub_col + l.gap2 + l.time_col; 311 + assert_eq!(sum, total as usize); 312 + } 313 + } 314 + 315 + #[test] 316 + fn row_layout_hides_subtitle_column_on_narrow_terminals() { 317 + // 30 chars is our documented cutoff: sub_col goes to 0 so the title 318 + // column takes the full middle. 319 + let l = RowLayout::compute(40); 320 + assert_eq!(l.sub_col, 0); 321 + // But nothing else disappears — icon, gap1/2, time still present. 322 + assert!(l.title_col > 0); 323 + assert_eq!(l.icon_col, RowLayout::ICON); 324 + assert_eq!(l.time_col, RowLayout::TIME_MAX); 325 + } 326 + 327 + #[test] 328 + fn row_layout_gives_title_roughly_55_percent_of_middle() { 329 + // Loose bound — implementation uses integer math, so ±1 is fine. 330 + let l = RowLayout::compute(120); 331 + let mid = l.title_col + l.sub_col; 332 + assert!(mid > 0); 333 + let ratio = l.title_col as f32 / mid as f32; 334 + assert!( 335 + (0.5..=0.6).contains(&ratio), 336 + "title ratio {} not in [0.50, 0.60]", 337 + ratio 338 + ); 339 + } 340 + 341 + // ------------------------------------------------------------------ 342 + // Truncation helper 343 + // ------------------------------------------------------------------ 344 + 345 + #[test] 346 + fn truncate_leaves_short_strings_alone() { 347 + assert_eq!(truncate("abc", 10), "abc"); 348 + assert_eq!(truncate("", 5), ""); 349 + } 350 + 351 + #[test] 352 + fn truncate_adds_ellipsis_and_stays_within_budget() { 353 + // "abcdefghij" → 10 cols. Truncated to 5 must land at 5 cols total, 354 + // ending with the ellipsis character. 355 + let out = truncate("abcdefghij", 5); 356 + assert!(out.ends_with('…')); 357 + assert!(out.width() <= 5); 358 + } 359 + 360 + #[test] 361 + fn truncate_single_column_gives_bare_ellipsis() { 362 + assert_eq!(truncate("longstring", 1), "…"); 363 + } 364 + }