fix(audio): #508 ALSA-exclusive + streaming regression; flatpak ReserveDevice1 own-name

Store the CMAF duration at command-accept so the seek bar and progress can't
freeze at 0:00, backfill it on the Resume rebuild, and clear + surface the
zombie stream-create-failure path. Degrade every non-priority device-reservation
contention error (only a higher-priority holder stays fatal) so a sandboxed
D-Bus RequestName denial can't block bit-perfect playback. Honor the
stream_buffer_seconds floor in the dynamic CMAF path and wire the network
throttle (record_segment_bandwidth / record_underrun / prefetch cap) to tame the
#591 underrun loop. Grant --own-name=org.freedesktop.ReserveDevice1.* in the
repo flatpak manifest so WirePlumber releases the card cleanly.

cc
This commit is contained in:
vicrodh committed 2026-07-16 16:29:58 -06:00
1 parent 5ae5d8e651
commit f778874fdd
5 files changed
+163 -19

No files matched your search

+86 -8
View File
@@ -2150,6 +2150,14 @@ impl Player {
*current_audio_data = None; // Clear regular audio data
thread_state.set_loaded_audio(true);
// Track metadata is known-good at command time. Store the
// duration NOW — current_position() clamps to it, so leaving
// it 0 until after the buffer wait freezes the bar at 0:00,
// and a Resume that recovers this source after a failed setup
// would pin the bar at 0:00 forever (#508 follow-up, 2.0.1
// Flatpak report 2026-07-15).
thread_state.duration.store(duration_secs, Ordering::SeqCst);
let StreamRecreateDecision {
needs_new_stream,
format_changed,
@@ -2297,7 +2305,17 @@ impl Player {
log::error!(
"No audio output device available for streaming"
);
thread_state.set_stream_error(true);
// Same cleanup as the failure arms below: the
// streaming source was already stored at
// command-accept, and a Resume that finds it
// would replay a track that never started.
*current_streaming_source = None;
*current_audio_data = None;
thread_state.set_loaded_audio(false);
thread_state.is_playing.store(false, Ordering::SeqCst);
thread_state.record_stream_error(
"No audio output device available for streaming",
);
return;
};
create_output_stream_with_config(
@@ -2325,7 +2343,18 @@ impl Player {
sample_rate,
e
);
thread_state.set_stream_error(true);
// Backend AND legacy CPAL both failed — a real
// error the user must see. Clear the zombie
// streaming state (same cleanup as the sibling
// failure arms below); leaving it set lets a
// later Resume replay the completed download
// with duration still 0, pinning the bar at
// 0:00 forever (#508/#592).
*current_streaming_source = None;
*current_audio_data = None;
thread_state.set_loaded_audio(false);
thread_state.is_playing.store(false, Ordering::SeqCst);
thread_state.record_stream_error(e);
return;
}
}
@@ -3092,6 +3121,21 @@ impl Player {
}
};
// A Resume that rebuilds from completed streaming data
// can run after a PlayStreaming that failed BEFORE
// storing the duration. Backfill from the decoded
// source so the position clamp (current_position)
// doesn't pin the bar at 0:00 (#508). Same derivation
// the Play handler uses. Must read total_duration()
// BEFORE skip_duration consumes `source` below.
if thread_state.duration.load(Ordering::SeqCst) == 0 {
if let Some(d) = source.total_duration() {
thread_state
.duration
.store(d.as_secs(), Ordering::SeqCst);
}
}
let resume_pos = thread_state.position.load(Ordering::SeqCst);
let skipped_source: Box<dyn Source<Item = f32> + Send> =
if resume_pos > 0 {
@@ -4590,16 +4634,21 @@ impl Player {
// Progress logging every 5 segments or on the last segment.
if seg_idx % 5 == 0 || seg_idx == n_segments {
let elapsed = start.elapsed().as_secs_f64();
let mbps = if elapsed > 0.0 {
total_written as f64 / (1024.0 * 1024.0) / elapsed
} else {
0.0
};
// Feed the adaptive prefetch throttle with the cumulative MB/s
// the log already reports; also the offline detector's
// positive liveness signal (#467, #591).
qbz_audio::network_throttle::state().record_segment_bandwidth(mbps);
log::info!(
"[CMAF-STREAM] Segment {}/{} ({:.1} MB, {:.1} MB/s)",
seg_idx,
n_segments - 1,
total_written as f64 / (1024.0 * 1024.0),
if elapsed > 0.0 {
total_written as f64 / (1024.0 * 1024.0) / elapsed
} else {
0.0
}
mbps
);
}
}
@@ -5028,7 +5077,36 @@ impl Player {
self.state.set_stream_quality(sample_rate, bit_depth);
// Use StreamingConfig::from_speed_mbps for dynamic buffer sizing
let config = StreamingConfig::from_speed_mbps(speed_mbps);
let mut config = StreamingConfig::from_speed_mbps(speed_mbps);
// Floor the initial buffer at the user's `stream_buffer_seconds` worth
// of REAL audio bytes (#591). content_length / duration is the track's
// true average byterate (both derive from the CMAF segment table),
// unlike `speed_mbps`, which is estimated from the tiny init fetch and
// is latency-dominated — it lands on the slowest ladder rung for every
// connection. Clamped to 256KB (format-detection minimum) .. 8MB (the
// process-wide ladder cap has no desktop caller, so this is the
// effective ceiling protecting low-memory hosts).
let user_secs = self
.audio_settings
.lock()
.map(|s| s.stream_buffer_seconds)
.unwrap_or(2);
let bps = content_length / duration_secs.max(1);
let floor = (user_secs as u64).saturating_mul(bps) as usize;
let ladder_bytes = config.initial_buffer_bytes;
config.initial_buffer_bytes = ladder_bytes
.max(floor)
.clamp(256 * 1024, 8 * 1024 * 1024);
if config.initial_buffer_bytes != ladder_bytes {
log::info!(
"Dynamic buffer: user floor {}s x {} B/s → {}KB initial buffer (ladder gave {}KB)",
user_secs,
bps,
config.initial_buffer_bytes / 1024,
ladder_bytes / 1024
);
}
let (source, writer) = BufferedMediaSource::new(config, Some(content_length));
let source = Arc::new(source);
@@ -580,6 +580,11 @@ pub struct IncrementalStreamingSource {
finished: bool,
/// Number of packets decoded (for stats)
packets_decoded: u64,
/// True while inside a WouldBlock stall episode (playback caught up
/// with the download). Set on the first WouldBlock after at least one
/// decoded packet, cleared on the next successful decode — so each
/// episode records exactly one underrun with the network throttle.
stalled: bool,
/// Reference to the buffered source (for cache retrieval after playback)
buffered_source: Arc<BufferedMediaSource>,
}
@@ -643,6 +648,7 @@ impl IncrementalStreamingSource {
track_id,
finished: false,
packets_decoded: 0,
stalled: false,
buffered_source,
})
}
@@ -708,6 +714,13 @@ impl IncrementalStreamingSource {
{
// Not enough data buffered yet - wait briefly and retry
// This happens when playback catches up with download
if !self.stalled && self.packets_decoded > 0 {
// Mid-playback stall, not initial buffering (≥1 packet
// already decoded): put the prefetch throttle in panic
// mode so the live stream gets the pipe to itself (#591).
self.stalled = true;
qbz_audio::network_throttle::state().record_underrun();
}
std::thread::sleep(Duration::from_millis(5));
continue;
}
@@ -741,6 +754,9 @@ impl IncrementalStreamingSource {
self.sample_queue
.extend(sample_buf.samples().iter().copied());
self.packets_decoded += 1;
// Successful decode ends any stall episode; the next
// WouldBlock streak records a fresh underrun.
self.stalled = false;
}
Err(SymphoniaError::DecodeError(e)) => {
log::warn!("Decode error (skipping packet): {}", e);