diff --git a/src/app.rs b/src/app.rs index 6c973d2..665facd 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,7 +4,7 @@ use std::collections::HashSet; use std::sync::{mpsc, Arc}; use std::time::{Duration, Instant}; -use crate::config::{Config, Peaks, TabKind}; +use crate::config::{Config, Peaks, TabKind, UnifiedImbalance}; use crate::wirehose::state::CaptureEligibility; use crate::wirehose::{ media_class, CommandSender, Event as PipewireEvent, PeakProcessor, @@ -58,8 +58,31 @@ pub enum Action { TabRight, SelectTab(usize), SetAbsoluteVolume(f32), + /// Sets the volume of a single channel by index (into the selected + /// node's own `positions`/`volumes`), leaving every other channel + /// alone - unlike `SetAbsoluteVolume`, which applies to every channel + /// together. No-op if the selected node doesn't have that many + /// channels. + SetChannelAbsoluteVolume(usize, f32), + /// Adjusts the volume of a single channel by index, relative to its + /// own current value - the per-channel counterpart to + /// `SetRelativeVolume`, which adjusts every channel together instead. + SetChannelRelativeVolume(usize, f32), + /// Switches directly to one of the three views (Unified/Linked/ + /// Channels - see `ChannelView`), regardless of `view_cycle`. + SelectView(crate::config::ChannelView), + /// Advances to the next view in `view_cycle`, wrapping. + CycleView, #[serde(skip_deserializing)] SelectObject(ObjectId), + /// Moves the channel-mode cursor to a specific channel index, without + /// changing which node is selected. Mouse-only (like `SelectObject`) - + /// paired with it in a channel row's own click mouse areas so clicking + /// a channel also targets it for subsequent keyboard volume keys, + /// rather than leaving a stale `selected_channel` from whatever was + /// last cursored via the keyboard. + #[serde(skip_deserializing)] + SelectChannel(usize), #[serde(skip_deserializing)] SetTarget(view::Target), // This can be used to delete a default keybinding - make it do nothing. @@ -79,14 +102,29 @@ impl std::fmt::Display for Action { Action::SelectObject(object_id) => { write!(f, "Select object {object_id:?}") } + Action::SelectChannel(channel) => { + write!(f, "Select channel {channel}") + } Action::SetTarget(_) => write!(f, "Set target"), Action::ToggleMute => write!(f, "Toggle mute"), Action::SetAbsoluteVolume(vol) => { write!(f, "Set volume to {}%", Self::format_percentage(*vol)) } + Action::SetChannelAbsoluteVolume(channel, vol) => { + write!( + f, + "Set channel {channel} volume to {}%", + Self::format_percentage(*vol) + ) + } Action::SetRelativeVolume(vol) => { Self::format_relative_volume(f, *vol) } + Action::SetChannelRelativeVolume(channel, vol) => { + Self::format_channel_relative_volume(f, *channel, *vol) + } + Action::SelectView(view) => write!(f, "Switch to {view:?} view"), + Action::CycleView => write!(f, "Cycle view"), Action::SetDefault => write!(f, "Set default"), Action::Help => write!(f, "Show/hide help"), Action::Exit => write!(f, "Exit wiremix"), @@ -115,6 +153,31 @@ impl Action { } } } + + fn format_channel_relative_volume( + f: &mut std::fmt::Formatter<'_>, + channel: usize, + vol: f32, + ) -> std::fmt::Result { + match vol { + 0.01 => write!(f, "Increment channel {channel} volume"), + -0.01 => write!(f, "Decrement channel {channel} volume"), + v if v >= 0.0 => { + write!( + f, + "Increase channel {channel} volume by {}%", + Self::format_percentage(v) + ) + } + v => { + write!( + f, + "Decrease channel {channel} volume by {}%", + Self::format_percentage(-v) + ) + } + } + } } struct Tab { @@ -214,6 +277,11 @@ pub struct App<'a> { capturable_objects: HashSet, /// Objects currently being captured. capturing_objects: HashSet, + /// Fixed reference point for `unified_imbalance = "cycle"`'s + /// stateless phase-offset timing (see `cycling_channel` in + /// `node_widget.rs`) - elapsed time since this is recomputed fresh + /// every render, nothing else is stored per-node. + start_time: Instant, } macro_rules! current_list { @@ -228,7 +296,16 @@ impl<'a> App<'a> { rx: mpsc::Receiver, config: Config, ) -> Self { - let tabs = config.tabs.iter().copied().map(Tab::from).collect(); + let mut tabs: Vec = + config.tabs.iter().copied().map(Tab::from).collect(); + for tab in &mut tabs { + tab.list.view = config.initial_view; + tab.list.unified_imbalance = config.unified_imbalance; + tab.list.unified_imbalance_cycle_seconds = + config.unified_imbalance_cycle_seconds; + tab.list.split_style = config.split_style; + tab.list.pair_label_style = config.pair_label_style; + } // Update peaks with VU-meter-style ballistics let peak_processor = |new_peak, current_peak, samples, rate| { @@ -261,9 +338,17 @@ impl<'a> App<'a> { peak_processor: Arc::new(peak_processor), capturable_objects: HashSet::new(), capturing_objects: HashSet::new(), + start_time: Instant::now(), } } + /// How often the event loop wakes up on its own (absent any real + /// event) to redraw purely for unified_imbalance = "cycle"'s sake - + /// see the call site in `run()`. Well under the ~1.5s per-channel + /// interval `cycling_channel` itself uses, so a channel swap never + /// waits noticeably longer than that to actually appear. + const CYCLING_WAKEUP_INTERVAL: Duration = Duration::from_millis(250); + pub fn run(mut self, terminal: &mut DefaultTerminal) -> Result<()> { // Wait until we've received all initial data from PipeWire let _ = terminal.draw(|frame| { @@ -311,11 +396,31 @@ impl<'a> App<'a> { })?; } - needs_render |= self.handle_events( - // If there's no fps limit, we definitely rendered in this - // iteration, so needs_render is false, and there is no timeout. - needs_render.then_some(pacer.duration_until_next_frame()), - )?; + // If there's no fps limit, we definitely rendered in this + // iteration, so needs_render is false, and there is no timeout. + // + // Otherwise, when nothing else needs a redraw, we'd normally + // block indefinitely for the next real event (keypress or + // PipeWire state change) - fine for everything else, since + // they're all driven by state that's already known to have + // changed. unified_imbalance = "cycle" is the one exception: + // its display depends purely on wall-clock time, with no + // event of its own to wake this loop up. Waking on our own + // timer whenever it's configured, and treating that wake-up + // itself as a reason to redraw (handle_events correctly + // returns false for it - no *event* was handled - so it + // can't be the one to set needs_render here), guarantees a + // redraw at least every CYCLING_WAKEUP_INTERVAL even when + // PipeWire and the keyboard both stay silent, so the cycling + // label doesn't freeze indefinitely during a quiet moment. + let cycling_wakeup = !needs_render + && self.config.unified_imbalance == UnifiedImbalance::Cycle; + let timeout = needs_render + .then_some(pacer.duration_until_next_frame()) + .or(cycling_wakeup.then_some(Self::CYCLING_WAKEUP_INTERVAL)); + + needs_render |= self.handle_events(timeout)?; + needs_render |= cycling_wakeup; } self.error_message.map_or(Ok(()), |s| Err(anyhow!(s))) @@ -326,6 +431,7 @@ impl<'a> App<'a> { current_tab_index: self.current_tab_index, view: &self.view, config: &self.config, + elapsed_seconds: self.start_time.elapsed().as_secs_f32(), }; let mut widget_state = AppWidgetState { mouse_areas: &mut self.mouse_areas, @@ -607,25 +713,65 @@ impl Handle for Action { Action::SelectObject(object_id) => { app.tabs[app.current_tab_index].list.selected = Some(object_id) } + Action::SelectChannel(channel) => { + app.tabs[app.current_tab_index].list.selected_channel = + Some(channel); + } Action::ToggleMute => { current_list!(app).toggle_mute(&app.view); } + Action::SelectView(target) => { + current_list!(app).select_view(target, &app.view); + } + Action::CycleView => { + let view_cycle = app.config.view_cycle.clone(); + current_list!(app).cycle_channel_view(&view_cycle, &app.view); + } Action::SetAbsoluteVolume(volume) => { let max = app .config .enforce_max_volume .then_some(app.config.max_volume_percent); + // In channel mode, the whole-node volume keys target only + // the currently-cursored channel instead - see §7.4. + if let Some(channel) = current_list!(app).selected_channel { + return Ok(current_list!(app).set_channel_absolute_volume( + &app.view, channel, volume, max, + )); + } current_list!(app).set_absolute_volume(&app.view, volume, max); return Ok(current_list!(app) .set_absolute_volume(&app.view, volume, max)); } + Action::SetChannelAbsoluteVolume(channel, volume) => { + let max = app + .config + .enforce_max_volume + .then_some(app.config.max_volume_percent); + return Ok(current_list!(app).set_channel_absolute_volume( + &app.view, channel, volume, max, + )); + } Action::SetRelativeVolume(volume) => { // Relative decreases have no maximum. let max = (volume > 0.0 && app.config.enforce_max_volume) .then_some(app.config.max_volume_percent); + if let Some(channel) = current_list!(app).selected_channel { + return Ok(current_list!(app).set_channel_relative_volume( + &app.view, channel, volume, max, + )); + } return Ok(current_list!(app) .set_relative_volume(&app.view, volume, max)); } + Action::SetChannelRelativeVolume(channel, volume) => { + // Relative decreases have no maximum. + let max = (volume > 0.0 && app.config.enforce_max_volume) + .then_some(app.config.max_volume_percent); + return Ok(current_list!(app).set_channel_relative_volume( + &app.view, channel, volume, max, + )); + } Action::SetDefault => { current_list!(app).set_default(&app.view); } @@ -732,6 +878,7 @@ pub struct AppWidget<'a, 'b> { current_tab_index: usize, view: &'a View<'b>, config: &'a Config, + elapsed_seconds: f32, } pub struct AppWidgetState<'a> { @@ -797,6 +944,7 @@ impl<'a> StatefulWidget for AppWidget<'a, '_> { object_list: &mut state.tabs[self.current_tab_index].list, view: self.view, config: self.config, + elapsed_seconds: self.elapsed_seconds, }; widget.render(list_area, buf, state.mouse_areas); @@ -881,6 +1029,17 @@ mod tests { tab: 0, tabs: vec![TabKind::Playback], lazy_capture: Default::default(), + initial_view: Default::default(), + unified_imbalance: Default::default(), + unified_imbalance_cycle_seconds: Default::default(), + split_style: Default::default(), + pair_label_style: Default::default(), + view_cycle: Default::default(), + unified_meter_layout: Default::default(), + linked_meter_layout: Default::default(), + channels_meter_layout: Default::default(), + expand_unused_label_space: Default::default(), + expand_unpaired_channel_bars: Default::default(), filters: Default::default(), }; @@ -982,6 +1141,17 @@ mod tests { TabKind::Configuration, ], lazy_capture: Default::default(), + initial_view: Default::default(), + unified_imbalance: Default::default(), + unified_imbalance_cycle_seconds: Default::default(), + split_style: Default::default(), + pair_label_style: Default::default(), + view_cycle: Default::default(), + unified_meter_layout: Default::default(), + linked_meter_layout: Default::default(), + channels_meter_layout: Default::default(), + expand_unused_label_space: Default::default(), + expand_unpaired_channel_bars: Default::default(), filters: Default::default(), }; let mut app = App::new(&wirehose, event_rx, config); @@ -1128,6 +1298,183 @@ mod tests { assert!(Action::SetAbsoluteVolume(0.90).handle(&mut app).unwrap()); } + #[test] + fn channel_volume_limit_not_enforcing() { + let wirehose = mock::WirehoseHandle::default(); + let mut app = fixture(&wirehose); + app.config.max_volume_percent = 100.0; + app.config.enforce_max_volume = false; + + // Channel 0 is currently at 100% + + // 110% is allowed + assert!(Action::SetChannelRelativeVolume(0, 0.10) + .handle(&mut app) + .unwrap()); + assert!(Action::SetChannelAbsoluteVolume(0, 1.10) + .handle(&mut app) + .unwrap()); + + // 90% is allowed + assert!(Action::SetChannelRelativeVolume(0, -0.10) + .handle(&mut app) + .unwrap()); + assert!(Action::SetChannelAbsoluteVolume(0, 0.90) + .handle(&mut app) + .unwrap()); + } + + #[test] + fn channel_volume_limit_at_max() { + let wirehose = mock::WirehoseHandle::default(); + let mut app = fixture(&wirehose); + app.config.max_volume_percent = 100.0; + app.config.enforce_max_volume = true; + + // Channel 0 is currently at 100% + + // 110% is not allowed + assert!(!Action::SetChannelRelativeVolume(0, 0.10) + .handle(&mut app) + .unwrap()); + assert!(!Action::SetChannelAbsoluteVolume(0, 1.10) + .handle(&mut app) + .unwrap()); + + // 90% is allowed + assert!(Action::SetChannelRelativeVolume(0, -0.10) + .handle(&mut app) + .unwrap()); + assert!(Action::SetChannelAbsoluteVolume(0, 0.90) + .handle(&mut app) + .unwrap()); + + // 100% is allowed + assert!(Action::SetChannelAbsoluteVolume(0, 1.00) + .handle(&mut app) + .unwrap()); + } + + #[test] + fn channel_volume_out_of_range_is_noop() { + let wirehose = mock::WirehoseHandle::default(); + let mut app = fixture(&wirehose); + app.config.enforce_max_volume = false; + + // The fixture node only has 2 channels (indices 0 and 1) + assert!(!Action::SetChannelRelativeVolume(2, -0.10) + .handle(&mut app) + .unwrap()); + assert!(!Action::SetChannelAbsoluteVolume(2, 0.90) + .handle(&mut app) + .unwrap()); + } + + #[test] + fn select_channel_sets_selected_channel_without_touching_selected() { + let wirehose = mock::WirehoseHandle::default(); + let mut app = fixture(&wirehose); + let object_id = app.tabs[app.current_tab_index].list.selected; + + assert!(Action::SelectChannel(1).handle(&mut app).unwrap()); + + let list = &app.tabs[app.current_tab_index].list; + assert_eq!(list.selected, object_id); + assert_eq!(list.selected_channel, Some(1)); + } + + #[test] + fn relative_volume_preserves_existing_channel_imbalance() { + // Whole-node relative volume (h/l, arrows, scroll) must apply the + // same delta to each channel's own current value, not collapse to + // a mean first - matching pulsemixer: +10 on a=30/b=50 gives + // a=40/b=60, not a=b=50. Absolute/set operations are unaffected + // (still fill every channel to the same explicit target). + let commands = RefCell::new(VecDeque::new()); + let wirehose = mock::WirehoseHandle::with_commands(&commands); + let (_, event_rx) = mpsc::channel(); + let config = Config::from_toml_str(""); + let mut app = App::new(&wirehose, event_rx, config); + + let object_id = ObjectId::from_raw_id(0); + let mut props = PropertyStore::default(); + props.set_node_description(String::from("Test node")); + props.set_media_class(String::from("Stream/Output/Audio")); + props.set_media_name(String::from("Media name")); + props.set_node_name(String::from("Node name")); + props.set_object_serial(0); + let events = vec![ + StateEvent::NodeProperties { object_id, props }, + StateEvent::NodePositions { + object_id, + positions: vec![0, 1], + }, + StateEvent::NodeVolumes { + object_id, + // Displayed as 30%/50% (raw = cube of the displayed + // fraction, matching how volumes are stored elsewhere). + volumes: vec![0.3_f32.powi(3), 0.5_f32.powi(3)], + }, + StateEvent::NodeMute { + object_id, + mute: false, + }, + ]; + for event in events { + event.handle(&mut app).unwrap(); + } + app.view = + View::from(&wirehose, &app.state, &app.config.names, &Vec::new()); + Action::SelectObject(object_id).handle(&mut app).unwrap(); + + assert!(Action::SetRelativeVolume(0.10).handle(&mut app).unwrap()); + + let dispatched = commands.borrow_mut().pop_back(); + let Some(mock::MockCommand::NodeVolumes(dispatched_id, volumes)) = + dispatched + else { + panic!("expected a NodeVolumes command, got {dispatched:?}"); + }; + assert_eq!(dispatched_id, object_id); + assert_eq!(volumes.len(), 2); + // 30% + 10% = 40%, 50% + 10% = 60% - each channel's own value, not + // both collapsed to (30+50)/2 + 10 = 50%. + assert!((volumes[0].cbrt() * 100.0 - 40.0).abs() < 0.5); + assert!((volumes[1].cbrt() * 100.0 - 60.0).abs() < 0.5); + } + + #[test] + fn channel_mode_routes_whole_node_volume_keys_to_selected_channel() { + let wirehose = mock::WirehoseHandle::default(); + let mut app = fixture(&wirehose); + app.config.enforce_max_volume = false; + + // Selecting a channel index past the fixture node's own channel + // count proves the whole-node volume keys really did redirect to + // the per-channel path - the whole-node path (`View::volume`) has + // no such bound to fail on, only `View::channel_volume` does. + app.tabs[app.current_tab_index].list.selected_channel = Some(2); + + assert!(!Action::SetRelativeVolume(0.10).handle(&mut app).unwrap()); + assert!(!Action::SetAbsoluteVolume(0.90).handle(&mut app).unwrap()); + } + + #[test] + fn channel_mode_whole_node_volume_keys_enforce_max_per_channel() { + let wirehose = mock::WirehoseHandle::default(); + let mut app = fixture(&wirehose); + app.config.max_volume_percent = 100.0; + app.config.enforce_max_volume = true; + app.tabs[app.current_tab_index].list.selected_channel = Some(0); + + // Channel 0 is at 100% already. + assert!(!Action::SetRelativeVolume(0.10).handle(&mut app).unwrap()); + assert!(!Action::SetAbsoluteVolume(1.10).handle(&mut app).unwrap()); + + assert!(Action::SetRelativeVolume(-0.10).handle(&mut app).unwrap()); + assert!(Action::SetAbsoluteVolume(0.90).handle(&mut app).unwrap()); + } + #[test] fn update_capturing_noop_when_lazy_disabled() { let commands = RefCell::new(VecDeque::new()); diff --git a/src/channel_pairing.rs b/src/channel_pairing.rs new file mode 100644 index 0000000..21e8e32 --- /dev/null +++ b/src/channel_pairing.rs @@ -0,0 +1,401 @@ +//! Detects stereo (and other named left/right) pairs among a node's audio +//! channel positions, so multi-channel rendering can group channels that +//! are actually a pair and leave everything else independent. +//! +//! Pairing is based on the channel *name* (per `enum spa_audio_channel` in +//! spa/param/audio/raw.h), not proximity in the position array - PipeWire +//! doesn't guarantee adjacent pairs are ordered adjacently, and channels +//! with no left/right semantics at all (LFE, FC, the generic AUX range) +//! must never be paired with anything, since there's no protocol-level +//! signal that they're related. + +use std::collections::HashSet; + +/// One channel, or a left/right pair of channels, in the order they first +/// appear in the `positions` slice `group_channels()` was given. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChannelGroup { + /// A stereo (or other named left/right) pair: `(left_index, + /// right_index)` into the original `positions` slice. + Pair(usize, usize), + /// A single channel with no detected pair, by index into the original + /// `positions` slice. + Single(usize), +} + +/// Known left/right channel name pairs, per `enum spa_audio_channel`, each +/// with a short, human-meaningful group name for labeling a radiating pair +/// row (see `pair_group_name`). `AUX*`/`UNKNOWN`/`NA`/`MONO` are +/// deliberately absent - nothing in the protocol says any of those are +/// paired with anything, so they always come out as `ChannelGroup::Single`. +/// +/// Group names are chosen to read sensibly on their own (using the +/// standard 5.1/7.1/Atmos-adjacent terms for each position: Front, Side, +/// Rear, Front-of-Center, Top-Front, Top-Rear, Rear-of-Center, +/// Front-Wide, Front-High, Top-Side, Stereo-LFE, Back-Center) *and* to +/// never collide with a real single-channel name from `channel_name` - +/// several of the natural-looking abbreviations (`FC`, `RC`, `BC`, `LFE`) +/// are already taken by actual channels, so those five pairs use a +/// distinguishable variant instead (`FoC`, `RoC`, `BoC`, `SLF`). +const LR_PAIRS: &[(u32, u32, &str)] = &[ + ( + libspa_sys::SPA_AUDIO_CHANNEL_FL, + libspa_sys::SPA_AUDIO_CHANNEL_FR, + "F", + ), + ( + libspa_sys::SPA_AUDIO_CHANNEL_SL, + libspa_sys::SPA_AUDIO_CHANNEL_SR, + "S", + ), + ( + libspa_sys::SPA_AUDIO_CHANNEL_RL, + libspa_sys::SPA_AUDIO_CHANNEL_RR, + "R", + ), + ( + libspa_sys::SPA_AUDIO_CHANNEL_FLC, + libspa_sys::SPA_AUDIO_CHANNEL_FRC, + "FoC", + ), + ( + libspa_sys::SPA_AUDIO_CHANNEL_TFL, + libspa_sys::SPA_AUDIO_CHANNEL_TFR, + "TF", + ), + ( + libspa_sys::SPA_AUDIO_CHANNEL_TRL, + libspa_sys::SPA_AUDIO_CHANNEL_TRR, + "TR", + ), + ( + libspa_sys::SPA_AUDIO_CHANNEL_RLC, + libspa_sys::SPA_AUDIO_CHANNEL_RRC, + "RoC", + ), + ( + libspa_sys::SPA_AUDIO_CHANNEL_FLW, + libspa_sys::SPA_AUDIO_CHANNEL_FRW, + "FW", + ), + ( + libspa_sys::SPA_AUDIO_CHANNEL_FLH, + libspa_sys::SPA_AUDIO_CHANNEL_FRH, + "FH", + ), + ( + libspa_sys::SPA_AUDIO_CHANNEL_TSL, + libspa_sys::SPA_AUDIO_CHANNEL_TSR, + "TS", + ), + ( + libspa_sys::SPA_AUDIO_CHANNEL_LLFE, + libspa_sys::SPA_AUDIO_CHANNEL_RLFE, + "SLF", + ), + ( + libspa_sys::SPA_AUDIO_CHANNEL_BLC, + libspa_sys::SPA_AUDIO_CHANNEL_BRC, + "BoC", + ), +]; + +/// The longest string any `LR_PAIRS` group name can be - lets callers +/// size a fixed label column without hand-tracking the table's contents. +pub const MAX_GROUP_NAME_WIDTH: usize = 3; + +/// The group name for a known left/right pair (see `LR_PAIRS`) - e.g. +/// `"F"` for the FL/FR pair, `"S"` for SL/SR. `None` if `left`/`right` +/// don't form one of the pairs `group_channels` recognizes (shouldn't +/// happen for a `ChannelGroup::Pair` it actually produced, but this +/// stays a plain lookup rather than assuming that). +pub fn pair_group_name(left: u32, right: u32) -> Option<&'static str> { + LR_PAIRS + .iter() + .find_map(|&(l, r, name)| (l == left && r == right).then_some(name)) +} + +/// A short, human-readable name for a single `enum spa_audio_channel` +/// value, for labeling individual channel rows in Channel mode display. +/// Named channels (including every `LR_PAIRS` entry) get their real +/// abbreviation straight from the enum's own doc comments in +/// spa/param/audio/raw.h (`FL`, `FR`, `LFE`, ...); the generic +/// `AUX0`..`AUX63` range gets a computed `AUX{n}` since there's no fixed +/// name to look up; anything else (`UNKNOWN`/`NA`, or a future value this +/// list hasn't caught up to) falls back to `?`. +pub fn channel_name(position: u32) -> String { + match position { + libspa_sys::SPA_AUDIO_CHANNEL_MONO => "MONO".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_FL => "FL".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_FR => "FR".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_FC => "FC".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_LFE => "LFE".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_SL => "SL".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_SR => "SR".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_FLC => "FLC".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_FRC => "FRC".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_RC => "RC".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_RL => "RL".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_RR => "RR".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_TC => "TC".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_TFL => "TFL".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_TFC => "TFC".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_TFR => "TFR".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_TRL => "TRL".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_TRC => "TRC".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_TRR => "TRR".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_RLC => "RLC".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_RRC => "RRC".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_FLW => "FLW".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_FRW => "FRW".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_LFE2 => "LFE2".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_FLH => "FLH".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_FCH => "FCH".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_FRH => "FRH".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_TFLC => "TFLC".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_TFRC => "TFRC".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_TSL => "TSL".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_TSR => "TSR".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_LLFE => "LLFE".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_RLFE => "RLFE".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_BC => "BC".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_BLC => "BLC".to_string(), + libspa_sys::SPA_AUDIO_CHANNEL_BRC => "BRC".to_string(), + p if (libspa_sys::SPA_AUDIO_CHANNEL_START_Aux + ..=libspa_sys::SPA_AUDIO_CHANNEL_LAST_Aux) + .contains(&p) => + { + format!("AUX{}", p - libspa_sys::SPA_AUDIO_CHANNEL_START_Aux) + } + _ => "?".to_string(), + } +} + +fn lr_partner(channel: u32) -> Option<(u32, bool)> { + LR_PAIRS.iter().find_map(|&(l, r, _)| { + if channel == l { + Some((r, true)) + } else if channel == r { + Some((l, false)) + } else { + None + } + }) +} + +/// Groups `positions` (raw `enum spa_audio_channel` values, in the order +/// PipeWire reported them) into named left/right pairs and leftover +/// singles. A channel only pairs with its documented counterpart (see +/// `LR_PAIRS`), and only if that counterpart is also present in +/// `positions` - adjacency in the array is not required, matching +/// PipeWire's own lack of an ordering guarantee here. Every index appears +/// in exactly one output group, in the order its *first* channel of the +/// group appears in `positions`. +pub fn group_channels(positions: &[u32]) -> Vec { + let mut paired: HashSet = HashSet::new(); + let mut groups = Vec::with_capacity(positions.len()); + + for (i, &channel) in positions.iter().enumerate() { + if paired.contains(&i) { + continue; + } + + let partner = + lr_partner(channel).and_then(|(partner_channel, is_left)| { + positions + .iter() + .enumerate() + .find(|&(j, &c)| { + j != i && !paired.contains(&j) && c == partner_channel + }) + .map(|(j, _)| (j, is_left)) + }); + + match partner { + Some((j, is_left)) => { + paired.insert(i); + paired.insert(j); + if is_left { + groups.push(ChannelGroup::Pair(i, j)); + } else { + groups.push(ChannelGroup::Pair(j, i)); + } + } + None => groups.push(ChannelGroup::Single(i)), + } + } + + groups +} + +#[cfg(test)] +mod tests { + use super::*; + + const FL: u32 = libspa_sys::SPA_AUDIO_CHANNEL_FL; + const FR: u32 = libspa_sys::SPA_AUDIO_CHANNEL_FR; + const FC: u32 = libspa_sys::SPA_AUDIO_CHANNEL_FC; + const LFE: u32 = libspa_sys::SPA_AUDIO_CHANNEL_LFE; + const LFE2: u32 = libspa_sys::SPA_AUDIO_CHANNEL_LFE2; + const RL: u32 = libspa_sys::SPA_AUDIO_CHANNEL_RL; + const RR: u32 = libspa_sys::SPA_AUDIO_CHANNEL_RR; + const MONO: u32 = libspa_sys::SPA_AUDIO_CHANNEL_MONO; + const AUX0: u32 = libspa_sys::SPA_AUDIO_CHANNEL_AUX0; + const AUX1: u32 = libspa_sys::SPA_AUDIO_CHANNEL_AUX1; + + #[test] + fn empty_positions_produce_no_groups() { + assert_eq!(group_channels(&[]), vec![]); + } + + #[test] + fn mono_is_a_single() { + assert_eq!(group_channels(&[MONO]), vec![ChannelGroup::Single(0)]); + } + + #[test] + fn simple_stereo_pair() { + assert_eq!(group_channels(&[FL, FR]), vec![ChannelGroup::Pair(0, 1)]); + } + + #[test] + fn reversed_order_still_identifies_left_and_right_correctly() { + // FR appears first in the array, but the pair's left/right indices + // must still reflect which channel is actually FL vs FR, not + // array order. + assert_eq!(group_channels(&[FR, FL]), vec![ChannelGroup::Pair(1, 0)]); + } + + #[test] + fn real_5_1_device_pairs_fronts_and_rears_leaves_center_and_lfe_single() { + // Real audio.position reported by hardware on this machine: + // "M-Audio Sonica Theater Analog Surround 5.1" -> FL,FR,RL,RR,FC,LFE + assert_eq!( + group_channels(&[FL, FR, RL, RR, FC, LFE]), + vec![ + ChannelGroup::Pair(0, 1), + ChannelGroup::Pair(2, 3), + ChannelGroup::Single(4), + ChannelGroup::Single(5), + ] + ); + } + + #[test] + fn generic_aux_channels_never_pair() { + // Real audio.position reported by hardware on this machine: + // "Built-in Audio Pro" -> AUX0,AUX1. Nothing in the protocol says + // these are a stereo pair - could just as easily be two unrelated + // mono paths. + assert_eq!( + group_channels(&[AUX0, AUX1]), + vec![ChannelGroup::Single(0), ChannelGroup::Single(1)] + ); + } + + #[test] + fn lfe_and_lfe2_are_not_a_pair() { + // Trap case: same stem, similar name, but both are low-frequency- + // effects channels, not a left/right pair. + assert_eq!( + group_channels(&[LFE, LFE2]), + vec![ChannelGroup::Single(0), ChannelGroup::Single(1)] + ); + } + + #[test] + fn unpaired_channel_missing_its_partner_stays_single() { + // FR with no FL anywhere in positions - can't pair with nothing. + assert_eq!( + group_channels(&[FR, FC]), + vec![ChannelGroup::Single(0), ChannelGroup::Single(1),] + ); + } + + #[test] + fn duplicate_channel_falls_back_to_single_when_no_partner_left() { + // Two FLs, one FR: first FL claims the only FR, second FL has + // nothing left to pair with. + assert_eq!( + group_channels(&[FL, FL, FR]), + vec![ChannelGroup::Pair(0, 2), ChannelGroup::Single(1)] + ); + } + + #[test] + fn channel_name_covers_named_channels() { + assert_eq!(channel_name(FL), "FL"); + assert_eq!(channel_name(FR), "FR"); + assert_eq!(channel_name(MONO), "MONO"); + assert_eq!(channel_name(FC), "FC"); + assert_eq!(channel_name(LFE), "LFE"); + assert_eq!(channel_name(LFE2), "LFE2"); + assert_eq!(channel_name(RL), "RL"); + assert_eq!(channel_name(RR), "RR"); + } + + #[test] + fn channel_name_formats_aux_range_with_computed_offset() { + assert_eq!(channel_name(AUX0), "AUX0"); + assert_eq!(channel_name(AUX1), "AUX1"); + assert_eq!(channel_name(libspa_sys::SPA_AUDIO_CHANNEL_AUX63), "AUX63"); + } + + #[test] + fn channel_name_falls_back_for_unknown_values() { + assert_eq!(channel_name(libspa_sys::SPA_AUDIO_CHANNEL_UNKNOWN), "?"); + assert_eq!(channel_name(libspa_sys::SPA_AUDIO_CHANNEL_NA), "?"); + } + + #[test] + fn pair_group_name_covers_every_known_pair() { + // Every LR_PAIRS entry must resolve to a name, and that name + // must fit MAX_GROUP_NAME_WIDTH - callers size a fixed column + // from that constant, not by re-deriving the widest entry. + for &(l, r, expected) in LR_PAIRS { + let name = pair_group_name(l, r) + .unwrap_or_else(|| panic!("no group name for ({l}, {r})")); + assert_eq!(name, expected); + assert!( + name.len() <= MAX_GROUP_NAME_WIDTH, + "{name:?} exceeds MAX_GROUP_NAME_WIDTH" + ); + } + } + + #[test] + fn pair_group_name_none_for_a_non_pair() { + assert_eq!(pair_group_name(AUX0, AUX1), None); + } + + #[test] + fn pair_group_names_never_collide_with_a_real_channel_name() { + // FLC/FRC, RLC/RRC, BLC/BRC, and LLFE/RLFE all have natural- + // looking abbreviations ("FC", "RC", "BC", "LFE") that are + // already real, distinct single-channel names - a pair row + // showing one of those would be indistinguishable from an + // actual FC/RC/BC/LFE row in the same node. + let real_channel_names = [ + channel_name(FC), + channel_name(libspa_sys::SPA_AUDIO_CHANNEL_RC), + channel_name(libspa_sys::SPA_AUDIO_CHANNEL_BC), + channel_name(LFE), + ]; + for &(_, _, group_name) in LR_PAIRS { + assert!( + !real_channel_names.contains(&group_name.to_string()), + "group name {group_name:?} collides with a real channel name" + ); + } + } + + #[test] + fn pair_group_names_are_all_distinct() { + let mut names: Vec<&str> = + LR_PAIRS.iter().map(|&(_, _, name)| name).collect(); + let count = names.len(); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), count, "duplicate group name in LR_PAIRS"); + } +} diff --git a/src/config.rs b/src/config.rs index ac871b0..e660096 100644 --- a/src/config.rs +++ b/src/config.rs @@ -44,9 +44,144 @@ pub struct Config { pub tab: usize, pub tabs: Vec, pub lazy_capture: bool, + /// Which of the three views (see `ChannelView`) the app starts in. + /// `Action::SelectView`/`Action::CycleView` (bound to `Space` by + /// default) change this at runtime; this is only the startup value. + /// See `unified_imbalance` for how an imbalanced node is indicated + /// without leaving `Unified`. + pub initial_view: ChannelView, + /// Only consulted while the current view is `Unified`: how an + /// imbalanced node (channels that don't all hold the same value) is + /// indicated without actually splitting the whole list's display. + pub unified_imbalance: UnifiedImbalance, + /// How long `unified_imbalance = "cycle"` shows each channel before + /// advancing to the next one, in seconds. Ignored otherwise. Actual + /// redraws are only guaranteed at least every ~250ms (see + /// `App::CYCLING_WAKEUP_INTERVAL`), so a value well under that won't + /// visibly update any faster than that floor. + pub unified_imbalance_cycle_seconds: f32, + /// Rendering style whenever a node's volume actually is split + /// (view is `Linked`/`Channels`, or `unified_imbalance = "split"` + /// triggering for one imbalanced node while otherwise `Unified`). + /// "radiating" renders a lone simple pair on one fixed-height row; + /// anything with more channels (extra singles alongside a pair, more + /// than one pair, or no pair at all) gets one row per detected + /// pair/channel instead, each pair still radiating on its own row. + pub split_style: SplitStyle, + /// How a radiating pair row (split_style = "radiating") labels which + /// physical pair it's showing - only matters once more than one row + /// can appear in the same node's split display (a lone pair takes + /// the classic unlabeled single-row fast path instead). "verbose" + /// spells out that it's a pair ("F L/R"); "short" is just the group + /// name ("F"). + pub pair_label_style: PairLabelStyle, + /// Which `ChannelView`s `Action::CycleView` steps through, and in + /// what order - see `ChannelView`. Removing one excludes it from + /// cycling without disabling it entirely; it's still reachable via + /// `Action::SelectView`. Must be non-empty. + pub view_cycle: Vec, + /// Bar/meter row layout for `Unified` view - see `MeterLayout`. + pub unified_meter_layout: MeterLayout, + /// Bar/meter row layout for `Linked` view - see `MeterLayout`. + pub linked_meter_layout: MeterLayout, + /// Bar/meter row layout for `Channels` view - see `MeterLayout`. + pub channels_meter_layout: MeterLayout, + /// Configurable, default on. A lone stereo pair's `StereoVolumeWidget` + /// (the classic single-row fast path - it never shows a group label + /// the way a multi-row block's `RadiatingRowWidget` does) shrinks its + /// own label area down to just what a plain `"{percent}%"` needs, + /// handing the rest to the volume bars - a real width increase, not + /// a token one, at the cost of no longer sharing a bar-start column + /// with `RadiatingRowWidget` rows elsewhere in the same view. + pub expand_unused_label_space: bool, + /// Opt-in, default off. An unpaired channel's row in a + /// `split_style = "radiating"` block normally occupies just the left + /// half of the row's column grid (mirroring where a paired row's own + /// left bar would be, so every row in the block starts/ends its bar + /// at the same columns) - when this is on, it stretches across the + /// row's full remaining width instead. + pub expand_unpaired_channel_bars: bool, pub filters: Vec, } +/// Overrides for one view's bar/meter row layout: the percentage of a +/// row's combined volume+meter width given to the meter side, the blank +/// gap between them, and the blank margin reserved at the row's right +/// edge (all once `peaks` is on - `gap`/`meter_width_percent` are moot +/// with `peaks = "off"`, nothing to gap/split against). `meter_width_percent` +/// left `None` (its default) reproduces stock wiremix's own proportional +/// ratio; setting it opts that one field, for that one view, into a +/// fixed-column override instead. `right_margin` is always a fixed +/// column count; `gap` is a *floor* rather than a fixed override - the +/// actual gap scales up above it with the meter side's own available +/// width (see `effective_gap` in `node_widget.rs`), rather than staying +/// visually cramped in a wide terminal. Both are carved out of the +/// meter/monitor side's own share of the row, not the volume side's, so +/// widening either never costs the volume area any width. The three +/// fields and three views are all independent of each other. See +/// `Config::meter_layout` for how a render picks which of the three (one +/// per `ChannelView`) applies. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct MeterLayout { + pub meter_width_percent: Option, + pub gap: u16, + pub right_margin: u16, +} + +// This is what actually gets parsed from the config - see `MeterLayout`. +#[derive(Deserialize, Debug, Clone, Copy)] +#[cfg_attr(test, derive(PartialEq))] +#[serde(deny_unknown_fields, default)] +struct MeterLayoutFile { + meter_width_percent: Option, + #[serde(default = "default_gap")] + gap: u16, + #[serde(default = "default_right_margin")] + right_margin: u16, +} + +// A small gap by default, carved from the meter side alone - still +// fully overridable per view with any other fixed value, including 0. +fn default_gap() -> u16 { + 2 +} + +// About half of the default gap's own trailing counterpart - a small +// margin by default, carved from the meter side alone - still fully +// overridable per view with any other fixed value, including 0. +fn default_right_margin() -> u16 { + 3 +} + +impl Default for MeterLayoutFile { + fn default() -> Self { + Self { + meter_width_percent: None, + gap: default_gap(), + right_margin: default_right_margin(), + } + } +} + +impl MeterLayoutFile { + fn validate(self, label: &str) -> anyhow::Result { + if let Some(percent) = self.meter_width_percent { + if !(1.0..=99.0).contains(&percent) { + anyhow::bail!( + "{label}.meter_width_percent {percent} must be \ + between 1 and 99 - to hide the meter entirely, use \ + peaks = \"off\" instead" + ); + } + } + Ok(MeterLayout { + meter_width_percent: self.meter_width_percent, + gap: self.gap, + right_margin: self.right_margin, + }) + } +} + /// Represents a configuration deserialized from a file. This gets baked into a /// Config, which, for example, has a single char_set and theme. #[derive(Deserialize, Debug)] @@ -88,6 +223,28 @@ struct ConfigFile { tabs: Vec, #[serde(default = "default_lazy_capture")] lazy_capture: bool, + #[serde(default = "default_initial_view")] + initial_view: Option, + #[serde(default = "default_unified_imbalance")] + unified_imbalance: Option, + #[serde(default = "default_unified_imbalance_cycle_seconds")] + unified_imbalance_cycle_seconds: Option, + #[serde(default = "default_split_style")] + split_style: Option, + #[serde(default = "default_pair_label_style")] + pair_label_style: Option, + #[serde(default = "default_view_cycle")] + view_cycle: Vec, + #[serde(default)] + unified_meter_layout: MeterLayoutFile, + #[serde(default)] + linked_meter_layout: MeterLayoutFile, + #[serde(default)] + channels_meter_layout: MeterLayoutFile, + #[serde(default = "default_expand_unused_label_space")] + expand_unused_label_space: bool, + #[serde(default = "default_expand_unpaired_channel_bars")] + expand_unpaired_channel_bars: bool, #[serde(default = "Filter::defaults", deserialize_with = "Filter::merge")] filters: Vec, } @@ -101,6 +258,85 @@ pub enum Peaks { Auto, } +#[derive( + Deserialize, Default, Debug, Clone, Copy, PartialEq, clap::ValueEnum, +)] +#[serde(rename_all = "kebab-case")] +pub enum UnifiedImbalance { + None, + #[default] + Cycle, + Split, +} + +#[derive( + Deserialize, Default, Debug, Clone, Copy, PartialEq, clap::ValueEnum, +)] +#[serde(rename_all = "kebab-case")] +pub enum SplitStyle { + #[default] + Radiating, + Stacked, +} + +#[derive( + Deserialize, Default, Debug, Clone, Copy, PartialEq, clap::ValueEnum, +)] +#[serde(rename_all = "kebab-case")] +pub enum PairLabelStyle { + #[default] + Verbose, + Short, +} + +/// The one high-level axis that decides both how a node's volume is +/// displayed and which channels a volume key adjusts - stored directly +/// as `ObjectList::view` (runtime) and `Config::initial_view` (startup), +/// not derived from separate booleans. "unified" = one collapsed +/// bar/row per node, a volume key adjusts the whole node (see +/// `Config::unified_imbalance` for how an imbalanced node is still +/// flagged without splitting). "linked" = every channel gets its own +/// row, but a volume key still adjusts the whole node at once - a +/// relative key applies the same delta to each channel's own value +/// (preserving any existing imbalance), an absolute key sets every +/// channel to that same value. "channels" = same split display, but a +/// volume key targets only the individually-cursored channel, leaving +/// every other channel exactly as it was. +#[derive( + Deserialize, + Default, + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + clap::ValueEnum, +)] +#[serde(rename_all = "kebab-case")] +pub enum ChannelView { + #[default] + Unified, + Linked, + Channels, +} + +/// Bundles the axes that decide how a node's volume is displayed/set, so +/// functions that need all of them (mainly `NodeWidget`/its height +/// calculation) don't need a separate parameter per axis. `view` is +/// runtime-mutable (see `ObjectList::view`); `unified_imbalance`/ +/// `split_style`/`pair_label_style` currently aren't (no toggle action +/// yet - config-only), but live here alongside it so adding one later +/// doesn't change this bundle's shape. +#[derive(Debug, Clone, Copy)] +pub struct ChannelState { + pub view: ChannelView, + pub unified_imbalance: UnifiedImbalance, + pub unified_imbalance_cycle_seconds: f32, + pub split_style: SplitStyle, + pub pair_label_style: PairLabelStyle, +} + #[derive(Deserialize, Debug)] #[serde(deny_unknown_fields)] pub struct Keybinding { @@ -163,6 +399,22 @@ pub struct CharSet { pub meter_center_left_active: String, pub meter_center_right_inactive: String, pub meter_center_right_active: String, + /// Monitor glyphs used whenever the active view (`ChannelView`) is + /// `Linked` or `Channels` rather than `Unified` - `None` means "not + /// configured", which falls back to the corresponding `meter_left`/ + /// `meter_right`/`meter_center_*` field above, so a split-view + /// monitor gauge looks identical to `Unified`'s until a theme opts + /// in to something distinct. `Unified` view never consults these. + pub meter_split_left_inactive: Option, + pub meter_split_left_active: Option, + pub meter_split_left_overload: Option, + pub meter_split_right_inactive: Option, + pub meter_split_right_active: Option, + pub meter_split_right_overload: Option, + pub meter_split_center_left_inactive: Option, + pub meter_split_center_left_active: Option, + pub meter_split_center_right_inactive: Option, + pub meter_split_center_right_active: Option, pub dropdown_icon: String, pub dropdown_selector: String, pub dropdown_more: String, @@ -191,6 +443,15 @@ pub struct Theme { pub meter_overload: Style, pub meter_center_inactive: Style, pub meter_center_active: Style, + /// Monitor colors used whenever the active view (`ChannelView`) is + /// `Linked` or `Channels` rather than `Unified`, same "unset falls + /// back to the stock meter_* field above" idea as `CharSet`'s + /// `meter_split_*` glyph overrides. + pub meter_split_inactive: Option