diff --git a/crates/qbz-player/src/player/mod.rs b/crates/qbz-player/src/player/mod.rs index cf21ce5be..e5b572499 100644 --- a/crates/qbz-player/src/player/mod.rs +++ b/crates/qbz-player/src/player/mod.rs @@ -14,7 +14,8 @@ mod playback_engine; mod streaming_source; pub use streaming_source::{ - BufferWriter, BufferedMediaSource, InMemorySource, IncrementalStreamingSource, StreamingConfig, + max_initial_buffer_bytes, set_max_initial_buffer_bytes, BufferWriter, BufferedMediaSource, + InMemorySource, IncrementalStreamingSource, StreamingConfig, }; use rodio::buffer::SamplesBuffer; diff --git a/crates/qbz-player/src/player/streaming_source.rs b/crates/qbz-player/src/player/streaming_source.rs index 4276cda5a..2a52e6ac7 100644 --- a/crates/qbz-player/src/player/streaming_source.rs +++ b/crates/qbz-player/src/player/streaming_source.rs @@ -90,32 +90,85 @@ impl StreamingConfig { /// - Normal (2-5 MB/s): 512KB /// - Slow (1-2 MB/s): 1MB (more buffer to prevent stutter) /// - Very slow (<1 MB/s): 2MB + /// + /// Result is clamped to the process-wide cap configured via + /// [`set_max_initial_buffer_bytes`] (typically derived from the host's + /// memory profile — see qbz-core's system_capabilities). On + /// memory-constrained hosts the slow-connection branches would + /// otherwise inflate to 2 MB, which is exactly the wrong direction + /// when "slow connection" is itself a symptom of swap thrash + /// (issue #331, Pi 3B). pub fn from_speed_mbps(speed_mbps: f64) -> Self { - let initial_buffer = if speed_mbps >= 10.0 { - 256 * 1024 // 256KB - instant start for very fast connections - } else if speed_mbps >= 5.0 { - 384 * 1024 // 384KB - } else if speed_mbps >= 2.0 { - 512 * 1024 // 512KB - default - } else if speed_mbps >= 1.0 { - 1024 * 1024 // 1MB - more buffer for slower connections + let cap = MAX_INITIAL_BUFFER_BYTES.load(std::sync::atomic::Ordering::Relaxed); + let cfg = Self::from_speed_mbps_with_cap(speed_mbps, cap); + + if cfg.initial_buffer_bytes < raw_initial_buffer_for_speed(speed_mbps) { + log::info!( + "Dynamic buffer: {:.1} MB/s detected → {}KB (capped from {}KB by host memory profile)", + speed_mbps, + cfg.initial_buffer_bytes / 1024, + raw_initial_buffer_for_speed(speed_mbps) / 1024 + ); } else { - 2 * 1024 * 1024 // 2MB - maximum buffer for very slow connections - }; + log::info!( + "Dynamic buffer: {:.1} MB/s detected → {}KB initial buffer", + speed_mbps, + cfg.initial_buffer_bytes / 1024 + ); + } - log::info!( - "Dynamic buffer: {:.1} MB/s detected → {}KB initial buffer", - speed_mbps, - initial_buffer / 1024 - ); + cfg + } + /// Pure variant of [`from_speed_mbps`] — derives the speed-based + /// initial buffer and clamps to `cap` without touching global state + /// or logging. Exposed for unit tests; production callers should use + /// `from_speed_mbps`, which reads the process-wide cap. + pub fn from_speed_mbps_with_cap(speed_mbps: f64, cap: usize) -> Self { + let raw_initial_buffer = raw_initial_buffer_for_speed(speed_mbps); Self { - initial_buffer_bytes: initial_buffer, + initial_buffer_bytes: raw_initial_buffer.min(cap), max_buffer_bytes: 100 * 1024 * 1024, } } } +/// Speed-driven initial buffer size, before any cap is applied. +/// Pure function — used by both `from_speed_mbps` and +/// `from_speed_mbps_with_cap` so they share the same ladder. +fn raw_initial_buffer_for_speed(speed_mbps: f64) -> usize { + if speed_mbps >= 10.0 { + 256 * 1024 // 256KB - instant start for very fast connections + } else if speed_mbps >= 5.0 { + 384 * 1024 // 384KB + } else if speed_mbps >= 2.0 { + 512 * 1024 // 512KB - default + } else if speed_mbps >= 1.0 { + 1024 * 1024 // 1MB - more buffer for slower connections + } else { + 2 * 1024 * 1024 // 2MB - maximum buffer for very slow connections + } +} + +/// Process-wide cap for dynamically-derived initial buffer sizes. +/// Defaults to `usize::MAX` (no cap) so behavior is unchanged unless the +/// host explicitly configures it via [`set_max_initial_buffer_bytes`] — +/// typically once at process start, derived from the detected memory +/// profile. +static MAX_INITIAL_BUFFER_BYTES: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(usize::MAX); + +/// Set the process-wide cap for `StreamingConfig::from_speed_mbps`. +/// Subsequent calls to that constructor clamp their result to this cap. +pub fn set_max_initial_buffer_bytes(bytes: usize) { + MAX_INITIAL_BUFFER_BYTES.store(bytes, std::sync::atomic::Ordering::Relaxed); +} + +/// Read the current cap. Mainly useful for tests. +pub fn max_initial_buffer_bytes() -> usize { + MAX_INITIAL_BUFFER_BYTES.load(std::sync::atomic::Ordering::Relaxed) +} + /// Internal state shared between reader and writer struct BufferState { /// Accumulated data from HTTP response @@ -929,6 +982,64 @@ mod tests { use std::thread; use std::time::Duration; + #[test] + fn raw_initial_buffer_for_speed_follows_documented_ladder() { + // Each band of the documented speed ladder produces its own size. + assert_eq!(raw_initial_buffer_for_speed(20.0), 256 * 1024); + assert_eq!(raw_initial_buffer_for_speed(10.0), 256 * 1024); + assert_eq!(raw_initial_buffer_for_speed(7.0), 384 * 1024); + assert_eq!(raw_initial_buffer_for_speed(5.0), 384 * 1024); + assert_eq!(raw_initial_buffer_for_speed(3.0), 512 * 1024); + assert_eq!(raw_initial_buffer_for_speed(2.0), 512 * 1024); + assert_eq!(raw_initial_buffer_for_speed(1.5), 1024 * 1024); + assert_eq!(raw_initial_buffer_for_speed(1.0), 1024 * 1024); + assert_eq!(raw_initial_buffer_for_speed(0.5), 2 * 1024 * 1024); + assert_eq!(raw_initial_buffer_for_speed(0.0), 2 * 1024 * 1024); + } + + #[test] + fn from_speed_mbps_with_cap_passes_through_when_under_cap() { + // Cap above the raw value: result equals the raw ladder. + let cfg = StreamingConfig::from_speed_mbps_with_cap(0.0, 4 * 1024 * 1024); + assert_eq!(cfg.initial_buffer_bytes, 2 * 1024 * 1024); + } + + #[test] + fn from_speed_mbps_with_cap_clamps_slow_connection_to_low_memory_cap() { + // The case from issue #331: Pi 3B, slow connection because of swap + // thrash, would otherwise inflate the buffer to 2 MB. With the + // LowMemory profile's 256KB cap applied, we stay at 256KB. + let cfg = StreamingConfig::from_speed_mbps_with_cap(0.0, 256 * 1024); + assert_eq!(cfg.initial_buffer_bytes, 256 * 1024); + + let cfg = StreamingConfig::from_speed_mbps_with_cap(1.5, 256 * 1024); + assert_eq!(cfg.initial_buffer_bytes, 256 * 1024); + } + + #[test] + fn from_speed_mbps_with_cap_no_op_for_normal_profile() { + // Normal profile cap is 2 MB — equal to the slowest raw band, so + // any raw value passes through unchanged. + let cap = 2 * 1024 * 1024; + for speed in [0.0, 0.5, 1.0, 2.0, 5.0, 10.0, 20.0] { + let cfg = StreamingConfig::from_speed_mbps_with_cap(speed, cap); + assert_eq!( + cfg.initial_buffer_bytes, + raw_initial_buffer_for_speed(speed), + "cap should not bind for speed={}", + speed + ); + } + } + + #[test] + fn from_speed_mbps_with_cap_max_buffer_unchanged() { + // Whatever the cap, the secondary max_buffer_bytes stays at its + // module default; we are only clamping the initial fill target. + let cfg = StreamingConfig::from_speed_mbps_with_cap(0.5, 64 * 1024); + assert_eq!(cfg.max_buffer_bytes, 100 * 1024 * 1024); + } + #[test] fn test_basic_read_write() { let config = StreamingConfig { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5100d837c..810d81e1c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -518,7 +518,14 @@ pub fn run() { // playback action. The profile is cached in qbz-core's OnceLock, // so subsequent callers (commands_v2/playback.rs, etc.) hit the // cache. - let _ = qbz_core::system_capabilities::memory_profile(); + let mem_profile = qbz_core::system_capabilities::memory_profile(); + + // Wire the streaming-buffer cap into qbz-player's process-wide state + // so StreamingConfig::from_speed_mbps clamps to the host's memory + // profile. Without this, slow connections inflate the initial + // buffer to 2 MB — counterproductive on a memory-pressured host + // where the slowness is itself the symptom (issue #331). + qbz_player::player::set_max_initial_buffer_bytes(mem_profile.max_initial_buffer_bytes); #[cfg(target_os = "linux")] apply_linux_webkit_workarounds();