//! `alloy audio` — a PipeWire front, via `pactl`. //! //! docs/CONSOLE.md names this a "wpctl / pactl front" and either would do, but //! `wpctl status` renders a box-drawing tree meant for human eyes: parsing it //! means guessing at indentation and glyphs that exist to look right, not to //! be read back. `pactl -f json` is a stated contract. After the nmcli //! escaping mistake, a format that documents itself is worth a `serde_json` //! dependency. //! //! # Two panes //! //! Streams on the left, devices on the right, joined by a connector through //! the gutter. A stream is an app or service moving audio; a device is the //! hardware it moves through. Every stream is routed to exactly one device, //! which is what makes the pairing drawable at all. //! //! On vocabulary: the left pane holds *streams*, not "sources". PulseAudio and //! PipeWire already use `source` for a capture device, and this module's //! `Direction::Input` means exactly that. Naming the app column "sources" //! would put the word next to a logged `pactl move-sink-input` meaning //! something else, and the log pane's whole job is teaching that vocabulary. //! //! Not a patchbay. PipeWire's real graph is port-level and many-to-many, and //! even one playing stream produces eight `pw-link` entries on a stereo setup. //! Routing a stream to a device covers what people actually want ("move this //! to my headphones"); the general graph is Helvum's job. use std::collections::HashMap; use alloy_tui::{ AlloyBlock, AlloyConnector, AlloyList, Cursor, FocusRing, Hint, Severity, Theme, hint, layout, list_row_y, text, }; use anyhow::{Context, Result}; use ratatui::Frame; use ratatui::crossterm::event::{KeyCode, KeyEvent}; use ratatui::layout::Rect; use ratatui::text::{Line, Span}; use serde::Deserialize; use crate::cli::{CommandLog, Invocation}; use crate::shell::{Flow, View, block_title}; /// How much one keypress moves the volume. Matches the step swayosd uses for /// the Fn keys, so the console and the hardware keys agree. const VOLUME_STEP: u8 = 5; /// PipeWire's unity volume — `pactl`'s raw values are relative to this, not to /// 100. Reading `value_percent` instead would mean parsing a localized string /// with a `%` glued to it. const VOLUME_UNITY: u32 = 65536; /// Ticks between device re-reads. Streams come and go with applications and /// are re-read every tick; devices change only when hardware is plugged in, so /// polling them at the same rate would spend four `pactl` spawns a second to /// learn nothing. `r` refreshes both immediately. const DEVICE_POLL_TICKS: u64 = 10; /// Pane indices for the focus ring. const PANE_STREAMS: usize = 0; const PANE_DEVICES: usize = 1; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Direction { Output, Input, } impl Direction { const fn label(self) -> &'static str { match self { Direction::Output => "output", Direction::Input => "input", } } /// The `pactl` noun for a *device* in this direction. const fn device_noun(self) -> &'static str { match self { Direction::Output => "sink", Direction::Input => "source", } } /// The `pactl` noun for a *stream* in this direction. Note the asymmetry: /// a stream playing *out* is an input *to a sink*, so playback streams are /// `sink-input` and capture streams are `source-output`. const fn stream_noun(self) -> &'static str { match self { Direction::Output => "sink-input", Direction::Input => "source-output", } } } #[derive(Debug, Clone)] pub struct Device { pub index: u32, pub name: String, pub description: String, pub direction: Direction, pub volume: u8, pub muted: bool, pub is_default: bool, } impl Device { fn severity(&self) -> Severity { match (self.is_default, self.muted) { (_, true) => Severity::Warn, (true, false) => Severity::Healthy, (false, false) => Severity::Info, } } fn state_label(&self) -> &'static str { match (self.is_default, self.muted) { (true, true) => "default, muted", (true, false) => "default", (false, true) => "muted", (false, false) => "", } } } /// An application or service moving audio through a device. #[derive(Debug, Clone)] pub struct Stream { pub index: u32, /// Who is playing. `media.name` (what is playing) is deliberately not kept /// alongside it: the streams pane is the narrower of the two and has no /// column to spare, and `media.name` already serves as the last fallback /// for this field when an app sets no name of its own. pub app: String, pub direction: Direction, /// Index of the device this stream is routed to. The pairing the /// connector draws. pub device_index: u32, pub volume: u8, pub muted: bool, /// Paused. PulseAudio's word, kept because it is what the CLI says. pub corked: bool, } impl Stream { fn severity(&self) -> Severity { match (self.muted, self.corked) { (true, _) => Severity::Warn, (false, true) => Severity::Info, (false, false) => Severity::Healthy, } } fn state_label(&self) -> &'static str { match (self.muted, self.corked) { (true, true) => "muted, idle", (true, false) => "muted", (false, true) => "idle", (false, false) => "playing", } } } /// What an action applies to. Devices and streams take the same verbs with /// different `pactl` nouns and identifiers, so the backend takes one of these /// rather than duplicating every method. #[derive(Debug, Clone, Copy)] pub enum Target<'a> { Device(&'a Device), Stream(&'a Stream), } impl Target<'_> { /// The `pactl` noun, which is what its subcommands are built from /// (`set-sink-volume`, `set-sink-input-mute`). fn noun(&self) -> &'static str { match self { Target::Device(device) => device.direction.device_noun(), Target::Stream(stream) => stream.direction.stream_noun(), } } /// How `pactl` names this target. Devices go by name, which is stable and /// readable in the log; streams go by index, which is all they have. fn id(&self) -> String { match self { Target::Device(device) => device.name.clone(), Target::Stream(stream) => stream.index.to_string(), } } fn volume(&self) -> u8 { match self { Target::Device(device) => device.volume, Target::Stream(stream) => stream.volume, } } } pub trait Backend { fn name(&self) -> &'static str; fn list_devices(&self, log: &mut CommandLog) -> Result>; fn list_streams(&self, log: &mut CommandLog) -> Result>; /// Set a target's volume, as a percentage of unity. fn set_volume(&self, target: Target<'_>, percent: u8, log: &mut CommandLog) -> Result<()>; /// Toggle a target's mute. fn toggle_mute(&self, target: Target<'_>, log: &mut CommandLog) -> Result<()>; /// Make a device the default for its direction. fn set_default(&self, device: &Device, log: &mut CommandLog) -> Result<()>; /// Route a stream to a device. The pairing edit. fn move_stream(&self, stream: &Stream, device: &Device, log: &mut CommandLog) -> Result<()>; } /// Pick a backend: `pactl` when it answers, the mock otherwise. pub fn detect() -> Box { if Invocation::new("pactl").arg("--version").probe() { Box::new(PaCtl) } else { Box::new(Mock) } } pub struct PaCtl; impl Backend for PaCtl { fn name(&self) -> &'static str { "pactl" } fn list_devices(&self, log: &mut CommandLog) -> Result> { let sinks = Invocation::new("pactl") .args(["-f", "json", "list", "sinks"]) .run(log)?; let default_sink = Invocation::new("pactl").arg("get-default-sink").run(log)?; let sources = Invocation::new("pactl") .args(["-f", "json", "list", "sources"]) .run(log)?; let default_source = Invocation::new("pactl").arg("get-default-source").run(log)?; let mut devices = parse_devices(&sinks, Direction::Output, default_sink.trim()) .context("parsing sinks")?; devices.extend( parse_devices(&sources, Direction::Input, default_source.trim()) .context("parsing sources")?, ); Ok(devices) } fn list_streams(&self, log: &mut CommandLog) -> Result> { let playback = Invocation::new("pactl") .args(["-f", "json", "list", "sink-inputs"]) .run(log)?; let capture = Invocation::new("pactl") .args(["-f", "json", "list", "source-outputs"]) .run(log)?; let mut streams = parse_streams(&playback, Direction::Output).context("parsing sink-inputs")?; streams.extend( parse_streams(&capture, Direction::Input).context("parsing source-outputs")?, ); Ok(streams) } fn set_volume(&self, target: Target<'_>, percent: u8, log: &mut CommandLog) -> Result<()> { Invocation::new("pactl") .arg(format!("set-{}-volume", target.noun())) .arg(target.id()) .arg(format!("{percent}%")) .run(log) .map(drop) } fn toggle_mute(&self, target: Target<'_>, log: &mut CommandLog) -> Result<()> { Invocation::new("pactl") .arg(format!("set-{}-mute", target.noun())) .arg(target.id()) .arg("toggle") .run(log) .map(drop) } fn set_default(&self, device: &Device, log: &mut CommandLog) -> Result<()> { Invocation::new("pactl") .arg(format!("set-default-{}", device.direction.device_noun())) .arg(&device.name) .run(log) .map(drop) } fn move_stream(&self, stream: &Stream, device: &Device, log: &mut CommandLog) -> Result<()> { Invocation::new("pactl") .arg(format!("move-{}", stream.direction.stream_noun())) .arg(stream.index.to_string()) .arg(&device.name) .run(log) .map(drop) } } /// Fixed sample state, for machines without PipeWire. pub struct Mock; impl Backend for Mock { fn name(&self) -> &'static str { "mock" } fn list_devices(&self, log: &mut CommandLog) -> Result> { log.record("# no PipeWire; showing mock devices", Severity::Warn); Ok(vec![ Device { index: 1, name: "alsa_output.analog-stereo".into(), description: "Analog Stereo".into(), direction: Direction::Output, volume: 90, muted: false, is_default: true, }, Device { index: 2, name: "alsa_output.hdmi-stereo".into(), description: "HDMI Stereo".into(), direction: Direction::Output, volume: 100, muted: true, is_default: false, }, Device { index: 3, name: "alsa_input.analog-stereo".into(), description: "Analog Stereo Microphone".into(), direction: Direction::Input, volume: 75, muted: false, is_default: true, }, ]) } fn list_streams(&self, _log: &mut CommandLog) -> Result> { Ok(vec![ Stream { index: 100, app: "Firefox".into(), direction: Direction::Output, device_index: 1, volume: 100, muted: false, corked: false, }, Stream { index: 101, app: "mpv".into(), direction: Direction::Output, device_index: 2, volume: 80, muted: false, corked: true, }, ]) } // The mock is a display fixture, not a simulator. Mutating it would show // the user a change that did not happen to any real device, which is worse // than plainly declining. fn set_volume(&self, _target: Target<'_>, _percent: u8, log: &mut CommandLog) -> Result<()> { log.record("# mock backend: volume unchanged", Severity::Warn); Ok(()) } fn toggle_mute(&self, _target: Target<'_>, log: &mut CommandLog) -> Result<()> { log.record("# mock backend: mute unchanged", Severity::Warn); Ok(()) } fn set_default(&self, _device: &Device, log: &mut CommandLog) -> Result<()> { log.record("# mock backend: default unchanged", Severity::Warn); Ok(()) } fn move_stream( &self, _stream: &Stream, _device: &Device, log: &mut CommandLog, ) -> Result<()> { log.record("# mock backend: routing unchanged", Severity::Warn); Ok(()) } } // ---- pactl JSON ---- #[derive(Deserialize)] struct PaDevice { index: u32, name: String, #[serde(default)] description: String, #[serde(default)] mute: bool, #[serde(default)] volume: HashMap, /// Field with two different meanings depending on which list it came from, /// which is a trap worth spelling out. /// /// On a **source** it names the sink this source is a monitor *of*, and is /// empty for real capture hardware. On a **sink** it names the monitor /// that sink *has*, and is non-empty for essentially every sink. So it /// identifies monitors only among sources; applied to sinks it matches /// almost all of them. #[serde(default)] monitor_source: String, } #[derive(Deserialize)] struct PaStream { index: u32, /// Set on a `sink-input`: the sink it plays to. #[serde(default)] sink: Option, /// Set on a `source-output`: the source it captures from. #[serde(default)] source: Option, #[serde(default)] mute: bool, #[serde(default)] corked: bool, #[serde(default)] volume: HashMap, #[serde(default)] properties: HashMap, } #[derive(Deserialize)] struct PaChannel { value: u32, } fn parse_devices(raw: &str, direction: Direction, default_name: &str) -> Result> { let parsed: Vec = serde_json::from_str(raw).context("pactl emitted invalid JSON")?; Ok(parsed .into_iter() .filter(|device| !is_monitor(device, direction)) .map(|device| Device { index: device.index, volume: channel_volume(&device.volume), muted: device.mute, is_default: device.name == default_name, // A device with no description falls back to its node name, which // is ugly but identifies the thing; an empty row does not. description: if device.description.is_empty() { device.name.clone() } else { device.description }, name: device.name, direction, }) .collect()) } fn parse_streams(raw: &str, direction: Direction) -> Result> { let parsed: Vec = serde_json::from_str(raw).context("pactl emitted invalid JSON")?; Ok(parsed .into_iter() .filter_map(|stream| { // A stream with no device is one PulseAudio is still setting up or // tearing down. It has nothing to pair with, and showing an // unroutable row invites a keypress that cannot work. let device_index = match direction { Direction::Output => stream.sink, Direction::Input => stream.source, }?; Some(Stream { index: stream.index, app: stream_app_name(&stream.properties), direction, device_index, volume: channel_volume(&stream.volume), muted: stream.mute, corked: stream.corked, }) }) .collect()) } fn property(properties: &HashMap, key: &str) -> Option { properties .get(key) .and_then(|value| value.as_str()) .map(str::to_string) .filter(|s| !s.is_empty()) } /// Best available human name for a stream. /// /// `application.name` is what an app sets for display and is right when /// present. Failing that the binary name at least identifies the process. /// `media.name` is the last resort because it describes what is playing rather /// than who is playing it, and is often something like "playback". fn stream_app_name(properties: &HashMap) -> String { property(properties, "application.name") .or_else(|| property(properties, "application.process.binary")) .or_else(|| property(properties, "media.name")) .unwrap_or_else(|| "unknown".to_string()) } /// Is this device a monitor (a loopback of an output rather than real capture /// hardware)? /// /// Only sources can be. `monitor_source` is populated on sinks too, with the /// opposite meaning, so the direction check is what keeps this from matching /// every output. fn is_monitor(device: &PaDevice, direction: Direction) -> bool { direction == Direction::Input && !device.monitor_source.is_empty() } /// Collapse a per-channel volume map to one number. /// /// The loudest channel, not an average: a device with one channel at 0 is not /// "half volume", and showing it as such would explain nothing about why the /// audio sounds wrong. Channel order is not meaningful here, so the max is /// also stable across the map's arbitrary iteration order. fn channel_volume(channels: &HashMap) -> u8 { let raw = channels.values().map(|c| c.value).max().unwrap_or(0); // Volumes can exceed unity (PipeWire allows boost); clamp so the display // stays a percentage a user can reason about. let percent = (u64::from(raw) * 100).div_ceil(u64::from(VOLUME_UNITY)); percent.min(100) as u8 } /// The `alloy audio` screen. pub struct AudioView { backend: Box, devices: Vec, streams: Vec, focus: FocusRing, stream_cursor: Cursor, device_cursor: Cursor, error: Option, ticks: u64, } impl AudioView { pub fn new(log: &mut CommandLog) -> Self { let mut view = Self { backend: detect(), devices: Vec::new(), streams: Vec::new(), focus: FocusRing::new(2), stream_cursor: Cursor::new(), device_cursor: Cursor::new(), error: None, ticks: 0, }; view.refresh_devices(log); view.refresh_streams(log); view } // Neither refresh clears `error` on success, which is deliberate and not // an oversight. Refreshes run on the background tick, so clearing there // would wipe an error the user needs to read within a second of it // appearing — a rejected route would flash and vanish before it could be // read. Errors are cleared by the next keypress instead (see `handle`), // which is the point at which the user has moved on. fn refresh_devices(&mut self, log: &mut CommandLog) { match self.backend.list_devices(log) { Ok(devices) => { self.devices = devices; self.device_cursor.resize(self.devices.len()); } Err(err) => self.error = Some(err.to_string()), } } fn refresh_streams(&mut self, log: &mut CommandLog) { match self.backend.list_streams(log) { Ok(streams) => { self.streams = streams; self.stream_cursor.resize(self.streams.len()); } Err(err) => self.error = Some(err.to_string()), } } fn selected_stream(&self) -> Option<&Stream> { self.streams.get(self.stream_cursor.selected()?) } fn selected_device(&self) -> Option<&Device> { self.devices.get(self.device_cursor.selected()?) } /// Index into `devices` of the device the selected stream is routed to. fn paired_device_index(&self) -> Option { let stream = self.selected_stream()?; self.devices .iter() .position(|device| device.index == stream.device_index) } /// The target the action keys apply to: whatever the focused pane has /// selected. fn target(&self) -> Option> { match self.focus.current() { PANE_STREAMS => self.selected_stream().map(Target::Stream), _ => self.selected_device().map(Target::Device), } } /// Re-read state after an action. /// /// Quiet: the user asked for the action, not for the four-command re-read /// that confirms it. Logging both buries the command that was actually /// pressed for. fn resync(&mut self, log: &mut CommandLog) { log.quiet(|log| { self.refresh_devices(log); self.refresh_streams(log); }); } fn set_volume(&mut self, log: &mut CommandLog, delta: i16) { let Some(target) = self.target() else { return; }; // Clamped here rather than handed to pactl as a relative `+5%`, so the // console never asks for a volume above unity. Boost is a real thing // users want occasionally, but not from a key that repeats. let percent = (i16::from(target.volume()) + delta).clamp(0, 100) as u8; let result = self.backend.set_volume(target, percent, log); self.finish(result, log); } fn toggle_mute(&mut self, log: &mut CommandLog) { let Some(target) = self.target() else { return; }; let result = self.backend.toggle_mute(target, log); self.finish(result, log); } fn set_default(&mut self, log: &mut CommandLog) { let Some(device) = self.selected_device() else { return; }; let result = self.backend.set_default(device, log); self.finish(result, log); } /// Route the selected stream to the selected device — the pairing edit. fn route(&mut self, log: &mut CommandLog) { let (Some(stream), Some(device)) = (self.selected_stream(), self.selected_device()) else { return; }; // Directions have to agree: a playback stream cannot be routed to a // microphone. pactl would reject it, but saying so here is clearer // than surfacing its error. if stream.direction != device.direction { self.error = Some(format!( "cannot route {} stream to {} device", stream.direction.label(), device.direction.label() )); return; } let result = self.backend.move_stream(stream, device, log); self.finish(result, log); } /// Record an action's outcome and re-read state if it worked. fn finish(&mut self, result: Result<()>, log: &mut CommandLog) { match result { Ok(()) => self.resync(log), Err(err) => self.error = Some(err.to_string()), } } fn stream_row<'a>(&self, theme: &Theme, stream: &'a Stream) -> Line<'a> { let volume = if stream.muted { " --".to_string() } else { format!("{:>3}%", stream.volume) }; Line::from(vec![ text::bold(theme, format!("{:<18}", truncate(&stream.app, 17))), Span::styled(format!("{volume:<6}"), stream.severity().style(theme)), text::muted(theme, stream.state_label()), ]) } fn device_row<'a>(&self, theme: &Theme, device: &'a Device) -> Line<'a> { let volume = if device.muted { " --".to_string() } else { format!("{:>3}%", device.volume) }; Line::from(vec![ text::bold(theme, format!("{:<22}", truncate(&device.description, 21))), Span::styled(format!("{volume:<6}"), device.severity().style(theme)), text::muted(theme, device.state_label()), ]) } } /// Everything one pane needs to draw itself. struct Pane<'a> { title: &'a str, focused: bool, rows: Vec>, selected: Option, /// Shown instead of the list when there are no rows. Both panes are /// legitimately empty in normal use — nothing is playing, or there is no /// hardware — so neither should render as a blank box. empty_message: &'a str, } /// Draw one pane, returning its inner area so the connector can locate rows /// inside it. fn render_pane(frame: &mut Frame, area: Rect, theme: &Theme, pane: Pane<'_>) -> Rect { let block = AlloyBlock::new(theme) .focused(pane.focused) .build() .title(block_title(pane.title)); let inner = block.inner(area); frame.render_widget(block, area); if pane.rows.is_empty() { frame.render_widget(Line::from(text::muted(theme, pane.empty_message)), inner); } else { frame.render_widget( AlloyList::new(theme, pane.rows).selected(pane.selected), inner, ); } inner } /// Clip a description to `width` columns, marking the clip. /// /// Counts `char`s rather than bytes: device descriptions carry non-ASCII /// (a "Björn's Headset"), and slicing those by byte index panics. fn truncate(text: &str, width: usize) -> String { if text.chars().count() <= width { return text.to_string(); } let kept: String = text.chars().take(width.saturating_sub(1)).collect(); format!("{kept}…") } impl View for AudioView { fn title(&self) -> String { format!("audio ({})", self.backend.name()) } fn hints(&self) -> Vec { vec![ hint("Tab", "pane"), hint("j/k", "select"), hint("Enter", "route"), hint("-/+", "volume"), hint("m", "mute"), hint("d", "default"), hint("r", "refresh"), ] } fn status(&self) -> Option<(Severity, String)> { self.error .as_ref() .map(|message| (Severity::Error, message.clone())) } fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { let panes = layout::panes(area); let stream_rows: Vec = self .streams .iter() .map(|stream| self.stream_row(theme, stream)) .collect(); let left = render_pane( frame, panes.left, theme, Pane { title: "streams", focused: self.focus.is_focused(PANE_STREAMS), rows: stream_rows, selected: self.stream_cursor.selected(), empty_message: "nothing playing", }, ); // A terminal too narrow for two panes shows streams alone. Devices are // reachable by widening; a pair of six-column panes is reachable by // nothing. if panes.right.width == 0 { return; } let device_rows: Vec = self .devices .iter() .map(|device| self.device_row(theme, device)) .collect(); let right = render_pane( frame, panes.right, theme, Pane { title: "devices", focused: self.focus.is_focused(PANE_DEVICES), rows: device_rows, selected: self.device_cursor.selected(), empty_message: "no audio devices", }, ); // The connector, for the selected stream only. let (Some(stream_index), Some(device_index)) = (self.stream_cursor.selected(), self.paired_device_index()) else { return; }; let from_y = list_row_y( left, self.streams.len(), self.stream_cursor.selected(), stream_index, ); let to_y = list_row_y( right, self.devices.len(), self.device_cursor.selected(), device_index, ); if let (Some(from_y), Some(to_y)) = (from_y, to_y) { // The gutter spans the panes' inner rows, so the connector lines // up with list rows rather than with the block borders. let gutter = Rect { y: left.y, height: left.height, ..panes.gutter }; frame.render_widget(AlloyConnector::new(theme, from_y, to_y), gutter); } } fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow { // Any keypress dismisses the previous error. Actions below may set a // new one, which then survives until the user presses another key // rather than until the next background tick. self.error = None; match alloy_tui::classify(key) { alloy_tui::Action::NextFocus => { self.focus.next(); return Flow::Continue; } alloy_tui::Action::PrevFocus => { self.focus.prev(); return Flow::Continue; } alloy_tui::Action::Activate => { self.route(log); return Flow::Continue; } _ => {} } let cursor = if self.focus.is_focused(PANE_STREAMS) { &mut self.stream_cursor } else { &mut self.device_cursor }; match key.code { KeyCode::Char('j') | KeyCode::Down => cursor.next(), KeyCode::Char('k') | KeyCode::Up => cursor.prev(), // `=` alongside `+` so the shifted key is not required. KeyCode::Char('+') | KeyCode::Char('=') => { self.set_volume(log, i16::from(VOLUME_STEP)); } KeyCode::Char('-') => self.set_volume(log, -i16::from(VOLUME_STEP)), KeyCode::Char('m') => self.toggle_mute(log), KeyCode::Char('d') => self.set_default(log), KeyCode::Char('r') => { // An explicit refresh is a user action, so it logs. self.refresh_devices(log); self.refresh_streams(log); } _ => {} } Flow::Continue } fn tick(&mut self, log: &mut CommandLog) { self.ticks += 1; let poll_devices = self.ticks % DEVICE_POLL_TICKS == 0; log.quiet(|log| { self.refresh_streams(log); if poll_devices { self.refresh_devices(log); } }); } } #[cfg(test)] mod tests { use super::*; // Captured from `pactl -f json list sinks` on PipeWire 1.5.84, with the // enormous `properties` blob dropped (the parser ignores it) and a second // sink added to give the list more than one row. Everything the parser // reads is verbatim. const SINKS: &str = r#"[ {"index":61,"state":"SUSPENDED","name":"alsa_output.pci-0000_c1_00.6.analog-stereo", "description":"Family 17h/19h HD Audio Controller Analog Stereo","mute":false, "volume":{"front-left":{"value":58980,"value_percent":"90%","db":"-2.75 dB"}, "front-right":{"value":58980,"value_percent":"90%","db":"-2.75 dB"}}, "monitor_source":"alsa_output.pci-0000_c1_00.6.analog-stereo.monitor"}, {"index":70,"state":"RUNNING","name":"alsa_output.hdmi-stereo", "description":"HDMI Stereo","mute":true, "volume":{"front-left":{"value":65536,"value_percent":"100%","db":"0.00 dB"}, "front-right":{"value":65536,"value_percent":"100%","db":"0.00 dB"}}, "monitor_source":""} ]"#; // Captured from `pactl -f json list sources`. The middle entry is a // monitor, which is the case the filter exists for. const SOURCES: &str = r#"[ {"index":60,"state":"SUSPENDED","name":"alsa_input.acp-pdm-mach.stereo-fallback", "description":"ACP/ACP3X/ACP6x Audio Coprocessor Stereo","mute":false, "volume":{"front-left":{"value":65536,"value_percent":"100%","db":"0.00 dB"}, "front-right":{"value":65536,"value_percent":"100%","db":"0.00 dB"}}, "monitor_source":""}, {"index":61,"state":"SUSPENDED","name":"alsa_output.pci-0000_c1_00.6.analog-stereo.monitor", "description":"Monitor of Family 17h/19h HD Audio Controller Analog Stereo","mute":false, "volume":{"front-left":{"value":65536,"value_percent":"100%","db":"0.00 dB"}}, "monitor_source":"alsa_output.pci-0000_c1_00.6.analog-stereo"}, {"index":62,"state":"SUSPENDED","name":"alsa_input.pci-0000_c1_00.6.analog-stereo", "description":"Family 17h/19h HD Audio Controller Analog Stereo","mute":false, "volume":{"front-left":{"value":32768,"value_percent":"50%","db":"-18.06 dB"}}, "monitor_source":""} ]"#; // Captured from `pactl -f json list sink-inputs`, properties trimmed to // the keys the parser reads, plus a second entry with no // `application.name` to exercise the name fallback. const SINK_INPUTS: &str = r#"[ {"index":342,"sink":61,"corked":false,"mute":false, "volume":{"front-left":{"value":65536,"value_percent":"100%","db":"0.00 dB"}}, "properties":{"application.name":"speech-dispatcher-dummy", "application.process.binary":"sd_dummy","media.name":"playback"}}, {"index":343,"sink":70,"corked":true,"mute":true, "volume":{"front-left":{"value":32768,"value_percent":"50%","db":"-18.06 dB"}}, "properties":{"application.process.binary":"mpv","media.name":"Some Song"}} ]"#; #[test] fn parses_sinks_with_volume_and_mute() { let devices = parse_devices(SINKS, Direction::Output, "alsa_output.hdmi-stereo").unwrap(); assert_eq!(devices.len(), 2); assert_eq!(devices[0].index, 61); assert_eq!(devices[0].volume, 90); assert!(!devices[0].muted); assert!(!devices[0].is_default); assert!(devices[1].muted); assert!(devices[1].is_default, "the default sink is matched by name"); } // A sink's `monitor_source` names the monitor it *has*; a source's names // the sink it *is a monitor of*. Filtering sinks on that field would hide // every real output, which is the bug this test pins down. #[test] fn the_monitor_filter_does_not_swallow_sinks() { let devices = parse_devices(SINKS, Direction::Output, "").unwrap(); assert_eq!(devices.len(), 2, "both sinks survive despite a monitor_source"); } #[test] fn drops_monitor_sources() { let devices = parse_devices(SOURCES, Direction::Input, "").unwrap(); assert_eq!(devices.len(), 2, "the monitor source is filtered out"); assert!( devices.iter().all(|d| !d.description.starts_with("Monitor of")), "no monitor survived the filter" ); assert_eq!(devices[1].volume, 50); } #[test] fn parses_streams_with_their_device_pairing() { let streams = parse_streams(SINK_INPUTS, Direction::Output).unwrap(); assert_eq!(streams.len(), 2); assert_eq!(streams[0].index, 342); assert_eq!(streams[0].app, "speech-dispatcher-dummy"); assert_eq!(streams[0].device_index, 61, "the pairing the connector draws"); assert!(!streams[0].corked); assert_eq!(streams[1].volume, 50); assert!(streams[1].corked); assert!(streams[1].muted); } // Not every stream sets `application.name`; falling through to the binary // beats showing "unknown", and beats `media.name`, which names the audio // rather than the app. #[test] fn stream_name_falls_back_to_the_binary() { let streams = parse_streams(SINK_INPUTS, Direction::Output).unwrap(); assert_eq!(streams[1].app, "mpv"); } // A stream mid-setup has no device. It cannot be paired or routed, so it // must not occupy a row that invites a keypress that cannot work. #[test] fn streams_with_no_device_are_dropped() { let raw = r#"[{"index":9,"corked":false,"mute":false,"volume":{},"properties":{}}]"#; assert!(parse_streams(raw, Direction::Output).unwrap().is_empty()); } // Capture streams carry `source`, not `sink`. Reading the wrong field // would drop every capture stream as unpaired. #[test] fn capture_streams_pair_via_the_source_field() { let raw = r#"[{"index":5,"source":62,"corked":false,"mute":false,"volume":{}, "properties":{"application.name":"Recorder"}}]"#; let streams = parse_streams(raw, Direction::Input).unwrap(); assert_eq!(streams.len(), 1); assert_eq!(streams[0].device_index, 62); } #[test] fn empty_device_list_parses_to_nothing() { assert!(parse_devices("[]", Direction::Output, "").unwrap().is_empty()); } #[test] fn malformed_json_is_an_error_not_an_empty_list() { assert!(parse_devices("not json", Direction::Output, "").is_err()); assert!(parse_streams("not json", Direction::Output).is_err()); } // Unity is 65536, not 100. Treating the raw value as a percent would show // a normal device at "65536%". #[test] fn volume_is_scaled_from_unity() { let full = HashMap::from([("mono".to_string(), PaChannel { value: VOLUME_UNITY })]); assert_eq!(channel_volume(&full), 100); let half = HashMap::from([("mono".to_string(), PaChannel { value: VOLUME_UNITY / 2 })]); assert_eq!(channel_volume(&half), 50); assert_eq!(channel_volume(&HashMap::new()), 0, "no channels reads as silent"); } #[test] fn volume_above_unity_clamps_to_100() { let boosted = HashMap::from([("mono".to_string(), PaChannel { value: VOLUME_UNITY * 2 })]); assert_eq!(channel_volume(&boosted), 100); } // The loudest channel, not the average: one silent channel of a stereo // pair must not read as 50%. #[test] fn volume_reports_the_loudest_channel() { let lopsided = HashMap::from([ ("front-left".to_string(), PaChannel { value: VOLUME_UNITY }), ("front-right".to_string(), PaChannel { value: 0 }), ]); assert_eq!(channel_volume(&lopsided), 100); } #[test] fn truncate_marks_clipped_descriptions() { assert_eq!(truncate("short", 10), "short"); assert_eq!(truncate("a very long device name", 10), "a very lo…"); } // Slicing a multi-byte description by byte index panics. Device names do // carry non-ASCII. #[test] fn truncate_handles_multibyte_descriptions() { assert_eq!(truncate("Björn's Headset Pro", 8), "Björn's…"); assert_eq!(truncate("Björn", 10), "Björn"); } // ---- view behavior ---- fn mock_view() -> (AudioView, CommandLog) { let mut log = CommandLog::new(); let mut view = AudioView { backend: Box::new(Mock), devices: Vec::new(), streams: Vec::new(), focus: FocusRing::new(2), stream_cursor: Cursor::new(), device_cursor: Cursor::new(), error: None, ticks: 0, }; view.refresh_devices(&mut log); view.refresh_streams(&mut log); (view, log) } // The pairing the connector draws: stream 0 routes to device index 1, // which is position 0 in the device list. Matching on the list position // instead of the device index would be right only by coincidence here. #[test] fn pairing_resolves_a_device_index_to_a_list_position() { let (view, _log) = mock_view(); assert_eq!(view.paired_device_index(), Some(0)); } #[test] fn pairing_follows_the_selected_stream() { let (mut view, _log) = mock_view(); view.stream_cursor.move_by(1); // Stream 1 routes to device index 2, which is position 1. assert_eq!(view.paired_device_index(), Some(1)); } // A stream routed to a device that is not in the list (filtered, or gone // between the two reads) has no drawable pairing. #[test] fn pairing_is_absent_when_the_device_is_missing() { let (mut view, _log) = mock_view(); view.devices.retain(|d| d.index != 1); assert_eq!(view.paired_device_index(), None); } #[test] fn tab_moves_focus_between_the_panes() { let (mut view, _log) = mock_view(); assert!(view.focus.is_focused(PANE_STREAMS)); view.focus.next(); assert!(view.focus.is_focused(PANE_DEVICES)); view.focus.next(); assert!(view.focus.is_focused(PANE_STREAMS), "two panes wrap"); } // The action keys follow focus, so `m` mutes the app when the stream pane // is focused and the device when it is not. #[test] fn the_action_target_follows_focus() { let (mut view, _log) = mock_view(); assert!(matches!(view.target(), Some(Target::Stream(_)))); view.focus.focus(PANE_DEVICES); assert!(matches!(view.target(), Some(Target::Device(_)))); } // pactl builds its subcommands from these nouns, and the stream case is // counterintuitive: a stream playing *out* is a `sink-input`. #[test] fn targets_use_the_right_pactl_nouns() { let (mut view, _log) = mock_view(); assert_eq!(view.target().unwrap().noun(), "sink-input"); assert_eq!(view.target().unwrap().id(), "100", "streams go by index"); view.focus.focus(PANE_DEVICES); assert_eq!(view.target().unwrap().noun(), "sink"); assert_eq!( view.target().unwrap().id(), "alsa_output.analog-stereo", "devices go by name" ); } #[test] fn capture_targets_use_the_source_nouns() { let (mut view, _log) = mock_view(); view.focus.focus(PANE_DEVICES); // The third mock device is the microphone. view.device_cursor.move_by(2); assert_eq!(view.target().unwrap().noun(), "source"); } // Routing a playback stream to a microphone is not a thing. pactl would // refuse, but refusing here says why. #[test] fn routing_across_directions_is_refused() { let (mut view, mut log) = mock_view(); view.device_cursor.move_by(2); // the input device view.route(&mut log); let error = view.error.expect("a cross-direction route reports an error"); assert!(error.contains("cannot route"), "got: {error}"); } #[test] fn routing_within_a_direction_is_allowed() { let (mut view, mut log) = mock_view(); view.device_cursor.move_by(1); // the HDMI output view.route(&mut log); assert!(view.error.is_none(), "same-direction routing is accepted"); } // Background polling is console bookkeeping. If it logged, the pane would // fill with commands nobody pressed a key for. #[test] fn ticks_do_not_write_to_the_command_log() { let (mut view, mut log) = mock_view(); let before = log.entries().len(); for _ in 0..DEVICE_POLL_TICKS * 2 { view.tick(&mut log); } assert_eq!(log.entries().len(), before, "ticks are silent"); } // Devices are polled on a slower cadence than streams, so the tick counter // has to actually reach the device poll. #[test] fn devices_are_polled_on_the_slower_cadence() { let (mut view, mut log) = mock_view(); view.devices.clear(); for _ in 0..DEVICE_POLL_TICKS - 1 { view.tick(&mut log); } assert!(view.devices.is_empty(), "not yet re-read"); view.tick(&mut log); assert!(!view.devices.is_empty(), "re-read on the tenth tick"); } // The bug this pins: refreshes run on the background tick, so if a // successful refresh cleared `error`, a rejected route would be readable // for under a second before a poll wiped it. #[test] fn a_background_tick_does_not_clear_an_action_error() { let (mut view, mut log) = mock_view(); view.device_cursor.move_by(2); // the input device view.route(&mut log); assert!(view.error.is_some(), "the route was refused"); for _ in 0..DEVICE_POLL_TICKS + 1 { view.tick(&mut log); } assert!( view.error.is_some(), "the error survived a full device-poll cycle" ); } #[test] fn a_keypress_clears_a_stale_error() { use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let (mut view, mut log) = mock_view(); view.device_cursor.move_by(2); view.route(&mut log); assert!(view.error.is_some()); view.handle( KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), &mut log, ); assert!(view.error.is_none(), "moving on dismisses the error"); } #[test] fn acting_with_no_selection_is_inert() { let mut log = CommandLog::new(); let mut view = AudioView { backend: Box::new(Mock), devices: Vec::new(), streams: Vec::new(), focus: FocusRing::new(2), stream_cursor: Cursor::new(), device_cursor: Cursor::new(), error: None, ticks: 0, }; view.set_volume(&mut log, 5); view.toggle_mute(&mut log); view.set_default(&mut log); view.route(&mut log); assert!(view.error.is_none(), "no selection is not an error"); } /// Parse whatever this machine's PipeWire actually reports. /// /// Ignored by default because it needs a running PipeWire and its result /// depends on the hardware. Run it (`cargo test -p alloy -- --ignored /// --nocapture`) when touching the parser: a fixture only proves the /// parser handles the output someone imagined it would get, which is /// exactly how the sink/source `monitor_source` inversion got written. #[test] #[ignore = "requires a running PipeWire"] fn parses_this_machines_real_state() { let mut log = CommandLog::new(); let devices = PaCtl.list_devices(&mut log).expect("pactl should answer"); let streams = PaCtl.list_streams(&mut log).expect("pactl should answer"); assert!(!devices.is_empty(), "a machine with PipeWire has some device"); assert!( devices.iter().any(|d| d.direction == Direction::Output), "at least one output must survive the monitor filter" ); for device in &devices { assert!(!device.description.is_empty(), "every row is identifiable"); assert!(device.volume <= 100, "volume is a clamped percentage"); assert!( !device.description.starts_with("Monitor of"), "monitor leaked into the list: {}", device.description ); println!( "device {:<8} {:<48} {:>3}% mute={} default={}", device.direction.label(), device.description, device.volume, device.muted, device.is_default ); } for stream in &streams { // Every listed stream must resolve to a listed device, or the // connector has nothing to draw to. let paired = devices.iter().find(|d| d.index == stream.device_index); println!( "stream {:<8} {:<20} -> {:<40} {:>3}%", stream.direction.label(), stream.app, paired.map_or("(unlisted)", |d| d.description.as_str()), stream.volume ); assert!(!stream.app.is_empty(), "every stream row is identifiable"); } } }