1make_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
135fn 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 Some("headset") | Some("headphone") | Some("hands-free") | Some("portable") => true,
144 _ => active_port
149 .as_ref()
150 .is_some_and(|p| p.to_lowercase().contains("headphone")),
151 }
152}
153
154fn 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
173fn 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 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 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 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 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 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}