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();