From c55a12dadee0f965f88d6706e1e0e71d7da52f92 Mon Sep 17 00:00:00 2001 From: awahab Date: Sun, 31 Aug 2025 09:52:24 -0400 Subject: [PATCH 1/4] initial support for stereo balance adjustment --- src/app.rs | 41 ++++++++++++++++++++++++++++++++++++++++ src/config/keybinding.rs | 12 ++++++++++++ src/object_list.rs | 38 +++++++++++++++++++++++++++++++++++++ src/view.rs | 31 ++++++++++++++++++++++++++++++ 4 files changed, 122 insertions(+) diff --git a/src/app.rs b/src/app.rs index cd2255b..b66fc36 100644 --- a/src/app.rs +++ b/src/app.rs @@ -53,6 +53,8 @@ pub enum Action { TabRight, SelectTab(usize), SetAbsoluteVolume(f32), + SetAbsoluteBalance(f32), + SetRelativeBalance(f32), #[serde(skip_deserializing)] SelectObject(ObjectId), #[serde(skip_deserializing)] @@ -82,6 +84,8 @@ impl std::fmt::Display for Action { Action::SetRelativeVolume(vol) => { Self::format_relative_volume(f, *vol) } + Action::SetAbsoluteBalance(bal) => Self::format_balance(f, *bal), + Action::SetRelativeBalance(bal) => Self::format_balance(f, *bal), Action::SetDefault => write!(f, "Set default"), Action::Help => write!(f, "Show/hide help"), Action::Exit => write!(f, "Exit wiremix"), @@ -110,6 +114,31 @@ impl Action { } } } + + fn format_balance( + f: &mut std::fmt::Formatter<'_>, + bal: f32, + ) -> std::fmt::Result { + match bal { + 0.0 => write!(f, "Center balance"), + 0.01 => write!(f, "Shift balance right"), + -0.01 => write!(f, "Shift balance left"), + v if v > 0.0 => { + write!( + f, + "Shift balance right by {}%", + Self::format_percentage(v) + ) + } + v => { + write!( + f, + "Shift balance left by {}%", + Self::format_percentage(-v) + ) + } + } + } } struct Tab { @@ -538,6 +567,18 @@ impl Handle for Action { return Ok(current_list!(app) .set_relative_volume(&app.view, volume, max)); } + Action::SetAbsoluteBalance(balance) => { + let max = (app.config.enforce_max_volume) + .then_some(app.config.max_volume_percent); + return Ok(current_list!(app) + .set_absolute_balance(&app.view, balance, max)); + } + Action::SetRelativeBalance(balance) => { + let max = (app.config.enforce_max_volume) + .then_some(app.config.max_volume_percent); + return Ok(current_list!(app) + .set_relative_balance(&app.view, balance, max)); + } Action::SetDefault => { current_list!(app).set_default(&app.view); } diff --git a/src/config/keybinding.rs b/src/config/keybinding.rs index 73334e7..bb4fc74 100644 --- a/src/config/keybinding.rs +++ b/src/config/keybinding.rs @@ -48,6 +48,18 @@ impl Keybinding { (event(KeyCode::Char('9')), Action::SetAbsoluteVolume(0.90)), (event(KeyCode::Char('0')), Action::SetAbsoluteVolume(1.00)), (event(KeyCode::Char('?')), Action::Help), + ( + KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT), + Action::SetRelativeBalance(-0.01), + ), + ( + KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT), + Action::SetRelativeBalance(0.01), + ), + ( + KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT), + Action::SetAbsoluteBalance(0.0), + ), ]) } diff --git a/src/object_list.rs b/src/object_list.rs index f034592..6c858c0 100644 --- a/src/object_list.rs +++ b/src/object_list.rs @@ -169,6 +169,44 @@ impl ObjectList { false } + pub fn set_absolute_balance( + &mut self, + view: &view::View, + balance: f32, + max: Option, + ) -> bool { + if matches!(self.list_kind, ListKind::Device) { + return false; + } + if let Some(node_id) = self.selected { + return view.volume( + node_id, + VolumeAdjustment::AbsoluteBalance(balance), + max, + ); + } + false + } + + pub fn set_relative_balance( + &mut self, + view: &view::View, + balance: f32, + max: Option, + ) -> bool { + if matches!(self.list_kind, ListKind::Device) { + return false; + } + if let Some(node_id) = self.selected { + return view.volume( + node_id, + VolumeAdjustment::RelativeBalance(balance), + max, + ); + } + false + } + pub fn set_default(&mut self, view: &view::View) { if matches!(self.list_kind, ListKind::Device) { return; diff --git a/src/view.rs b/src/view.rs index 75584d5..07744a9 100644 --- a/src/view.rs +++ b/src/view.rs @@ -100,6 +100,8 @@ pub struct Device { pub enum VolumeAdjustment { Relative(f32), Absolute(f32), + RelativeBalance(f32), + AbsoluteBalance(f32), } #[derive(Default, Debug, Clone, Copy)] @@ -687,6 +689,27 @@ impl<'a> View<'a> { } } + /// Get current balance (stereo only) + fn balance(&self, volumes: &Vec) -> Option { + if volumes.len() == 2 { + Some((volumes[1] / volumes[0]) - 1.0) + } else { + None + } + } + + /// Update channel balance balance (stereo only) + fn rebalance(&self, volumes: &mut Vec, balance: f32) { + if let Some(bal) = self.balance(volumes) { + let bal_new = balance.clamp(-1.0, 1.0); + if bal <= 0.0 { + volumes[1] = volumes[0] * (bal_new + 1.0); + } else { + volumes[0] = volumes[1] / (bal_new + 1.0); + } + } + } + /// Changes the volume of the provided node. If max volume is provided, /// won't change volume if result would be greater than max. Returns true /// if volume was changed, otherwise false. @@ -712,6 +735,14 @@ impl<'a> View<'a> { VolumeAdjustment::Absolute(volume) => { volumes.fill(volume.max(0.0).powi(3)); } + VolumeAdjustment::AbsoluteBalance(balance) => { + self.rebalance(&mut volumes, balance); + } + VolumeAdjustment::RelativeBalance(delta) => { + if let Some(balance) = self.balance(&volumes) { + self.rebalance(&mut volumes, balance + delta); + } + } } let volumes = volumes; From 081fb919bdf71f26b5a0ca82936ed65b93bab388 Mon Sep 17 00:00:00 2001 From: awahab Date: Mon, 1 Sep 2025 10:42:23 -0400 Subject: [PATCH 2/4] volume adjustments respect current balance --- src/view.rs | 59 +++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/src/view.rs b/src/view.rs index 07744a9..be39b21 100644 --- a/src/view.rs +++ b/src/view.rs @@ -690,22 +690,28 @@ impl<'a> View<'a> { } /// Get current balance (stereo only) - fn balance(&self, volumes: &Vec) -> Option { + fn balance(&self, volumes: &[f32]) -> Option { if volumes.len() == 2 { - Some((volumes[1] / volumes[0]) - 1.0) + if volumes[0] == volumes[1] { + Some(0.0) // handles div by 0 case + } else if volumes[0] > volumes[1] { + Some((volumes[1] / volumes[0]) - 1.0) + } else { + Some(1.0 - volumes[0] / volumes[1]) + } } else { None } } /// Update channel balance balance (stereo only) - fn rebalance(&self, volumes: &mut Vec, balance: f32) { + fn rebalance(&self, volumes: &mut [f32], balance: f32) { if let Some(bal) = self.balance(volumes) { let bal_new = balance.clamp(-1.0, 1.0); - if bal <= 0.0 { - volumes[1] = volumes[0] * (bal_new + 1.0); + if bal < 0.0 || (bal == 0.0 && bal_new < 0.0) { + volumes[1] = volumes[0] * (1.0 - bal_new * bal.signum()); } else { - volumes[0] = volumes[1] / (bal_new + 1.0); + volumes[0] = volumes[1] * (1.0 - bal_new * bal.signum()); } } } @@ -729,11 +735,46 @@ impl<'a> View<'a> { } match adjustment { VolumeAdjustment::Relative(delta) => { - let avg = volumes.iter().sum::() / volumes.len() as f32; - volumes.fill((avg.cbrt() + delta).max(0.0).powi(3)); + let bal = self.balance(&volumes); + match bal { + // TODO: consolidate first two cases + Some(bal) if bal < 0.0 => { + volumes[0] = + (volumes[0].cbrt() + delta).max(0.0).powi(3); + volumes[1] = + (volumes[0] * (1.0 - bal * bal.signum())).max(0.0); + } + Some(bal) if bal > 0.0 => { + volumes[1] = + (volumes[1].cbrt() + delta).max(0.0).powi(3); + volumes[0] = + (volumes[1] * (1.0 + bal * bal.signum())).max(0.0); + } + _ => { + let avg = + volumes.iter().sum::() / volumes.len() as f32; + volumes.fill((avg.cbrt() + delta).max(0.0).powi(3)); + } + } } VolumeAdjustment::Absolute(volume) => { - volumes.fill(volume.max(0.0).powi(3)); + let bal = self.balance(&volumes); + match bal { + // TODO: consolidate first two cases + Some(bal) if bal <= 0.0 => { + volumes[0] = volume.max(0.0).powi(3); + volumes[1] = + (volumes[0] * (1.0 - bal * bal.signum())).max(0.0); + } + Some(bal) => { + volumes[1] = volume.max(0.0).powi(3); + volumes[0] = + (volumes[1] * (1.0 - bal * bal.signum())).max(0.0); + } + None => { + volumes.fill(volume.max(0.0).powi(3)); + } + } } VolumeAdjustment::AbsoluteBalance(balance) => { self.rebalance(&mut volumes, balance); From 037fcb6aed2d22ddc6f4e48e80db3e05bcc8a7eb Mon Sep 17 00:00:00 2001 From: awahab Date: Mon, 1 Sep 2025 11:16:05 -0400 Subject: [PATCH 3/4] added balance to default config toml --- wiremix.toml | 214 ++++++++++++++++++++++++++------------------------- 1 file changed, 109 insertions(+), 105 deletions(-) diff --git a/wiremix.toml b/wiremix.toml index 8c877d3..ca2ece8 100644 --- a/wiremix.toml +++ b/wiremix.toml @@ -72,52 +72,56 @@ enforce_max_volume = false # # Each of the available keybinding actions are documented below. keybindings = [ - # Exit the program - { key = { Char = "q" }, action = "Exit" }, - # Toggle mute for the selected item - { key = { Char = "m" }, action = "ToggleMute" }, - # Make the selected item in Input/Output Devices the default endpoint - { key = { Char = "d" }, action = "SetDefault" }, - # Increase the volume of the selected item by 1% - { key = { Char = "l" }, action = { SetRelativeVolume = 0.01 } }, - { key = "Right", action = { SetRelativeVolume = 0.01 } }, - # Decrease the volume of the selected item by 1% - { key = { Char = "h" }, action = { SetRelativeVolume = -0.01 } }, - { key = "Left", action = { SetRelativeVolume = -0.01 } }, - # Open a dropdown for the selected item or chose an item in the dropdown - { key = { Char = "c" }, action = "ActivateDropdown" }, - { key = "Enter", action = "ActivateDropdown" }, - # Close an open dropdown - { key = "Esc", action = "CloseDropdown" }, - # Select the next item - { key = { Char = "j" }, action = "MoveDown" }, - { key = "Down", action = "MoveDown" }, - # Select the previous item - { key = { Char = "k" }, action = "MoveUp" }, - { key = "Up", action = "MoveUp" }, - # Select the next tab - { key = { Char = "L" }, action = "TabRight" }, - { key = "Tab", action = "TabRight" }, - # Select the previous tab - { key = { Char = "H" }, action = "TabLeft" }, - { key = "BackTab", modifiers = "SHIFT", action = "TabLeft" }, - # Set the volume of the selected item in 10% increments from 0% to 100% - { key = { Char = "`" }, action = { SetAbsoluteVolume = 0.00 } }, - { key = { Char = "1" }, action = { SetAbsoluteVolume = 0.10 } }, - { key = { Char = "2" }, action = { SetAbsoluteVolume = 0.20 } }, - { key = { Char = "3" }, action = { SetAbsoluteVolume = 0.30 } }, - { key = { Char = "4" }, action = { SetAbsoluteVolume = 0.40 } }, - { key = { Char = "5" }, action = { SetAbsoluteVolume = 0.50 } }, - { key = { Char = "6" }, action = { SetAbsoluteVolume = 0.60 } }, - { key = { Char = "7" }, action = { SetAbsoluteVolume = 0.70 } }, - { key = { Char = "8" }, action = { SetAbsoluteVolume = 0.80 } }, - { key = { Char = "9" }, action = { SetAbsoluteVolume = 0.90 } }, - { key = { Char = "0" }, action = { SetAbsoluteVolume = 1.00 } }, - # Open the help menu - { key = { Char = "?" }, action = "Help" }, - # There are two actions which don't have default bindings: - # 1. "Nothing": Do nothing - can effectively delete a default keybinding - # 2. { SelectTab = N }: Open the Nth tab + # Exit the program + { key = { Char = "q" }, action = "Exit" }, + # Toggle mute for the selected item + { key = { Char = "m" }, action = "ToggleMute" }, + # Make the selected item in Input/Output Devices the default endpoint + { key = { Char = "d" }, action = "SetDefault" }, + # Increase the volume of the selected item by 1% + { key = { Char = "l" }, action = { SetRelativeVolume = 0.01 } }, + { key = "Right", action = { SetRelativeVolume = 0.01 } }, + # Decrease the volume of the selected item by 1% + { key = { Char = "h" }, action = { SetRelativeVolume = -0.01 } }, + { key = "Left", action = { SetRelativeVolume = -0.01 } }, + # Open a dropdown for the selected item or chose an item in the dropdown + { key = { Char = "c" }, action = "ActivateDropdown" }, + { key = "Enter", action = "ActivateDropdown" }, + # Close an open dropdown + { key = "Esc", action = "CloseDropdown" }, + # Select the next item + { key = { Char = "j" }, action = "MoveDown" }, + { key = "Down", action = "MoveDown" }, + # Select the previous item + { key = { Char = "k" }, action = "MoveUp" }, + { key = "Up", action = "MoveUp" }, + # Select the next tab + { key = { Char = "L" }, action = "TabRight" }, + { key = "Tab", action = "TabRight" }, + # Select the previous tab + { key = { Char = "H" }, action = "TabLeft" }, + { key = "BackTab", modifiers = "SHIFT", action = "TabLeft" }, + # Set the volume of the selected item in 10% increments from 0% to 100% + { key = { Char = "`" }, action = { SetAbsoluteVolume = 0.00 } }, + { key = { Char = "1" }, action = { SetAbsoluteVolume = 0.10 } }, + { key = { Char = "2" }, action = { SetAbsoluteVolume = 0.20 } }, + { key = { Char = "3" }, action = { SetAbsoluteVolume = 0.30 } }, + { key = { Char = "4" }, action = { SetAbsoluteVolume = 0.40 } }, + { key = { Char = "5" }, action = { SetAbsoluteVolume = 0.50 } }, + { key = { Char = "6" }, action = { SetAbsoluteVolume = 0.60 } }, + { key = { Char = "7" }, action = { SetAbsoluteVolume = 0.70 } }, + { key = { Char = "8" }, action = { SetAbsoluteVolume = 0.80 } }, + { key = { Char = "9" }, action = { SetAbsoluteVolume = 0.90 } }, + { key = { Char = "0" }, action = { SetAbsoluteVolume = 1.00 } }, + # Open the help menu + { key = { Char = "?" }, action = "Help" }, + # Adjust audio balance + { key = "Down", modifiers = "SHIFT", action = { SetAbsoluteBalance = 0.00 } }, + { key = "Left", modifiers = "SHIFT", action = { SetRelativeBalance = -0.01 } }, + { key = "Right", modifiers = "SHIFT", action = { SetRelativeBalance = 0.01 } }, + # There are two actions which don't have default bindings: + # 1. "Nothing": Do nothing - can effectively delete a default keybinding + # 2. { SelectTab = N }: Open the Nth tab ] @@ -159,11 +163,11 @@ keybindings = [ # 3. Fall back to the object's name property [names] # Streams in the Playback/Recording tabs -stream = [ "{node:node.name}: {node:media.name}" ] +stream = ["{node:node.name}: {node:media.name}"] # Endpoints in the Input/Output Devices tabs -endpoint = [ "{device:device.nick}", "{node:node.description}" ] +endpoint = ["{device:device.nick}", "{node:node.description}"] # Devices in the Configuration tab -device = [ "{device:device.nick}", "{device:device.description}" ] +device = ["{device:device.nick}", "{device:device.description}"] # Name Overrides @@ -239,13 +243,13 @@ device = [ "{device:device.nick}", "{device:device.description}" ] # The following is the default theme with each themeable property described. [themes.default] # The symbol marking the default device on the Input/Output Devices tabs -default_device = { } +default_device = {} # The symbol marking the default endpoint on the Playback/Recording tabs -default_stream = { } +default_stream = {} # The selection indicator in a tab selector = { fg = "LightCyan" } # The name of a tab in the tab menu -tab = { } +tab = {} # The name of the selected tab in the tab menu tab_selected = { fg = "LightCyan" } # The symbols surrounding the selected tab in the tab menu @@ -253,11 +257,11 @@ tab_marker = { fg = "LightCyan" } # The symbol at the top/bottom of a tab indicating that there are more items list_more = { fg = "DarkGray" } # The name of a PipeWire node -node_title = { } +node_title = {} # The name of the selected target for a node -node_target = { } +node_target = {} # The volume percentage label -volume = { } +volume = {} # Volume bar volume_empty = { fg = "DarkGray" } volume_filled = { fg = "LightBlue" } @@ -269,23 +273,23 @@ meter_overload = { fg = "Red" } meter_center_inactive = { fg = "DarkGray" } meter_center_active = { fg = "LightGreen" } # The name of a device in the Configuration tab -config_device = { } +config_device = {} # The name of the selected profile in the Configuration tab -config_profile = { } +config_profile = {} # Dropdown marker next to the profiles in the Conifguration tab -dropdown_icon = { } +dropdown_icon = {} # Border around dropdowns -dropdown_border = { } +dropdown_border = {} # The name of an item in a dropdown -dropdown_item = { } +dropdown_item = {} # The name of the currently-selected item in a dropdown dropdown_selected = { fg = "LightCyan", add_modifier = "REVERSED" } # The symbol at the top/bottom of a dropdown indicating that there are more items dropdown_more = { fg = "DarkGray" } # Border around help menu -help_border = { } +help_border = {} # The name of an item in a the help menu -help_item = { } +help_item = {} # The symbol at the top/bottom of the help menu indicating that there are more items help_more = { fg = "DarkGray" } @@ -365,16 +369,16 @@ help_border = "Rounded" # The other built-in themes and character sets are defined for reference here. [themes.nocolor] -default_device = { } -default_stream = { } +default_device = {} +default_stream = {} selector = { add_modifier = "BOLD" } -tab = { } +tab = {} tab_selected = { add_modifier = "BOLD" } tab_marker = { add_modifier = "BOLD" } -list_more = { } -node_title = { } -node_target = { } -volume = { } +list_more = {} +node_title = {} +node_target = {} +volume = {} volume_empty = { add_modifier = "DIM" } volume_filled = { add_modifier = "BOLD" } meter_inactive = { add_modifier = "DIM" } @@ -382,45 +386,45 @@ meter_active = { add_modifier = "BOLD" } meter_overload = { add_modifier = "BOLD" } meter_center_inactive = { add_modifier = "DIM" } meter_center_active = { add_modifier = "BOLD" } -config_device = { } -config_profile = { } -dropdown_icon = { } -dropdown_border = { } -dropdown_item = { } +config_device = {} +config_profile = {} +dropdown_icon = {} +dropdown_border = {} +dropdown_item = {} dropdown_selected = { add_modifier = "BOLD | REVERSED" } -dropdown_more = { } -help_border = { } -help_item = { } -help_more = { } +dropdown_more = {} +help_border = {} +help_item = {} +help_more = {} [themes.plain] -default_device = { } -default_stream = { } -selector = { } -tab = { } -tab_selected = { } -tab_marker = { } -list_more = { } -node_title = { } -node_target = { } -volume = { } -volume_empty = { } -volume_filled = { } -meter_inactive = { } -meter_active = { } -meter_overload = { } -meter_center_inactive = { } -meter_center_active = { } -config_device = { } -config_profile = { } -dropdown_icon = { } -dropdown_border = { } -dropdown_item = { } -dropdown_selected = { } -dropdown_more = { } -help_border = { } -help_item = { } -help_more = { } +default_device = {} +default_stream = {} +selector = {} +tab = {} +tab_selected = {} +tab_marker = {} +list_more = {} +node_title = {} +node_target = {} +volume = {} +volume_empty = {} +volume_filled = {} +meter_inactive = {} +meter_active = {} +meter_overload = {} +meter_center_inactive = {} +meter_center_active = {} +config_device = {} +config_profile = {} +dropdown_icon = {} +dropdown_border = {} +dropdown_item = {} +dropdown_selected = {} +dropdown_more = {} +help_border = {} +help_item = {} +help_more = {} [char_sets.compat] default_device = "◊" From 94c123585d10c793f85af3a3766b186c41b435d5 Mon Sep 17 00:00:00 2001 From: awahab Date: Mon, 1 Sep 2025 11:51:09 -0400 Subject: [PATCH 4/4] corrected rel volume change / balance incr. bug --- src/view.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/view.rs b/src/view.rs index be39b21..0c33013 100644 --- a/src/view.rs +++ b/src/view.rs @@ -710,7 +710,7 @@ impl<'a> View<'a> { let bal_new = balance.clamp(-1.0, 1.0); if bal < 0.0 || (bal == 0.0 && bal_new < 0.0) { volumes[1] = volumes[0] * (1.0 - bal_new * bal.signum()); - } else { + } else if bal > 0.0 || (bal == 0.0 && bal_new > 0.0) { volumes[0] = volumes[1] * (1.0 - bal_new * bal.signum()); } } @@ -748,7 +748,7 @@ impl<'a> View<'a> { volumes[1] = (volumes[1].cbrt() + delta).max(0.0).powi(3); volumes[0] = - (volumes[1] * (1.0 + bal * bal.signum())).max(0.0); + (volumes[1] * (1.0 - bal * bal.signum())).max(0.0); } _ => { let avg =