Files
amane-config/src/sound.rs
T
server2 318b2a8443 control centre: menus for Wi-Fi, Bluetooth, VPN and Sound in their own window
The tile or block grows into its menu; the panel fades its content and takes the
menu's size underneath. Wi-Fi lists networks (join saved/open, disconnect, stop
iwd), Bluetooth lists devices, VPN has a switch per openvpn service, Sound has
every output with its own slider and a default picker. A second Sound slider for
the output named by extra_output in shell.conf (NR515). Screen and keyboard light
are written to sysfs directly: logind refuses a shell without a seat.
2026-10-10 12:13:34 +02:00

526 lines
16 KiB
Rust

use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use std::{fs, thread};
use amane::{Argument, Bus, Media, Service};
use crate::art;
use crate::parse::{self, EQ_PREFIX, Sink, Stream};
use crate::theme;
// how far a band can lift or cut, in dB
pub const MAX_GAIN: f32 = 12.0;
// gains for BANDS (31 Hz .. 16 kHz)
pub const EQ_PRESETS: [(&str, [f32; 10]); 8] = [
("Flat", [0.0; 10]),
("Bass", [6.0, 5.0, 4.0, 2.5, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
("Treble", [-2.0, -1.5, -1.0, 0.0, 1.0, 2.0, 3.0, 4.5, 5.5, 6.0]),
("Vocal", [-3.0, -2.0, -1.0, 1.0, 3.0, 4.0, 3.5, 2.0, 0.0, -1.0]),
("Pop", [-1.0, 1.0, 2.5, 3.5, 2.5, 0.5, -0.5, -0.5, 1.0, 2.0]),
("Rock", [5.0, 4.0, 2.5, -0.5, -1.5, -0.5, 2.0, 3.5, 4.5, 5.0]),
("Jazz", [3.0, 2.0, 1.0, 2.0, -1.0, -1.0, 0.0, 1.5, 2.5, 3.0]),
("Classic", [4.0, 3.0, 2.0, 1.0, -1.0, -1.0, 0.0, 2.0, 3.0, 4.0]),
];
#[derive(Clone, PartialEq)]
pub struct Eq {
// a preset's name, or "Custom" once a band is dragged
pub preset: String,
pub gains: [f32; 10],
}
impl Default for Eq {
fn default() -> Self {
Self { preset: String::from("Flat"), gains: [0.0; 10] }
}
}
// one player's sound
#[derive(Clone, PartialEq)]
pub struct Channel {
// the mpris bus name, and the same made safe for node names
pub player: String,
pub key: String,
// the player's own sink-inputs, before any equalizer
pub streams: Vec<u32>,
pub volume: u8,
pub muted: bool,
// the device the sound finally reaches
pub output: Option<Sink>,
// the output stream of the player's equalizer, once it has one
pub eq_output: Option<u32>,
}
/*
* per-player sound through pactl and pipewire: volume, output device, and a
* 10-band equalizer. a player gets its own filter-chain sink the first time
* its equalizer or output is changed; its streams are then moved into it, and
* the equalizer's output stream is what moves between devices
*/
#[derive(Default)]
pub struct Sound {
outputs: Vec<Sink>,
channels: Vec<Channel>,
// the sink new sound goes to (`pactl get-default-sink`)
default_output: String,
// by player identity, so "Cider" keeps its curve across restarts
eqs: BTreeMap<String, Eq>,
unsaved: bool,
// a dragged control wins over polls until pactl has caught up
held_until: Option<Instant>,
// equalizers launched but not yet seen as sinks, so they aren't launched twice
starting: BTreeMap<String, Instant>,
}
impl Service for Sound {
fn new() -> Self {
Self::default()
}
// reads pactl outside the lock, so a slow sound server never stalls a frame
fn listen() {
let saved = fs::read_to_string(store_file()).unwrap_or_default();
Self::write().eqs = parse::eq_store(&saved).into_iter().map(|(identity, preset, gains)| (identity, Eq { preset, gains })).collect();
// equalizer sinks this shell has seen, so each gets the current gains once
let mut known = BTreeSet::new();
loop {
poll(&mut known);
save();
thread::sleep(Duration::from_secs(1));
}
}
}
impl Sound {
pub fn outputs(&self) -> &[Sink] {
&self.outputs
}
pub fn default_output(&self) -> &str {
&self.default_output
}
// the first output whose pactl name or description contains `wanted`; none for an empty `wanted`
pub fn output_named(&self, wanted: &str) -> Option<&Sink> {
let wanted = wanted.to_lowercase();
(!wanted.is_empty())
.then(|| self.outputs.iter().find(|sink| sink.description.to_lowercase().contains(&wanted) || sink.name.to_lowercase().contains(&wanted)))
.flatten()
}
// a whole output's volume, unmuting it, as the control centre's sliders set it
pub fn set_output_volume(name: &str, volume: u8) {
let mut sound = Self::write();
sound.hold();
let Some(sink) = sound.outputs.iter_mut().find(|sink| sink.name == name) else {
return;
};
if sink.muted {
run(&["pactl", "set-sink-mute", name, "0"]);
}
run(&["pactl", "set-sink-volume", name, &format!("{volume}%")]);
sink.volume = volume;
sink.muted = false;
}
pub fn toggle_output_mute(name: &str) {
let mut sound = Self::write();
sound.hold();
let Some(sink) = sound.outputs.iter_mut().find(|sink| sink.name == name) else {
return;
};
sink.muted = !sink.muted;
run(&["pactl", "set-sink-mute", name, if sink.muted { "1" } else { "0" }]);
}
// where new sound goes; players without an output of their own follow it
pub fn set_default_output(name: &str) {
let mut sound = Self::write();
sound.hold();
sound.default_output = name.to_string();
run(&["pactl", "set-default-sink", name]);
}
pub fn channel(&self, player: &str) -> Option<&Channel> {
self.channels.iter().find(|channel| channel.player == player)
}
pub fn eq(&self, identity: &str) -> Eq {
self.eqs.get(identity).cloned().unwrap_or_default()
}
pub fn unsaved(&self) -> bool {
self.unsaved
}
fn held(&self) -> bool {
self.held_until.is_some_and(|until| Instant::now() < until)
}
fn hold(&mut self) {
self.held_until = Some(Instant::now() + Duration::from_millis(2500));
}
// from input handlers: sets and unmutes every stream of one player
pub fn set_volume(player: &str, volume: u8) {
let mut sound = Self::write();
sound.hold();
let Some(channel) = sound.channels.iter_mut().find(|channel| channel.player == player) else {
return;
};
if channel.volume == volume && !channel.muted {
return;
}
// ponytail: one pactl per step; a long-lived `pactl` pipe if drags ever stutter
for id in &channel.streams {
if channel.muted {
run(&["pactl", "set-sink-input-mute", &id.to_string(), "0"]);
}
run(&["pactl", "set-sink-input-volume", &id.to_string(), &format!("{volume}%")]);
}
channel.volume = volume;
channel.muted = false;
}
pub fn toggle_mute(player: &str) {
let mut sound = Self::write();
sound.hold();
let Some(channel) = sound.channels.iter_mut().find(|channel| channel.player == player) else {
return;
};
channel.muted = !channel.muted;
for id in &channel.streams {
run(&["pactl", "set-sink-input-mute", &id.to_string(), if channel.muted { "1" } else { "0" }]);
}
}
// moves one player's sound to another device, through its equalizer so new streams follow
pub fn set_output(player: &str, identity: &str, device: &str) {
let (key, eq_output) = {
let mut sound = Self::write();
sound.hold();
let chosen = sound.outputs.iter().find(|sink| sink.name == device).cloned();
let Some(channel) = sound.channels.iter_mut().find(|channel| channel.player == player) else {
return;
};
channel.output = chosen;
(channel.key.clone(), channel.eq_output)
};
match eq_output {
Some(stream) => run(&["pactl", "move-sink-input", &stream.to_string(), device]),
None => {
let gains = Self::read().eq(identity).gains;
start_eq(&key, identity, &gains, device);
}
}
}
// from a dragged equalizer band; half-dB steps keep pw-cli calls few
pub fn set_band(player: &str, identity: &str, band: usize, gain: f32) {
let gain = ((gain * 2.0).round() / 2.0).clamp(-MAX_GAIN, MAX_GAIN);
let gains = {
let mut sound = Self::write();
let eq = sound.eqs.entry(identity.to_string()).or_default();
if eq.gains[band] == gain {
return;
}
eq.gains[band] = gain;
eq.preset = String::from("Custom");
let gains = eq.gains;
sound.unsaved = true;
gains
};
apply(player, identity, &gains);
}
// false when there is no preset by that name
pub fn preset(player: &str, identity: &str, name: &str) -> bool {
let Some((preset, gains)) = EQ_PRESETS.iter().find(|(preset, _)| preset.eq_ignore_ascii_case(name)) else {
return false;
};
{
let mut sound = Self::write();
sound.eqs.insert(identity.to_string(), Eq { preset: preset.to_string(), gains: *gains });
sound.unsaved = true;
}
apply(player, identity, gains);
true
}
}
// pushes gains to the player's running equalizer, or starts one with them
fn apply(player: &str, identity: &str, gains: &[f32; 10]) {
let (running, target) = {
let sound = Sound::read();
let channel = sound.channel(player);
let running = channel.is_some_and(|channel| channel.eq_output.is_some());
let target = channel.and_then(|channel| channel.output.as_ref()).map_or(String::new(), |sink| sink.name.clone());
(running, target)
};
let key = parse::key_of(player);
if running {
run(&["pw-cli", "set-param", &format!("{EQ_PREFIX}{key}"), "Props", &parse::eq_params(gains)]);
} else {
start_eq(&key, identity, gains, &target);
}
}
fn poll(known: &mut BTreeSet<String>) {
let sinks = parse::sinks(&amane::output("LC_ALL=C pactl list sinks"));
let streams = parse::sink_inputs(&amane::output("LC_ALL=C pactl list sink-inputs"));
// copied out, so Media isn't held while this thread waits on d-bus and /proc
let players: Vec<(String, String)> =
Media::read().players().iter().map(|player| (player.name().to_string(), player.identity().to_string())).collect();
let eqs = Sound::read().eqs.clone();
// the equalizers' own output streams belong to no player
let (eq_streams, app_streams): (Vec<Stream>, Vec<Stream>) =
streams.into_iter().partition(|stream| stream.node_name.starts_with(EQ_PREFIX));
let mut channels = Vec::new();
for (player, identity) in players {
let key = parse::key_of(&player);
let eq_name = format!("{EQ_PREFIX}{key}");
let eq_sink = sinks.iter().find(|sink| sink.name == eq_name);
let eq_output = eq_streams.iter().find(|stream| stream.node_name == format!("{eq_name}.out"));
let owned = parse::owned(&app_streams, process_of(&player), &parse::app_of(&player), ancestry);
let device_of = |stream: &&Stream| sinks.iter().find(|sink| sink.index == stream.sink);
match eq_sink {
Some(eq_sink) => {
// first sight of this equalizer (just launched, or left by an earlier shell): give it the current curve
if known.insert(key.clone()) {
Sound::write().starting.remove(&key);
if let Some(eq) = eqs.get(&identity) {
run(&["pw-cli", "set-param", &eq_name, "Props", &parse::eq_params(&eq.gains)]);
}
}
// every stream the player opens plays through its equalizer
for stream in owned.iter().filter(|stream| stream.sink != eq_sink.index) {
run(&["pactl", "move-sink-input", &stream.id.to_string(), &eq_name]);
}
}
None => {
known.remove(&key);
// a saved curve comes back with its player
if let Some(eq) = eqs.get(&identity).filter(|eq| eq.gains != [0.0; 10]) {
let target = owned.first().and_then(device_of).map_or("", |sink| sink.name.as_str());
start_eq(&key, &identity, &eq.gains, target);
}
}
}
// where the sound ends up: through the equalizer to its output, or straight to a device
let routed = eq_sink.is_some_and(|eq_sink| owned.iter().any(|stream| stream.sink == eq_sink.index));
let output = match eq_output {
Some(eq_output) if routed => sinks.iter().find(|sink| sink.index == eq_output.sink),
_ => owned.first().and_then(device_of),
};
channels.push(Channel {
output: output.cloned(),
eq_output: eq_output.map(|stream| stream.id),
streams: owned.iter().map(|stream| stream.id).collect(),
volume: owned.first().map_or(100, |stream| stream.volume),
muted: !owned.is_empty() && owned.iter().all(|stream| stream.muted),
player,
key,
});
}
// an equalizer whose player has gone
for sink in &sinks {
if let Some(key) = sink.name.strip_prefix(EQ_PREFIX)
&& !channels.iter().any(|channel| channel.key == key)
{
run(&["pkill", "-f", &eq_file(key).display().to_string()]);
}
}
let outputs: Vec<Sink> = sinks.into_iter().filter(|sink| !sink.name.starts_with(EQ_PREFIX)).collect();
let default_output = amane::output("pactl get-default-sink");
let stale = {
let sound = Sound::read();
!sound.held() && (sound.channels != channels || sound.outputs != outputs || sound.default_output != default_output)
};
if stale {
let mut sound = Sound::write();
sound.channels = channels;
sound.outputs = outputs;
sound.default_output = default_output;
}
}
/*
* a standalone filter-chain process in its own session, so the sound keeps
* flowing while the shell restarts; the next shell finds it by node name
*/
fn start_eq(key: &str, identity: &str, gains: &[f32; 10], target: &str) {
{
let mut sound = Sound::write();
if sound.starting.get(key).is_some_and(|since| since.elapsed() < Duration::from_secs(5)) {
return;
}
sound.starting.insert(key.to_string(), Instant::now());
}
let config = eq_file(key);
if fs::write(&config, parse::eq_config(key, identity, gains, target)).is_ok() {
run(&["setsid", "-f", "pipewire", "-c", &config.display().to_string()]);
}
}
fn eq_file(key: &str) -> PathBuf {
art::folder().join(format!("eq-{key}.conf"))
}
// saved equalizers live with the shell's config, so they outlast logouts
fn store_file() -> PathBuf {
theme::config_file("island-eq")
}
fn save() {
let lines: String = {
let sound = Sound::read();
if !sound.unsaved {
return;
}
sound.eqs.iter().map(|(identity, eq)| parse::eq_line(identity, &eq.preset, &eq.gains)).collect()
};
// ponytail: a change landing between this read and the write below is saved on the next change
if fs::write(store_file(), lines).is_ok() {
Sound::write().unsaved = false;
}
}
// no shell: device and player names come from other programs and must never run as commands
fn run(command: &[&str]) {
let Some((program, arguments)) = command.split_first() else {
return;
};
let child = Command::new(program).args(arguments).stdout(Stdio::null()).stderr(Stdio::null()).spawn();
// a finished child nobody waits for stays behind as a zombie
if let Ok(mut child) = child {
thread::spawn(move || child.wait());
}
}
// the pid behind a bus name, 0 when d-bus can't tell
fn process_of(bus_name: &str) -> u32 {
let pid = Bus::session().call(
"org.freedesktop.DBus",
"/org/freedesktop/DBus",
"org.freedesktop.DBus",
"GetConnectionUnixProcessID",
&[Argument::from(bus_name)],
);
pid.number() as u32
}
// a process and its parents, up to init
fn ancestry(mut pid: u32) -> Vec<u32> {
let mut chain = Vec::new();
while pid > 1 && chain.len() < 32 {
chain.push(pid);
let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) else {
break;
};
let Some(parent) = parse::parent_of(&stat) else {
break;
};
pid = parent;
}
chain
}