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

Configure Feed

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

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).

Tsiry Sandratraina (Jul 13, 2026, 9:57 PM +0300) 472c394a 5b8a0802

+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