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 | Default button
36//! ----------------|---------------
37//! `toggle_mute` | Right
38//! `volume_down` | Wheel Down
39//! `volume_up` | 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` (as a progression)
91//! - `microphone` (as a progression)
92//! - `volume_muted` (as a progression)
93//! - `volume` (as a progression)
94//! - `headphones`
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
135pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {
136 let mut actions = api.get_actions()?;
137 api.set_default_actions(&[
138 (MouseButton::Left, None, "next_format"),
139 (MouseButton::Right, None, "toggle_mute"),
140 (MouseButton::WheelUp, None, "volume_up"),
141 (MouseButton::WheelDown, None, "volume_down"),
142 ])?;
143
144 let mut formats = config.formats.with_default(" $icon {$volume.eng(w:2)|} ")?;
145
146 let device_kind = config.device_kind;
147 let step_width = config.step_width.clamp(0, 50) as i32;
148
149 let icon = |muted: bool, device: &dyn SoundDevice| -> &'static str {
150 if config.headphones_indicator && device_kind == DeviceKind::Sink {
151 let form_factor = device.form_factor();
152 let active_port = device.active_port();
153 debug!("form_factor = {form_factor:?} active_port = {active_port:?}");
154 let headphones = match form_factor {
155 // form_factor's possible values are listed at:
156 // https://docs.rs/libpulse-binding/2.25.0/libpulse_binding/proplist/properties/constant.DEVICE_FORM_FACTOR.html
157 Some("headset") | Some("headphone") | Some("hands-free") | Some("portable") => true,
158 // Per discussion at
159 // https://github.com/greshake/i3status-rust/pull/1363#issuecomment-1046095869,
160 // fall back to checking active_port if form_factor is absent, unknown, or doesn't match
161 // known headphone values (common on PipeWire/WirePlumber systems).
162 _ => active_port
163 .as_ref()
164 .is_some_and(|p| p.to_lowercase().contains("headphone")),
165 };
166 if headphones {
167 return "headphones";
168 }
169 }
170 if muted {
171 match device_kind {
172 DeviceKind::Source => "microphone_muted",
173 DeviceKind::Sink => "volume_muted",
174 }
175 } else {
176 match device_kind {
177 DeviceKind::Source => "microphone",
178 DeviceKind::Sink => "volume",
179 }
180 }
181 };
182
183 type DeviceType = Box<dyn SoundDevice>;
184 let mut device: DeviceType = match config.driver {
185 SoundDriver::Alsa => Box::new(alsa::Device::new(
186 config.name.clone().unwrap_or_else(|| "Master".into()),
187 config.device.clone().unwrap_or_else(|| "default".into()),
188 config.natural_mapping,
189 )?),
190 #[cfg(feature = "pipewire")]
191 SoundDriver::Pipewire => {
192 Box::new(pipewire::Device::new(config.device_kind, config.name.clone()).await?)
193 }
194 #[cfg(feature = "pulseaudio")]
195 SoundDriver::PulseAudio => Box::new(pulseaudio::Device::new(
196 config.device_kind,
197 config.name.clone(),
198 )?),
199 SoundDriver::Auto => 'blk: {
200 #[cfg(feature = "pulseaudio")]
201 if let Ok(pulse) = pulseaudio::Device::new(config.device_kind, config.name.clone()) {
202 break 'blk Box::new(pulse);
203 }
204 #[cfg(feature = "pipewire")]
205 if let Ok(pipewire) =
206 pipewire::Device::new(config.device_kind, config.name.clone()).await
207 {
208 break 'blk Box::new(pipewire);
209 }
210 Box::new(alsa::Device::new(
211 config.name.clone().unwrap_or_else(|| "Master".into()),
212 config.device.clone().unwrap_or_else(|| "default".into()),
213 config.natural_mapping,
214 )?)
215 }
216 };
217
218 let mappings = match &config.mappings {
219 Some(m) => {
220 if config.mappings_use_regex {
221 Some(Mappings::Regex(
222 m.iter()
223 .map(|(key, val)| {
224 Ok((
225 Regex::new(key)
226 .error("Failed to parse `{key}` in mappings as regex")?,
227 val.as_str(),
228 ))
229 })
230 .collect::<Result<_>>()?,
231 ))
232 } else {
233 Some(Mappings::Exact(m))
234 }
235 }
236 None => None,
237 };
238
239 loop {
240 device.get_info().await?;
241 let volume = device.volume();
242 let muted = device.muted();
243 let mut output_name = device.output_name();
244 let mut active_port = device.active_port();
245 match &mappings {
246 Some(Mappings::Regex(m)) => {
247 if let Some((regex, mapped)) =
248 m.iter().find(|(regex, _)| regex.is_match(&output_name))
249 {
250 output_name = regex.replace(&output_name, *mapped).into_owned();
251 }
252 }
253 Some(Mappings::Exact(m)) => {
254 if let Some(mapped) = m.get(&output_name) {
255 output_name.clone_from(mapped);
256 }
257 }
258 None => (),
259 }
260 if let Some(ap) = &active_port
261 && let Some((regex, mapped)) = config
262 .active_port_mappings
263 .iter()
264 .find(|(regex, _)| regex.0.is_match(ap))
265 {
266 let mapped = regex.0.replace(ap, mapped);
267 if mapped.is_empty() {
268 active_port = None;
269 } else {
270 active_port = Some(mapped.into_owned());
271 }
272 }
273
274 let output_description = device
275 .output_description()
276 .unwrap_or_else(|| output_name.clone());
277
278 let mut values = map! {
279 "icon" => Value::icon_progression(icon(muted, &*device), volume as f64 / 100.0),
280 "volume" => Value::percents(volume),
281 "output_name" => Value::text(output_name),
282 "output_description" => Value::text(output_description),
283 [if let Some(ap) = active_port] "active_port" => Value::text(ap),
284 };
285
286 let mut widget = Widget::new().with_format(formats.get_format());
287
288 if muted {
289 widget.state = State::Warning;
290 if !config.show_volume_when_muted {
291 values.remove("volume");
292 }
293 }
294
295 widget.set_values(values);
296 api.set_widget(widget)?;
297
298 loop {
299 select! {
300 val = device.wait_for_update() => {
301 val?;
302 break;
303 }
304 _ = api.wait_for_update_request() => break,
305 Some(action) = actions.recv() => match action.as_ref() {
306 "next_format" | "toggle_format" => {
307 formats.next_format();
308 break;
309 }
310 "prev_format" => {
311 formats.prev_format();
312 break;
313 }
314 "toggle_mute" => {
315 device.toggle().await?;
316 }
317 "volume_up" => {
318 device.set_volume(step_width, config.max_vol).await?;
319 }
320 "volume_down" => {
321 device.set_volume(-step_width, config.max_vol).await?;
322 }
323 _ => (),
324 }
325 }
326 }
327 }
328}
329
330#[derive(Deserialize, Debug, SmartDefault, Clone, Copy)]
331#[serde(rename_all = "lowercase")]
332pub enum SoundDriver {
333 #[default]
334 Auto,
335 Alsa,
336 #[cfg(feature = "pipewire")]
337 Pipewire,
338 #[cfg(feature = "pulseaudio")]
339 PulseAudio,
340}
341
342#[derive(Deserialize, Debug, SmartDefault, Clone, Copy, PartialEq, Eq, Hash)]
343#[serde(rename_all = "lowercase")]
344pub enum DeviceKind {
345 #[default]
346 Sink,
347 Source,
348}
349
350#[async_trait::async_trait]
351trait SoundDevice {
352 fn volume(&self) -> u32;
353 fn muted(&self) -> bool;
354 fn output_name(&self) -> String;
355 fn output_description(&self) -> Option<String>;
356 fn active_port(&self) -> Option<String>;
357 fn form_factor(&self) -> Option<&str>;
358
359 async fn get_info(&mut self) -> Result<()>;
360 async fn set_volume(&mut self, step: i32, max_vol: Option<u32>) -> Result<()>;
361 async fn toggle(&mut self) -> Result<()>;
362 async fn wait_for_update(&mut self) -> Result<()>;
363}