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.

Replace symphonia audio backend with rockbox-playback

The local audio renderer previously hand-rolled a symphonia + cpal + rb
stack (queue, shuffle/repeat, resume, ReplayGain, crossfade, EQ/tone DSP,
HTTP streaming) in a 2215-line SymphoniaPlayer. rockbox-playback 0.4 now
provides all of that natively, so swap the implementation for a thin
adapter over its Player.

- Add rockbox-playback = "0.4"; drop symphonia, cpal, rb and the direct
rockbox-dsp dep (all now transitive).
- New rockbox_player.rs (RockboxPlayer) implements Renderer by delegating
to rockbox_playback::Player. Player owns a cpal stream and is !Send +
!Sync, so it lives on a dedicated worker thread driven by FnOnce(&Player)
command closures; the struct holds only the Sender + a Send+Sync metadata
mirror. Delete symphonia_player.rs and voice_dsp.rs.
- Persistence: the engine owns queue order + exact playhead via its own
resume_file; fin's queue.json stays a metadata sidecar so the TUI keeps
server-provided titles/artwork. restore() calls Player::resume() with a
seed-from-sidecar fallback for first-run migration.
- Slim replaygain.rs / crossfade.rs to re-exports of the fin-config types
(tag extraction + fade curves now live in the engine).
- Rewire lib.rs / local.rs and refresh docs; sweep stale "symphonia"
mentions across fin, fin-tui and fin-mediarenderer.
- flake.nix: add stdenv.cc to the package and dev shell — rockbox-codecs /
rockbox-dsp compile Rockbox's C sources via the cc crate.

Note: rockbox-playback is GPL-2.0-or-later, so linking it makes the fin
binary GPL (already true via rockbox-dsp).

+644 -2997
+38 -234
Cargo.lock
··· 105 105 checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" 106 106 107 107 [[package]] 108 - name = "arrayvec" 109 - version = "0.7.8" 110 - source = "registry+https://github.com/rust-lang/crates.io-index" 111 - checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" 112 - 113 - [[package]] 114 108 name = "async-broadcast" 115 109 version = "0.7.2" 116 110 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 330 324 version = "3.20.3" 331 325 source = "registry+https://github.com/rust-lang/crates.io-index" 332 326 checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" 333 - 334 - [[package]] 335 - name = "bytemuck" 336 - version = "1.25.0" 337 - source = "registry+https://github.com/rust-lang/crates.io-index" 338 - checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" 339 327 340 328 [[package]] 341 329 name = "byteorder" ··· 692 680 checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" 693 681 694 682 [[package]] 695 - name = "encoding_rs" 696 - version = "0.8.35" 697 - source = "registry+https://github.com/rust-lang/crates.io-index" 698 - checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" 699 - dependencies = [ 700 - "cfg-if", 701 - ] 702 - 703 - [[package]] 704 683 name = "endi" 705 684 version = "1.1.1" 706 685 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 763 742 "event-listener", 764 743 "pin-project-lite", 765 744 ] 766 - 767 - [[package]] 768 - name = "extended" 769 - version = "0.1.0" 770 - source = "registry+https://github.com/rust-lang/crates.io-index" 771 - checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" 772 745 773 746 [[package]] 774 747 name = "fastrand" ··· 882 855 dependencies = [ 883 856 "anyhow", 884 857 "async-trait", 885 - "cpal", 886 858 "fin-config", 887 859 "fin-jellyfin", 888 860 "futures", 889 861 "mdns-sd", 890 862 "parking_lot", 891 863 "rand", 892 - "rb", 893 864 "reqwest", 894 - "rockbox-dsp", 865 + "rockbox-playback", 895 866 "rust_cast", 896 867 "serde", 897 868 "serde_json", 898 - "symphonia", 899 869 "thiserror 1.0.69", 900 870 "tokio", 901 871 "tracing", ··· 1244 1214 "hyper", 1245 1215 "hyper-util", 1246 1216 "rustls", 1217 + "rustls-native-certs", 1247 1218 "tokio", 1248 1219 "tokio-rustls", 1249 1220 "tower-service", ··· 2166 2137 ] 2167 2138 2168 2139 [[package]] 2169 - name = "rb" 2170 - version = "0.4.1" 2171 - source = "registry+https://github.com/rust-lang/crates.io-index" 2172 - checksum = "56cf8381505b60ae18a4097f1d0be093287ca3bf4fbb23d36ac5ad3bba335daa" 2173 - 2174 - [[package]] 2175 2140 name = "redox_syscall" 2176 2141 version = "0.5.18" 2177 2142 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2252 2217 "pin-project-lite", 2253 2218 "quinn", 2254 2219 "rustls", 2220 + "rustls-native-certs", 2255 2221 "rustls-pki-types", 2256 2222 "serde", 2257 2223 "serde_json", ··· 2286 2252 ] 2287 2253 2288 2254 [[package]] 2289 - name = "rockbox-dsp" 2255 + name = "rockbox-codecs" 2290 2256 version = "0.2.0" 2291 2257 source = "registry+https://github.com/rust-lang/crates.io-index" 2292 - checksum = "4310fa190f0ffdd5dd2c49578dbe5111d8a068b4a51f8baa6d2e5f94d1dec80a" 2258 + checksum = "f2fad09bba95a53f6f26b9722d1521b0d786410fa18a51ca432d6b0a0ea7d87e" 2259 + dependencies = [ 2260 + "cc", 2261 + "rockbox-metadata", 2262 + ] 2263 + 2264 + [[package]] 2265 + name = "rockbox-dsp" 2266 + version = "0.3.0" 2267 + source = "registry+https://github.com/rust-lang/crates.io-index" 2268 + checksum = "413a59a41ed9e936c283b28d494c22d19e42c1043292a920b84b3ff0bd5af6c2" 2293 2269 dependencies = [ 2294 2270 "cc", 2295 2271 ] 2296 2272 2297 2273 [[package]] 2274 + name = "rockbox-metadata" 2275 + version = "0.1.0" 2276 + source = "registry+https://github.com/rust-lang/crates.io-index" 2277 + checksum = "a4d438a91e9bb4fe2243acfef1c135a229b6a64f95f48b0d7eaf7a936e5abb92" 2278 + dependencies = [ 2279 + "cc", 2280 + ] 2281 + 2282 + [[package]] 2283 + name = "rockbox-playback" 2284 + version = "0.4.0" 2285 + source = "registry+https://github.com/rust-lang/crates.io-index" 2286 + checksum = "39ac1f8715f88a47e8ab6ed3963e17d17e0d78690659d657aa93977de4c13706" 2287 + dependencies = [ 2288 + "cpal", 2289 + "reqwest", 2290 + "rockbox-codecs", 2291 + "rockbox-dsp", 2292 + "rockbox-metadata", 2293 + "tempfile", 2294 + ] 2295 + 2296 + [[package]] 2298 2297 name = "rpassword" 2299 2298 version = "7.5.4" 2300 2299 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2686 2685 version = "2.6.1" 2687 2686 source = "registry+https://github.com/rust-lang/crates.io-index" 2688 2687 checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" 2689 - 2690 - [[package]] 2691 - name = "symphonia" 2692 - version = "0.5.5" 2693 - source = "registry+https://github.com/rust-lang/crates.io-index" 2694 - checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" 2695 - dependencies = [ 2696 - "lazy_static", 2697 - "symphonia-bundle-flac", 2698 - "symphonia-bundle-mp3", 2699 - "symphonia-codec-aac", 2700 - "symphonia-codec-adpcm", 2701 - "symphonia-codec-alac", 2702 - "symphonia-codec-pcm", 2703 - "symphonia-codec-vorbis", 2704 - "symphonia-core", 2705 - "symphonia-format-caf", 2706 - "symphonia-format-isomp4", 2707 - "symphonia-format-mkv", 2708 - "symphonia-format-ogg", 2709 - "symphonia-format-riff", 2710 - "symphonia-metadata", 2711 - ] 2712 - 2713 - [[package]] 2714 - name = "symphonia-bundle-flac" 2715 - version = "0.5.5" 2716 - source = "registry+https://github.com/rust-lang/crates.io-index" 2717 - checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" 2718 - dependencies = [ 2719 - "log", 2720 - "symphonia-core", 2721 - "symphonia-metadata", 2722 - "symphonia-utils-xiph", 2723 - ] 2724 - 2725 - [[package]] 2726 - name = "symphonia-bundle-mp3" 2727 - version = "0.5.5" 2728 - source = "registry+https://github.com/rust-lang/crates.io-index" 2729 - checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" 2730 - dependencies = [ 2731 - "lazy_static", 2732 - "log", 2733 - "symphonia-core", 2734 - "symphonia-metadata", 2735 - ] 2736 - 2737 - [[package]] 2738 - name = "symphonia-codec-aac" 2739 - version = "0.5.5" 2740 - source = "registry+https://github.com/rust-lang/crates.io-index" 2741 - checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790" 2742 - dependencies = [ 2743 - "lazy_static", 2744 - "log", 2745 - "symphonia-core", 2746 - ] 2747 - 2748 - [[package]] 2749 - name = "symphonia-codec-adpcm" 2750 - version = "0.5.5" 2751 - source = "registry+https://github.com/rust-lang/crates.io-index" 2752 - checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f" 2753 - dependencies = [ 2754 - "log", 2755 - "symphonia-core", 2756 - ] 2757 - 2758 - [[package]] 2759 - name = "symphonia-codec-alac" 2760 - version = "0.5.5" 2761 - source = "registry+https://github.com/rust-lang/crates.io-index" 2762 - checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab" 2763 - dependencies = [ 2764 - "log", 2765 - "symphonia-core", 2766 - ] 2767 - 2768 - [[package]] 2769 - name = "symphonia-codec-pcm" 2770 - version = "0.5.5" 2771 - source = "registry+https://github.com/rust-lang/crates.io-index" 2772 - checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" 2773 - dependencies = [ 2774 - "log", 2775 - "symphonia-core", 2776 - ] 2777 - 2778 - [[package]] 2779 - name = "symphonia-codec-vorbis" 2780 - version = "0.5.5" 2781 - source = "registry+https://github.com/rust-lang/crates.io-index" 2782 - checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73" 2783 - dependencies = [ 2784 - "log", 2785 - "symphonia-core", 2786 - "symphonia-utils-xiph", 2787 - ] 2788 - 2789 - [[package]] 2790 - name = "symphonia-core" 2791 - version = "0.5.5" 2792 - source = "registry+https://github.com/rust-lang/crates.io-index" 2793 - checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" 2794 - dependencies = [ 2795 - "arrayvec", 2796 - "bitflags 1.3.2", 2797 - "bytemuck", 2798 - "lazy_static", 2799 - "log", 2800 - ] 2801 - 2802 - [[package]] 2803 - name = "symphonia-format-caf" 2804 - version = "0.5.5" 2805 - source = "registry+https://github.com/rust-lang/crates.io-index" 2806 - checksum = "b8faf379316b6b6e6bbc274d00e7a592e0d63ff1a7e182ce8ba25e24edd3d096" 2807 - dependencies = [ 2808 - "log", 2809 - "symphonia-core", 2810 - "symphonia-metadata", 2811 - ] 2812 - 2813 - [[package]] 2814 - name = "symphonia-format-isomp4" 2815 - version = "0.5.5" 2816 - source = "registry+https://github.com/rust-lang/crates.io-index" 2817 - checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5" 2818 - dependencies = [ 2819 - "encoding_rs", 2820 - "log", 2821 - "symphonia-core", 2822 - "symphonia-metadata", 2823 - "symphonia-utils-xiph", 2824 - ] 2825 - 2826 - [[package]] 2827 - name = "symphonia-format-mkv" 2828 - version = "0.5.5" 2829 - source = "registry+https://github.com/rust-lang/crates.io-index" 2830 - checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0" 2831 - dependencies = [ 2832 - "lazy_static", 2833 - "log", 2834 - "symphonia-core", 2835 - "symphonia-metadata", 2836 - "symphonia-utils-xiph", 2837 - ] 2838 - 2839 - [[package]] 2840 - name = "symphonia-format-ogg" 2841 - version = "0.5.5" 2842 - source = "registry+https://github.com/rust-lang/crates.io-index" 2843 - checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" 2844 - dependencies = [ 2845 - "log", 2846 - "symphonia-core", 2847 - "symphonia-metadata", 2848 - "symphonia-utils-xiph", 2849 - ] 2850 - 2851 - [[package]] 2852 - name = "symphonia-format-riff" 2853 - version = "0.5.5" 2854 - source = "registry+https://github.com/rust-lang/crates.io-index" 2855 - checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" 2856 - dependencies = [ 2857 - "extended", 2858 - "log", 2859 - "symphonia-core", 2860 - "symphonia-metadata", 2861 - ] 2862 - 2863 - [[package]] 2864 - name = "symphonia-metadata" 2865 - version = "0.5.5" 2866 - source = "registry+https://github.com/rust-lang/crates.io-index" 2867 - checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" 2868 - dependencies = [ 2869 - "encoding_rs", 2870 - "lazy_static", 2871 - "log", 2872 - "symphonia-core", 2873 - ] 2874 - 2875 - [[package]] 2876 - name = "symphonia-utils-xiph" 2877 - version = "0.5.5" 2878 - source = "registry+https://github.com/rust-lang/crates.io-index" 2879 - checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" 2880 - dependencies = [ 2881 - "symphonia-core", 2882 - "symphonia-metadata", 2883 - ] 2884 2688 2885 2689 [[package]] 2886 2690 name = "syn"
+2 -2
crates/fin-mediarenderer/src/desc.rs
··· 15 15 <friendlyName>{name}</friendlyName> 16 16 <manufacturer>tsirysndr</manufacturer> 17 17 <manufacturerURL>https://github.com/tsirysndr/fin</manufacturerURL> 18 - <modelDescription>fin terminal media player — symphonia audio, mpv video</modelDescription> 18 + <modelDescription>fin terminal media player — rockbox-playback audio, mpv video</modelDescription> 19 19 <modelName>fin</modelName> 20 20 <modelNumber>{version}</modelNumber> 21 21 <modelURL>https://github.com/tsirysndr/fin</modelURL> ··· 313 313 scpd(actions, vars) 314 314 } 315 315 316 - /// Everything symphonia (audio) or mpv (video) can realistically take. 316 + /// Everything rockbox-playback (audio) or mpv (video) can realistically take. 317 317 /// Control points filter their "cast to" pickers on this list. 318 318 pub const SINK_PROTOCOL_INFO: &str = "http-get:*:audio/mpeg:*,\ 319 319 http-get:*:audio/mp3:*,\
+1 -1
crates/fin-mediarenderer/src/lib.rs
··· 4 4 //! image: it advertises this machine on the LAN (SSDP), serves the device / 5 5 //! service description XML plus SOAP control endpoints over a tiny embedded 6 6 //! HTTP server, and routes whatever a control point pushes at us into the 7 - //! local playback stack — audio lands in the in-process symphonia player, 7 + //! local playback stack — audio lands in the in-process rockbox-playback player, 8 8 //! video is handed to mpv. The split happens for free by driving the shared 9 9 //! [`fin_player::LocalRenderer`] through the same `Renderer` trait the TUI 10 10 //! uses, so incoming casts show up in the Now Playing bar and respond to the
+1 -1
crates/fin-mediarenderer/src/soap.rs
··· 2 2 //! 3 3 //! This is where an external control point's actions turn into calls on the 4 4 //! shared `Renderer`: `SetAVTransportURI` + `Play` build a `QueueItem` whose 5 - //! `is_video` flag makes `LocalRenderer` route audio to symphonia and video 5 + //! `is_video` flag makes `LocalRenderer` route audio to rockbox-playback and video 6 6 //! to mpv; the transport/volume queries read the same live `PlaybackState` 7 7 //! the TUI shows. 8 8
+2 -2
crates/fin-mediarenderer/src/xml.rs
··· 124 124 } 125 125 } 126 126 127 - /// Audio vs video decides the whole downstream path (symphonia vs mpv), so 127 + /// Audio vs video decides the whole downstream path (rockbox-playback vs mpv), so 128 128 /// check every signal in confidence order: DIDL `upnp:class`, then the 129 129 /// protocolInfo MIME, then the URL extension. Unknown defaults to audio — 130 - /// symphonia fails fast and loud, mpv would pop a window. 130 + /// rockbox-playback fails fast and loud, mpv would pop a window. 131 131 pub fn is_video(meta: &CastMeta, uri: &str) -> bool { 132 132 if let Some(class) = &meta.upnp_class { 133 133 if class.contains("videoItem") {
+9 -10
crates/fin-player/Cargo.toml
··· 24 24 fin-jellyfin.workspace = true 25 25 fin-config.workspace = true 26 26 27 - # Local audio-only playback: fetch → symphonia decode → cpal output. 28 - # mpv is only used for video; audio never touches mpv. 29 - symphonia = { version = "0.5", features = ["all-codecs", "all-formats"] } 30 - cpal = "0.15" 31 - rb = "0.4" 32 - 33 27 # Shuffle uses Fisher-Yates via `rand` — pulled in as a transitive dep 34 28 # already, so this is a zero-cost explicit dep. 35 29 rand = "0.9" 36 30 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" 31 + # Local audio-only playback engine. Owns the queue, transport, shuffle/ 32 + # repeat, exact-position resume, ReplayGain, crossfade and the full Rockbox 33 + # DSP chain (10-band EQ, tone controls, …) plus HTTP(S) streaming — the 34 + # `http` feature is on by default. Decoding is done by Rockbox's own codecs 35 + # and output goes straight to cpal; mpv is only used for video. 36 + # 37 + # Note: rockbox-playback is GPL-2.0-or-later (it links the Rockbox C codec 38 + # and DSP sources), so linking it makes the fin binary GPL. 39 + rockbox-playback = "0.4"
+8 -114
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. 1 + //! Crossfade configuration types. 7 2 //! 8 - //! The two samples are then summed and pushed to the ring buffer. 3 + //! The cross-track blending itself — keeping the outgoing track's tail 4 + //! playing while the incoming track fades in, on a manual skip or an 5 + //! automatic advance — is a faithful port of Rockbox's `pcmbuf` mixer that 6 + //! lives in the `rockbox-playback` engine. fin only carries the user's 7 + //! settings, so this module just re-exports the config-facing types (defined 8 + //! in `fin-config` because they're pure config data) under 9 + //! `fin_player::Crossfade*`. 9 10 10 11 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 { 24 - out: 1.0, 25 - incoming: 0.0, 26 - }; 27 - /// No outgoing, full incoming — used after the fade completes. 28 - pub const INCOMING_ONLY: Self = Self { 29 - out: 0.0, 30 - incoming: 1.0, 31 - }; 32 - } 33 - 34 - /// Evaluate the fade curve at `progress`. `Mixed` mode returns 1.0/1.0 — 35 - /// both samples pass through, and the sum can exceed unity. 36 - pub fn fade_at(mode: CrossfadeMode, progress: f32) -> FadePair { 37 - let p = progress.clamp(0.0, 1.0); 38 - match mode { 39 - CrossfadeMode::Off => FadePair::OUTGOING_ONLY, 40 - CrossfadeMode::Mixed => FadePair { 41 - out: 1.0, 42 - incoming: 1.0, 43 - }, 44 - CrossfadeMode::Crossfade => { 45 - // Equal-power (cosine/sine) curves so the sum-of-squares stays 46 - // constant — perceptually smooth on music. 47 - let half_pi = std::f32::consts::FRAC_PI_2; 48 - FadePair { 49 - out: (p * half_pi).cos(), 50 - incoming: (p * half_pi).sin(), 51 - } 52 - } 53 - } 54 - } 55 - 56 - #[cfg(test)] 57 - mod tests { 58 - use super::*; 59 - 60 - fn approx(a: f32, b: f32) -> bool { 61 - (a - b).abs() < 1e-4 62 - } 63 - 64 - #[test] 65 - fn off_mode_is_outgoing_only_regardless_of_progress() { 66 - for p in [0.0, 0.25, 0.5, 0.75, 1.0] { 67 - let pair = fade_at(CrossfadeMode::Off, p); 68 - assert!(approx(pair.out, 1.0)); 69 - assert!(approx(pair.incoming, 0.0)); 70 - } 71 - } 72 - 73 - #[test] 74 - fn mixed_mode_is_unity_on_both_sides() { 75 - for p in [0.0, 0.5, 1.0] { 76 - let pair = fade_at(CrossfadeMode::Mixed, p); 77 - assert!(approx(pair.out, 1.0)); 78 - assert!(approx(pair.incoming, 1.0)); 79 - } 80 - } 81 - 82 - #[test] 83 - fn crossfade_endpoints_are_pure_outgoing_or_pure_incoming() { 84 - let start = fade_at(CrossfadeMode::Crossfade, 0.0); 85 - assert!(approx(start.out, 1.0)); 86 - assert!(approx(start.incoming, 0.0)); 87 - 88 - let end = fade_at(CrossfadeMode::Crossfade, 1.0); 89 - assert!(approx(end.out, 0.0)); 90 - assert!(approx(end.incoming, 1.0)); 91 - } 92 - 93 - #[test] 94 - fn crossfade_midpoint_is_equal_power() { 95 - // At 50% progress, both are cos(pi/4) = sin(pi/4) ≈ 0.7071. 96 - let mid = fade_at(CrossfadeMode::Crossfade, 0.5); 97 - assert!(approx(mid.out, std::f32::consts::FRAC_1_SQRT_2)); 98 - assert!(approx(mid.incoming, std::f32::consts::FRAC_1_SQRT_2)); 99 - } 100 - 101 - #[test] 102 - fn crossfade_sum_of_squares_is_constant() { 103 - for p in [0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0] { 104 - let pair = fade_at(CrossfadeMode::Crossfade, p); 105 - let sos = pair.out * pair.out + pair.incoming * pair.incoming; 106 - assert!(approx(sos, 1.0), "sum of squares at p={} was {}", p, sos); 107 - } 108 - } 109 - 110 - #[test] 111 - fn progress_clamped_outside_unit_interval() { 112 - let neg = fade_at(CrossfadeMode::Crossfade, -0.5); 113 - let over = fade_at(CrossfadeMode::Crossfade, 1.5); 114 - assert!(approx(neg.out, 1.0)); 115 - assert!(approx(over.out, 0.0)); 116 - } 117 - }
+3 -4
crates/fin-player/src/lib.rs
··· 7 7 pub mod queue; 8 8 pub mod renderer; 9 9 pub mod replaygain; 10 - pub mod symphonia_player; 10 + pub mod rockbox_player; 11 11 pub mod upnp; 12 - pub mod voice_dsp; 13 12 14 13 pub use cast::ChromecastRenderer; 15 14 pub use crossfade::{CrossfadeMode, CrossfadeSettings}; ··· 19 18 pub use persist::{load as load_persisted_queue, PersistedQueue}; 20 19 pub use queue::{PlaybackQueue, QueueItem, RepeatMode, UPNP_CAST_ID_PREFIX}; 21 20 pub use renderer::{PlaybackState, PlaybackStatus, Renderer, RendererKind}; 22 - pub use replaygain::{ReplayGainInfo, ReplayGainMode, ReplayGainSettings}; 23 - pub use symphonia_player::SymphoniaPlayer; 21 + pub use replaygain::{ReplayGainMode, ReplayGainSettings}; 22 + pub use rockbox_player::RockboxPlayer; 24 23 pub use upnp::{discover_upnp_renderers, UpnpDevice, UpnpRenderer};
+10 -7
crates/fin-player/src/local.rs
··· 1 1 //! Local renderer — routes each item to the right backend: 2 - //! audio → symphonia (never mpv), video → mpv. 2 + //! audio → rockbox-playback (never mpv), video → mpv. 3 3 //! 4 4 //! To the caller this looks like a single `Renderer`. Internally we keep a 5 5 //! merged queue view so pause/next/prev/etc. hit whichever backend is ··· 21 21 use crate::queue::{QueueItem, RepeatMode}; 22 22 use crate::renderer::{PlaybackState, PlaybackStatus, Renderer, RendererKind}; 23 23 use crate::replaygain::ReplayGainSettings; 24 - use crate::symphonia_player::SymphoniaPlayer; 24 + use crate::rockbox_player::RockboxPlayer; 25 25 26 26 /// Which backend is currently sourcing playback. 27 27 #[derive(Debug, Clone, Copy, PartialEq, Eq)] ··· 32 32 } 33 33 34 34 pub struct LocalRenderer { 35 - audio: Arc<SymphoniaPlayer>, 35 + audio: Arc<RockboxPlayer>, 36 36 video: Arc<MpvRenderer>, 37 37 active: Arc<Mutex<Active>>, 38 38 } ··· 42 42 Self::with_persist(None) 43 43 } 44 44 45 - /// Build a LocalRenderer whose SymphoniaPlayer persists its queue to 45 + /// Build a LocalRenderer whose RockboxPlayer persists its queue to 46 46 /// `queue_path`. mpv's video queue is transient by design — a video 47 47 /// process spawns per session and doesn't persist. 48 48 pub fn with_persist(queue_path: Option<PathBuf>) -> Self { 49 49 Self { 50 - audio: Arc::new(SymphoniaPlayer::with_persist(queue_path)), 50 + audio: Arc::new(RockboxPlayer::with_persist(queue_path)), 51 51 video: Arc::new(MpvRenderer::new(None)), 52 52 active: Arc::new(Mutex::new(Active::None)), 53 53 } ··· 120 120 Active::Audio => { 121 121 let filtered = filter_kind(items, false); 122 122 let idx = start_index.min(filtered.len().saturating_sub(1)); 123 - debug!(items = filtered.len(), "local: routing audio → symphonia"); 123 + debug!( 124 + items = filtered.len(), 125 + "local: routing audio → rockbox-playback" 126 + ); 124 127 self.set_active(Active::Audio); 125 128 self.audio.play(filtered, idx).await 126 129 } ··· 280 283 async fn restore(&self, snapshot: PersistedQueue) -> Result<()> { 281 284 // Restore always goes to the audio path — that's where persistence 282 285 // lives. If the snapshot has video items they're dropped by the 283 - // SymphoniaPlayer's Restore handler. 286 + // RockboxPlayer's restore handler. 284 287 self.set_active(Active::Audio); 285 288 self.audio.restore(snapshot).await 286 289 }
+1 -1
crates/fin-player/src/persist.rs
··· 1 1 //! Background persistence of the playback queue. 2 2 //! 3 - //! The `SymphoniaPlayer` worker sends a `PersistedQueue` snapshot whenever 3 + //! The `RockboxPlayer` sends a `PersistedQueue` snapshot whenever 4 4 //! the queue mutates or every few seconds while playing. A dedicated writer 5 5 //! thread coalesces bursts (rapid consecutive mutations only produce one 6 6 //! disk write) and atomically renames a `.tmp` into place, so a crash mid-
+3 -3
crates/fin-player/src/renderer.rs
··· 148 148 } 149 149 150 150 /// Update crossfade settings (mode + duration). Only the local 151 - /// SymphoniaPlayer implements this; Chromecast + UPnP receivers each 151 + /// RockboxPlayer implements this; Chromecast + UPnP receivers each 152 152 /// manage their own track transitions. 153 153 async fn set_crossfade(&self, _settings: CrossfadeSettings) -> anyhow::Result<()> { 154 154 Ok(()) ··· 156 156 157 157 /// Enable/disable the Rockbox 10-band equalizer and load the band 158 158 /// coefficients. `bands` is truncated to `EQ_NUM_BANDS`. Only the local 159 - /// SymphoniaPlayer implements this — non-local receivers each apply 159 + /// RockboxPlayer implements this — non-local receivers each apply 160 160 /// their own EQ (or none). 161 161 async fn set_eq(&self, _enabled: bool, _bands: Vec<EqBand>) -> anyhow::Result<()> { 162 162 Ok(()) ··· 164 164 165 165 /// Bass/treble shelf gains in whole dB, plus optional cutoffs in Hz 166 166 /// (0 = Rockbox defaults 200 Hz bass, 3500 Hz treble). Only the local 167 - /// SymphoniaPlayer applies these; non-local receivers no-op. 167 + /// RockboxPlayer applies these; non-local receivers no-op. 168 168 async fn set_tone( 169 169 &self, 170 170 _bass_db: i32,
+7 -294
crates/fin-player/src/replaygain.rs
··· 1 - //! ReplayGain support — read `REPLAYGAIN_*` tags from decoded tracks. 1 + //! ReplayGain configuration types. 2 2 //! 3 3 //! Reference: <https://en.wikipedia.org/wiki/ReplayGain> 4 4 //! 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`): 15 - //! ```text 16 - //! gain_dB = base_gain_dB + preamp_dB 17 - //! linear = 10 ^ (gain_dB / 20) 18 - //! ``` 19 - //! 20 - //! If clip prevention is on and a peak is known, `linear` is reduced so 21 - //! `linear * peak <= 1.0` — this stops the ~0.5 % of tracks whose album 22 - //! gain would otherwise cause clipping. 23 - //! 24 - //! Falls back gracefully: if the requested mode's tags are missing, we try 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. 28 - 29 - use symphonia::core::formats::FormatReader; 30 - use symphonia::core::meta::StandardTagKey; 5 + //! The actual gain — reading the `REPLAYGAIN_*` tags off each track and 6 + //! applying track/album gain with a preamp and optional clip-prevention — is 7 + //! done natively by the `rockbox-playback` engine's DSP. fin only carries the 8 + //! user's settings, so this module just re-exports the config-facing types 9 + //! (defined in `fin-config` because they're pure config data) under 10 + //! `fin_player::ReplayGain*`. 31 11 32 - // Re-export the config-facing types so consumers of fin-player still see 33 - // them under `fin_player::ReplayGain*`. Definitions live in fin-config 34 - // because they're pure config data. 35 12 pub use fin_config::{ReplayGainMode, ReplayGainSettings}; 36 - 37 - /// The four ReplayGain-related metadata values a track can carry. 38 - #[derive(Debug, Clone, Copy, Default, PartialEq)] 39 - pub struct ReplayGainInfo { 40 - pub track_gain_db: Option<f32>, 41 - pub album_gain_db: Option<f32>, 42 - pub track_peak: Option<f32>, 43 - pub album_peak: Option<f32>, 44 - } 45 - 46 - impl ReplayGainInfo { 47 - /// Pull whatever RG tags are present in the current metadata revision 48 - /// of `format`. Handles both Vorbis-comment style 49 - /// (`REPLAYGAIN_TRACK_GAIN`) and ID3v2 TXXX (`replaygain_track_gain`) 50 - /// by matching case-insensitively on the raw key, then also 51 - /// promotes `StandardTagKey::ReplayGain*` if symphonia populated it. 52 - pub fn extract_from(format: &mut Box<dyn FormatReader>) -> Self { 53 - let mut info = Self::default(); 54 - 55 - // Preferred path: the current metadata revision (post-probe). 56 - if let Some(rev) = format.metadata().current() { 57 - for tag in rev.tags() { 58 - info.absorb_tag(tag); 59 - } 60 - } 61 - // Some formats (FLAC/Ogg) put the ReplayGain tags in the initial 62 - // container metadata — MP3 tends to put them in ID3v2 which lands 63 - // in the same rev queue. The single scan above covers both. 64 - info 65 - } 66 - 67 - fn absorb_tag(&mut self, tag: &symphonia::core::meta::Tag) { 68 - let raw = tag.value.to_string(); 69 - let value = raw.trim(); 70 - // Prefer the std_key if symphonia identified it. 71 - if let Some(std_key) = tag.std_key { 72 - match std_key { 73 - StandardTagKey::ReplayGainTrackGain => { 74 - self.track_gain_db = self.track_gain_db.or(parse_db(value)); 75 - } 76 - StandardTagKey::ReplayGainAlbumGain => { 77 - self.album_gain_db = self.album_gain_db.or(parse_db(value)); 78 - } 79 - StandardTagKey::ReplayGainTrackPeak => { 80 - self.track_peak = self.track_peak.or(parse_peak(value)); 81 - } 82 - StandardTagKey::ReplayGainAlbumPeak => { 83 - self.album_peak = self.album_peak.or(parse_peak(value)); 84 - } 85 - _ => {} 86 - } 87 - return; 88 - } 89 - // Fallback: match the raw key case-insensitively. 90 - let key = tag.key.to_ascii_lowercase(); 91 - match key.as_str() { 92 - "replaygain_track_gain" => { 93 - self.track_gain_db = self.track_gain_db.or(parse_db(value)); 94 - } 95 - "replaygain_album_gain" => { 96 - self.album_gain_db = self.album_gain_db.or(parse_db(value)); 97 - } 98 - "replaygain_track_peak" => { 99 - self.track_peak = self.track_peak.or(parse_peak(value)); 100 - } 101 - "replaygain_album_peak" => { 102 - self.album_peak = self.album_peak.or(parse_peak(value)); 103 - } 104 - _ => {} 105 - } 106 - } 107 - 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). 112 - pub fn linear_gain(&self, settings: ReplayGainSettings) -> f32 { 113 - if !settings.mode.is_active() { 114 - return 1.0; 115 - } 116 - // Pick gain + peak based on mode, falling back to the other scope 117 - // if the requested one is missing. 118 - let (gain_db, peak) = match settings.mode { 119 - ReplayGainMode::Album => ( 120 - self.album_gain_db.or(self.track_gain_db), 121 - self.album_peak.or(self.track_peak), 122 - ), 123 - ReplayGainMode::Track => ( 124 - self.track_gain_db.or(self.album_gain_db), 125 - self.track_peak.or(self.album_peak), 126 - ), 127 - ReplayGainMode::Off => unreachable!(), 128 - }; 129 - let Some(gain_db) = gain_db else { 130 - return 1.0; 131 - }; 132 - let total_db = gain_db + settings.preamp_db; 133 - let mut linear = db_to_linear(total_db); 134 - if settings.prevent_clip { 135 - if let Some(peak) = peak { 136 - if peak > 0.0 && linear * peak > 1.0 { 137 - linear = 1.0 / peak; 138 - } 139 - } 140 - } 141 - linear 142 - } 143 - } 144 - 145 - fn db_to_linear(db: f32) -> f32 { 146 - 10f32.powf(db / 20.0) 147 - } 148 - 149 - /// Parse an RG gain value like `"-6.5 dB"`, `"+3.14"`, `"0"`. Returns 150 - /// `None` for empty / unparseable strings. 151 - fn parse_db(s: &str) -> Option<f32> { 152 - let trimmed = s.trim(); 153 - if trimmed.is_empty() { 154 - return None; 155 - } 156 - // Strip a trailing " dB" (case-insensitive) if present. 157 - let num_part = trimmed 158 - .to_ascii_lowercase() 159 - .trim_end_matches(" db") 160 - .trim() 161 - .to_string(); 162 - num_part.parse::<f32>().ok() 163 - } 164 - 165 - /// Parse an RG peak value like `"1.0"` or `"0.988312"`. Same lenient rules 166 - /// as `parse_db` — negative or non-numeric input becomes `None`. 167 - fn parse_peak(s: &str) -> Option<f32> { 168 - let v: f32 = s.trim().parse().ok()?; 169 - if v.is_finite() && v >= 0.0 { 170 - Some(v) 171 - } else { 172 - None 173 - } 174 - } 175 - 176 - #[cfg(test)] 177 - mod tests { 178 - use super::*; 179 - 180 - #[test] 181 - fn parse_db_accepts_common_shapes() { 182 - assert_eq!(parse_db("-6.5 dB"), Some(-6.5)); 183 - assert_eq!(parse_db(" +3.14 "), Some(3.14)); 184 - assert_eq!(parse_db("0"), Some(0.0)); 185 - assert_eq!(parse_db("-6.5 DB"), Some(-6.5)); 186 - } 187 - 188 - #[test] 189 - fn parse_db_rejects_junk() { 190 - assert_eq!(parse_db(""), None); 191 - assert_eq!(parse_db("not a number"), None); 192 - } 193 - 194 - #[test] 195 - fn parse_peak_bounds() { 196 - assert_eq!(parse_peak("0.988"), Some(0.988)); 197 - assert_eq!(parse_peak("1.0"), Some(1.0)); 198 - assert_eq!(parse_peak(""), None); 199 - assert_eq!(parse_peak("-0.5"), None); 200 - assert_eq!(parse_peak("nan"), None); 201 - } 202 - 203 - #[test] 204 - fn db_to_linear_reference_values() { 205 - assert!((db_to_linear(0.0) - 1.0).abs() < 1e-6); 206 - // -6 dB ≈ 0.501187 207 - assert!((db_to_linear(-6.0) - 0.501187).abs() < 1e-4); 208 - // +6 dB ≈ 1.995262 209 - assert!((db_to_linear(6.0) - 1.995262).abs() < 1e-4); 210 - } 211 - 212 - fn info_with(track: Option<f32>, album: Option<f32>, peak: Option<f32>) -> ReplayGainInfo { 213 - ReplayGainInfo { 214 - track_gain_db: track, 215 - album_gain_db: album, 216 - track_peak: peak, 217 - album_peak: peak, 218 - } 219 - } 220 - 221 - #[test] 222 - fn linear_gain_off_is_unity() { 223 - let info = info_with(Some(-6.0), Some(-4.0), Some(0.9)); 224 - let s = ReplayGainSettings::default(); // Off 225 - assert!((info.linear_gain(s) - 1.0).abs() < 1e-6); 226 - } 227 - 228 - #[test] 229 - fn linear_gain_track_mode_uses_track_gain() { 230 - let info = info_with(Some(-6.0), Some(-3.0), None); 231 - let s = ReplayGainSettings { 232 - mode: ReplayGainMode::Track, 233 - preamp_db: 0.0, 234 - prevent_clip: false, 235 - }; 236 - // -6 dB → ~0.501 237 - assert!((info.linear_gain(s) - 0.501187).abs() < 1e-4); 238 - } 239 - 240 - #[test] 241 - fn linear_gain_falls_back_when_tag_missing() { 242 - // Album mode requested, but only track gain is tagged → use track. 243 - let info = info_with(Some(-6.0), None, None); 244 - let s = ReplayGainSettings { 245 - mode: ReplayGainMode::Album, 246 - preamp_db: 0.0, 247 - prevent_clip: false, 248 - }; 249 - assert!((info.linear_gain(s) - 0.501187).abs() < 1e-4); 250 - } 251 - 252 - #[test] 253 - fn linear_gain_returns_unity_when_all_tags_missing() { 254 - let info = ReplayGainInfo::default(); 255 - let s = ReplayGainSettings { 256 - mode: ReplayGainMode::Track, 257 - preamp_db: 0.0, 258 - prevent_clip: false, 259 - }; 260 - assert_eq!(info.linear_gain(s), 1.0); 261 - } 262 - 263 - #[test] 264 - fn preamp_is_additive_in_db_domain() { 265 - let info = info_with(Some(-6.0), None, None); 266 - let s = ReplayGainSettings { 267 - mode: ReplayGainMode::Track, 268 - preamp_db: 6.0, 269 - prevent_clip: false, 270 - }; 271 - // -6 + 6 = 0 dB → 1.0 linear 272 - assert!((info.linear_gain(s) - 1.0).abs() < 1e-6); 273 - } 274 - 275 - #[test] 276 - fn clip_prevention_caps_linear_when_peak_would_clip() { 277 - // +12 dB pushes linear well above 1.0. With peak=1.0 that clips — 278 - // prevent_clip should hold linear at 1/peak = 1.0. 279 - let info = info_with(Some(12.0), None, Some(1.0)); 280 - let s = ReplayGainSettings { 281 - mode: ReplayGainMode::Track, 282 - preamp_db: 0.0, 283 - prevent_clip: true, 284 - }; 285 - assert!((info.linear_gain(s) - 1.0).abs() < 1e-6); 286 - } 287 - 288 - #[test] 289 - fn clip_prevention_off_lets_gain_exceed_unity() { 290 - let info = info_with(Some(12.0), None, Some(1.0)); 291 - let s = ReplayGainSettings { 292 - mode: ReplayGainMode::Track, 293 - preamp_db: 0.0, 294 - prevent_clip: false, 295 - }; 296 - // +12 dB ≈ 3.981 297 - assert!((info.linear_gain(s) - 3.981).abs() < 1e-3); 298 - } 299 - }
+541
crates/fin-player/src/rockbox_player.rs
··· 1 + //! Local audio-only renderer backed by [`rockbox_playback::Player`]. 2 + //! 3 + //! The heavy lifting — queue, transport, shuffle/repeat, exact-position 4 + //! resume, ReplayGain, crossfade, the full Rockbox DSP chain (10-band EQ, 5 + //! tone controls, …) and HTTP(S) streaming — all lives in the 6 + //! `rockbox-playback` engine. This module is a thin adapter that maps fin's 7 + //! [`Renderer`] trait onto that engine. 8 + //! 9 + //! The engine keys everything off track paths / URLs, so we hand it each 10 + //! item's `stream_url` and keep a parallel `Vec<QueueItem>` alongside it. 11 + //! That parallel list is what lets fin keep showing server-provided titles 12 + //! and artwork in [`state`](Renderer::state) — metadata the engine (which 13 + //! only knows the decoded audio tags) has no way to reproduce. 14 + //! 15 + //! `Player` owns a `cpal` output stream, which makes it neither `Send` nor 16 + //! `Sync`, whereas [`Renderer`] must be both. So the `Player` lives on a 17 + //! dedicated worker thread and every operation is a `FnOnce(&Player)` closure 18 + //! sent over a channel — the same shape the old symphonia worker used. Only 19 + //! the `Sender` and a metadata mirror (both `Send + Sync`) are held in the 20 + //! struct. 21 + //! 22 + //! mpv is not involved anywhere in this path — it is reserved for video by 23 + //! the sibling `LocalRenderer` dispatcher. 24 + 25 + use std::path::PathBuf; 26 + use std::sync::mpsc; 27 + use std::thread::{self, JoinHandle}; 28 + use std::time::Duration; 29 + 30 + use anyhow::Result; 31 + use async_trait::async_trait; 32 + use parking_lot::Mutex; 33 + use tracing::error; 34 + 35 + use rockbox_playback::{ 36 + CrossfadeMode as RbCrossfadeMode, CrossfadeSettings as RbCrossfade, EqBand as RbEqBand, 37 + MixMode, PlaybackState as RbState, Player, PlayerConfig, RepeatMode as RbRepeat, 38 + ReplayGainMode as RbReplayGainMode, ToneControls, EQ_BANDS, 39 + }; 40 + 41 + use fin_config::EqBand; 42 + 43 + use crate::crossfade::{CrossfadeMode, CrossfadeSettings}; 44 + use crate::persist::{PersistedQueue, Persister}; 45 + use crate::queue::{QueueItem, RepeatMode}; 46 + use crate::renderer::{PlaybackState, PlaybackStatus, Renderer, RendererKind}; 47 + use crate::replaygain::{ReplayGainMode, ReplayGainSettings}; 48 + 49 + /// A `Send` snapshot of the engine's live status, plucked off the worker 50 + /// thread (which is the only place the `!Send` `Player` can be touched). 51 + #[derive(Clone, Copy)] 52 + struct EngineStatus { 53 + state: RbState, 54 + index: Option<usize>, 55 + position: Duration, 56 + duration: Duration, 57 + volume: f32, 58 + shuffle: bool, 59 + repeat: RbRepeat, 60 + } 61 + 62 + /// Work handed to the player thread. Every op is expressed as a closure over 63 + /// `&Player` so each `Renderer` method captures only plain `Send` data. 64 + enum Command { 65 + /// Run an operation against the engine. 66 + Op(Box<dyn FnOnce(&Player) + Send>), 67 + /// Reply with the current engine status (`None` if there is no engine). 68 + Status(mpsc::Sender<Option<EngineStatus>>), 69 + /// Join cleanly. 70 + Quit, 71 + } 72 + 73 + /// Display-only mirror of state the `rockbox-playback` engine either doesn't 74 + /// expose in fin's own types, or that we want to surface without a decode. 75 + /// The queue *order* and playhead live in the engine — this is metadata. 76 + #[derive(Default)] 77 + struct Mirror { 78 + /// The queue as fin knows it, in engine order — the source of the 79 + /// titles/artwork/`is_video`/`content_type` shown in the UI. 80 + items: Vec<QueueItem>, 81 + /// Settings echoed back on `state()` so the TUI can render badges 82 + /// without a separate query path. These mirror the last values pushed 83 + /// into the engine. 84 + replaygain: ReplayGainSettings, 85 + crossfade: CrossfadeSettings, 86 + eq_enabled: bool, 87 + eq_band_count: usize, 88 + bass_db: i32, 89 + treble_db: i32, 90 + } 91 + 92 + /// A local, audio-only renderer that drives a `rockbox-playback` engine on a 93 + /// dedicated worker thread. 94 + pub struct RockboxPlayer { 95 + cmd_tx: mpsc::Sender<Command>, 96 + mirror: Mutex<Mirror>, 97 + /// Background writer for the metadata sidecar (fin's `queue.json`). The 98 + /// engine persists the queue + exact position itself via its resume 99 + /// file; this only records the display metadata the engine can't. 100 + persister: Option<Persister>, 101 + /// Kept so the worker is joined on `Drop`. 102 + worker: Mutex<Option<JoinHandle<()>>>, 103 + } 104 + 105 + impl RockboxPlayer { 106 + pub fn new() -> Self { 107 + Self::with_persist(None) 108 + } 109 + 110 + /// Build a player that persists across restarts. `queue_path` is fin's 111 + /// metadata sidecar (`queue.json`); the engine's own resume file — which 112 + /// holds the queue order and the exact playhead — lives next to it. 113 + pub fn with_persist(queue_path: Option<PathBuf>) -> Self { 114 + let resume_file = queue_path 115 + .as_ref() 116 + .map(|p| p.with_file_name("rockbox-resume.m3u8")); 117 + 118 + let (cmd_tx, cmd_rx) = mpsc::channel::<Command>(); 119 + let worker = thread::Builder::new() 120 + .name("fin-rockbox".into()) 121 + .spawn(move || run_worker(cmd_rx, resume_file)) 122 + .expect("spawn rockbox-playback worker thread"); 123 + 124 + Self { 125 + cmd_tx, 126 + mirror: Mutex::new(Mirror::default()), 127 + persister: queue_path.map(Persister::spawn), 128 + worker: Mutex::new(Some(worker)), 129 + } 130 + } 131 + 132 + /// Queue an operation to run against the engine on the worker thread. 133 + fn exec<F: FnOnce(&Player) + Send + 'static>(&self, f: F) { 134 + let _ = self.cmd_tx.send(Command::Op(Box::new(f))); 135 + } 136 + 137 + /// Block briefly for a fresh engine-status snapshot. The worker is 138 + /// almost always idle on its channel, so this round-trips immediately. 139 + fn engine_status(&self) -> Option<EngineStatus> { 140 + let (tx, rx) = mpsc::channel(); 141 + self.cmd_tx.send(Command::Status(tx)).ok()?; 142 + rx.recv().ok().flatten() 143 + } 144 + 145 + /// Push a snapshot of the display metadata to the sidecar writer. Cheap 146 + /// and debounced downstream, so it's fine to call on every mutation. 147 + fn persist(&self) { 148 + let Some(persister) = &self.persister else { 149 + return; 150 + }; 151 + let (index, position_secs, shuffle, repeat) = self 152 + .engine_status() 153 + .map(|s| { 154 + ( 155 + s.index, 156 + s.position.as_secs_f64(), 157 + s.shuffle, 158 + from_rb_repeat(s.repeat), 159 + ) 160 + }) 161 + .unwrap_or((None, 0.0, false, RepeatMode::Off)); 162 + persister.queue_write(PersistedQueue { 163 + items: self.mirror.lock().items.clone(), 164 + current_index: index, 165 + shuffle, 166 + repeat, 167 + position_secs, 168 + }); 169 + } 170 + } 171 + 172 + impl Default for RockboxPlayer { 173 + fn default() -> Self { 174 + Self::new() 175 + } 176 + } 177 + 178 + impl Drop for RockboxPlayer { 179 + fn drop(&mut self) { 180 + let _ = self.cmd_tx.send(Command::Quit); 181 + if let Some(h) = self.worker.lock().take() { 182 + let _ = h.join(); 183 + } 184 + } 185 + } 186 + 187 + /// The player thread: owns the `!Send` `Player` and services commands. 188 + fn run_worker(rx: mpsc::Receiver<Command>, resume_file: Option<PathBuf>) { 189 + let config = PlayerConfig { 190 + resume_file, 191 + ..PlayerConfig::default() 192 + }; 193 + let player = match Player::with_config(config) { 194 + Ok(p) => Some(p), 195 + Err(e) => { 196 + error!(error = ?e, "could not open audio output — local playback disabled"); 197 + None 198 + } 199 + }; 200 + 201 + for cmd in rx { 202 + match cmd { 203 + Command::Op(f) => { 204 + if let Some(p) = &player { 205 + f(p); 206 + } 207 + } 208 + Command::Status(reply) => { 209 + let snapshot = player.as_ref().map(|p| { 210 + let st = p.status(); 211 + EngineStatus { 212 + state: st.state, 213 + index: st.index, 214 + position: st.position, 215 + duration: st.duration, 216 + volume: p.volume(), 217 + shuffle: st.shuffle, 218 + repeat: st.repeat, 219 + } 220 + }); 221 + let _ = reply.send(snapshot); 222 + } 223 + Command::Quit => break, 224 + } 225 + } 226 + } 227 + 228 + /// Collect the engine-facing track list (stream URLs) from fin queue items. 229 + fn urls(items: &[QueueItem]) -> Vec<String> { 230 + items.iter().map(|i| i.stream_url.clone()).collect() 231 + } 232 + 233 + fn to_rb_repeat(mode: RepeatMode) -> RbRepeat { 234 + match mode { 235 + RepeatMode::Off => RbRepeat::Off, 236 + RepeatMode::One => RbRepeat::One, 237 + RepeatMode::All => RbRepeat::All, 238 + } 239 + } 240 + 241 + fn from_rb_repeat(mode: RbRepeat) -> RepeatMode { 242 + match mode { 243 + RbRepeat::Off => RepeatMode::Off, 244 + RbRepeat::One => RepeatMode::One, 245 + RbRepeat::All => RepeatMode::All, 246 + } 247 + } 248 + 249 + fn to_rb_replaygain_mode(mode: ReplayGainMode) -> RbReplayGainMode { 250 + match mode { 251 + ReplayGainMode::Off => RbReplayGainMode::Off, 252 + ReplayGainMode::Track => RbReplayGainMode::Track, 253 + ReplayGainMode::Album => RbReplayGainMode::Album, 254 + } 255 + } 256 + 257 + /// Map fin's simplified crossfade config onto the engine's fuller model. 258 + /// fin exposes just off / crossfade / mixed with a single symmetric 259 + /// duration; that becomes an "always" crossfade with equal fade-in/out 260 + /// ramps and no lead-in/out delay. 261 + fn to_rb_crossfade(settings: CrossfadeSettings) -> RbCrossfade { 262 + let dur = Duration::from_secs_f32(settings.duration_secs.max(0.0)); 263 + match settings.mode { 264 + CrossfadeMode::Off => RbCrossfade { 265 + mode: RbCrossfadeMode::Off, 266 + ..RbCrossfade::default() 267 + }, 268 + CrossfadeMode::Crossfade => RbCrossfade { 269 + mode: RbCrossfadeMode::Always, 270 + fade_out_duration: dur, 271 + fade_in_duration: dur, 272 + mix_mode: MixMode::Crossfade, 273 + ..RbCrossfade::default() 274 + }, 275 + CrossfadeMode::Mixed => RbCrossfade { 276 + mode: RbCrossfadeMode::Always, 277 + fade_out_duration: dur, 278 + fade_in_duration: dur, 279 + mix_mode: MixMode::Mix, 280 + ..RbCrossfade::default() 281 + }, 282 + } 283 + } 284 + 285 + /// fin `EqBand` stores Q and gain scaled ×10 (integer tenths); the engine 286 + /// wants real units. 287 + fn to_rb_eq_band(band: &EqBand) -> RbEqBand { 288 + RbEqBand { 289 + cutoff_hz: band.cutoff, 290 + q: band.q as f32 / 10.0, 291 + gain_db: band.gain as f32 / 10.0, 292 + } 293 + } 294 + 295 + #[async_trait] 296 + impl Renderer for RockboxPlayer { 297 + fn kind(&self) -> RendererKind { 298 + RendererKind::Mpv 299 + } 300 + 301 + async fn play(&self, items: Vec<QueueItem>, start_index: usize) -> Result<()> { 302 + let track_urls = urls(&items); 303 + let len = items.len(); 304 + self.mirror.lock().items = items; 305 + self.exec(move |p| { 306 + p.set_queue(track_urls); 307 + if start_index > 0 && start_index < len { 308 + p.skip_to(start_index); 309 + } 310 + p.play(); 311 + }); 312 + self.persist(); 313 + Ok(()) 314 + } 315 + 316 + async fn enqueue(&self, items: Vec<QueueItem>) -> Result<()> { 317 + let track_urls = urls(&items); 318 + self.mirror.lock().items.extend(items); 319 + self.exec(move |p| p.insert_tracks_last(track_urls)); 320 + self.persist(); 321 + Ok(()) 322 + } 323 + 324 + async fn play_next(&self, items: Vec<QueueItem>) -> Result<()> { 325 + let track_urls = urls(&items); 326 + // Mirror the engine's placement: right after the current track. 327 + let at = self 328 + .engine_status() 329 + .and_then(|s| s.index) 330 + .map(|i| i + 1) 331 + .unwrap_or(0); 332 + { 333 + let mut mirror = self.mirror.lock(); 334 + let at = at.min(mirror.items.len()); 335 + for (offset, item) in items.into_iter().enumerate() { 336 + let pos = (at + offset).min(mirror.items.len()); 337 + mirror.items.insert(pos, item); 338 + } 339 + } 340 + self.exec(move |p| p.insert_tracks_next(track_urls)); 341 + self.persist(); 342 + Ok(()) 343 + } 344 + 345 + async fn pause(&self) -> Result<()> { 346 + self.exec(|p| p.pause()); 347 + Ok(()) 348 + } 349 + 350 + async fn resume(&self) -> Result<()> { 351 + self.exec(|p| p.play()); 352 + Ok(()) 353 + } 354 + 355 + async fn stop(&self) -> Result<()> { 356 + self.exec(|p| p.stop()); 357 + Ok(()) 358 + } 359 + 360 + async fn next(&self) -> Result<()> { 361 + self.exec(|p| p.next()); 362 + Ok(()) 363 + } 364 + 365 + async fn previous(&self) -> Result<()> { 366 + self.exec(|p| p.previous()); 367 + Ok(()) 368 + } 369 + 370 + async fn seek(&self, position_secs: f64) -> Result<()> { 371 + let pos = Duration::from_secs_f64(position_secs.max(0.0)); 372 + self.exec(move |p| p.seek(pos)); 373 + Ok(()) 374 + } 375 + 376 + async fn set_volume(&self, volume: f32) -> Result<()> { 377 + let v = volume.clamp(0.0, 1.0); 378 + self.exec(move |p| p.set_volume(v)); 379 + Ok(()) 380 + } 381 + 382 + async fn set_shuffle(&self, on: bool) -> Result<()> { 383 + self.exec(move |p| p.set_shuffle(on)); 384 + self.persist(); 385 + Ok(()) 386 + } 387 + 388 + async fn set_repeat(&self, mode: RepeatMode) -> Result<()> { 389 + let rb = to_rb_repeat(mode); 390 + self.exec(move |p| p.set_repeat(rb)); 391 + self.persist(); 392 + Ok(()) 393 + } 394 + 395 + async fn restore(&self, snapshot: PersistedQueue) -> Result<()> { 396 + let shuffle = snapshot.shuffle; 397 + let repeat = to_rb_repeat(snapshot.repeat); 398 + let seed_urls = urls(&snapshot.items); 399 + let seed_index = snapshot.current_index; 400 + self.mirror.lock().items = snapshot.items; 401 + 402 + self.exec(move |p| { 403 + // The engine restores the queue order + exact playhead from its 404 + // own resume file; we only need to reinstate shuffle/repeat. 405 + p.set_shuffle(shuffle); 406 + p.set_repeat(repeat); 407 + 408 + // `resume()` cues the engine's saved queue + position without 409 + // playing. If it has nothing (a fresh install, or a queue.json 410 + // left by an older fin with no engine resume file yet), fall back 411 + // to seeding the engine from the sidecar — the exact position is 412 + // lost on that one migration, but the queue is preserved. 413 + if p.resume().is_none() && !seed_urls.is_empty() { 414 + p.set_queue(seed_urls); 415 + if let Some(i) = seed_index { 416 + if i > 0 { 417 + p.skip_to(i); 418 + } 419 + } 420 + } 421 + }); 422 + Ok(()) 423 + } 424 + 425 + async fn remove_from_queue(&self, index: usize) -> Result<()> { 426 + { 427 + let mut mirror = self.mirror.lock(); 428 + if index < mirror.items.len() { 429 + mirror.items.remove(index); 430 + } 431 + } 432 + self.exec(move |p| p.remove(index)); 433 + self.persist(); 434 + Ok(()) 435 + } 436 + 437 + async fn set_replaygain(&self, settings: ReplayGainSettings) -> Result<()> { 438 + let mode = to_rb_replaygain_mode(settings.mode); 439 + let preamp = settings.preamp_db; 440 + let clip = settings.prevent_clip; 441 + self.exec(move |p| p.set_replaygain(mode, preamp, clip)); 442 + self.mirror.lock().replaygain = settings; 443 + Ok(()) 444 + } 445 + 446 + async fn set_crossfade(&self, settings: CrossfadeSettings) -> Result<()> { 447 + let rb = to_rb_crossfade(settings); 448 + self.exec(move |p| p.set_crossfade(rb)); 449 + self.mirror.lock().crossfade = settings; 450 + Ok(()) 451 + } 452 + 453 + async fn set_eq(&self, enabled: bool, bands: Vec<EqBand>) -> Result<()> { 454 + let rb_bands: Vec<RbEqBand> = bands.iter().take(EQ_BANDS).map(to_rb_eq_band).collect(); 455 + self.exec(move |p| { 456 + p.set_eq_enabled(enabled); 457 + for (i, band) in rb_bands.into_iter().enumerate() { 458 + p.set_eq_band(i, band); 459 + } 460 + }); 461 + let mut mirror = self.mirror.lock(); 462 + mirror.eq_enabled = enabled; 463 + mirror.eq_band_count = bands.len().min(EQ_BANDS); 464 + Ok(()) 465 + } 466 + 467 + async fn set_tone( 468 + &self, 469 + bass_db: i32, 470 + treble_db: i32, 471 + bass_cutoff_hz: i32, 472 + treble_cutoff_hz: i32, 473 + ) -> Result<()> { 474 + self.exec(move |p| { 475 + p.set_tone(ToneControls { 476 + bass_db, 477 + treble_db, 478 + bass_cutoff_hz, 479 + treble_cutoff_hz, 480 + }); 481 + }); 482 + let mut mirror = self.mirror.lock(); 483 + mirror.bass_db = bass_db; 484 + mirror.treble_db = treble_db; 485 + Ok(()) 486 + } 487 + 488 + fn state(&self) -> PlaybackState { 489 + let mirror = self.mirror.lock(); 490 + let base = PlaybackState { 491 + replaygain: mirror.replaygain, 492 + crossfade: mirror.crossfade, 493 + eq_enabled: mirror.eq_enabled, 494 + eq_band_count: mirror.eq_band_count, 495 + bass_db: mirror.bass_db, 496 + treble_db: mirror.treble_db, 497 + queue: mirror.items.clone(), 498 + ..PlaybackState::default() 499 + }; 500 + 501 + let Some(st) = self.engine_status() else { 502 + return base; 503 + }; 504 + 505 + let current_index = st.index.filter(|&i| i < mirror.items.len()); 506 + let now_playing = current_index.and_then(|i| mirror.items.get(i).cloned()); 507 + 508 + // The engine's duration is authoritative once the track is decoded; 509 + // before that, fall back to the server-provided length so the UI has 510 + // a sensible total to show. 511 + let engine_dur = st.duration.as_secs_f64(); 512 + let duration_secs = if engine_dur > 0.0 { 513 + engine_dur 514 + } else { 515 + now_playing 516 + .as_ref() 517 + .and_then(|i| i.duration_secs) 518 + .map(|d| d as f64) 519 + .unwrap_or(0.0) 520 + }; 521 + 522 + let status = match st.state { 523 + RbState::Playing => PlaybackStatus::Playing, 524 + RbState::Paused => PlaybackStatus::Paused, 525 + RbState::Stopped if mirror.items.is_empty() => PlaybackStatus::Idle, 526 + RbState::Stopped => PlaybackStatus::Stopped, 527 + }; 528 + 529 + PlaybackState { 530 + status, 531 + position_secs: st.position.as_secs_f64(), 532 + duration_secs, 533 + volume: st.volume, 534 + now_playing, 535 + current_index, 536 + shuffle: st.shuffle, 537 + repeat: from_rb_repeat(st.repeat), 538 + ..base 539 + } 540 + } 541 + }
-2215
crates/fin-player/src/symphonia_player.rs
··· 1 - //! Local audio-only renderer backed by symphonia + cpal. 2 - //! 3 - //! The stack is deliberately minimal: reqwest streams the HTTP body into an 4 - //! in-memory buffer, symphonia probes the container and decodes packets, and 5 - //! cpal owns the OS audio output. mpv is not involved anywhere in this path — 6 - //! it is reserved for video by the sibling `LocalRenderer` dispatcher. 7 - 8 - use std::io::{self, Read, Seek, SeekFrom}; 9 - use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; 10 - use std::sync::{mpsc, Arc}; 11 - use std::thread::{self, JoinHandle}; 12 - use std::time::{Duration, Instant}; 13 - 14 - use anyhow::{anyhow, Context, Result}; 15 - use async_trait::async_trait; 16 - use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; 17 - use cpal::SampleFormat; 18 - use parking_lot::{Condvar, Mutex}; 19 - use rb::{Producer, RbConsumer, RbInspector, RbProducer, SpscRb, RB}; 20 - use symphonia::core::audio::SampleBuffer; 21 - use symphonia::core::codecs::{Decoder, DecoderOptions, CODEC_TYPE_NULL}; 22 - use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo}; 23 - use symphonia::core::io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions}; 24 - use symphonia::core::meta::MetadataOptions; 25 - use symphonia::core::probe::Hint; 26 - use symphonia::core::units::{Time, TimeBase}; 27 - use tracing::{debug, error, warn}; 28 - 29 - use fin_config::EqBand; 30 - use rockbox_dsp::{ 31 - eq_band_setting, Dsp, EQ_NUM_BANDS, REPLAYGAIN_ALBUM, REPLAYGAIN_OFF, REPLAYGAIN_TRACK, 32 - }; 33 - 34 - use crate::voice_dsp::VoiceDsp; 35 - 36 - /// Uniform "process interleaved stereo i16 → interleaved stereo i16" 37 - /// interface so `decode_one_packet` can accept either the process-wide 38 - /// audio DSP or the crossfade-only voice DSP without duplicating logic. 39 - trait DspProcess { 40 - fn process_stereo(&mut self, input: &[i16], out: &mut Vec<i16>); 41 - } 42 - 43 - impl DspProcess for Dsp { 44 - fn process_stereo(&mut self, input: &[i16], out: &mut Vec<i16>) { 45 - self.process(input, out); 46 - } 47 - } 48 - 49 - impl DspProcess for VoiceDsp { 50 - fn process_stereo(&mut self, input: &[i16], out: &mut Vec<i16>) { 51 - self.process(input, out); 52 - } 53 - } 54 - 55 - use crate::crossfade::{fade_at, CrossfadeMode, CrossfadeSettings}; 56 - use crate::persist::{PersistedQueue, Persister}; 57 - use crate::queue::{PlaybackQueue, QueueItem}; 58 - use crate::renderer::{PlaybackState, PlaybackStatus, Renderer, RendererKind}; 59 - use crate::replaygain::{ReplayGainInfo, ReplayGainMode, ReplayGainSettings}; 60 - 61 - /// A local, audio-only renderer. Streams the HTTP body, decodes with symphonia, 62 - /// and pushes float samples to the default cpal output device. 63 - /// 64 - /// The renderer is fire-and-forget from the async side: every method sends a 65 - /// command on a `std::sync::mpsc` channel to a dedicated worker thread that 66 - /// owns the cpal stream and the symphonia decoder. 67 - pub struct SymphoniaPlayer { 68 - queue: PlaybackQueue, 69 - state: Arc<Mutex<PlaybackState>>, 70 - cmd_tx: mpsc::Sender<PlayerCommand>, 71 - // Kept so the worker is joined on Drop. 72 - worker: Mutex<Option<JoinHandle<()>>>, 73 - } 74 - 75 - enum PlayerCommand { 76 - Play { 77 - items: Vec<QueueItem>, 78 - start_index: usize, 79 - }, 80 - Enqueue(Vec<QueueItem>), 81 - PlayNext(Vec<QueueItem>), 82 - Pause, 83 - Resume, 84 - Stop, 85 - Next, 86 - Previous, 87 - Seek(f64), 88 - SetVolume(f32), 89 - SetShuffle(bool), 90 - SetRepeat(crate::queue::RepeatMode), 91 - /// Populate queue + shuffle/repeat + pending seek from an on-disk 92 - /// snapshot. Doesn't start playback — the next Resume/Play does. 93 - Restore(PersistedQueue), 94 - RemoveAt(usize), 95 - SetReplayGain(ReplayGainSettings), 96 - SetCrossfade(CrossfadeSettings), 97 - SetEq { 98 - enabled: bool, 99 - bands: Vec<EqBand>, 100 - }, 101 - SetTone { 102 - bass_db: i32, 103 - treble_db: i32, 104 - bass_cutoff_hz: i32, 105 - treble_cutoff_hz: i32, 106 - }, 107 - Quit, 108 - } 109 - 110 - impl SymphoniaPlayer { 111 - pub fn new() -> Self { 112 - Self::with_persist(None) 113 - } 114 - 115 - /// Same as `new`, but with a queue-persistence path — the worker will 116 - /// write a snapshot of the current queue + shuffle/repeat/position to 117 - /// `path` on every relevant event. 118 - pub fn with_persist(persist_path: Option<std::path::PathBuf>) -> Self { 119 - let queue = PlaybackQueue::new(); 120 - let state = Arc::new(Mutex::new(PlaybackState::default())); 121 - let (cmd_tx, cmd_rx) = mpsc::channel::<PlayerCommand>(); 122 - let persister = persist_path.map(Persister::spawn); 123 - 124 - let worker_state = state.clone(); 125 - let worker_queue = queue.clone(); 126 - let worker_persister = persister.clone(); 127 - let worker = thread::Builder::new() 128 - .name("fin-symphonia".into()) 129 - .spawn(move || { 130 - if let Err(e) = run_worker(cmd_rx, worker_state, worker_queue, worker_persister) { 131 - error!(error = ?e, "symphonia worker exited"); 132 - } 133 - }) 134 - .expect("spawn symphonia worker thread"); 135 - 136 - Self { 137 - queue, 138 - state, 139 - cmd_tx, 140 - worker: Mutex::new(Some(worker)), 141 - } 142 - } 143 - 144 - pub fn queue_handle(&self) -> PlaybackQueue { 145 - self.queue.clone() 146 - } 147 - 148 - fn send(&self, cmd: PlayerCommand) -> Result<()> { 149 - self.cmd_tx 150 - .send(cmd) 151 - .map_err(|_| anyhow!("symphonia worker channel closed")) 152 - } 153 - } 154 - 155 - impl Default for SymphoniaPlayer { 156 - fn default() -> Self { 157 - Self::new() 158 - } 159 - } 160 - 161 - #[async_trait] 162 - impl Renderer for SymphoniaPlayer { 163 - fn kind(&self) -> RendererKind { 164 - RendererKind::Mpv 165 - } 166 - 167 - async fn play(&self, items: Vec<QueueItem>, start_index: usize) -> Result<()> { 168 - self.send(PlayerCommand::Play { items, start_index }) 169 - } 170 - 171 - async fn enqueue(&self, items: Vec<QueueItem>) -> Result<()> { 172 - self.send(PlayerCommand::Enqueue(items)) 173 - } 174 - 175 - async fn play_next(&self, items: Vec<QueueItem>) -> Result<()> { 176 - self.send(PlayerCommand::PlayNext(items)) 177 - } 178 - 179 - async fn pause(&self) -> Result<()> { 180 - self.send(PlayerCommand::Pause) 181 - } 182 - 183 - async fn resume(&self) -> Result<()> { 184 - self.send(PlayerCommand::Resume) 185 - } 186 - 187 - async fn stop(&self) -> Result<()> { 188 - self.send(PlayerCommand::Stop) 189 - } 190 - 191 - async fn next(&self) -> Result<()> { 192 - self.send(PlayerCommand::Next) 193 - } 194 - 195 - async fn previous(&self) -> Result<()> { 196 - self.send(PlayerCommand::Previous) 197 - } 198 - 199 - async fn seek(&self, position_secs: f64) -> Result<()> { 200 - self.send(PlayerCommand::Seek(position_secs)) 201 - } 202 - 203 - async fn set_volume(&self, volume: f32) -> Result<()> { 204 - self.send(PlayerCommand::SetVolume(volume)) 205 - } 206 - 207 - async fn set_shuffle(&self, on: bool) -> Result<()> { 208 - self.send(PlayerCommand::SetShuffle(on)) 209 - } 210 - 211 - async fn set_repeat(&self, mode: crate::queue::RepeatMode) -> Result<()> { 212 - self.send(PlayerCommand::SetRepeat(mode)) 213 - } 214 - 215 - async fn restore(&self, snapshot: PersistedQueue) -> Result<()> { 216 - self.send(PlayerCommand::Restore(snapshot)) 217 - } 218 - 219 - async fn remove_from_queue(&self, index: usize) -> Result<()> { 220 - self.send(PlayerCommand::RemoveAt(index)) 221 - } 222 - 223 - async fn set_replaygain(&self, settings: ReplayGainSettings) -> Result<()> { 224 - self.send(PlayerCommand::SetReplayGain(settings)) 225 - } 226 - 227 - async fn set_crossfade(&self, settings: CrossfadeSettings) -> Result<()> { 228 - self.send(PlayerCommand::SetCrossfade(settings)) 229 - } 230 - 231 - async fn set_eq(&self, enabled: bool, bands: Vec<EqBand>) -> Result<()> { 232 - self.send(PlayerCommand::SetEq { enabled, bands }) 233 - } 234 - 235 - async fn set_tone( 236 - &self, 237 - bass_db: i32, 238 - treble_db: i32, 239 - bass_cutoff_hz: i32, 240 - treble_cutoff_hz: i32, 241 - ) -> Result<()> { 242 - self.send(PlayerCommand::SetTone { 243 - bass_db, 244 - treble_db, 245 - bass_cutoff_hz, 246 - treble_cutoff_hz, 247 - }) 248 - } 249 - 250 - fn state(&self) -> PlaybackState { 251 - self.state.lock().clone() 252 - } 253 - } 254 - 255 - impl Drop for SymphoniaPlayer { 256 - fn drop(&mut self) { 257 - let _ = self.cmd_tx.send(PlayerCommand::Quit); 258 - if let Some(h) = self.worker.lock().take() { 259 - let _ = h.join(); 260 - } 261 - } 262 - } 263 - 264 - // --------------------------------------------------------------------------- 265 - // Streaming HTTP media source 266 - // --------------------------------------------------------------------------- 267 - 268 - /// Progressive, in-memory buffer of an HTTP response. Reads block until the 269 - /// backing fetcher thread has enough bytes; if the fetcher has finished the 270 - /// buffer behaves like `Cursor<&[u8]>`. 271 - struct StreamingSource { 272 - buf: Arc<Mutex<SharedBuf>>, 273 - ready: Arc<Condvar>, 274 - pos: u64, 275 - } 276 - 277 - struct SharedBuf { 278 - data: Vec<u8>, 279 - done: bool, 280 - error: Option<String>, 281 - total: Option<u64>, 282 - /// Set by the worker thread to signal the fetcher to bail out — e.g. the 283 - /// user pressed Stop or moved to the next track before we're done. 284 - cancelled: bool, 285 - } 286 - 287 - impl Read for StreamingSource { 288 - fn read(&mut self, out: &mut [u8]) -> io::Result<usize> { 289 - if out.is_empty() { 290 - return Ok(0); 291 - } 292 - let mut guard = self.buf.lock(); 293 - loop { 294 - let len = guard.data.len() as u64; 295 - if self.pos < len { 296 - let start = self.pos as usize; 297 - let n = std::cmp::min(out.len(), guard.data.len() - start); 298 - out[..n].copy_from_slice(&guard.data[start..start + n]); 299 - self.pos += n as u64; 300 - return Ok(n); 301 - } 302 - if guard.done { 303 - return Ok(0); 304 - } 305 - if let Some(err) = guard.error.clone() { 306 - return Err(io::Error::new(io::ErrorKind::Other, err)); 307 - } 308 - if guard.cancelled { 309 - return Err(io::Error::new(io::ErrorKind::Interrupted, "cancelled")); 310 - } 311 - self.ready.wait(&mut guard); 312 - } 313 - } 314 - } 315 - 316 - impl Seek for StreamingSource { 317 - fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> { 318 - let target = match pos { 319 - SeekFrom::Start(n) => n as i64, 320 - SeekFrom::Current(n) => self.pos as i64 + n, 321 - SeekFrom::End(n) => { 322 - let guard = self.buf.lock(); 323 - let len = if let Some(t) = guard.total { 324 - t 325 - } else if guard.done { 326 - guard.data.len() as u64 327 - } else { 328 - return Err(io::Error::new( 329 - io::ErrorKind::Unsupported, 330 - "seek from end on live stream with unknown length", 331 - )); 332 - }; 333 - len as i64 + n 334 - } 335 - }; 336 - if target < 0 { 337 - return Err(io::Error::new( 338 - io::ErrorKind::InvalidInput, 339 - "seek before start", 340 - )); 341 - } 342 - self.pos = target as u64; 343 - Ok(self.pos) 344 - } 345 - } 346 - 347 - impl MediaSource for StreamingSource { 348 - fn is_seekable(&self) -> bool { 349 - false 350 - } 351 - 352 - fn byte_len(&self) -> Option<u64> { 353 - self.buf.lock().total 354 - } 355 - } 356 - 357 - /// Spawn a background thread that downloads the URL body into a shared 358 - /// buffer. The returned `StreamingSource` can be handed to symphonia 359 - /// straight away — reads will block until enough data is available. 360 - fn spawn_streaming_source(url: &str) -> Result<StreamingSource> { 361 - let buf = Arc::new(Mutex::new(SharedBuf { 362 - data: Vec::new(), 363 - done: false, 364 - error: None, 365 - total: None, 366 - cancelled: false, 367 - })); 368 - let ready = Arc::new(Condvar::new()); 369 - 370 - let buf_c = buf.clone(); 371 - let ready_c = ready.clone(); 372 - let url = url.to_string(); 373 - 374 - thread::Builder::new() 375 - .name("fin-fetch".into()) 376 - .spawn(move || { 377 - let client = match reqwest::blocking::Client::builder() 378 - .connect_timeout(Duration::from_secs(10)) 379 - .build() 380 - { 381 - Ok(c) => c, 382 - Err(e) => { 383 - let mut g = buf_c.lock(); 384 - g.error = Some(format!("http client: {e}")); 385 - g.done = true; 386 - ready_c.notify_all(); 387 - return; 388 - } 389 - }; 390 - let mut resp = match client.get(&url).send().and_then(|r| r.error_for_status()) { 391 - Ok(r) => r, 392 - Err(e) => { 393 - let mut g = buf_c.lock(); 394 - g.error = Some(format!("fetch: {e}")); 395 - g.done = true; 396 - ready_c.notify_all(); 397 - return; 398 - } 399 - }; 400 - if let Some(len) = resp.content_length() { 401 - buf_c.lock().total = Some(len); 402 - } 403 - let mut chunk = vec![0u8; 32 * 1024]; 404 - loop { 405 - match resp.read(&mut chunk) { 406 - Ok(0) => { 407 - let mut g = buf_c.lock(); 408 - g.done = true; 409 - ready_c.notify_all(); 410 - return; 411 - } 412 - Ok(n) => { 413 - let mut g = buf_c.lock(); 414 - if g.cancelled { 415 - g.done = true; 416 - ready_c.notify_all(); 417 - return; 418 - } 419 - g.data.extend_from_slice(&chunk[..n]); 420 - ready_c.notify_all(); 421 - } 422 - Err(e) => { 423 - let mut g = buf_c.lock(); 424 - g.error = Some(format!("read: {e}")); 425 - g.done = true; 426 - ready_c.notify_all(); 427 - return; 428 - } 429 - } 430 - } 431 - }) 432 - .context("spawn HTTP fetch thread")?; 433 - 434 - Ok(StreamingSource { buf, ready, pos: 0 }) 435 - } 436 - 437 - // --------------------------------------------------------------------------- 438 - // Worker thread — owns cpal stream + symphonia decoder 439 - // --------------------------------------------------------------------------- 440 - 441 - /// Everything the worker keeps alive while a track is playing. 442 - struct Track { 443 - format: Box<dyn FormatReader>, 444 - decoder: Box<dyn Decoder>, 445 - track_id: u32, 446 - // Source (decoded) audio spec — what symphonia hands us. `source_channels` 447 - // lives on the Resampler; only `source_sr` is used for duration reporting. 448 - source_sr: u32, 449 - // Output (cpal) audio spec — what the OS device consumes. 450 - output_sr: u32, 451 - output_channels: usize, 452 - time_base: Option<TimeBase>, 453 - total_frames: Option<u64>, 454 - // OUTPUT frames pushed to the ring so far — the ring holds output-rate 455 - // samples, so this is what "position" is computed against. 456 - produced_output_frames: u64, 457 - // Backing samples buffer we reuse packet-to-packet. 458 - sample_buf: Option<SampleBuffer<f32>>, 459 - // Resamples + channel-converts source → output. 460 - resampler: Resampler, 461 - // Producer half of the ring buffer feeding the cpal callback. 462 - producer: Producer<f32>, 463 - // Kept so we can query occupancy (`Producer` alone can't). 464 - ring: Arc<SpscRb<f32>>, 465 - // Cancellation flag for the fetcher — flipped when we drop the track early. 466 - fetch_cancel: Arc<Mutex<SharedBuf>>, 467 - // Kept alive alongside the decoder — dropping this stops OS output. 468 - _stream: cpal::Stream, 469 - // Item metadata for state reporting. 470 - item: QueueItem, 471 - // Latest volume applied to the samples we push. 472 - volume: Arc<AtomicU32>, 473 - paused: Arc<AtomicBool>, 474 - // Instant we started the current track — used only as a tie-breaker in logs. 475 - _started: Instant, 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`). 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. 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, 489 - // True once the decoder has returned EndOfStream (no more packets). 490 - // Used by the crossfade path — we can't advance the queue on EOF 491 - // anymore because that's the promotion step's job. 492 - ended: bool, 493 - // Set when this track is the "incoming" side of a crossfade. The push 494 - // loop scales samples by fade_in(progress); progress is measured off 495 - // this track's own produced_output_frames counter (which starts at 0). 496 - // `Some(OverlapContext)` means "I'm fading in". None means "full gain". 497 - overlap_incoming: Option<OverlapContext>, 498 - // Same for the outgoing side. `start_frame` records 499 - // `produced_output_frames` at overlap start so we can compute 500 - // progress = (produced - start) / length. 501 - overlap_outgoing: Option<OverlapContext>, 502 - } 503 - 504 - /// Per-frame fade envelope parameters. Baked in `push_samples_with_volume`. 505 - #[derive(Debug, Clone, Copy)] 506 - struct OverlapContext { 507 - mode: CrossfadeMode, 508 - length_frames: u64, 509 - start_frame: u64, 510 - } 511 - 512 - impl Track { 513 - /// Output frames currently sitting in the ring, waiting to be played out. 514 - fn ring_pending_frames(&self) -> usize { 515 - let used = self.ring.count(); 516 - used / self.output_channels.max(1) 517 - } 518 - 519 - fn ring_free_slots(&self) -> usize { 520 - self.ring.slots_free() 521 - } 522 - 523 - fn position_secs(&self) -> f64 { 524 - // Playhead: output frames consumed by cpal / output_sr = wall-clock seconds. 525 - let played = self 526 - .produced_output_frames 527 - .saturating_sub(self.ring_pending_frames() as u64); 528 - played as f64 / self.output_sr as f64 529 - } 530 - 531 - fn duration_secs(&self) -> f64 { 532 - if let (Some(tb), Some(n)) = (self.time_base, self.total_frames) { 533 - let t = tb.calc_time(n); 534 - t.seconds as f64 + t.frac 535 - } else if let Some(n) = self.total_frames { 536 - n as f64 / self.source_sr as f64 537 - } else { 538 - 0.0 539 - } 540 - } 541 - } 542 - 543 - /// Linear-interpolating resampler + channel converter. Stateful so it can 544 - /// stitch samples across packet boundaries without clicks. 545 - struct Resampler { 546 - src_sr: u32, 547 - dst_sr: u32, 548 - src_ch: usize, 549 - dst_ch: usize, 550 - /// Fractional source-frame position of the next output frame we owe, 551 - /// relative to the START of the next input packet. 552 - src_pos_frac: f64, 553 - /// Last channel-converted source frame — kept so the first output frame 554 - /// of a new packet can interpolate against it. 555 - last_frame: Vec<f32>, 556 - have_last: bool, 557 - } 558 - 559 - impl Resampler { 560 - fn new(src_sr: u32, dst_sr: u32, src_ch: usize, dst_ch: usize) -> Self { 561 - Self { 562 - src_sr, 563 - dst_sr, 564 - src_ch, 565 - dst_ch, 566 - src_pos_frac: 0.0, 567 - last_frame: vec![0.0; dst_ch.max(1)], 568 - have_last: false, 569 - } 570 - } 571 - 572 - fn convert_channels(&self, input: &[f32]) -> Vec<f32> { 573 - if self.src_ch == self.dst_ch { 574 - return input.to_vec(); 575 - } 576 - let n_frames = input.len() / self.src_ch.max(1); 577 - let mut out = Vec::with_capacity(n_frames * self.dst_ch); 578 - for f in 0..n_frames { 579 - for c in 0..self.dst_ch { 580 - let s = if self.src_ch == 2 && self.dst_ch == 1 { 581 - (input[f * 2] + input[f * 2 + 1]) * 0.5 582 - } else if self.src_ch == 1 { 583 - // Mono → broadcast to every output channel. 584 - input[f] 585 - } else if c < self.src_ch { 586 - // Take first N source channels, drop the rest. 587 - input[f * self.src_ch + c] 588 - } else if self.src_ch >= 2 { 589 - // Duplicate front L/R across missing outputs. 590 - input[f * self.src_ch + (c % 2)] 591 - } else { 592 - 0.0 593 - }; 594 - out.push(s); 595 - } 596 - } 597 - out 598 - } 599 - 600 - /// Resample the input (already channel-converted, interleaved at dst_ch) 601 - /// from src_sr to dst_sr. Returns interleaved output at dst_sr / dst_ch. 602 - fn resample(&mut self, input: Vec<f32>) -> Vec<f32> { 603 - if self.src_sr == self.dst_sr { 604 - if !input.is_empty() { 605 - let n = input.len() / self.dst_ch.max(1); 606 - for c in 0..self.dst_ch { 607 - self.last_frame[c] = input[(n - 1) * self.dst_ch + c]; 608 - } 609 - self.have_last = true; 610 - } 611 - return input; 612 - } 613 - let n_frames = input.len() / self.dst_ch.max(1); 614 - if n_frames == 0 { 615 - return Vec::new(); 616 - } 617 - let ratio = self.src_sr as f64 / self.dst_sr as f64; 618 - let mut out = Vec::new(); 619 - let mut src_pos = self.src_pos_frac; 620 - loop { 621 - let idx = src_pos.floor() as isize; 622 - let frac = (src_pos - src_pos.floor()) as f32; 623 - let next_idx = idx + 1; 624 - if next_idx < 0 || (next_idx as usize) >= n_frames { 625 - break; 626 - } 627 - for c in 0..self.dst_ch { 628 - let a = if idx < 0 { 629 - if self.have_last { 630 - self.last_frame[c] 631 - } else { 632 - 0.0 633 - } 634 - } else { 635 - input[(idx as usize) * self.dst_ch + c] 636 - }; 637 - let b = input[(next_idx as usize) * self.dst_ch + c]; 638 - out.push(a * (1.0 - frac) + b * frac); 639 - } 640 - src_pos += ratio; 641 - } 642 - // Save state for the next packet. 643 - self.src_pos_frac = src_pos - n_frames as f64; 644 - for c in 0..self.dst_ch { 645 - self.last_frame[c] = input[(n_frames - 1) * self.dst_ch + c]; 646 - } 647 - self.have_last = true; 648 - out 649 - } 650 - 651 - fn process(&mut self, input: &[f32]) -> Vec<f32> { 652 - let ch_converted = self.convert_channels(input); 653 - self.resample(ch_converted) 654 - } 655 - } 656 - 657 - fn run_worker( 658 - rx: mpsc::Receiver<PlayerCommand>, 659 - state: Arc<Mutex<PlaybackState>>, 660 - queue: PlaybackQueue, 661 - persister: Option<Persister>, 662 - ) -> Result<()> { 663 - let host = cpal::default_host(); 664 - let volume = Arc::new(AtomicU32::new(f32::to_bits(1.0))); 665 - let paused = Arc::new(AtomicBool::new(false)); 666 - 667 - let mut track: Option<Track> = None; 668 - // Playhead position we still owe to a freshly-loaded track, e.g. from a 669 - // restored queue. Consumed the first time we load a track after being 670 - // set, then cleared. 671 - let mut pending_seek: Option<f64> = None; 672 - // Rate-limit position-only persist writes. 673 - let mut last_position_persist = Instant::now(); 674 - // Current ReplayGain settings — applied to each track we load. 675 - let mut rg_settings = ReplayGainSettings::default(); 676 - let mut xf_settings = CrossfadeSettings::default(); 677 - // Rockbox DSP pipeline for the CURRENT track only. Initialised lazily 678 - // on the first track load (we need the device output rate). The 679 - // instance is a process-wide singleton (`CODEC_IDX_AUDIO`), so we 680 - // never own two at once — the crossfade `next` track bypasses it and 681 - // uses the plain resampler. Besides EQ/tone this config also owns the 682 - // PGA stage, which applies ReplayGain in fixed point. 683 - let mut dsp: Option<Dsp> = None; 684 - // Second DSP instance bound to Rockbox's `CODEC_IDX_VOICE`. Rockbox 685 - // shares EQ / tone coefficients across configs but keeps biquad delay 686 - // lines per-config — so running the crossfade next track through here 687 - // gives it the same filter curve as `dsp` without churning `dsp`'s 688 - // delay lines (the source of the "small noises" during overlap). 689 - let mut voice_dsp: Option<VoiceDsp> = None; 690 - let mut eq_enabled = false; 691 - let mut eq_bands: Vec<EqBand> = Vec::new(); 692 - // Tone (bass/treble) shelf gains in whole dB, plus optional cutoff 693 - // overrides in Hz (0 = Rockbox defaults). Applied to the Dsp singleton 694 - // like EQ — set once on init, updated live via SetTone. 695 - let mut tone_bass_db: i32 = 0; 696 - let mut tone_treble_db: i32 = 0; 697 - let mut tone_bass_cutoff_hz: i32 = 0; 698 - let mut tone_treble_cutoff_hz: i32 = 0; 699 - // The "incoming" track being preloaded during a crossfade overlap. 700 - // `Some` only during an active overlap. When the fade-in completes, 701 - // this promotes to `track` and the previous `track` is dropped. 702 - let mut next: Option<Track> = None; 703 - // Whether the pending promotion should also `queue.advance()`. True for 704 - // end-of-track preloads (the next-in-queue item was peeked but not yet 705 - // committed). False for user-triggered Play/jump-to-queue crossfades, 706 - // where `queue.replace()` has already positioned current_index at the 707 - // incoming track — advancing again would shift the now-playing marker 708 - // one row past the actual playing track. 709 - let mut promote_advances_queue: bool = false; 710 - 711 - 'main: loop { 712 - // Consume commands. If nothing's playing, block; otherwise poll. 713 - let cmd = if track.is_some() { 714 - match rx.try_recv() { 715 - Ok(c) => Some(c), 716 - Err(mpsc::TryRecvError::Empty) => None, 717 - Err(mpsc::TryRecvError::Disconnected) => break 'main, 718 - } 719 - } else { 720 - match rx.recv() { 721 - Ok(c) => Some(c), 722 - Err(_) => break 'main, 723 - } 724 - }; 725 - 726 - if let Some(cmd) = cmd { 727 - match cmd { 728 - PlayerCommand::Play { items, start_index } => { 729 - // If crossfade is active and something's playing, load 730 - // the new track as an incoming next and fade the old 731 - // one out — same feel as end-of-track crossfade. 732 - let can_crossfade = xf_settings.mode.is_active() 733 - && track 734 - .as_ref() 735 - .map(|t| !t.ended && !t.paused.load(Ordering::Relaxed)) 736 - .unwrap_or(false); 737 - if let Some(nt) = next.take() { 738 - drop(nt); 739 - } 740 - queue.replace(items, start_index); 741 - sync_queue_meta(&state, &queue); 742 - paused.store(false, Ordering::Relaxed); 743 - pending_seek = None; 744 - 745 - match (can_crossfade, queue.current()) { 746 - (true, Some(item)) => { 747 - match load_track( 748 - &host, 749 - item.clone(), 750 - volume.clone(), 751 - paused.clone(), 752 - rg_settings, 753 - ) { 754 - Ok(mut nt) => { 755 - let length_frames = (xf_settings.duration_secs as f64 756 - * nt.output_sr as f64) 757 - as u64; 758 - let mode = xf_settings.mode; 759 - let ct = track.as_mut().unwrap(); 760 - nt.overlap_incoming = Some(OverlapContext { 761 - mode, 762 - length_frames, 763 - start_frame: 0, 764 - }); 765 - ct.overlap_outgoing = Some(OverlapContext { 766 - mode, 767 - length_frames, 768 - start_frame: ct.produced_output_frames, 769 - }); 770 - debug!( 771 - mode = ?mode, 772 - length_frames, 773 - title = %item.title, 774 - "crossfade on user Play" 775 - ); 776 - // queue.replace(...) already positioned 777 - // current_index at the incoming track, 778 - // so promotion must NOT advance it. 779 - promote_advances_queue = false; 780 - if let Some(vd) = voice_dsp.as_mut() { 781 - vd.flush(); 782 - } 783 - next = Some(nt); 784 - } 785 - Err(e) => { 786 - warn!(?e, "crossfade Play preload failed; hard-cutting"); 787 - stop_current(&mut track); 788 - track = load_and_maybe_seek( 789 - &host, 790 - item, 791 - &volume, 792 - &paused, 793 - &state, 794 - &queue, 795 - &mut pending_seek, 796 - rg_settings, 797 - ); 798 - } 799 - } 800 - } 801 - (false, Some(item)) => { 802 - stop_current(&mut track); 803 - track = load_and_maybe_seek( 804 - &host, 805 - item, 806 - &volume, 807 - &paused, 808 - &state, 809 - &queue, 810 - &mut pending_seek, 811 - rg_settings, 812 - ); 813 - } 814 - (_, None) => { 815 - stop_current(&mut track); 816 - mark_idle(&state); 817 - } 818 - } 819 - persist_now(&persister, &queue, &state); 820 - } 821 - PlayerCommand::Enqueue(items) => { 822 - let was_empty = queue.is_empty(); 823 - queue.append(items); 824 - sync_queue_meta(&state, &queue); 825 - if was_empty && track.is_none() { 826 - if let Some(item) = queue.current() { 827 - track = load_and_maybe_seek( 828 - &host, 829 - item, 830 - &volume, 831 - &paused, 832 - &state, 833 - &queue, 834 - &mut pending_seek, 835 - rg_settings, 836 - ); 837 - } 838 - } 839 - persist_now(&persister, &queue, &state); 840 - } 841 - PlayerCommand::PlayNext(items) => { 842 - let was_empty = queue.is_empty(); 843 - queue.insert_next(items); 844 - sync_queue_meta(&state, &queue); 845 - if was_empty && track.is_none() { 846 - if let Some(item) = queue.current() { 847 - track = load_and_maybe_seek( 848 - &host, 849 - item, 850 - &volume, 851 - &paused, 852 - &state, 853 - &queue, 854 - &mut pending_seek, 855 - rg_settings, 856 - ); 857 - } 858 - } 859 - persist_now(&persister, &queue, &state); 860 - } 861 - PlayerCommand::Pause => { 862 - paused.store(true, Ordering::Relaxed); 863 - let mut s = state.lock(); 864 - if s.now_playing.is_some() { 865 - s.status = PlaybackStatus::Paused; 866 - } 867 - drop(s); 868 - persist_now(&persister, &queue, &state); 869 - } 870 - PlayerCommand::Resume => { 871 - paused.store(false, Ordering::Relaxed); 872 - // If we're idle but the queue has a current item (e.g. 873 - // just after a restore), start playing it now. Any 874 - // pending_seek from restore is applied on load. 875 - if track.is_none() { 876 - if let Some(item) = queue.current() { 877 - track = load_and_maybe_seek( 878 - &host, 879 - item, 880 - &volume, 881 - &paused, 882 - &state, 883 - &queue, 884 - &mut pending_seek, 885 - rg_settings, 886 - ); 887 - } 888 - } 889 - let mut s = state.lock(); 890 - if s.now_playing.is_some() { 891 - s.status = PlaybackStatus::Playing; 892 - } 893 - } 894 - PlayerCommand::Stop => { 895 - stop_current(&mut track); 896 - if let Some(nt) = next.take() { 897 - drop(nt); 898 - } 899 - queue.clear(); 900 - pending_seek = None; 901 - let mut s = state.lock(); 902 - s.queue.clear(); 903 - s.current_index = None; 904 - s.now_playing = None; 905 - s.status = PlaybackStatus::Stopped; 906 - s.position_secs = 0.0; 907 - s.duration_secs = 0.0; 908 - drop(s); 909 - persist_now(&persister, &queue, &state); 910 - } 911 - PlayerCommand::Next => { 912 - // If a crossfade is already ramping in, snap-promote it 913 - // instead of loading fresh. Feels responsive, and it 914 - // preserves the buffered next-track audio. 915 - stop_current(&mut track); 916 - if let Some(mut nt) = next.take() { 917 - nt.overlap_incoming = None; 918 - nt.overlap_outgoing = None; 919 - track = Some(nt); 920 - // Only advance the queue for end-approach overlaps 921 - // — Play-based overlaps already have current_index 922 - // pointing at the incoming track. 923 - if promote_advances_queue { 924 - queue.advance(); 925 - } 926 - promote_advances_queue = false; 927 - sync_queue_meta(&state, &queue); 928 - persist_now(&persister, &queue, &state); 929 - continue; 930 - } 931 - queue.advance(); 932 - sync_queue_meta(&state, &queue); 933 - if let Some(item) = queue.current() { 934 - track = load_and_maybe_seek( 935 - &host, 936 - item, 937 - &volume, 938 - &paused, 939 - &state, 940 - &queue, 941 - &mut pending_seek, 942 - rg_settings, 943 - ); 944 - } else { 945 - mark_idle(&state); 946 - } 947 - persist_now(&persister, &queue, &state); 948 - } 949 - PlayerCommand::Previous => { 950 - stop_current(&mut track); 951 - if let Some(nt) = next.take() { 952 - drop(nt); 953 - } 954 - queue.back(); 955 - sync_queue_meta(&state, &queue); 956 - if let Some(item) = queue.current() { 957 - track = load_and_maybe_seek( 958 - &host, 959 - item, 960 - &volume, 961 - &paused, 962 - &state, 963 - &queue, 964 - &mut pending_seek, 965 - rg_settings, 966 - ); 967 - } else { 968 - mark_idle(&state); 969 - } 970 - persist_now(&persister, &queue, &state); 971 - } 972 - PlayerCommand::Seek(pos_secs) => { 973 - if let Some(ref mut t) = track { 974 - seek_track(t, pos_secs); 975 - } else { 976 - // No track yet — record for the next load (used after Restore). 977 - pending_seek = Some(pos_secs.max(0.0)); 978 - } 979 - persist_now(&persister, &queue, &state); 980 - } 981 - PlayerCommand::SetVolume(v) => { 982 - let clamped = v.clamp(0.0, 1.5); 983 - volume.store(f32::to_bits(clamped), Ordering::Relaxed); 984 - state.lock().volume = clamped; 985 - } 986 - PlayerCommand::SetShuffle(on) => { 987 - queue.set_shuffle(on); 988 - sync_queue_meta(&state, &queue); 989 - persist_now(&persister, &queue, &state); 990 - } 991 - PlayerCommand::SetRepeat(mode) => { 992 - queue.set_repeat(mode); 993 - sync_queue_meta(&state, &queue); 994 - persist_now(&persister, &queue, &state); 995 - } 996 - PlayerCommand::Restore(snapshot) => { 997 - stop_current(&mut track); 998 - // Filter to audio-only — video items in a saved queue are 999 - // legitimate but the SymphoniaPlayer can't play them. The 1000 - // LocalRenderer's dispatcher already partitions on load. 1001 - let audio_items: Vec<QueueItem> = snapshot 1002 - .items 1003 - .iter() 1004 - .filter(|i| !i.is_video) 1005 - .cloned() 1006 - .collect(); 1007 - if !audio_items.is_empty() { 1008 - // Remap the saved index into the filtered list. 1009 - let target_id = snapshot 1010 - .current_index 1011 - .and_then(|i| snapshot.items.get(i)) 1012 - .map(|it| it.id.clone()); 1013 - let new_idx = target_id 1014 - .and_then(|id| audio_items.iter().position(|it| it.id == id)) 1015 - .unwrap_or(0); 1016 - queue.replace(audio_items, new_idx); 1017 - } 1018 - queue.set_shuffle(snapshot.shuffle); 1019 - queue.set_repeat(snapshot.repeat); 1020 - pending_seek = if snapshot.position_secs > 0.0 { 1021 - Some(snapshot.position_secs) 1022 - } else { 1023 - None 1024 - }; 1025 - sync_queue_meta(&state, &queue); 1026 - // Restore leaves playback paused so the user hits Space 1027 - // when they're ready. The pending_seek is applied to 1028 - // whichever track loads next. 1029 - paused.store(true, Ordering::Relaxed); 1030 - let mut s = state.lock(); 1031 - s.status = PlaybackStatus::Paused; 1032 - s.position_secs = snapshot.position_secs; 1033 - drop(s); 1034 - // Do NOT overwrite the snapshot on restore itself. 1035 - } 1036 - PlayerCommand::SetReplayGain(settings) => { 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). 1046 - if let Some(ref mut t) = track { 1047 - t.replaygain_linear = t.replaygain_info.linear_gain(settings); 1048 - } 1049 - if let Some(ref mut nt) = next { 1050 - nt.replaygain_linear = nt.replaygain_info.linear_gain(settings); 1051 - } 1052 - state.lock().replaygain = settings; 1053 - persist_now(&persister, &queue, &state); 1054 - } 1055 - PlayerCommand::SetEq { enabled, bands } => { 1056 - eq_enabled = enabled; 1057 - eq_bands = bands; 1058 - if let Some(ref mut d) = dsp { 1059 - apply_eq_to_dsp(d, eq_enabled, &eq_bands); 1060 - } 1061 - { 1062 - let mut s = state.lock(); 1063 - s.eq_enabled = eq_enabled; 1064 - s.eq_band_count = eq_bands.len().min(EQ_NUM_BANDS); 1065 - } 1066 - } 1067 - PlayerCommand::SetTone { 1068 - bass_db, 1069 - treble_db, 1070 - bass_cutoff_hz, 1071 - treble_cutoff_hz, 1072 - } => { 1073 - tone_bass_db = bass_db; 1074 - tone_treble_db = treble_db; 1075 - tone_bass_cutoff_hz = bass_cutoff_hz; 1076 - tone_treble_cutoff_hz = treble_cutoff_hz; 1077 - if let Some(ref mut d) = dsp { 1078 - apply_tone_to_dsp( 1079 - d, 1080 - tone_bass_db, 1081 - tone_treble_db, 1082 - tone_bass_cutoff_hz, 1083 - tone_treble_cutoff_hz, 1084 - ); 1085 - } 1086 - let mut s = state.lock(); 1087 - s.bass_db = bass_db; 1088 - s.treble_db = treble_db; 1089 - } 1090 - PlayerCommand::SetCrossfade(settings) => { 1091 - xf_settings = settings; 1092 - // If the mode was turned OFF mid-overlap, kill the 1093 - // pending next track so we stop mixing. The current 1094 - // track keeps playing normally. 1095 - if !settings.mode.is_active() { 1096 - if let Some(nt) = next.take() { 1097 - drop(nt); 1098 - } 1099 - if let Some(ref mut t) = track { 1100 - t.overlap_outgoing = None; 1101 - t.overlap_incoming = None; 1102 - } 1103 - } 1104 - state.lock().crossfade = settings; 1105 - } 1106 - PlayerCommand::RemoveAt(idx) => { 1107 - let cur = queue.current_index(); 1108 - let removing_current = cur == Some(idx); 1109 - queue.remove(idx); 1110 - sync_queue_meta(&state, &queue); 1111 - if removing_current { 1112 - // Was playing this exact track — stop it and load 1113 - // whatever the new current is, or go idle. 1114 - stop_current(&mut track); 1115 - pending_seek = None; 1116 - if let Some(item) = queue.current() { 1117 - track = load_and_maybe_seek( 1118 - &host, 1119 - item, 1120 - &volume, 1121 - &paused, 1122 - &state, 1123 - &queue, 1124 - &mut pending_seek, 1125 - rg_settings, 1126 - ); 1127 - } else { 1128 - mark_idle(&state); 1129 - } 1130 - } 1131 - persist_now(&persister, &queue, &state); 1132 - } 1133 - PlayerCommand::Quit => break 'main, 1134 - } 1135 - } 1136 - 1137 - // Once every ~3 s while playing, checkpoint the current position so 1138 - // a crash / kill leaves us close to where we were. 1139 - if track.is_some() 1140 - && !paused.load(Ordering::Relaxed) 1141 - && last_position_persist.elapsed() >= Duration::from_secs(3) 1142 - { 1143 - persist_now(&persister, &queue, &state); 1144 - last_position_persist = Instant::now(); 1145 - } 1146 - 1147 - // Decode current — same shape as before, but doesn't advance on 1148 - // EOF anymore. That's now the promotion step's job (below). 1149 - if let Some(ref mut t) = track { 1150 - if t.paused.load(Ordering::Relaxed) { 1151 - thread::sleep(Duration::from_millis(25)); 1152 - update_position(&state, t); 1153 - continue; 1154 - } 1155 - 1156 - // Lazy-init the Dsp singleton on the first track load — we need 1157 - // the device output rate, which comes from the track itself. 1158 - if dsp.is_none() { 1159 - let mut d = Dsp::new(t.output_sr); 1160 - // My Resampler already delivers samples at the device rate, 1161 - // so keep Dsp's input rate = output rate — no internal 1162 - // resample, just the EQ / tone stack. 1163 - d.set_input_frequency(t.output_sr); 1164 - apply_eq_to_dsp(&mut d, eq_enabled, &eq_bands); 1165 - apply_tone_to_dsp( 1166 - &mut d, 1167 - tone_bass_db, 1168 - tone_treble_db, 1169 - tone_bass_cutoff_hz, 1170 - tone_treble_cutoff_hz, 1171 - ); 1172 - apply_replaygain_to_dsp(&mut d, rg_settings); 1173 - dsp = Some(d); 1174 - // Spin up the second config now too — cheap, and it means 1175 - // the crossfade preload path never blocks on init. 1176 - if voice_dsp.is_none() { 1177 - let mut vd = VoiceDsp::new(t.output_sr); 1178 - vd.set_input_frequency(t.output_sr); 1179 - voice_dsp = Some(vd); 1180 - } 1181 - } 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 - 1194 - let free = t.ring_free_slots(); 1195 - if free >= 8192 && !t.ended { 1196 - // Current track always uses the AUDIO Rockbox config; the 1197 - // next track (during crossfade) uses the VOICE config 1198 - // below. Coefficients are global, so the EQ curve matches; 1199 - // only the biquad delay lines differ. Skip both configs 1200 - // when no stage is doing anything to avoid the f32↔i16 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(); 1207 - let dsp_arg: Option<&mut dyn DspProcess> = if dsp_active { 1208 - dsp.as_mut().map(|d| d as &mut dyn DspProcess) 1209 - } else { 1210 - None 1211 - }; 1212 - match decode_one_packet(t, dsp_arg, true) { 1213 - DecodeStep::Pushed => update_position(&state, t), 1214 - DecodeStep::EndOfStream => { 1215 - debug!(item = %t.item.title, "current track ended"); 1216 - t.ended = true; 1217 - } 1218 - DecodeStep::Error(e) => { 1219 - warn!(error = %e, item = %t.item.title, "current decode error"); 1220 - t.ended = true; 1221 - } 1222 - } 1223 - } 1224 - } 1225 - 1226 - // Decode next in parallel — it has its own ring buffer, so no 1227 - // backpressure conflict with current. During crossfade the next 1228 - // track routes through the dedicated VOICE DSP config so the 1229 - // AUDIO config's biquad delay lines are undisturbed — that's what 1230 - // caused the "small noises" during overlap in the previous 1231 - // shared-DSP version. Coefficients are still global, so the EQ 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. 1236 - if let Some(ref mut nt) = next { 1237 - let free = nt.ring_free_slots(); 1238 - if free >= 8192 && !nt.ended { 1239 - let dsp_active = eq_enabled || tone_bass_db != 0 || tone_treble_db != 0; 1240 - let dsp_arg: Option<&mut dyn DspProcess> = if dsp_active { 1241 - voice_dsp.as_mut().map(|d| d as &mut dyn DspProcess) 1242 - } else { 1243 - None 1244 - }; 1245 - match decode_one_packet(nt, dsp_arg, false) { 1246 - DecodeStep::Pushed => {} 1247 - DecodeStep::EndOfStream => nt.ended = true, 1248 - DecodeStep::Error(e) => { 1249 - warn!(error = %e, "crossfade next decode error"); 1250 - nt.ended = true; 1251 - } 1252 - } 1253 - } 1254 - } 1255 - 1256 - // Should we trigger a crossfade preload? Only when: 1257 - // - a mode is selected 1258 - // - we don't already have a next track 1259 - // - the current track has a known duration 1260 - // - the remaining decoder-time is within the overlap window 1261 - if next.is_none() && xf_settings.mode.is_active() { 1262 - if let Some(ref mut ct) = track { 1263 - let duration = ct.duration_secs(); 1264 - let decoded_secs = ct.produced_output_frames as f64 / ct.output_sr as f64; 1265 - let remaining = duration - decoded_secs; 1266 - if duration > 0.0 && remaining <= xf_settings.duration_secs as f64 && !ct.ended { 1267 - if let Some(next_item) = queue.peek_next_item() { 1268 - // Attempt to load next. On failure we silently skip 1269 - // crossfade for this transition; the current track's 1270 - // natural EOF path will handle end-of-stream. 1271 - match load_track( 1272 - &host, 1273 - next_item.clone(), 1274 - volume.clone(), 1275 - paused.clone(), 1276 - rg_settings, 1277 - ) { 1278 - Ok(mut nt) => { 1279 - let length_frames = 1280 - (xf_settings.duration_secs as f64 * nt.output_sr as f64) as u64; 1281 - let ctx = OverlapContext { 1282 - mode: xf_settings.mode, 1283 - length_frames, 1284 - start_frame: 0, 1285 - }; 1286 - nt.overlap_incoming = Some(ctx); 1287 - ct.overlap_outgoing = Some(OverlapContext { 1288 - mode: xf_settings.mode, 1289 - length_frames, 1290 - start_frame: ct.produced_output_frames, 1291 - }); 1292 - debug!( 1293 - mode = ?xf_settings.mode, 1294 - length_frames, 1295 - next = %next_item.title, 1296 - "crossfade triggered" 1297 - ); 1298 - // End-approach preload — the peeked item is 1299 - // NOT yet the queue's current, so promotion 1300 - // must advance. 1301 - promote_advances_queue = true; 1302 - // Fresh biquad state for the incoming track 1303 - // — no delay-line pollution from a prior 1304 - // preload that never got promoted. 1305 - if let Some(vd) = voice_dsp.as_mut() { 1306 - vd.flush(); 1307 - } 1308 - next = Some(nt); 1309 - } 1310 - Err(e) => { 1311 - warn!(?e, title = %next_item.title, "crossfade preload failed"); 1312 - } 1313 - } 1314 - } 1315 - } 1316 - } 1317 - } 1318 - 1319 - // Promote next → current when the fade-in has completed OR when the 1320 - // outgoing track's fade-out has run its full length. 1321 - let should_promote = match (&track, &next) { 1322 - (Some(ct), Some(nt)) => { 1323 - let nt_progress_done = nt 1324 - .overlap_incoming 1325 - .map(|ctx| nt.produced_output_frames >= ctx.length_frames) 1326 - .unwrap_or(false); 1327 - let ct_faded_out = ct 1328 - .overlap_outgoing 1329 - .map(|ctx| ct.produced_output_frames >= ctx.start_frame + ctx.length_frames) 1330 - .unwrap_or(false); 1331 - nt_progress_done || ct_faded_out || ct.ended 1332 - } 1333 - _ => false, 1334 - }; 1335 - if should_promote { 1336 - debug!( 1337 - advance = promote_advances_queue, 1338 - "crossfade complete, promoting next → current" 1339 - ); 1340 - stop_current(&mut track); 1341 - if let Some(mut nt) = next.take() { 1342 - nt.overlap_incoming = None; 1343 - nt.overlap_outgoing = None; 1344 - track = Some(nt); 1345 - if promote_advances_queue { 1346 - queue.advance(); 1347 - } 1348 - sync_queue_meta(&state, &queue); 1349 - persist_now(&persister, &queue, &state); 1350 - // The AUDIO DSP was tuned to the OUTGOING track's samples. 1351 - // The newly-promoted track will now route through it, so 1352 - // reset both configs' delay lines to zero — cheaper and 1353 - // cleaner than letting stale state resonate through the 1354 - // first packet or two. 1355 - if let Some(d) = dsp.as_mut() { 1356 - d.flush(); 1357 - } 1358 - if let Some(vd) = voice_dsp.as_mut() { 1359 - vd.flush(); 1360 - } 1361 - } 1362 - promote_advances_queue = false; 1363 - } 1364 - 1365 - // Current ended and there's no next to promote — advance the queue 1366 - // and load the next item straight (no crossfade). 1367 - let current_dry = track 1368 - .as_ref() 1369 - .map(|t| t.ended && t.ring_pending_frames() == 0) 1370 - .unwrap_or(false); 1371 - if current_dry && next.is_none() { 1372 - debug!("current track drained, advancing queue"); 1373 - stop_current(&mut track); 1374 - queue.advance(); 1375 - sync_queue_meta(&state, &queue); 1376 - if let Some(item) = queue.current() { 1377 - track = load_and_maybe_seek( 1378 - &host, 1379 - item, 1380 - &volume, 1381 - &paused, 1382 - &state, 1383 - &queue, 1384 - &mut pending_seek, 1385 - rg_settings, 1386 - ); 1387 - } else { 1388 - mark_idle(&state); 1389 - } 1390 - persist_now(&persister, &queue, &state); 1391 - } 1392 - 1393 - // If we didn't decode anything this tick, take a short nap to 1394 - // avoid pegging the CPU. 1395 - let idle = track.as_ref().map(|t| t.ended).unwrap_or(true) 1396 - || track 1397 - .as_ref() 1398 - .map(|t| t.ring_free_slots() < 8192) 1399 - .unwrap_or(true); 1400 - if idle { 1401 - thread::sleep(Duration::from_millis(5)); 1402 - } 1403 - } 1404 - 1405 - stop_current(&mut track); 1406 - // Flush one final snapshot so the exit position is persisted. 1407 - persist_now(&persister, &queue, &state); 1408 - Ok(()) 1409 - } 1410 - 1411 - enum DecodeStep { 1412 - Pushed, 1413 - EndOfStream, 1414 - Error(String), 1415 - } 1416 - 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 { 1427 - let packet = match t.format.next_packet() { 1428 - Ok(p) => p, 1429 - Err(symphonia::core::errors::Error::IoError(e)) 1430 - if e.kind() == io::ErrorKind::UnexpectedEof => 1431 - { 1432 - return DecodeStep::EndOfStream; 1433 - } 1434 - Err(symphonia::core::errors::Error::ResetRequired) => { 1435 - return DecodeStep::EndOfStream; 1436 - } 1437 - Err(e) => return DecodeStep::Error(e.to_string()), 1438 - }; 1439 - if packet.track_id() != t.track_id { 1440 - return DecodeStep::Pushed; 1441 - } 1442 - let audio_buf = match t.decoder.decode(&packet) { 1443 - Ok(b) => b, 1444 - Err(symphonia::core::errors::Error::DecodeError(_)) => return DecodeStep::Pushed, 1445 - Err(e) => return DecodeStep::Error(e.to_string()), 1446 - }; 1447 - let spec = *audio_buf.spec(); 1448 - let cap = audio_buf.capacity() as u64; 1449 - let sample_buf = t 1450 - .sample_buf 1451 - .get_or_insert_with(|| SampleBuffer::<f32>::new(cap, spec)); 1452 - sample_buf.copy_interleaved_ref(audio_buf); 1453 - let src_samples = sample_buf.samples(); 1454 - // Convert channels + sample rate to what cpal is actually consuming. 1455 - let mut out_samples = t.resampler.process(src_samples); 1456 - if out_samples.is_empty() { 1457 - return DecodeStep::Pushed; 1458 - } 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; 1463 - if let Some(d) = dsp { 1464 - if t.output_channels == 2 { 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 - } 1471 - if out_samples.is_empty() { 1472 - return DecodeStep::Pushed; 1473 - } 1474 - } 1475 - } 1476 - let out_frames = out_samples.len() / t.output_channels.max(1); 1477 - push_samples_with_volume(t, &out_samples, rg_mult); 1478 - t.produced_output_frames += out_frames as u64; 1479 - DecodeStep::Pushed 1480 - } 1481 - 1482 - /// Route interleaved-stereo f32 samples through a Rockbox DSP pipeline 1483 - /// with input rate == output rate (no internal resample; my Resampler 1484 - /// already ran). Loudness scales aside, this is just the EQ + tone stack. 1485 - fn apply_dsp(dsp: &mut dyn DspProcess, samples: &[f32]) -> Vec<f32> { 1486 - // f32 → i16, saturating. 1487 - let mut input = Vec::with_capacity(samples.len()); 1488 - for &s in samples { 1489 - let v = (s * 32767.0) 1490 - .round() 1491 - .clamp(i16::MIN as f32, i16::MAX as f32) as i16; 1492 - input.push(v); 1493 - } 1494 - let mut out_i16: Vec<i16> = Vec::with_capacity(input.len()); 1495 - dsp.process_stereo(&input, &mut out_i16); 1496 - // i16 → f32. 1497 - out_i16.iter().map(|&s| s as f32 / 32768.0).collect() 1498 - } 1499 - 1500 - /// Apply bass/treble shelf gains + cutoffs to the Dsp singleton. Cutoffs 1501 - /// of `0` fall back to Rockbox defaults (200 Hz bass, 3500 Hz treble). 1502 - /// The Dsp wrapper multiplies dB values by 10 internally. 1503 - fn apply_tone_to_dsp( 1504 - dsp: &mut Dsp, 1505 - bass_db: i32, 1506 - treble_db: i32, 1507 - bass_cutoff_hz: i32, 1508 - treble_cutoff_hz: i32, 1509 - ) { 1510 - // Cutoffs must be set BEFORE gains — Dsp::set_tone runs the prescale 1511 - // which recomputes filter coefficients from whatever cutoff is active. 1512 - dsp.set_tone_cutoffs(bass_cutoff_hz, treble_cutoff_hz); 1513 - dsp.set_tone(bass_db, treble_db); 1514 - } 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 - 1541 - /// Apply an on/off toggle plus the current bands to the Dsp singleton. 1542 - /// Truncates to EQ_NUM_BANDS silently. 1543 - fn apply_eq_to_dsp(dsp: &mut Dsp, enabled: bool, bands: &[EqBand]) { 1544 - for (i, band) in bands.iter().take(EQ_NUM_BANDS).enumerate() { 1545 - dsp.set_eq_band_raw( 1546 - i, 1547 - eq_band_setting { 1548 - cutoff: band.cutoff, 1549 - q: band.q, 1550 - gain: band.gain, 1551 - }, 1552 - ); 1553 - } 1554 - dsp.eq_enable(enabled); 1555 - } 1556 - 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 1563 - // produced_output_frames counter. 1564 - let base = f32::from_bits(t.volume.load(Ordering::Relaxed)) * rg_mult; 1565 - let ch = t.output_channels.max(1); 1566 - let mut offset = 0; 1567 - while offset < samples.len() { 1568 - if t.paused.load(Ordering::Relaxed) { 1569 - thread::sleep(Duration::from_millis(20)); 1570 - continue; 1571 - } 1572 - let end = std::cmp::min(offset + 4096, samples.len()); 1573 - let src = &samples[offset..end]; 1574 - let chunk: Vec<f32> = if t.overlap_outgoing.is_none() && t.overlap_incoming.is_none() { 1575 - // Fast path: no fade active. 1576 - src.iter().map(|s| s * base).collect() 1577 - } else { 1578 - let frames_in_chunk = src.len() / ch; 1579 - // frame index in the OUTPUT-frame timeline this chunk starts at. 1580 - let start_frame = t.produced_output_frames + (offset / ch) as u64; 1581 - let mut out = Vec::with_capacity(src.len()); 1582 - for f in 0..frames_in_chunk { 1583 - let global = start_frame + f as u64; 1584 - let fade_mult = fade_multiplier(t, global); 1585 - let mul = base * fade_mult; 1586 - for c in 0..ch { 1587 - out.push(src[f * ch + c] * mul); 1588 - } 1589 - } 1590 - out 1591 - }; 1592 - match t.producer.write(&chunk) { 1593 - Ok(n) => offset += n, 1594 - Err(_) => thread::sleep(Duration::from_millis(5)), 1595 - } 1596 - } 1597 - } 1598 - 1599 - /// Compute the fade multiplier this track should apply at output-frame 1600 - /// `global`. Combines both directions when set — a track shouldn't normally 1601 - /// have both simultaneously, but if it does they compose multiplicatively. 1602 - fn fade_multiplier(t: &Track, global: u64) -> f32 { 1603 - let mut m = 1.0; 1604 - if let Some(ctx) = t.overlap_outgoing { 1605 - let relative = global.saturating_sub(ctx.start_frame); 1606 - let progress = relative as f32 / ctx.length_frames.max(1) as f32; 1607 - m *= fade_at(ctx.mode, progress).out; 1608 - } 1609 - if let Some(ctx) = t.overlap_incoming { 1610 - let relative = global.saturating_sub(ctx.start_frame); 1611 - let progress = relative as f32 / ctx.length_frames.max(1) as f32; 1612 - m *= fade_at(ctx.mode, progress).incoming; 1613 - } 1614 - m 1615 - } 1616 - 1617 - fn seek_track(t: &mut Track, pos_secs: f64) { 1618 - let seconds = pos_secs.max(0.0) as u64; 1619 - let frac = (pos_secs - seconds as f64).clamp(0.0, 1.0); 1620 - let time = Time::new(seconds, frac); 1621 - match t.format.seek( 1622 - SeekMode::Coarse, 1623 - SeekTo::Time { 1624 - time, 1625 - track_id: Some(t.track_id), 1626 - }, 1627 - ) { 1628 - Ok(seeked) => { 1629 - let _ = t.decoder.reset(); 1630 - // Playhead is measured in output frames (what cpal has consumed). 1631 - let played_secs = if let Some(tb) = t.time_base { 1632 - let time = tb.calc_time(seeked.actual_ts); 1633 - time.seconds as f64 + time.frac 1634 - } else { 1635 - pos_secs 1636 - }; 1637 - t.produced_output_frames = (played_secs * t.output_sr as f64) as u64; 1638 - // The resampler holds stale interpolation state; reset it. 1639 - t.resampler.src_pos_frac = 0.0; 1640 - t.resampler.have_last = false; 1641 - } 1642 - Err(e) => { 1643 - warn!(error = ?e, "seek failed"); 1644 - } 1645 - } 1646 - } 1647 - 1648 - fn stop_current(track: &mut Option<Track>) { 1649 - if let Some(t) = track.take() { 1650 - // Signal the fetcher thread that we no longer care about the data. 1651 - { 1652 - let mut g = t.fetch_cancel.lock(); 1653 - g.cancelled = true; 1654 - } 1655 - // Drop t → cpal Stream drops → OS output stops. 1656 - drop(t); 1657 - } 1658 - } 1659 - 1660 - fn mark_idle(state: &Arc<Mutex<PlaybackState>>) { 1661 - let mut s = state.lock(); 1662 - s.status = PlaybackStatus::Idle; 1663 - s.now_playing = None; 1664 - s.position_secs = 0.0; 1665 - s.duration_secs = 0.0; 1666 - } 1667 - 1668 - fn sync_queue_meta(state: &Arc<Mutex<PlaybackState>>, queue: &PlaybackQueue) { 1669 - let items = queue.items(); 1670 - let idx = queue.current_index(); 1671 - let shuffle = queue.shuffle_enabled(); 1672 - let repeat = queue.repeat_mode(); 1673 - let mut s = state.lock(); 1674 - s.queue = items.clone(); 1675 - s.current_index = idx; 1676 - s.now_playing = idx.and_then(|i| items.get(i).cloned()); 1677 - s.shuffle = shuffle; 1678 - s.repeat = repeat; 1679 - } 1680 - 1681 - /// Send the current queue + state to the background persister. Cheap enough 1682 - /// to call from every mutating command handler; the persister debounces 1683 - /// bursts into a single write. 1684 - fn persist_now( 1685 - persister: &Option<Persister>, 1686 - queue: &PlaybackQueue, 1687 - state: &Arc<Mutex<PlaybackState>>, 1688 - ) { 1689 - if let Some(p) = persister { 1690 - let s = state.lock(); 1691 - let snap = PersistedQueue { 1692 - items: queue.items(), 1693 - current_index: queue.current_index(), 1694 - shuffle: queue.shuffle_enabled(), 1695 - repeat: queue.repeat_mode(), 1696 - position_secs: s.position_secs, 1697 - }; 1698 - drop(s); 1699 - p.queue_write(snap); 1700 - } 1701 - } 1702 - 1703 - /// Load a track and, if a `pending_seek` is pending (e.g. from a restore), 1704 - /// apply it once. Returns None if loading failed after skipping the entire 1705 - /// remaining queue. 1706 - fn load_and_maybe_seek( 1707 - host: &cpal::Host, 1708 - item: QueueItem, 1709 - volume: &Arc<AtomicU32>, 1710 - paused: &Arc<AtomicBool>, 1711 - state: &Arc<Mutex<PlaybackState>>, 1712 - queue: &PlaybackQueue, 1713 - pending_seek: &mut Option<f64>, 1714 - rg_settings: ReplayGainSettings, 1715 - ) -> Option<Track> { 1716 - let mut track = try_load_track(host, item, volume, paused, state, queue, rg_settings)?; 1717 - if let Some(secs) = pending_seek.take() { 1718 - if secs > 0.0 { 1719 - seek_track(&mut track, secs); 1720 - state.lock().position_secs = secs; 1721 - } 1722 - } 1723 - Some(track) 1724 - } 1725 - 1726 - fn update_position(state: &Arc<Mutex<PlaybackState>>, t: &Track) { 1727 - let mut s = state.lock(); 1728 - s.position_secs = t.position_secs(); 1729 - s.duration_secs = t.duration_secs(); 1730 - if !t.paused.load(Ordering::Relaxed) && s.now_playing.is_some() { 1731 - s.status = PlaybackStatus::Playing; 1732 - } 1733 - } 1734 - 1735 - // --------------------------------------------------------------------------- 1736 - // Track loading 1737 - // --------------------------------------------------------------------------- 1738 - 1739 - fn try_load_track( 1740 - host: &cpal::Host, 1741 - item: QueueItem, 1742 - volume: &Arc<AtomicU32>, 1743 - paused: &Arc<AtomicBool>, 1744 - state: &Arc<Mutex<PlaybackState>>, 1745 - queue: &PlaybackQueue, 1746 - rg_settings: ReplayGainSettings, 1747 - ) -> Option<Track> { 1748 - { 1749 - let mut s = state.lock(); 1750 - s.status = PlaybackStatus::Buffering; 1751 - s.now_playing = Some(item.clone()); 1752 - s.position_secs = 0.0; 1753 - s.duration_secs = item.duration_secs.map(|d| d as f64).unwrap_or(0.0); 1754 - } 1755 - match load_track( 1756 - host, 1757 - item.clone(), 1758 - volume.clone(), 1759 - paused.clone(), 1760 - rg_settings, 1761 - ) { 1762 - Ok(t) => { 1763 - let mut s = state.lock(); 1764 - s.status = PlaybackStatus::Playing; 1765 - s.duration_secs = if s.duration_secs > 0.0 { 1766 - s.duration_secs 1767 - } else { 1768 - t.duration_secs() 1769 - }; 1770 - Some(t) 1771 - } 1772 - Err(e) => { 1773 - warn!(error = ?e, title = %item.title, "audio load failed, skipping"); 1774 - // Skip past broken tracks without recursion. 1775 - loop { 1776 - queue.advance(); 1777 - sync_queue_meta(state, queue); 1778 - match queue.current() { 1779 - None => { 1780 - mark_idle(state); 1781 - return None; 1782 - } 1783 - Some(next) => match load_track( 1784 - host, 1785 - next.clone(), 1786 - volume.clone(), 1787 - paused.clone(), 1788 - rg_settings, 1789 - ) { 1790 - Ok(t) => { 1791 - let mut s = state.lock(); 1792 - s.status = PlaybackStatus::Playing; 1793 - return Some(t); 1794 - } 1795 - Err(e2) => { 1796 - warn!(error = ?e2, title = %next.title, "audio load failed, skipping"); 1797 - } 1798 - }, 1799 - } 1800 - } 1801 - } 1802 - } 1803 - } 1804 - 1805 - fn load_track( 1806 - host: &cpal::Host, 1807 - item: QueueItem, 1808 - volume: Arc<AtomicU32>, 1809 - paused: Arc<AtomicBool>, 1810 - rg_settings: ReplayGainSettings, 1811 - ) -> Result<Track> { 1812 - // 1. Kick off the HTTP fetch. 1813 - let source = spawn_streaming_source(&item.stream_url)?; 1814 - let fetch_cancel = source.buf.clone(); 1815 - 1816 - // 2. Probe with symphonia. 1817 - let mut hint = Hint::new(); 1818 - if let Some(ext) = ext_from_content_type(&item.content_type) { 1819 - hint.with_extension(ext); 1820 - } 1821 - let mss = MediaSourceStream::new(Box::new(source), MediaSourceStreamOptions::default()); 1822 - let probed = symphonia::default::get_probe() 1823 - .format( 1824 - &hint, 1825 - mss, 1826 - &FormatOptions { 1827 - enable_gapless: true, 1828 - ..Default::default() 1829 - }, 1830 - &MetadataOptions::default(), 1831 - ) 1832 - .context("symphonia probe failed")?; 1833 - let mut format = probed.format; 1834 - 1835 - let track_meta = format 1836 - .tracks() 1837 - .iter() 1838 - .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) 1839 - .cloned() 1840 - .context("no decodable audio track")?; 1841 - let track_id = track_meta.id; 1842 - let time_base = track_meta.codec_params.time_base; 1843 - let total_frames = track_meta.codec_params.n_frames; 1844 - let mut decoder = symphonia::default::get_codecs() 1845 - .make(&track_meta.codec_params, &DecoderOptions::default()) 1846 - .context("no decoder for this codec")?; 1847 - 1848 - // 3. Decode the FIRST packet so we learn the ACTUAL sample rate and 1849 - // channel count from the decoder — the codec_params metadata can lie 1850 - // for formats like HE-AAC (SBR doubles the effective rate) and some 1851 - // Ogg streams where the header is optimistic. 1852 - let (source_sr, source_channels, first_samples) = 1853 - prime_decoder(&mut format, decoder.as_mut(), track_id)?; 1854 - 1855 - // 4. Open cpal at the device's DEFAULT config — that's the rate/channels 1856 - // the OS actually plays at. Asking cpal to reconfigure the device 1857 - // silently fails on some backends, which is what causes slowed audio. 1858 - let device = host 1859 - .default_output_device() 1860 - .context("no default audio output device")?; 1861 - let default_cfg = device 1862 - .default_output_config() 1863 - .context("no default output config")?; 1864 - let output_sr = default_cfg.sample_rate().0; 1865 - let output_channels = default_cfg.channels() as usize; 1866 - 1867 - debug!( 1868 - source_sr, 1869 - source_channels, 1870 - output_sr, 1871 - output_channels, 1872 - codec = ?track_meta.codec_params.codec, 1873 - "opening cpal output" 1874 - ); 1875 - 1876 - let (stream, producer, ring) = build_output_stream(&device, &default_cfg, paused.clone())?; 1877 - 1878 - let mut resampler = Resampler::new(source_sr, output_sr, source_channels, output_channels); 1879 - 1880 - // 4b. Read whatever ReplayGain tags this track carries. Done AFTER the 1881 - // first-packet decode primer above — several formats only expose 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. 1885 - let replaygain_info = ReplayGainInfo::extract_from(&mut format); 1886 - let replaygain_linear = replaygain_info.linear_gain(rg_settings); 1887 - debug!( 1888 - title = %item.title, 1889 - track_gain = ?replaygain_info.track_gain_db, 1890 - album_gain = ?replaygain_info.album_gain_db, 1891 - linear = replaygain_linear, 1892 - "replaygain resolved" 1893 - ); 1894 - 1895 - // 5. Prime the ring with the first packet's samples so playback starts 1896 - // immediately instead of waiting a full worker tick. 1897 - let mut track = Track { 1898 - format, 1899 - decoder, 1900 - track_id, 1901 - source_sr, 1902 - output_sr, 1903 - output_channels, 1904 - time_base, 1905 - total_frames, 1906 - produced_output_frames: 0, 1907 - sample_buf: None, 1908 - resampler: Resampler::new(source_sr, output_sr, source_channels, output_channels), 1909 - producer, 1910 - ring, 1911 - fetch_cancel, 1912 - _stream: stream, 1913 - item, 1914 - volume, 1915 - paused, 1916 - _started: Instant::now(), 1917 - replaygain_info, 1918 - replaygain_linear, 1919 - rg_gains_pushed: false, 1920 - ended: false, 1921 - overlap_incoming: None, 1922 - overlap_outgoing: None, 1923 - }; 1924 - let first_out = resampler.process(&first_samples); 1925 - if !first_out.is_empty() { 1926 - let frames = first_out.len() / output_channels.max(1); 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); 1931 - track.produced_output_frames += frames as u64; 1932 - } 1933 - // Adopt the primed resampler state. 1934 - track.resampler = resampler; 1935 - Ok(track) 1936 - } 1937 - 1938 - /// Decode packets until we get non-empty audio, then return its interleaved 1939 - /// f32 samples along with the actual sample rate and channel count. 1940 - fn prime_decoder( 1941 - format: &mut Box<dyn FormatReader>, 1942 - decoder: &mut dyn Decoder, 1943 - track_id: u32, 1944 - ) -> Result<(u32, usize, Vec<f32>)> { 1945 - loop { 1946 - let packet = format.next_packet().context("no audio packet in stream")?; 1947 - if packet.track_id() != track_id { 1948 - continue; 1949 - } 1950 - let audio_buf = match decoder.decode(&packet) { 1951 - Ok(b) => b, 1952 - Err(symphonia::core::errors::Error::DecodeError(_)) => continue, 1953 - Err(e) => return Err(anyhow!("decode primer: {e}")), 1954 - }; 1955 - let spec = *audio_buf.spec(); 1956 - let cap = audio_buf.capacity() as u64; 1957 - if cap == 0 { 1958 - continue; 1959 - } 1960 - let mut sb = SampleBuffer::<f32>::new(cap, spec); 1961 - sb.copy_interleaved_ref(audio_buf); 1962 - let samples = sb.samples().to_vec(); 1963 - if samples.is_empty() { 1964 - continue; 1965 - } 1966 - return Ok((spec.rate, spec.channels.count(), samples)); 1967 - } 1968 - } 1969 - 1970 - fn ext_from_content_type(ct: &str) -> Option<&'static str> { 1971 - let ct = ct 1972 - .split(';') 1973 - .next() 1974 - .unwrap_or("") 1975 - .trim() 1976 - .to_ascii_lowercase(); 1977 - match ct.as_str() { 1978 - "audio/mpeg" | "audio/mp3" => Some("mp3"), 1979 - "audio/flac" | "audio/x-flac" => Some("flac"), 1980 - "audio/aac" => Some("aac"), 1981 - "audio/mp4" | "audio/m4a" | "audio/x-m4a" => Some("m4a"), 1982 - "audio/ogg" | "application/ogg" => Some("ogg"), 1983 - "audio/opus" => Some("opus"), 1984 - "audio/wav" | "audio/wave" | "audio/x-wav" => Some("wav"), 1985 - _ => None, 1986 - } 1987 - } 1988 - 1989 - // --------------------------------------------------------------------------- 1990 - // cpal output stream 1991 - // --------------------------------------------------------------------------- 1992 - 1993 - /// Open the default output device using its declared default config — the 1994 - /// only config guaranteed to be honored by every cpal backend. The ring 1995 - /// buffer stores samples already resampled + channel-converted to this spec, 1996 - /// so the callback is a straight copy. 1997 - fn build_output_stream( 1998 - device: &cpal::Device, 1999 - default_cfg: &cpal::SupportedStreamConfig, 2000 - paused: Arc<AtomicBool>, 2001 - ) -> Result<(cpal::Stream, Producer<f32>, Arc<SpscRb<f32>>)> { 2002 - if default_cfg.sample_format() != SampleFormat::F32 { 2003 - return Err(anyhow!( 2004 - "device default output is {:?}, only f32 is supported", 2005 - default_cfg.sample_format() 2006 - )); 2007 - } 2008 - let config: cpal::StreamConfig = default_cfg.clone().into(); 2009 - let output_sr = config.sample_rate.0; 2010 - let output_channels = config.channels as usize; 2011 - 2012 - // ~250 ms of interleaved output-rate audio. 2013 - let ring_frames = (output_sr as usize / 4).max(2048); 2014 - let rb = Arc::new(SpscRb::<f32>::new(ring_frames * output_channels)); 2015 - let producer = rb.producer(); 2016 - let consumer = rb.consumer(); 2017 - 2018 - let err_fn = |e| error!(error = %e, "cpal stream error"); 2019 - 2020 - let stream = device 2021 - .build_output_stream( 2022 - &config, 2023 - move |out: &mut [f32], _| { 2024 - if paused.load(Ordering::Relaxed) { 2025 - for s in out.iter_mut() { 2026 - *s = 0.0; 2027 - } 2028 - return; 2029 - } 2030 - let n = consumer.read(out).unwrap_or(0); 2031 - for s in &mut out[n..] { 2032 - *s = 0.0; 2033 - } 2034 - }, 2035 - err_fn, 2036 - None, 2037 - ) 2038 - .context("build cpal output stream")?; 2039 - 2040 - stream.play().context("start cpal stream")?; 2041 - Ok((stream, producer, rb)) 2042 - } 2043 - 2044 - #[cfg(test)] 2045 - mod tests { 2046 - use super::*; 2047 - 2048 - // ------------------------------------------------------------------ 2049 - // ext_from_content_type 2050 - // ------------------------------------------------------------------ 2051 - 2052 - #[test] 2053 - fn maps_common_audio_mimes_to_extensions() { 2054 - assert_eq!(ext_from_content_type("audio/mpeg"), Some("mp3")); 2055 - assert_eq!(ext_from_content_type("audio/flac"), Some("flac")); 2056 - assert_eq!(ext_from_content_type("audio/x-flac"), Some("flac")); 2057 - assert_eq!(ext_from_content_type("audio/mp4"), Some("m4a")); 2058 - assert_eq!(ext_from_content_type("audio/ogg"), Some("ogg")); 2059 - assert_eq!(ext_from_content_type("audio/opus"), Some("opus")); 2060 - assert_eq!(ext_from_content_type("audio/wav"), Some("wav")); 2061 - } 2062 - 2063 - #[test] 2064 - fn ignores_content_type_parameters_and_case() { 2065 - assert_eq!( 2066 - ext_from_content_type("audio/MPEG; charset=binary"), 2067 - Some("mp3") 2068 - ); 2069 - assert_eq!(ext_from_content_type(" Audio/FLAC "), Some("flac")); 2070 - } 2071 - 2072 - #[test] 2073 - fn unknown_mime_returns_none() { 2074 - assert_eq!(ext_from_content_type("video/mp4"), None); 2075 - assert_eq!(ext_from_content_type(""), None); 2076 - } 2077 - 2078 - // ------------------------------------------------------------------ 2079 - // Resampler::convert_channels 2080 - // ------------------------------------------------------------------ 2081 - 2082 - #[test] 2083 - fn channel_conversion_is_a_noop_for_matching_layouts() { 2084 - let r = Resampler::new(48_000, 48_000, 2, 2); 2085 - let input = vec![1.0, 2.0, 3.0, 4.0]; 2086 - assert_eq!(r.convert_channels(&input), input); 2087 - } 2088 - 2089 - #[test] 2090 - fn stereo_downmixes_to_mono_as_average() { 2091 - let r = Resampler::new(48_000, 48_000, 2, 1); 2092 - // Two frames: (L=1.0, R=3.0), (L=-1.0, R=1.0). 2093 - let input = vec![1.0, 3.0, -1.0, 1.0]; 2094 - let out = r.convert_channels(&input); 2095 - assert_eq!(out, vec![2.0, 0.0]); 2096 - } 2097 - 2098 - #[test] 2099 - fn mono_broadcasts_to_every_output_channel() { 2100 - let r = Resampler::new(48_000, 48_000, 1, 2); 2101 - let input = vec![0.5, -0.25]; 2102 - assert_eq!(r.convert_channels(&input), vec![0.5, 0.5, -0.25, -0.25]); 2103 - } 2104 - 2105 - #[test] 2106 - fn extra_output_channels_get_duplicated_front_pair() { 2107 - // Stereo → 5.1 layout: c=0/1 pass through, c>=2 fall back to L/R pattern. 2108 - let r = Resampler::new(48_000, 48_000, 2, 6); 2109 - let input = vec![1.0, 2.0]; // one frame: L=1, R=2 2110 - let out = r.convert_channels(&input); 2111 - // 6 channels: L, R, then alternating L/R fill. 2112 - assert_eq!(out, vec![1.0, 2.0, 1.0, 2.0, 1.0, 2.0]); 2113 - } 2114 - 2115 - // ------------------------------------------------------------------ 2116 - // Resampler::resample 2117 - // ------------------------------------------------------------------ 2118 - 2119 - #[test] 2120 - fn resample_is_a_noop_when_rates_match() { 2121 - let mut r = Resampler::new(44_100, 44_100, 2, 2); 2122 - let input: Vec<f32> = (0..20).map(|i| i as f32).collect(); 2123 - let out = r.resample(input.clone()); 2124 - assert_eq!(out, input); 2125 - } 2126 - 2127 - #[test] 2128 - fn downsample_48k_to_44_1k_produces_expected_frame_count() { 2129 - // 48 kHz → 44.1 kHz means output_frames ≈ input_frames * 44100/48000. 2130 - // The resampler only emits frames it can fully interpolate, so with a 2131 - // single packet we get floor rather than round — but close enough. 2132 - let mut r = Resampler::new(48_000, 44_100, 2, 2); 2133 - let input_frames = 48_000; // one second of 48 kHz 2134 - let input = vec![0.5f32; input_frames * 2]; 2135 - let out = r.resample(input); 2136 - let out_frames = out.len() / 2; 2137 - // One second at 44.1 kHz = 44100 frames. Allow ±2 for boundary rounding. 2138 - assert!( 2139 - (out_frames as i64 - 44_100).abs() <= 2, 2140 - "downsample produced {} frames, expected ~44100", 2141 - out_frames 2142 - ); 2143 - } 2144 - 2145 - #[test] 2146 - fn upsample_44_1k_to_48k_produces_expected_frame_count() { 2147 - let mut r = Resampler::new(44_100, 48_000, 2, 2); 2148 - let input_frames = 44_100; 2149 - let input = vec![0.5f32; input_frames * 2]; 2150 - let out = r.resample(input); 2151 - let out_frames = out.len() / 2; 2152 - assert!( 2153 - (out_frames as i64 - 48_000).abs() <= 2, 2154 - "upsample produced {} frames, expected ~48000", 2155 - out_frames 2156 - ); 2157 - } 2158 - 2159 - #[test] 2160 - fn cross_packet_output_matches_single_packet_output() { 2161 - // Same total input, delivered as one packet vs two halves, should 2162 - // yield essentially the same output stream. Boundary interpolation 2163 - // uses `last_frame`, so results converge within a couple of samples. 2164 - let sr_in = 48_000; 2165 - let sr_out = 44_100; 2166 - let n_frames = 4_800; // 100 ms 2167 - let input: Vec<f32> = (0..n_frames * 2).map(|i| (i as f32).sin()).collect(); 2168 - 2169 - let mut r1 = Resampler::new(sr_in, sr_out, 2, 2); 2170 - let single = r1.resample(input.clone()); 2171 - 2172 - let mut r2 = Resampler::new(sr_in, sr_out, 2, 2); 2173 - let (a, b) = input.split_at(n_frames); // half the frames = n_frames/2 samples each 2174 - let half1 = r2.resample(a.to_vec()); 2175 - let half2 = r2.resample(b.to_vec()); 2176 - let combined: Vec<f32> = half1.into_iter().chain(half2).collect(); 2177 - 2178 - // Both paths should produce close-to-identical frame counts. 2179 - let diff = (single.len() as i64 - combined.len() as i64).abs(); 2180 - assert!( 2181 - diff <= 4, 2182 - "single-packet and split-packet output differ by {} samples", 2183 - diff 2184 - ); 2185 - } 2186 - 2187 - #[test] 2188 - fn resampler_saves_last_frame_across_calls_at_matching_rates() { 2189 - // Even in the passthrough path we save last_frame so a subsequent 2190 - // rate change would interpolate cleanly. Verifies the have_last 2191 - // flag is set after any non-empty process(). 2192 - let mut r = Resampler::new(48_000, 48_000, 2, 2); 2193 - let _ = r.resample(vec![0.0, 1.0, 2.0, 3.0]); 2194 - assert!(r.have_last); 2195 - assert_eq!(r.last_frame, vec![2.0, 3.0]); 2196 - } 2197 - 2198 - // ------------------------------------------------------------------ 2199 - // Resampler::process (end-to-end: channel conv + rate conv) 2200 - // ------------------------------------------------------------------ 2201 - 2202 - #[test] 2203 - fn process_downmixes_and_resamples_together() { 2204 - let mut r = Resampler::new(48_000, 44_100, 2, 1); 2205 - // 480 stereo frames at 48 kHz = 10 ms. After downmix + downsample we 2206 - // expect ~441 mono frames. 2207 - let input: Vec<f32> = (0..480).flat_map(|i| [i as f32, -(i as f32)]).collect(); 2208 - let out = r.process(&input); 2209 - assert!( 2210 - (out.len() as i64 - 441).abs() <= 2, 2211 - "process yielded {} samples, expected ~441", 2212 - out.len() 2213 - ); 2214 - } 2215 - }
-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 - }
+2 -2
crates/fin-tui/src/app.rs
··· 999 999 } 1000 1000 1001 1001 async fn switch_to_mpv(&self) { 1002 - // Local playback: audio → symphonia, video → mpv. Persistence is 1002 + // Local playback: audio → rockbox-playback, video → mpv. Persistence is 1003 1003 // wired in so switching to local while there's a saved queue picks 1004 1004 // it up on next restart (the current session keeps whatever queue 1005 1005 // was already active). ··· 1014 1014 cfg.renderer = RendererPref::Mpv; 1015 1015 let _ = cfg.save(); 1016 1016 } 1017 - self.set_status("Streaming locally (symphonia audio, mpv video)."); 1017 + self.set_status("Streaming locally (rockbox-playback audio, mpv video)."); 1018 1018 } 1019 1019 } 1020 1020
+1 -1
crates/fin-tui/src/widgets/help.rs
··· 41 41 HelpEntry { key: "/", description: "focus Search input" }, 42 42 HelpEntry { key: "r", description: "refresh current screen" }, 43 43 HelpEntry { key: "t", description: "cycle to next saved server (Jellyfin or Subsonic)" }, 44 - HelpEntry { key: "m", description: "switch to local (symphonia + mpv) renderer" }, 44 + HelpEntry { key: "m", description: "switch to local (rockbox-playback + mpv) renderer" }, 45 45 HelpEntry { key: "q / Ctrl+C", description: "quit" }, 46 46 ], 47 47 },
+4 -4
crates/fin/src/main.rs
··· 32 32 let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); 33 33 34 34 // preflight: mpv is needed for *video* only — audio is decoded in-process 35 - // via symphonia. Warn once if it's missing so video playback fails loudly 35 + // via rockbox-playback. Warn once if it's missing so video playback fails loudly 36 36 // with an actionable message rather than silently. 37 37 if !preflight::probe_mpv() { 38 38 eprintln!( 39 - "note: mpv not found on PATH — audio still works (symphonia + cpal),\n\ 39 + "note: mpv not found on PATH — audio still works (rockbox-playback),\n\ 40 40 but video playback will fail. Install with:\n {}\n", 41 41 preflight::mpv_install_hint() 42 42 ); ··· 223 223 match cfg.renderer { 224 224 RendererPref::Mpv => { 225 225 // "Mpv" pref = local playback. Audio decodes in-process via 226 - // symphonia; video still shells out to mpv. See `LocalRenderer`. 226 + // rockbox-playback; video still shells out to mpv. See `LocalRenderer`. 227 227 let queue_path = fin_config::queue_path().ok(); 228 228 let saved = queue_path 229 229 .as_ref() ··· 342 342 }; 343 343 344 344 // The MediaRenderer device drives the same renderer cell the TUI holds, 345 - // so an incoming cast plays through symphonia (audio) / mpv (video) and 345 + // so an incoming cast plays through rockbox-playback (audio) / mpv (video) and 346 346 // shows up in the Now Playing bar like any other queue item. 347 347 let server = if mr.enabled { 348 348 let opts = fin_mediarenderer::Options {
+1 -1
crates/fin/src/preflight.rs
··· 1 1 use std::process::Command; 2 2 3 3 /// Check whether mpv is on `$PATH`. Audio playback goes through the built-in 4 - /// symphonia + cpal path and doesn't need mpv, so a missing binary is only 4 + /// rockbox-playback path and doesn't need mpv, so a missing binary is only 5 5 /// a problem when the user tries to play video. We surface a hint on the way 6 6 /// in rather than failing outright. 7 7 pub fn probe_mpv() -> bool {
+10 -2
flake.nix
··· 45 45 version = "0.5.0"; 46 46 strictDeps = true; 47 47 48 - # No native TLS or system libs needed — pure Rust deps. 48 + # rockbox-playback pulls in rockbox-codecs + rockbox-dsp, whose 49 + # build scripts compile Rockbox's C codec/DSP sources with the `cc` 50 + # crate — so a C compiler must be on PATH. `stdenv.cc` is the 51 + # toolchain for this platform (clang on Darwin, gcc on Linux). 52 + # TLS is pure-Rust (rustls), so still no openssl. 49 53 # Modern nixpkgs (post-25.05) auto-links the Darwin SDK, so no 50 54 # framework references here — `darwin.apple_sdk_11_0` was removed 51 55 # as a legacy compatibility stub. 52 56 nativeBuildInputs = [ 53 57 pkgs.pkg-config 58 + pkgs.stdenv.cc 54 59 ] ++ lib.optionals pkgs.stdenv.isDarwin [ 55 60 # coreaudio-sys generates its CoreAudio bindings with bindgen at 56 61 # build time; bindgenHook provides libclang (LIBCLANG_PATH) and ··· 157 162 inputsFrom = builtins.attrValues self.checks.${system}; 158 163 159 164 # Build-time tools. pkg-config is required so cpal's build.rs can 160 - # resolve libasound on Linux. 165 + # resolve libasound on Linux; stdenv.cc supplies the C compiler the 166 + # rockbox-codecs / rockbox-dsp build scripts need to compile 167 + # Rockbox's C sources. 161 168 nativeBuildInputs = with pkgs; [ 162 169 cargo 163 170 rustc ··· 166 173 rust-analyzer 167 174 mpv 168 175 pkg-config 176 + stdenv.cc 169 177 ]; 170 178 171 179 # Link-time libraries. Position matters: pkg-config only picks up