// plain text parsing, kept free of amane so `rustc --test parse.rs` checks it on its own // the equalizer's bands, in Hz pub const BANDS: [u32; 10] = [31, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]; // node names of the island's own equalizer sinks start with this pub const EQ_PREFIX: &str = "magic_island_eq."; // one sound stream from `LC_ALL=C pactl list sink-inputs` #[derive(Debug, Clone, PartialEq, Default)] pub struct Stream { pub id: u32, // the sink it plays into pub sink: u32, // first channel, in percent; pactl allows more than 100 pub volume: u8, pub muted: bool, // application.process.id, 0 when the stream doesn't say pub pid: u32, // application.process.binary and application.name, lowercased for matching pub binary: String, pub name: String, // node.name, which tells the island's own equalizer streams apart pub node_name: String, } // one output device from `LC_ALL=C pactl list sinks` #[derive(Debug, Clone, PartialEq, Default)] pub struct Sink { pub index: u32, pub name: String, pub description: String, pub bluetooth: bool, } pub fn sink_inputs(text: &str) -> Vec { let mut streams: Vec = Vec::new(); for line in text.lines() { let line = line.trim(); if let Some(id) = line.strip_prefix("Sink Input #") { streams.push(Stream { id: id.parse().unwrap_or(0), ..Stream::default() }); continue; } let Some(stream) = streams.last_mut() else { continue; }; if let Some(sink) = line.strip_prefix("Sink: ") { stream.sink = sink.parse().unwrap_or(0); } else if let Some(mute) = line.strip_prefix("Mute: ") { stream.muted = mute == "yes"; } else if let Some(volume) = line.strip_prefix("Volume: ") { stream.volume = first_percent(volume); } else if let Some((key, value)) = line.split_once(" = ") { let value = value.trim_matches('"'); match key { "application.process.id" => stream.pid = value.parse().unwrap_or(0), "application.process.binary" => stream.binary = value.to_lowercase(), "application.name" => stream.name = value.to_lowercase(), "node.name" => stream.node_name = value.to_string(), _ => {} } } } streams } pub fn sinks(text: &str) -> Vec { let mut sinks: Vec = Vec::new(); for line in text.lines() { let line = line.trim(); if let Some(index) = line.strip_prefix("Sink #") { sinks.push(Sink { index: index.parse().unwrap_or(0), ..Sink::default() }); continue; } let Some(sink) = sinks.last_mut() else { continue; }; if let Some(name) = line.strip_prefix("Name: ") { sink.name = name.to_string(); } else if let Some(description) = line.strip_prefix("Description: ") { sink.description = description.to_string(); } else if line == "device.api = \"bluez5\"" { sink.bluetooth = true; } } sinks } // "front-left: 52429 / 80% / -5.81 dB, front-right: ..." gives 80 fn first_percent(volume: &str) -> u8 { let percent = volume.split('/').nth(1).map(|part| part.trim().trim_end_matches('%')); percent.and_then(|part| part.parse::().ok()).map_or(0, |part| part.min(255) as u8) } // one frame of cava's raw ascii output, "12;80;3;" with ascii_max_range 100, as 0 to 1 pub fn levels(line: &str) -> Vec { line.split(';') .filter_map(|value| value.trim().parse::().ok()) .map(|value| (value / 100.0).clamp(0.0, 1.0)) .collect() } /* * the streams a player owns: the ones its process (or a child, like a browser's * audio process) plays, else the ones named after it, for sandboxes that hide pids */ pub fn owned<'a>( streams: &'a [Stream], player_pid: u32, app: &str, ancestry: impl Fn(u32) -> Vec, ) -> Vec<&'a Stream> { let by_process: Vec<&Stream> = streams .iter() .filter(|stream| player_pid > 1 && stream.pid > 1 && ancestry(stream.pid).contains(&player_pid)) .collect(); if !by_process.is_empty() || app.is_empty() { return by_process; } streams .iter() .filter(|stream| stream.binary.contains(app) || stream.name.contains(app)) .collect() } // "org.mpris.MediaPlayer2.chromium.instance123" gives "chromium" pub fn app_of(bus_name: &str) -> String { let rest = bus_name.trim_start_matches("org.mpris.MediaPlayer2."); rest.split('.').next().unwrap_or("").to_lowercase() } // "org.mpris.MediaPlayer2.chromium.instance123" gives "chromium_instance123", safe in node names and paths pub fn key_of(bus_name: &str) -> String { let rest = bus_name.trim_start_matches("org.mpris.MediaPlayer2."); rest.chars().map(|letter| if letter.is_ascii_alphanumeric() { letter } else { '_' }).collect() } // the parent pid in /proc//stat; the name in parentheses may hold spaces and ')' pub fn parent_of(stat: &str) -> Option { let after_name = &stat[stat.rfind(')')? + 1..]; after_name.split_whitespace().nth(1)?.parse().ok() } // "file:///home/me/My%20Music/cover.jpg" gives "/home/me/My Music/cover.jpg" pub fn file_path(url: &str) -> Option { let bytes = url.strip_prefix("file://")?.as_bytes(); let mut decoded = Vec::with_capacity(bytes.len()); let mut index = 0; while index < bytes.len() { let escaped = bytes .get(index + 1..index + 3) .and_then(|hex| std::str::from_utf8(hex).ok()) .and_then(|hex| u8::from_str_radix(hex, 16).ok()); match (bytes[index], escaped) { (b'%', Some(byte)) => { decoded.push(byte); index += 3; } (byte, _) => { decoded.push(byte); index += 1; } } } Some(String::from_utf8_lossy(&decoded).into_owned()) } // what may go inside a quoted pipewire config string: player names come from other programs fn config_text(text: &str) -> String { text.chars().filter(|letter| letter.is_alphanumeric() || " .-_:".contains(*letter)).collect() } /* * cuts the middle, not the end: "700 Series Chipset Family HD Audio HDMI / DisplayPort 3 Output" * and its "... 2 Output" sibling only differ at the end */ pub fn middle_ellipsis(label: &str, max: usize) -> String { let letters: Vec = label.chars().collect(); if letters.len() <= max || max < 3 { return label.to_string(); } let head = (max - 1) / 2; let tail = max - 1 - head; letters[..head].iter().chain(['…'].iter()).chain(letters[letters.len() - tail..].iter()).collect() } /* * a standalone `pipewire -c` config: one 10-band filter-chain sink named * magic_island_eq., whose output plays into `target` (empty: the default) */ pub fn eq_config(key: &str, description: &str, gains: &[f32; 10], target: &str) -> String { let mut nodes = String::new(); for (index, (band, gain)) in BANDS.iter().zip(gains).enumerate() { let (label, q) = match index { 0 => ("bq_lowshelf", 0.7), 9 => ("bq_highshelf", 0.7), _ => ("bq_peaking", 1.4), }; nodes += &format!( " {{ type = builtin name = b{} label = {label} control = {{ \"Freq\" = {band}.0 \"Q\" = {q} \"Gain\" = {gain:.1} }} }}\n", index + 1 ); } let links: String = (1..BANDS.len()) .map(|index| format!(" {{ output = \"b{index}:Out\" input = \"b{}:In\" }}\n", index + 1)) .collect(); let target = config_text(target); let target = if target.is_empty() { String::new() } else { format!("target.object = \"{target}\"") }; format!( "context.spa-libs = {{ audio.convert.* = audioconvert/libspa-audioconvert support.* = support/libspa-support }} context.modules = [ {{ name = libpipewire-module-rt args = {{ }} flags = [ ifexists nofail ] }} {{ name = libpipewire-module-protocol-native }} {{ name = libpipewire-module-client-node }} {{ name = libpipewire-module-adapter }} {{ name = libpipewire-module-filter-chain args = {{ node.description = \"{description} EQ\" media.name = \"{description} EQ\" filter.graph = {{ nodes = [ {nodes} ] links = [ {links} ] }} audio.channels = 2 audio.position = [ FL FR ] capture.props = {{ node.name = \"{EQ_PREFIX}{key}\" media.class = Audio/Sink }} playback.props = {{ node.name = \"{EQ_PREFIX}{key}.out\" node.passive = true {target} }} }} }} ] ", description = config_text(description), ) } // the `pw-cli set-param Props` value that sets every band at once pub fn eq_params(gains: &[f32; 10]) -> String { let params: Vec = gains.iter().enumerate().map(|(index, gain)| format!("\"b{}:Gain\" {gain:.1}", index + 1)).collect(); format!("{{ params = [ {} ] }}", params.join(" ")) } // saved equalizers, one per line: identity, preset name, ten gains pub fn eq_store(text: &str) -> Vec<(String, String, [f32; 10])> { let mut saved = Vec::new(); for line in text.lines() { let mut fields = line.split('\t'); let (Some(identity), Some(preset), Some(gains)) = (fields.next(), fields.next(), fields.next()) else { continue; }; let values: Vec = gains.split_whitespace().filter_map(|gain| gain.parse().ok()).collect(); if let Ok(gains) = <[f32; 10]>::try_from(values) { saved.push((identity.to_string(), preset.to_string(), gains)); } } saved } pub fn eq_line(identity: &str, preset: &str, gains: &[f32; 10]) -> String { let clean = |text: &str| text.replace(['\t', '\n'], " "); let gains: Vec = gains.iter().map(|gain| format!("{gain:.1}")).collect(); format!("{}\t{}\t{}\n", clean(identity), clean(preset), gains.join(" ")) } #[cfg(test)] mod tests { use super::*; const PACTL: &str = "Sink Input #42 \tDriver: PipeWire \tSink: 65 \tFormat: pcm, format.sample_format = \"\\\"float32le\\\"\" format.rate = \"48000\" \tMute: no \tVolume: front-left: 52429 / 80% / -5.81 dB, front-right: 52429 / 80% / -5.81 dB \t balance 0.00 \tSink Latency: 0 usec \tProperties: \t\tapplication.name = \"Chromium\" \t\tapplication.process.id = \"5003\" \t\tapplication.process.binary = \"chrome\" \t\tnode.name = \"Chromium\" Sink Input #57 \tSink: 3479 \tMute: yes \tVolume: mono: 98304 / 150% / 10.57 dB \tProperties: \t\tapplication.name = \"spotify\" \t\tapplication.process.binary = \"spotify\" "; const SINKS: &str = "Sink #65 \tState: RUNNING \tName: alsa_output.usb-Yamaha_AG03MK2-00.analog-stereo \tDescription: Yamaha AG03MK2 Analog Stereo \tProperties: \t\tdevice.api = \"alsa\" \tPorts: \t\tanalog-output: Analog Output (type: Line, priority: 9900, availability unknown) Sink #80 \tName: bluez_output.00_1B_66_AA_BB_CC.1 \tDescription: JBL Tune 720BT \tProperties: \t\tdevice.api = \"bluez5\" "; #[test] fn reads_streams() { let streams = sink_inputs(PACTL); assert_eq!(streams.len(), 2); assert_eq!((streams[0].id, streams[0].sink, streams[0].volume, streams[0].muted), (42, 65, 80, false)); assert_eq!((streams[0].pid, streams[0].binary.as_str(), streams[0].node_name.as_str()), (5003, "chrome", "Chromium")); assert_eq!((streams[1].id, streams[1].sink, streams[1].volume, streams[1].muted), (57, 3479, 150, true)); assert_eq!((streams[1].pid, streams[1].name.as_str()), (0, "spotify")); } #[test] fn reads_sinks() { let sinks = sinks(SINKS); assert_eq!(sinks.len(), 2); assert_eq!((sinks[0].index, sinks[0].bluetooth), (65, false)); assert_eq!(sinks[0].description, "Yamaha AG03MK2 Analog Stereo"); assert_eq!((sinks[1].name.as_str(), sinks[1].description.as_str(), sinks[1].bluetooth), ("bluez_output.00_1B_66_AA_BB_CC.1", "JBL Tune 720BT", true)); } #[test] fn matches_children_then_names() { let streams = sink_inputs(PACTL); // chrome's audio process 5003 is a child of the browser 5000 that owns the mpris name let ancestry = |pid: u32| if pid == 5003 { vec![5003, 5000, 1] } else { vec![pid] }; let chromium = owned(&streams, 5000, "chromium", ancestry); assert_eq!(chromium.iter().map(|stream| stream.id).collect::>(), [42]); // no pid on spotify's stream, so its name decides let spotify = owned(&streams, 777, "spotify", ancestry); assert_eq!(spotify.iter().map(|stream| stream.id).collect::>(), [57]); assert!(owned(&streams, 9999, "mpv", ancestry).is_empty()); } #[test] fn reads_names_paths_and_levels() { assert_eq!(app_of("org.mpris.MediaPlayer2.chromium.instance123"), "chromium"); assert_eq!(key_of("org.mpris.MediaPlayer2.chromium.instance123"), "chromium_instance123"); assert_eq!(parent_of("5003 (Web (Content)) S 5000 5003 0"), Some(5000)); assert_eq!(file_path("file:///home/me/My%20Music/a%2Fb.jpg").as_deref(), Some("/home/me/My Music/a/b.jpg")); assert_eq!(file_path("file:///x/100%"), Some(String::from("/x/100%"))); assert_eq!(file_path("https://i.scdn.co/image/ab"), None); assert_eq!(levels("0;50;100;250;\n"), [0.0, 0.5, 1.0, 1.0]); let hdmi = "700 Series Chipset Family HD Audio HDMI / DisplayPort 3 Output"; assert_eq!(middle_ellipsis(hdmi, 30), "700 Series Chi…ayPort 3 Output"); assert_eq!(middle_ellipsis(hdmi, 30).chars().count(), 30); assert_eq!(middle_ellipsis("JBL Tune 720BT", 30), "JBL Tune 720BT"); } #[test] fn writes_equalizers() { let gains = [6.0, 5.0, 4.0, 2.5, 1.0, 0.0, 0.0, -1.0, 0.0, 3.5]; let config = eq_config("fake0", "Lo\"-Fi ${x} Lab", &gains, "bluez_output.00_1B.1"); assert!(config.contains("node.name = \"magic_island_eq.fake0\" media.class = Audio/Sink")); assert!(config.contains("target.object = \"bluez_output.00_1B.1\"")); assert!(config.contains("name = b1 label = bq_lowshelf control = { \"Freq\" = 31.0 \"Q\" = 0.7 \"Gain\" = 6.0 }")); assert!(config.contains("name = b10 label = bq_highshelf")); assert!(config.contains("{ output = \"b9:Out\" input = \"b10:In\" }")); // a hostile player name can't close the quoted string assert!(config.contains("node.description = \"Lo-Fi x Lab EQ\"")); assert!(!eq_config("fake0", "x", &gains, "").contains("target.object")); assert!(eq_params(&gains).starts_with("{ params = [ \"b1:Gain\" 6.0 \"b2:Gain\" 5.0")); let line = eq_line("Lo-Fi\tLab", "Bass", &gains); assert_eq!(eq_store(&line), [(String::from("Lo-Fi Lab"), String::from("Bass"), gains)]); assert!(eq_store("broken\tline\n").is_empty()); } }