fix(player): surface live audio stream errors as PlaybackError

A mid-playback stream error (e.g. an ALSA buffer underrun/EPIPE) was
only visible through rodio's default error callback, which eprintln!s
and drops it: playback died with a bare PlaybackStateChanged(paused)
and no PlaybackError on the bus, so hook/SSE integrators (moOde) could
not tell a device hiccup from a user pause.

Install an error callback on the rodio output stream that records the
error on SharedState, where the playback driver already drains it into
the stream-error latch and the daemon bridges it to
CoreEvent::PlaybackError. Rate-limited to one recording per 5s since a
wedged ALSA device repeats EPIPE every period and each drained message
becomes one bus event (and one forked hook script).

Found while testing the moOde qbzevent.sh integration on-device: a
buffer underrun paused playback with no PlaybackError emitted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Filippo VicentiniandClaude Fable 5 committed 2026-08-20 10:13:42 +02:00
1 parent 8ec1f3c275
commit 83e6f92050
1 file changed
+21
+21
View File
@@ -408,6 +408,7 @@ fn create_output_stream_with_config(
sample_rate: u32,
channels: u16,
exclusive_mode: bool,
state: SharedState,
) -> Result<MixerDeviceSink, String> {
log::info!(
"Creating MixerDeviceSink: {}Hz, {} channels, exclusive: {}",
@@ -478,9 +479,25 @@ fn create_output_stream_with_config(
// Create MixerDeviceSink with custom config
match DeviceSinkBuilder::from_device(device) {
Ok(builder) => {
// rodio's default error callback only eprintln!s a live stream
// error (e.g. an ALSA buffer underrun mid-playback), so playback
// dies without the driver ever seeing a message to latch. Record
// it on the shared state instead so it drains to the event bus as
// a PlaybackError. Rate-limited: ALSA can repeat EPIPE every
// period on a wedged device, and each recorded message is one
// bus event (and one forked hook script) after the drain.
let mut last_reported: Option<std::time::Instant> = None;
match builder
.with_supported_config(&supported_config)
.with_buffer_size(cpal_buffer_size)
.with_error_callback(move |err| {
log::error!("Audio stream error: {err}");
let now = std::time::Instant::now();
if last_reported.map_or(true, |t| now.duration_since(t).as_secs() >= 5) {
last_reported = Some(now);
state.record_stream_error(format!("Audio stream error: {err}"));
}
})
.open_stream()
{
Ok(mixer_sink) => {
@@ -1777,6 +1794,7 @@ impl Player {
sample_rate,
channels,
dac_passthrough,
thread_state.clone(),
)
.map(StreamType::rodio)
};
@@ -1868,6 +1886,7 @@ impl Player {
sample_rate,
channels,
dac_passthrough,
thread_state.clone(),
)
.map(StreamType::rodio)
};
@@ -2249,6 +2268,7 @@ impl Player {
sample_rate,
channels,
dac_passthrough,
thread_state.clone(),
)
.map(StreamType::rodio)
};
@@ -2323,6 +2343,7 @@ impl Player {
sample_rate,
channels,
dac_passthrough,
thread_state.clone(),
)
.map(StreamType::rodio)
};