Files
amane-config/src/sound.rs
T
server2 4fbfad3ce3 Config window: every setting in config.toml, Hyprland options from Hyprland
All tunable values (sizes, timings, text sizes, colours, polls, equalizer
presets and bands, hotspot, extra output) are settings declared once in
config.rs, read from ~/.config/amane/config.toml, applied live on save.
shell.conf's four values move over on first start.

settings.rs: a normal window (control centre gear, or ipc config) with a
page per part, a page per Hyprland category, search and reset per row.
hypr.rs: Hyprland's options from hyprctl descriptions/getoption, applied
with hl.config and kept in ~/.config/hypr/amane.lua.
2026-10-15 02:18:57 +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::config::sound::{equalizer, presets as gains, timing};
use crate::parse::{self, EQ_PREFIX, Sink, Stream};
use crate::theme;
// the presets with their gains for the 10 bands, low to high (config.rs: sound.presets.*)
pub fn presets() -> [(&'static str, [f32; 10]); 8] {
[
("Flat", gains::flat()),
("Bass", gains::bass()),
("Treble", gains::treble()),
("Vocal", gains::vocal()),
("Pop", gains::pop()),
("Rock", gains::rock()),
("Jazz", gains::jazz()),
("Classic", gains::classic()),
]
}
#[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_millis(timing::poll_ms()));
}
}
}
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(timing::hold_ms()));
}
// 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(-equalizer::max_gain(), equalizer::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)) = presets().into_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 });
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, &equalizer::bands(), 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
}