Initial
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
use amane::*;
|
||||
|
||||
fn main() {
|
||||
App::new().window(view).run();
|
||||
}
|
||||
|
||||
fn view() -> LayerWindow {
|
||||
LayerWindow::new()
|
||||
.width(Full)
|
||||
.height(20.0)
|
||||
.anchor_vertical(Vertical::Top)
|
||||
.layer(Layer::Top)
|
||||
.child(
|
||||
Rectangle::new()
|
||||
.width(Parent)
|
||||
.height(Parent)
|
||||
.fill(Color::BLUE)
|
||||
.align_child(End, Center)
|
||||
.child(
|
||||
Text::new("hello world").size(20.0).color(Color::WHITE)
|
||||
),
|
||||
)
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
use std::{env, fs, thread};
|
||||
|
||||
use amane::{Media, Service};
|
||||
|
||||
use crate::island::{self, Island};
|
||||
use crate::parse;
|
||||
|
||||
/*
|
||||
* the shown player's cover as a local file Image can draw: file:// links are
|
||||
* decoded, https:// ones (spotify) downloaded once. a copy also goes to one
|
||||
* fixed path the Palette watches, so the background glow follows the cover
|
||||
*/
|
||||
#[derive(Default)]
|
||||
pub struct Art {
|
||||
url: String,
|
||||
|
||||
// empty when there is no cover
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
impl Service for Art {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn listen() {
|
||||
loop {
|
||||
let url = shown_art_url();
|
||||
|
||||
if url != Self::read().url {
|
||||
let path = local_copy(&url);
|
||||
|
||||
if !path.is_empty() {
|
||||
let _ = fs::copy(&path, palette_file());
|
||||
}
|
||||
|
||||
*Self::write() = Self { url, path };
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn shown_art_url() -> String {
|
||||
let selected = Island::read().selected.clone();
|
||||
|
||||
let media = Media::read();
|
||||
|
||||
island::shown(&media, selected.as_deref()).map_or(String::new(), |player| player.art_url().to_string())
|
||||
}
|
||||
|
||||
fn local_copy(url: &str) -> String {
|
||||
if let Some(path) = parse::file_path(url) {
|
||||
return path;
|
||||
}
|
||||
|
||||
if !url.starts_with("https://") && !url.starts_with("http://") {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut hasher = DefaultHasher::new();
|
||||
|
||||
url.hash(&mut hasher);
|
||||
|
||||
let path = folder().join(format!("{:016x}", hasher.finish()));
|
||||
|
||||
if !path.exists() {
|
||||
// downloaded beside, then moved, so Image never caches a half written file
|
||||
let partial = path.with_extension("part");
|
||||
|
||||
// no shell: the url comes from another program and must never run as a command
|
||||
let fetched = Command::new("curl")
|
||||
.args(["-sfL", "--max-time", "10", "-o"])
|
||||
.arg(&partial)
|
||||
.arg(url)
|
||||
.status()
|
||||
.is_ok_and(|status| status.success());
|
||||
|
||||
if !fetched || fs::rename(&partial, &path).is_err() {
|
||||
let _ = fs::remove_file(&partial);
|
||||
|
||||
return String::new();
|
||||
}
|
||||
}
|
||||
|
||||
path.display().to_string()
|
||||
}
|
||||
|
||||
// tmpfs under XDG_RUNTIME_DIR, so downloaded covers and equalizer configs go away at logout
|
||||
pub fn folder() -> PathBuf {
|
||||
let base = env::var_os("XDG_RUNTIME_DIR").map_or_else(env::temp_dir, PathBuf::from);
|
||||
|
||||
let folder = base.join("magic-island");
|
||||
|
||||
let _ = fs::create_dir_all(&folder);
|
||||
|
||||
folder
|
||||
}
|
||||
|
||||
pub fn palette_file() -> String {
|
||||
folder().join("palette-cover").display().to_string()
|
||||
}
|
||||
+1129
File diff suppressed because it is too large
Load Diff
+14
@@ -0,0 +1,14 @@
|
||||
mod art;
|
||||
mod island;
|
||||
mod parse;
|
||||
mod sound;
|
||||
mod spectrum;
|
||||
|
||||
use amane::{App, Palette, Service};
|
||||
|
||||
fn main() {
|
||||
// the card's glow follows the cover art, which art::Art copies to this one path
|
||||
Palette::write().open(&art::palette_file(), 8);
|
||||
|
||||
App::new().font("Fira Code").ipc("island", island::command).window(island::view).run();
|
||||
}
|
||||
+433
@@ -0,0 +1,433 @@
|
||||
// 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<Stream> {
|
||||
let mut streams: Vec<Stream> = 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<Sink> {
|
||||
let mut sinks: Vec<Sink> = 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::<u32>().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<f32> {
|
||||
line.split(';')
|
||||
.filter_map(|value| value.trim().parse::<f32>().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<u32>,
|
||||
) -> 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/<pid>/stat; the name in parentheses may hold spaces and ')'
|
||||
pub fn parent_of(stat: &str) -> Option<u32> {
|
||||
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<String> {
|
||||
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<char> = 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.<key>, 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 <node> Props` value that sets every band at once
|
||||
pub fn eq_params(gains: &[f32; 10]) -> String {
|
||||
let params: Vec<String> = 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<f32> = 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<String> = 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::<Vec<_>>(), [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::<Vec<_>>(), [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());
|
||||
}
|
||||
}
|
||||
+466
@@ -0,0 +1,466 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::{env, fs, thread};
|
||||
|
||||
use amane::{Argument, Bus, Media, Service};
|
||||
|
||||
use crate::art;
|
||||
use crate::parse::{self, EQ_PREFIX, Sink, Stream};
|
||||
|
||||
// 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>,
|
||||
|
||||
// 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 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 stale = {
|
||||
let sound = Sound::read();
|
||||
|
||||
!sound.held() && (sound.channels != channels || sound.outputs != outputs)
|
||||
};
|
||||
|
||||
if stale {
|
||||
let mut sound = Sound::write();
|
||||
|
||||
sound.channels = channels;
|
||||
sound.outputs = outputs;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* 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 {
|
||||
let config = env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
|
||||
.unwrap_or_default();
|
||||
|
||||
config.join("amane").join("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
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::Path;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::Duration;
|
||||
use std::{fs, thread};
|
||||
|
||||
use amane::{Media, Service};
|
||||
|
||||
use crate::art;
|
||||
use crate::island::{self, Island};
|
||||
use crate::parse;
|
||||
use crate::sound::Sound;
|
||||
|
||||
// cava's bars, low to high frequencies; the ring mirrors them
|
||||
pub const BARS: usize = 24;
|
||||
|
||||
/*
|
||||
* the shown player's own sound as bars: `pw-record --target <its stream>`
|
||||
* piped into cava. only runs while the card is open and the player plays,
|
||||
* since every frame it sends redraws the island
|
||||
*/
|
||||
#[derive(Default)]
|
||||
pub struct Spectrum {
|
||||
stream: Option<u32>,
|
||||
|
||||
// 0 to 1 per bar; empty while nothing is captured
|
||||
pub levels: Vec<f32>,
|
||||
}
|
||||
|
||||
impl Service for Spectrum {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn listen() {
|
||||
let config = art::folder().join("cava.conf");
|
||||
|
||||
let _ = fs::write(&config, cava_config());
|
||||
|
||||
let mut running: Option<Capture> = None;
|
||||
|
||||
loop {
|
||||
let wanted = wanted_stream();
|
||||
|
||||
if wanted != running.as_ref().map(|capture| capture.stream) {
|
||||
if let Some(capture) = running.take() {
|
||||
capture.stop();
|
||||
}
|
||||
|
||||
*Self::write() = Self { stream: wanted, levels: Vec::new() };
|
||||
|
||||
running = wanted.and_then(|stream| Capture::start(stream, &config));
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cava_config() -> String {
|
||||
format!(
|
||||
"[general]\nframerate = 60\nbars = {BARS}\n\
|
||||
[input]\nmethod = fifo\nsource = /dev/stdin\nsample_rate = 44100\nsample_bits = 16\n\
|
||||
[output]\nmethod = raw\nraw_target = /dev/stdout\ndata_format = ascii\nascii_max_range = 100\nchannels = mono\n\
|
||||
[smoothing]\nnoise_reduction = 70\n"
|
||||
)
|
||||
}
|
||||
|
||||
// the first stream of the shown player, while the card is open and it plays
|
||||
fn wanted_stream() -> Option<u32> {
|
||||
let (expanded, selected) = {
|
||||
let island = Island::read();
|
||||
|
||||
(island.expanded(), island.selected.clone())
|
||||
};
|
||||
|
||||
if !expanded {
|
||||
return None;
|
||||
}
|
||||
|
||||
let player = {
|
||||
let media = Media::read();
|
||||
|
||||
island::shown(&media, selected.as_deref()).filter(|player| player.playing()).map(|player| player.name().to_string())
|
||||
}?;
|
||||
|
||||
Sound::read().channel(&player).and_then(|channel| channel.streams.first().copied())
|
||||
}
|
||||
|
||||
struct Capture {
|
||||
stream: u32,
|
||||
record: Child,
|
||||
cava: Child,
|
||||
}
|
||||
|
||||
impl Capture {
|
||||
fn start(stream: u32, config: &Path) -> Option<Self> {
|
||||
let mut record = Command::new("pw-record")
|
||||
.args(["--target", &stream.to_string(), "--rate", "44100", "--channels", "2", "--format", "s16", "-"])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.ok()?;
|
||||
|
||||
let audio = record.stdout.take()?;
|
||||
|
||||
let cava = Command::new("cava").arg("-p").arg(config).stdin(audio).stdout(Stdio::piped()).stderr(Stdio::null()).spawn();
|
||||
|
||||
let Ok(mut cava) = cava else {
|
||||
let _ = record.kill();
|
||||
let _ = record.wait();
|
||||
|
||||
return None;
|
||||
};
|
||||
|
||||
let frames = cava.stdout.take()?;
|
||||
|
||||
// ends by itself once cava is killed and its output closes
|
||||
thread::spawn(move || {
|
||||
for line in BufReader::new(frames).lines() {
|
||||
let Ok(line) = line else {
|
||||
break;
|
||||
};
|
||||
|
||||
let levels = parse::levels(&line);
|
||||
|
||||
// silence repeats the same frame; skipping it keeps the island at rest
|
||||
let fresh = {
|
||||
let spectrum = Spectrum::read();
|
||||
|
||||
spectrum.stream == Some(stream) && spectrum.levels != levels
|
||||
};
|
||||
|
||||
if fresh {
|
||||
Spectrum::write().levels = levels;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Some(Self { stream, record, cava })
|
||||
}
|
||||
|
||||
fn stop(mut self) {
|
||||
let _ = self.record.kill();
|
||||
let _ = self.cava.kill();
|
||||
|
||||
let _ = self.record.wait();
|
||||
let _ = self.cava.wait();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user