From f02b05af134c4a993ccb95e85d639a2588d09f61 Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Fri, 31 Jul 2026 10:26:53 -0600 Subject: [PATCH] take system audio from the default sink monitor Source selection returned the first monitor PulseAudio enumerated and used the default sink name only to report whether that guess was right. A USB microphone that also exposes an output enumerates ahead of the real speakers, so the chosen monitor was one nothing ever plays to and the system channel recorded digital silence while warning about it every few seconds. Prefer the monitor of the default sink, falling back to the first monitor only when there is no default sink or no monitor matches it. Announce a selection only when it changes, so a steady desktop stops rewriting the same two log lines every tick. --- crates/solstone-linux/src/sources.rs | 58 +++++++++++++++++++++-- crates/solstone-linux/src/subscription.rs | 10 +++- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/crates/solstone-linux/src/sources.rs b/crates/solstone-linux/src/sources.rs index 26463d0..b88208d 100644 --- a/crates/solstone-linux/src/sources.rs +++ b/crates/solstone-linux/src/sources.rs @@ -52,9 +52,22 @@ pub fn classify_sources( default_sink_name: Option<&str>, microphone_override: Option<&str>, ) -> Result { - let monitor = sources - .iter() - .find(|source| source.monitor_of_sink.is_some()) + // The default sink's monitor is the one carrying what the owner actually hears. + // Enumeration order is not that: a USB microphone that also exposes an output + // sorts ahead of the real speakers, and its monitor is permanently silent. + // Fall back to the first monitor only when there is no default sink to match. + let monitor = default_sink_name + .and_then(|default| { + sources.iter().find(|source| { + source.monitor_of_sink.is_some() + && source.monitor_of_sink_name.as_deref() == Some(default) + }) + }) + .or_else(|| { + sources + .iter() + .find(|source| source.monitor_of_sink.is_some()) + }) .cloned(); let microphone = if let Some(name) = microphone_override { match sources @@ -132,6 +145,45 @@ mod tests { assert_eq!(selected.monitor_matches_default_sink, Some(true)); } + #[test] + fn default_sink_monitor_wins_over_enumeration_order() { + // The shape that silenced system audio in the field: a USB microphone that + // also exposes an output enumerates first, so its monitor — which nothing + // ever plays to — was chosen over the default sink's. + let sources = vec![ + microphone(1, "usb-mic"), + monitor(2, "usb-mic.monitor", Some("usb-mic-sink")), + monitor(3, "speakers.monitor", Some("speakers")), + ]; + let selected = classify_sources(&sources, Some("speakers"), None).unwrap(); + assert_eq!(selected.monitor.index, 3); + assert_eq!(selected.monitor_matches_default_sink, Some(true)); + } + + #[test] + fn unmatched_default_sink_falls_back_to_first_monitor() { + let sources = vec![ + microphone(1, "mic"), + monitor(2, "a.monitor", Some("sink-a")), + monitor(3, "b.monitor", Some("sink-b")), + ]; + let selected = classify_sources(&sources, Some("absent-sink"), None).unwrap(); + assert_eq!(selected.monitor.index, 2); + assert_eq!(selected.monitor_matches_default_sink, Some(false)); + } + + #[test] + fn no_default_sink_falls_back_to_first_monitor() { + let sources = vec![ + microphone(1, "mic"), + monitor(2, "a.monitor", Some("sink-a")), + monitor(3, "b.monitor", Some("sink-b")), + ]; + let selected = classify_sources(&sources, None, None).unwrap(); + assert_eq!(selected.monitor.index, 2); + assert_eq!(selected.monitor_matches_default_sink, None); + } + #[test] fn monitor_first_order_does_not_swap_roles() { let sources = vec![monitor(2, "monitor", Some("other")), microphone(1, "mic")]; diff --git a/crates/solstone-linux/src/subscription.rs b/crates/solstone-linux/src/subscription.rs index 4d49356..ffd4508 100644 --- a/crates/solstone-linux/src/subscription.rs +++ b/crates/solstone-linux/src/subscription.rs @@ -83,9 +83,17 @@ pub fn transition(mut state: SubscriptionState, event: SubscriptionEvent) -> Tra }), SubscriptionEvent::ServerSubscriptionChanged => vec![QueryDefaultSink], SubscriptionEvent::SourcesResolved(Ok(selection)) => { + // The redetect backstop re-resolves sources every few seconds. Announce a + // selection only when it actually changes, or a steady-state desktop writes + // the same two lines to the system log every tick, forever. + let changed = state.source_selection.as_ref() != Some(&selection); state.source_selection = Some(selection.clone()); state.degraded_reason = None; - vec![ApplySourceSelection(selection)] + if changed { + vec![ApplySourceSelection(selection)] + } else { + Vec::new() + } } SubscriptionEvent::SourcesResolved(Err(error)) => { let reason = error.to_string(); -- 2.51.2