i3status_rs/blocks/sound.rs
1//! Volume level
2//!
3//! This block displays the volume level (according to PulseAudio or ALSA). Right click to toggle mute, scroll to adjust volume.
4//!
5//! Requires a PulseAudio installation or `alsa-utils` for ALSA.
6//!
7//! Note that if you are using PulseAudio commands (such as `pactl`) to control your volume, you should select the `"pulseaudio"` (or `"auto"`) driver to see volume changes that exceed 100%.
8//!
9//! # Configuration
10//!
11//! Key | Values | Default
12//! ----|--------|--------
13//! `driver` | `"auto"`, `pipewire`, `"pulseaudio"`, `"alsa"`. | `"auto"` (Pipewire with Pulseaudio fallback with ALSA fallback)
14//! `format` | A string to customise the output of this block. See below for available placeholders. | <code>\" $icon {$volume.eng(w:2) \|}\"</code>
15//! `format_alt` | If set, block will switch between `format` and `format_alt` on every click. | `None`
16//! `name` | PulseAudio device name, or the ALSA control name as found in the output of `amixer -D yourdevice scontrols`. | PulseAudio: `@DEFAULT_SINK@` / ALSA: `Master`
17//! `device` | ALSA device name, usually in the form "hw:X" or "hw:X,Y" where `X` is the card number and `Y` is the device number as found in the output of `aplay -l`. | `default`
18//! `device_kind` | PulseAudio device kind: `source` or `sink`. | `"sink"`
19//! `natural_mapping` | When using the ALSA driver, display the "mapped volume" as given by `alsamixer`/`amixer -M`, which represents the volume level more naturally with respect for the human ear. | `false`
20//! `step_width` | The percent volume level is increased/decreased for the selected audio device when scrolling. Capped automatically at 50. | `5`
21//! `max_vol` | Max volume in percent that can be set via scrolling. Note it can still be set above this value if changed by another application. | `None`
22//! `show_volume_when_muted` | Show the volume even if it is currently muted. | `false`
23//! `headphones_indicator` | Change icon when headphones are plugged in (pulseaudio only) | `false`
24//! `mappings` | Map `output_name` to a custom name. | `None`
25//! `mappings_use_regex` | Let `mappings` match using regex instead of string equality. The replacement will be regex aware and can contain capture groups. | `true`
26//! `active_port_mappings` | Map `active_port` to a custom name. The replacement will be regex aware and can contain capture groups. | `None`
27//!
28//! Placeholder | Value | Type | Unit
29//! ---------------------|-----------------------------------|--------|---------------
30//! `icon` | Icon based on volume | Icon | -
31//! `volume` | Current volume. Missing if muted. | Number | %
32//! `output_name` | PulseAudio or ALSA device name | Text | -
33//! `output_description` | PulseAudio device description, will fallback to `output_name` if no description is available and will be overwritten by mappings (mappings will still use `output_name`) | Text | -
34//! `active_port` | Active port (same as information in Ports section of `pactl list cards`). Will be absent if not supported by `driver` or if mapped to `""` in `active_port_mappings`. | Text | -
35//!
36//! Action | Default button
37//! ----------------|---------------
38//! `toggle_format` | Left
39//! `toggle_mute` | Right
40//! `volume_down` | Wheel Down
41//! `volume_up` | Wheel Up
42//!
43//! # Examples
44//!
45//! Change the default scrolling step width to 3 percent:
46//!
47//! ```toml
48//! [[block]]
49//! block = "sound"
50//! step_width = 3
51//! ```
52//!
53//! Change the output name shown:
54//!
55//! ```toml
56//! [[block]]
57//! block = "sound"
58//! format = " $icon $output_name{ $volume|} "
59//! [block.mappings]
60//! "alsa_output.usb-Harman_Multimedia_JBL_Pebbles_1.0.0-00.analog-stereo" = "Speakers"
61//! "alsa_output.pci-0000_00_1b.0.analog-stereo" = "Headset"
62//! ```
63//!
64//! Since the default value for the `device_kind` key is `sink`,
65//! to display ***microphone*** block you have to use the `source` value:
66//!
67//! ```toml
68//! [[block]]
69//! block = "sound"
70//! driver = "pulseaudio"
71//! device_kind = "source"
72//! ```
73//!
74//! Display warning in block if microphone if using the wrong port:
75//!
76//! ```toml
77//! [[block]]
78//! block = "sound"
79//! driver = "pulseaudio"
80//! device_kind = "source"
81//! format = " $icon { $volume|} {$active_port |}"
82//! [block.active_port_mappings]
83//! "analog-input-rear-mic" = "" # Mapping to an empty string makes `$active_port` absent
84//! "analog-input-front-mic" = "ERR!"
85//! ```
86//!
87//! # Icons Used
88//!
89//! - `microphone_muted` (as a progression)
90//! - `microphone` (as a progression)
91//! - `volume_muted` (as a progression)
92//! - `volume` (as a progression)
93//! - `headphones`
94
95make_log_macro!(debug, "sound");
96
97mod alsa;
98#[cfg(feature = "pipewire")]
99pub mod pipewire;
100#[cfg(feature = "pulseaudio")]
101mod pulseaudio;
102
103use super::prelude::*;
104use crate::wrappers::SerdeRegex;
105use indexmap::IndexMap;
106use regex::Regex;
107
108#[derive(Deserialize, Debug, SmartDefault)]
109#[serde(deny_unknown_fields, default)]
110pub struct Config {
111 pub driver: SoundDriver,
112 pub name: Option<String>,
113 pub device: Option<String>,
114 pub device_kind: DeviceKind,
115 pub natural_mapping: bool,
116 #[default(5)]
117 pub step_width: u32,
118 pub format: FormatConfig,
119 pub format_alt: Option<FormatConfig>,
120 pub headphones_indicator: bool,
121 pub show_volume_when_muted: bool,
122 pub mappings: Option<IndexMap<String, String>>,
123 #[default(true)]
124 pub mappings_use_regex: bool,
125 pub max_vol: Option<u32>,
126 pub active_port_mappings: IndexMap<SerdeRegex, String>,
127}
128
129enum Mappings<'a> {
130 Exact(&'a IndexMap<String, String>),
131 Regex(Vec<(Regex, &'a str)>),
132}
133
134pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {
135 let mut actions = api.get_actions()?;
136 api.set_default_actions(&[
137 (MouseButton::Left, None, "toggle_format"),
138 (MouseButton::Right, None, "toggle_mute"),
139 (MouseButton::WheelUp, None, "volume_up"),
140 (MouseButton::WheelDown, None, "volume_down"),
141 ])?;
142
143 let mut format = config.format.with_default(" $icon {$volume.eng(w:2)|} ")?;
144 let mut format_alt = match &config.format_alt {
145 Some(f) => Some(f.with_default("")?),
146 None => None,
147 };
148
149 let device_kind = config.device_kind;
150 let step_width = config.step_width.clamp(0, 50) as i32;
151
152 let icon = |muted: bool, device: &dyn SoundDevice| -> &'static str {
153 if config.headphones_indicator && device_kind == DeviceKind::Sink {
154 let form_factor = device.form_factor();
155 let active_port = device.active_port();
156 debug!("form_factor = {form_factor:?} active_port = {active_port:?}");
157 let headphones = match form_factor {
158 // form_factor's possible values are listed at:
159 // https://docs.rs/libpulse-binding/2.25.0/libpulse_binding/proplist/properties/constant.DEVICE_FORM_FACTOR.html
160 Some("headset") | Some("headphone") | Some("hands-free") | Some("portable") => true,
161 // Per discussion at
162 // https://github.com/greshake/i3status-rust/pull/1363#issuecomment-1046095869,
163 // some sinks may not have the form_factor property, so we should fall back to the
164 // active_port if that property is not present.
165 None => active_port.is_some_and(|p| p.to_lowercase().contains("headphones")),
166 // form_factor is present and is some non-headphone value
167 _ => false,
168 };
169 if headphones {
170 return "headphones";
171 }
172 }
173 if muted {
174 match device_kind {
175 DeviceKind::Source => "microphone_muted",
176 DeviceKind::Sink => "volume_muted",
177 }
178 } else {
179 match device_kind {
180 DeviceKind::Source => "microphone",
181 DeviceKind::Sink => "volume",
182 }
183 }
184 };
185
186 type DeviceType = Box<dyn SoundDevice>;
187 let mut device: DeviceType = match config.driver {
188 SoundDriver::Alsa => Box::new(alsa::Device::new(
189 config.name.clone().unwrap_or_else(|| "Master".into()),
190 config.device.clone().unwrap_or_else(|| "default".into()),
191 config.natural_mapping,
192 )?),
193 #[cfg(feature = "pipewire")]
194 SoundDriver::Pipewire => {
195 Box::new(pipewire::Device::new(config.device_kind, config.name.clone()).await?)
196 }
197 #[cfg(feature = "pulseaudio")]
198 SoundDriver::PulseAudio => Box::new(pulseaudio::Device::new(
199 config.device_kind,
200 config.name.clone(),
201 )?),
202 SoundDriver::Auto => 'blk: {
203 #[cfg(feature = "pipewire")]
204 if let Ok(pipewire) =
205 pipewire::Device::new(config.device_kind, config.name.clone()).await
206 {
207 break 'blk Box::new(pipewire);
208 }
209 #[cfg(feature = "pulseaudio")]
210 if let Ok(pulse) = pulseaudio::Device::new(config.device_kind, config.name.clone()) {
211 break 'blk Box::new(pulse);
212 }
213 Box::new(alsa::Device::new(
214 config.name.clone().unwrap_or_else(|| "Master".into()),
215 config.device.clone().unwrap_or_else(|| "default".into()),
216 config.natural_mapping,
217 )?)
218 }
219 };
220
221 let mappings = match &config.mappings {
222 Some(m) => {
223 if config.mappings_use_regex {
224 Some(Mappings::Regex(
225 m.iter()
226 .map(|(key, val)| {
227 Ok((
228 Regex::new(key)
229 .error("Failed to parse `{key}` in mappings as regex")?,
230 val.as_str(),
231 ))
232 })
233 .collect::<Result<_>>()?,
234 ))
235 } else {
236 Some(Mappings::Exact(m))
237 }
238 }
239 None => None,
240 };
241
242 loop {
243 device.get_info().await?;
244 let volume = device.volume();
245 let muted = device.muted();
246 let mut output_name = device.output_name();
247 let mut active_port = device.active_port();
248 match &mappings {
249 Some(Mappings::Regex(m)) => {
250 if let Some((regex, mapped)) =
251 m.iter().find(|(regex, _)| regex.is_match(&output_name))
252 {
253 output_name = regex.replace(&output_name, *mapped).into_owned();
254 }
255 }
256 Some(Mappings::Exact(m)) => {
257 if let Some(mapped) = m.get(&output_name) {
258 output_name.clone_from(mapped);
259 }
260 }
261 None => (),
262 }
263 if let Some(ap) = &active_port
264 && let Some((regex, mapped)) = config
265 .active_port_mappings
266 .iter()
267 .find(|(regex, _)| regex.0.is_match(ap))
268 {
269 let mapped = regex.0.replace(ap, mapped);
270 if mapped.is_empty() {
271 active_port = None;
272 } else {
273 active_port = Some(mapped.into_owned());
274 }
275 }
276
277 let output_description = device
278 .output_description()
279 .unwrap_or_else(|| output_name.clone());
280
281 let mut values = map! {
282 "icon" => Value::icon_progression(icon(muted, &*device), volume as f64 / 100.0),
283 "volume" => Value::percents(volume),
284 "output_name" => Value::text(output_name),
285 "output_description" => Value::text(output_description),
286 [if let Some(ap) = active_port] "active_port" => Value::text(ap),
287 };
288
289 let mut widget = Widget::new().with_format(format.clone());
290
291 if muted {
292 widget.state = State::Warning;
293 if !config.show_volume_when_muted {
294 values.remove("volume");
295 }
296 }
297
298 widget.set_values(values);
299 api.set_widget(widget)?;
300
301 loop {
302 select! {
303 val = device.wait_for_update() => {
304 val?;
305 break;
306 }
307 _ = api.wait_for_update_request() => break,
308 Some(action) = actions.recv() => match action.as_ref() {
309 "toggle_format" => {
310 if let Some(format_alt) = &mut format_alt {
311 std::mem::swap(format_alt, &mut format);
312 break;
313 }
314 }
315 "toggle_mute" => {
316 device.toggle().await?;
317 }
318 "volume_up" => {
319 device.set_volume(step_width, config.max_vol).await?;
320 }
321 "volume_down" => {
322 device.set_volume(-step_width, config.max_vol).await?;
323 }
324 _ => (),
325 }
326 }
327 }
328 }
329}
330
331#[derive(Deserialize, Debug, SmartDefault, Clone, Copy)]
332#[serde(rename_all = "lowercase")]
333pub enum SoundDriver {
334 #[default]
335 Auto,
336 Alsa,
337 #[cfg(feature = "pipewire")]
338 Pipewire,
339 #[cfg(feature = "pulseaudio")]
340 PulseAudio,
341}
342
343#[derive(Deserialize, Debug, SmartDefault, Clone, Copy, PartialEq, Eq, Hash)]
344#[serde(rename_all = "lowercase")]
345pub enum DeviceKind {
346 #[default]
347 Sink,
348 Source,
349}
350
351#[async_trait::async_trait]
352trait SoundDevice {
353 fn volume(&self) -> u32;
354 fn muted(&self) -> bool;
355 fn output_name(&self) -> String;
356 fn output_description(&self) -> Option<String>;
357 fn active_port(&self) -> Option<String>;
358 fn form_factor(&self) -> Option<&str>;
359
360 async fn get_info(&mut self) -> Result<()>;
361 async fn set_volume(&mut self, step: i32, max_vol: Option<u32>) -> Result<()>;
362 async fn toggle(&mut self) -> Result<()>;
363 async fn wait_for_update(&mut self) -> Result<()>;
364}