Skip to main content

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 [MultiFormat][MaybeMultiFormatConfig] string to customise the output of this block. See below for available placeholders. | <code>[\" $icon {$volume.eng(w:2) \|}\"]</code>
15//! `name` | PulseAudio device name, or the ALSA control name as found in the output of `amixer -D yourdevice scontrols`. | PulseAudio: `@DEFAULT_SINK@` / ALSA: `Master`
16//! `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`
17//! `device_kind` | PulseAudio device kind: `source` or `sink`. | `"sink"`
18//! `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`
19//! `step_width` | The percent volume level is increased/decreased for the selected audio device when scrolling. Capped automatically at 50. | `5`
20//! `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`
21//! `show_volume_when_muted` | Show the volume even if it is currently muted. | `false`
22//! `headphones_indicator` | Change icon when headphones are plugged in (pulseaudio only) | `false`
23//! `mappings` | Map `output_name` to a custom name. | `None`
24//! `mappings_use_regex` | Let `mappings` match using regex instead of string equality. The replacement will be regex aware and can contain capture groups. | `true`
25//! `active_port_mappings` | Map `active_port` to a custom name. The replacement will be regex aware and can contain capture groups. | `None`
26//!
27//! Placeholder          | Value                             | Type   | Unit
28//! ---------------------|-----------------------------------|--------|---------------
29//! `icon`               | Icon based on volume              | Icon   | -
30//! `volume`             | Current volume. Missing if muted. | Number | %
31//! `output_name`        | PulseAudio or ALSA device name    | Text   | -
32//! `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 | -
33//! `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 | -
34//!
35//! Action          | Description                     | Default button
36//! ----------------|---------------------------------|---------------
37//! `toggle_mute`   | Toggle mute                         | Right
38//! `volume_down`   | Decrease volume                     | Wheel Down
39//! `volume_up`     | Increase volume                     | Wheel Up
40//! `toggle_format` **DEPRECATED** | Toggles between `format` and `format_alt` | -
41//! `next_format`  | Switches to the next format in the list     | Left
42//! `prev_format`  | Switches to the previous format in the list | -
43//!
44//! # Examples
45//!
46//! Change the default scrolling step width to 3 percent:
47//!
48//! ```toml
49//! [[block]]
50//! block = "sound"
51//! step_width = 3
52//! ```
53//!
54//! Change the output name shown:
55//!
56//! ```toml
57//! [[block]]
58//! block = "sound"
59//! format = " $icon $output_name{ $volume|} "
60//! [block.mappings]
61//! "alsa_output.usb-Harman_Multimedia_JBL_Pebbles_1.0.0-00.analog-stereo" = "Speakers"
62//! "alsa_output.pci-0000_00_1b.0.analog-stereo" = "Headset"
63//! ```
64//!
65//! Since the default value for the `device_kind` key is `sink`,
66//! to display ***microphone*** block you have to use the `source` value:
67//!
68//! ```toml
69//! [[block]]
70//! block = "sound"
71//! driver = "pulseaudio"
72//! device_kind = "source"
73//! ```
74//!
75//! Display warning in block if microphone if using the wrong port:
76//!
77//! ```toml
78//! [[block]]
79//! block = "sound"
80//! driver = "pulseaudio"
81//! device_kind = "source"
82//! format = " $icon { $volume|} {$active_port |}"
83//! [block.active_port_mappings]
84//! "analog-input-rear-mic" = "" # Mapping to an empty string makes `$active_port` absent
85//! "analog-input-front-mic" = "ERR!"
86//! ```
87//!
88//! #  Icons Used
89//!
90//! - `microphone_muted` (`$icon`, as a progression)
91//! - `microphone` (`$icon`, as a progression)
92//! - `volume_muted` (`$icon`, as a progression)
93//! - `volume` (`$icon`, as a progression)
94//! - `headphones` (`$icon`)
95
96make_log_macro!(debug, "sound");
97
98mod alsa;
99#[cfg(feature = "pipewire")]
100pub mod pipewire;
101#[cfg(feature = "pulseaudio")]
102mod pulseaudio;
103
104use super::prelude::*;
105use crate::wrappers::SerdeRegex;
106use indexmap::IndexMap;
107use regex::Regex;
108
109#[derive(Deserialize, Debug, SmartDefault)]
110#[serde(default)]
111pub struct Config {
112    pub driver: SoundDriver,
113    pub name: Option<String>,
114    pub device: Option<String>,
115    pub device_kind: DeviceKind,
116    pub natural_mapping: bool,
117    #[default(5)]
118    pub step_width: u32,
119    #[serde(flatten)]
120    pub formats: MaybeMultiFormatConfig,
121    pub headphones_indicator: bool,
122    pub show_volume_when_muted: bool,
123    pub mappings: Option<IndexMap<String, String>>,
124    #[default(true)]
125    pub mappings_use_regex: bool,
126    pub max_vol: Option<u32>,
127    pub active_port_mappings: IndexMap<SerdeRegex, String>,
128}
129
130enum Mappings<'a> {
131    Exact(&'a IndexMap<String, String>),
132    Regex(Vec<(Regex, &'a str)>),
133}
134
135/// Whether the device should be represented by the `headphones` icon.
136fn is_headphones(device: &dyn SoundDevice) -> bool {
137    let form_factor = device.form_factor();
138    let active_port = device.active_port();
139    debug!("form_factor = {form_factor:?} active_port = {active_port:?}");
140    match form_factor {
141        // form_factor's possible values are listed at:
142        // https://docs.rs/libpulse-binding/2.25.0/libpulse_binding/proplist/properties/constant.DEVICE_FORM_FACTOR.html
143        Some("headset") | Some("headphone") | Some("hands-free") | Some("portable") => true,
144        // Per discussion at
145        // https://github.com/greshake/i3status-rust/pull/1363#issuecomment-1046095869,
146        // fall back to checking active_port if form_factor is absent, unknown, or doesn't match
147        // known headphone values (common on PipeWire/WirePlumber systems).
148        _ => active_port
149            .as_ref()
150            .is_some_and(|p| p.to_lowercase().contains("headphone")),
151    }
152}
153
154/// The icon name for the current device state. `headphones` can only be true
155/// when `headphones_indicator` is set and the device is a sink.
156fn icon_name(device_kind: DeviceKind, headphones: bool, muted: bool) -> &'static str {
157    if headphones {
158        return icons::HEADPHONES;
159    }
160    if muted {
161        match device_kind {
162            DeviceKind::Source => icons::MICROPHONE_MUTED,
163            DeviceKind::Sink => icons::VOLUME_MUTED,
164        }
165    } else {
166        match device_kind {
167            DeviceKind::Source => icons::MICROPHONE,
168            DeviceKind::Sink => icons::VOLUME,
169        }
170    }
171}
172
173/// Every name [`icon_name`] can return for this configuration. The runtime
174/// state (muted, headphones plugged in) is externally selected but finite,
175/// so the block plan declares the full set.
176fn declared_icon_names(device_kind: DeviceKind, headphones_indicator: bool) -> Vec<&'static str> {
177    let mut names = match device_kind {
178        DeviceKind::Sink => vec![icons::VOLUME, icons::VOLUME_MUTED],
179        DeviceKind::Source => vec![icons::MICROPHONE, icons::MICROPHONE_MUTED],
180    };
181    if headphones_indicator && device_kind == DeviceKind::Sink {
182        names.push(icons::HEADPHONES);
183    }
184    names
185}
186
187pub(crate) fn prepare(config: &Config) -> Result<Arc<BlockPlan>> {
188    let icons = || {
189        IconChoices::fixed(declared_icon_names(
190            config.device_kind,
191            config.headphones_indicator,
192        ))
193    };
194    // `volume` is removed when muted (unless `show_volume_when_muted`) and
195    // `active_port` can be absent or mapped away, so neither is guaranteed.
196    let declare = |output: OutputPlan| output.icon("icon", icons());
197    let formats = config.formats.with_default(" $icon {$volume.eng(w:2)|} ")?;
198    BlockPlan::new(format_outputs(formats, declare))
199}
200
201pub(crate) async fn run(config: &Config, api: &CommonApi, plan: &Arc<BlockPlan>) -> Result<()> {
202    let mut actions = api.get_actions()?;
203    api.set_default_actions(&[
204        (MouseButton::Left, None, "next_format"),
205        (MouseButton::Right, None, "toggle_mute"),
206        (MouseButton::WheelUp, None, "volume_up"),
207        (MouseButton::WheelDown, None, "volume_down"),
208    ])?;
209
210    let mut formats = FormatRotation::new(plan)?;
211
212    let device_kind = config.device_kind;
213    let step_width = config.step_width.clamp(0, 50) as i32;
214
215    type DeviceType = Box<dyn SoundDevice>;
216    let mut device: DeviceType = match config.driver {
217        SoundDriver::Alsa => Box::new(alsa::Device::new(
218            config.name.clone().unwrap_or_else(|| "Master".into()),
219            config.device.clone().unwrap_or_else(|| "default".into()),
220            config.natural_mapping,
221        )?),
222        #[cfg(feature = "pipewire")]
223        SoundDriver::Pipewire => {
224            Box::new(pipewire::Device::new(config.device_kind, config.name.clone()).await?)
225        }
226        #[cfg(feature = "pulseaudio")]
227        SoundDriver::PulseAudio => Box::new(pulseaudio::Device::new(
228            config.device_kind,
229            config.name.clone(),
230        )?),
231        SoundDriver::Auto => 'blk: {
232            #[cfg(feature = "pulseaudio")]
233            if let Ok(pulse) = pulseaudio::Device::new(config.device_kind, config.name.clone()) {
234                break 'blk Box::new(pulse);
235            }
236            #[cfg(feature = "pipewire")]
237            if let Ok(pipewire) =
238                pipewire::Device::new(config.device_kind, config.name.clone()).await
239            {
240                break 'blk Box::new(pipewire);
241            }
242            Box::new(alsa::Device::new(
243                config.name.clone().unwrap_or_else(|| "Master".into()),
244                config.device.clone().unwrap_or_else(|| "default".into()),
245                config.natural_mapping,
246            )?)
247        }
248    };
249
250    let mappings = match &config.mappings {
251        Some(m) => {
252            if config.mappings_use_regex {
253                Some(Mappings::Regex(
254                    m.iter()
255                        .map(|(key, val)| {
256                            Ok((
257                                Regex::new(key)
258                                    .error("Failed to parse `{key}` in mappings as regex")?,
259                                val.as_str(),
260                            ))
261                        })
262                        .collect::<Result<_>>()?,
263                ))
264            } else {
265                Some(Mappings::Exact(m))
266            }
267        }
268        None => None,
269    };
270
271    loop {
272        device.get_info().await?;
273        let volume = device.volume();
274        let muted = device.muted();
275        let mut output_name = device.output_name();
276        let mut active_port = device.active_port();
277        match &mappings {
278            Some(Mappings::Regex(m)) => {
279                if let Some((regex, mapped)) =
280                    m.iter().find(|(regex, _)| regex.is_match(&output_name))
281                {
282                    output_name = regex.replace(&output_name, *mapped).into_owned();
283                }
284            }
285            Some(Mappings::Exact(m)) => {
286                if let Some(mapped) = m.get(&output_name) {
287                    output_name.clone_from(mapped);
288                }
289            }
290            None => (),
291        }
292        if let Some(ap) = &active_port
293            && let Some((regex, mapped)) = config
294                .active_port_mappings
295                .iter()
296                .find(|(regex, _)| regex.0.is_match(ap))
297        {
298            let mapped = regex.0.replace(ap, mapped);
299            if mapped.is_empty() {
300                active_port = None;
301            } else {
302                active_port = Some(mapped.into_owned());
303            }
304        }
305
306        let output_description = device
307            .output_description()
308            .unwrap_or_else(|| output_name.clone());
309
310        let headphones = config.headphones_indicator
311            && device_kind == DeviceKind::Sink
312            && is_headphones(&*device);
313
314        let output = formats.current();
315        let mut values = map! {
316            "icon" => Value::icon_progression(
317                icon_name(device_kind, headphones, muted),
318                volume as f64 / 100.0),
319            "volume" => Value::percents(volume),
320            "output_name" => Value::text(output_name),
321            "output_description" => Value::text(output_description),
322            [if let Some(ap) = active_port] "active_port" => Value::text(ap),
323        };
324        let mut widget = output.new_widget();
325
326        if muted {
327            widget.state = State::Warning;
328            if !config.show_volume_when_muted {
329                values.remove("volume");
330            }
331        }
332
333        widget.set_values(values);
334        api.set_widget(widget)?;
335
336        loop {
337            select! {
338                val = device.wait_for_update() => {
339                    val?;
340                    break;
341                }
342                _ = api.wait_for_update_request() => break,
343                Some(action) = actions.recv() => match action.as_ref() {
344                    "next_format" | "toggle_format" => {
345                        formats.next();
346                        break;
347                    }
348                    "prev_format" => {
349                        formats.prev();
350                        break;
351                    }
352                    "toggle_mute" => {
353                        device.toggle().await?;
354                    }
355                    "volume_up" => {
356                        device.set_volume(step_width, config.max_vol).await?;
357                    }
358                    "volume_down" => {
359                        device.set_volume(-step_width, config.max_vol).await?;
360                    }
361                    _ => (),
362                }
363            }
364        }
365    }
366}
367
368#[derive(Deserialize, Debug, SmartDefault, Clone, Copy)]
369#[serde(rename_all = "lowercase")]
370pub enum SoundDriver {
371    #[default]
372    Auto,
373    Alsa,
374    #[cfg(feature = "pipewire")]
375    Pipewire,
376    #[cfg(feature = "pulseaudio")]
377    PulseAudio,
378}
379
380#[derive(Deserialize, Debug, SmartDefault, Clone, Copy, PartialEq, Eq, Hash)]
381#[serde(rename_all = "lowercase")]
382pub enum DeviceKind {
383    #[default]
384    Sink,
385    Source,
386}
387
388#[async_trait::async_trait]
389trait SoundDevice {
390    fn volume(&self) -> u32;
391    fn muted(&self) -> bool;
392    fn output_name(&self) -> String;
393    fn output_description(&self) -> Option<String>;
394    fn active_port(&self) -> Option<String>;
395    fn form_factor(&self) -> Option<&str>;
396
397    async fn get_info(&mut self) -> Result<()>;
398    async fn set_volume(&mut self, step: i32, max_vol: Option<u32>) -> Result<()>;
399    async fn toggle(&mut self) -> Result<()>;
400    async fn wait_for_update(&mut self) -> Result<()>;
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    fn config(toml_str: &str) -> Config {
408        toml::from_str(toml_str).unwrap()
409    }
410
411    #[test]
412    fn plan_declares_device_kind_specific_icons() {
413        // Default: sink without headphones indicator.
414        let plan = prepare(&Config::default()).unwrap();
415        let ids: Vec<_> = plan.outputs().map(|o| o.id()).collect();
416        assert_eq!(ids, ["format"]);
417        let choices = plan
418            .output("format")
419            .unwrap()
420            .output()
421            .choices_for("icon")
422            .unwrap()
423            .clone();
424        assert!(choices.permits("volume"));
425        assert!(choices.permits("volume_muted"));
426        assert!(!choices.permits("headphones"));
427        assert!(!choices.permits("microphone"));
428        assert!(!choices.permits("microphone_muted"));
429
430        // Source devices use the microphone icons.
431        let config = Config {
432            device_kind: DeviceKind::Source,
433            ..Config::default()
434        };
435        let plan = prepare(&config).unwrap();
436        let choices = plan
437            .output("format")
438            .unwrap()
439            .output()
440            .choices_for("icon")
441            .unwrap()
442            .clone();
443        assert!(choices.permits("microphone"));
444        assert!(choices.permits("microphone_muted"));
445        assert!(!choices.permits("volume"));
446        assert!(!choices.permits("headphones"));
447    }
448
449    #[test]
450    fn headphones_icon_declared_only_for_sinks_with_indicator() {
451        let config = Config {
452            headphones_indicator: true,
453            ..Config::default()
454        };
455        let plan = prepare(&config).unwrap();
456        assert!(
457            plan.output("format")
458                .unwrap()
459                .output()
460                .choices_for("icon")
461                .unwrap()
462                .permits("headphones")
463        );
464
465        // The indicator only applies to sinks.
466        let config = Config {
467            headphones_indicator: true,
468            device_kind: DeviceKind::Source,
469            ..Config::default()
470        };
471        let plan = prepare(&config).unwrap();
472        assert!(
473            !plan
474                .output("format")
475                .unwrap()
476                .output()
477                .choices_for("icon")
478                .unwrap()
479                .permits("headphones")
480        );
481    }
482
483    #[test]
484    fn every_configured_format_gets_an_output() {
485        let plan = prepare(&Config::default()).unwrap();
486        assert!(plan.output("format2").is_err());
487
488        let plan = prepare(&config(r#"format = [" $icon ", " $icon $output_name "]"#)).unwrap();
489        let ids: Vec<_> = plan.outputs().map(|o| o.id()).collect();
490        assert_eq!(ids, ["format", "format2"]);
491        let alt = plan.output("format2").unwrap();
492        assert!(alt.format().contains_key("output_name"));
493        assert!(alt.output().choices_for("icon").unwrap().permits("volume"));
494    }
495
496    #[test]
497    fn format_alt_still_produces_a_second_output() {
498        let plan = prepare(&config(r#"format_alt = " $icon $output_name ""#)).unwrap();
499        let alt = plan.output("format2").unwrap();
500        assert!(alt.format().contains_key("output_name"));
501        assert!(alt.output().choices_for("icon").unwrap().permits("volume"));
502    }
503
504    #[test]
505    fn chooser_only_produces_declared_names() {
506        for device_kind in [DeviceKind::Sink, DeviceKind::Source] {
507            for headphones_indicator in [false, true] {
508                let declared = declared_icon_names(device_kind, headphones_indicator);
509                // Headphones can only be detected for sinks with the
510                // indicator enabled; mirror that reachability here.
511                let headphone_states: &[bool] =
512                    if headphones_indicator && device_kind == DeviceKind::Sink {
513                        &[false, true]
514                    } else {
515                        &[false]
516                    };
517                for &headphones in headphone_states {
518                    for muted in [false, true] {
519                        let name = icon_name(device_kind, headphones, muted);
520                        assert!(
521                            declared.contains(&name),
522                            "icon '{name}' not declared for {device_kind:?} \
523                             (headphones_indicator: {headphones_indicator})"
524                        );
525                    }
526                }
527            }
528        }
529    }
530}