This commit is contained in:
Moritz Ruth 2024-01-15 15:47:15 +01:00
parent aee74cb528
commit 6c7d749a2c
Signed by: moritzruth
GPG key ID: C9BBAB79405EE56D
8 changed files with 274 additions and 78 deletions

60
Cargo.lock generated
View file

@ -376,6 +376,7 @@ dependencies = [
"loupedeck_serial",
"once_cell",
"pa-volume-interface",
"parse-display",
"regex",
"resvg",
"rgb",
@ -1061,6 +1062,32 @@ dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "parse-display"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6509d08722b53e8dafe97f2027b22ccbe3a5db83cb352931e9716b0aa44bc5c"
dependencies = [
"once_cell",
"parse-display-derive",
"regex",
]
[[package]]
name = "parse-display-derive"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68517892c8daf78da08c0db777fcc17e07f2f63ef70041718f8a7630ad84f341"
dependencies = [
"once_cell",
"proc-macro2",
"quote",
"regex",
"regex-syntax 0.7.5",
"structmeta",
"syn 2.0.48",
]
[[package]]
name = "pico-args"
version = "0.5.0"
@ -1161,7 +1188,7 @@ dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
"regex-syntax 0.8.2",
]
[[package]]
@ -1172,9 +1199,15 @@ checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
"regex-syntax 0.8.2",
]
[[package]]
name = "regex-syntax"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da"
[[package]]
name = "regex-syntax"
version = "0.8.2"
@ -1499,6 +1532,29 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "structmeta"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ad9e09554f0456d67a69c1584c9798ba733a5b50349a6c0d0948710523922d"
dependencies = [
"proc-macro2",
"quote",
"structmeta-derive",
"syn 2.0.48",
]
[[package]]
name = "structmeta-derive"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a60bcaff7397072dca0017d1db428e30d5002e00b6847703e2e42005c95fbe00"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.48",
]
[[package]]
name = "svgtypes"
version = "0.13.0"

View file

@ -30,3 +30,4 @@ tokio = { version = "1.35.1", features = ["macros", "parking_lot", "rt", "rt-mul
toml = "0.8.8"
walkdir = "2.4.0"
once_cell = "1.19.0"
parse-display = "0.8.2"

View file

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>

After

Width:  |  Height:  |  Size: 426 B

View file

@ -1,30 +1,49 @@
#[knobs.right-top]
#icon = "@ph/microphone-light[scale=0.9]"
#indicators.bar.color = "#ffffff50"
#
#mode.audio_volume.direction = "input"
#mode.audio_volume.regex = "^(SC425 USB Microphone Analog Stereo)$"
#mode.audio_volume.disable_press_to_unmute = true
#mode.audio_volume.muted_turn_action = "unmute-at-zero"
#mode.audio_volume.style.muted.label = "Muted"
#mode.audio_volume.style.inactive.icon = "@ph/microphone-slash-light[alpha=0.9|color=#fc4646]"
#
#[knobs.right-middle]
#icon = "@apps/discord[scale=0.25]"
#indicators.bar.color = "#ffffff50"
#
#mode.audio_volume.regex = "Discord"
#mode.audio_volume.style.active.label = "{percentage}%"
#mode.audio_volume.style.muted.label = "Muted"
#mode.audio_volume.style.inactive.label = ""
#mode.audio_volume.style.inactive.icon = "@apps/discord[grayscale|alpha=0.9]"
[knobs.right-bottom]
icon = "@apps/spotify[scale=1.1]"
[knobs.right-top]
icon = "@ph/microphone-light[scale=0.9]"
indicators.bar.color = "#ffffff50"
mode.audio_volume.regex = "Spotify"
mode.audio_volume.delta = 0.05
mode.audio_volume.target.type = "input"
mode.audio_volume.target.predicates = [{ property = "description", value = "SC425 USB Microphone Analog Stereo" }]
mode.audio_volume.disable_press_to_unmute = true
mode.audio_volume.muted_turn_action = "unmute-at-zero"
mode.audio_volume.style.active.label = "{percentage}%"
mode.audio_volume.style.muted.label = "Muted"
mode.audio_volume.style.inactive.label = ""
mode.audio_volume.style.inactive.icon = "@apps/spotify[grayscale|alpha=0.9]"
mode.audio_volume.style.muted.icon = "@ph/microphone-slash-light[scale=0.9|color=#fc4646]"
mode.audio_volume.style.muted.indicators.bar.color = "#fc464690"
mode.audio_volume.style.inactive.label = "N/A"
mode.audio_volume.style.inactive.icon = "@ph/microphone-slash-light[scale=0.9|alpha=0.8|color=#fc4646]"
[knobs.left-top]
icon = "@apps/discord[scale=0.25]"
indicators.bar.color = "#ffffff50"
mode.audio_volume.delta = 0.05
mode.audio_volume.target.type = "application"
mode.audio_volume.target.predicates = [{ property = "binary-name", value = "Discord" }, { property = "description", value = "playStream" }]
mode.audio_volume.style.muted.indicators.bar.color = "#fc464690"
mode.audio_volume.style.inactive.icon = "@apps/discord[scale=0.25|grayscale|alpha=0.8]"
[knobs.left-middle]
icon = "@apps/youtube[scale=1.3|color=#ff0000]"
indicators.bar.color = "#ffffff50"
mode.audio_volume.delta = 0.05
mode.audio_volume.target.type = "application"
mode.audio_volume.target.predicates = [{ property = "binary-name", value = "librewolf" }, { property = "description", regex = "\\- Piped$" }]
mode.audio_volume.style.muted.indicators.bar.color = "#fc464690"
mode.audio_volume.style.inactive.icon = "@apps/youtube[scale=1.3|grayscale|alpha=0.8]"
[knobs.left-bottom]
icon = "@apps/spotify[scale=1.2]"
indicators.bar.color = "#ffffff50"
mode.audio_volume.delta = 0.05
mode.audio_volume.target.type = "application"
mode.audio_volume.target.predicates = [{ property = "application-name", value = "spotify" }]
mode.audio_volume.style.muted.indicators.bar.color = "#fc464690"
mode.audio_volume.style.inactive.icon = "@apps/spotify[scale=1.2|grayscale|alpha=0.8]"

View file

@ -67,6 +67,12 @@ pub enum KnobPosition {
RightBottom,
}
impl KnobPosition {
pub fn is_left(&self) -> bool {
matches!(self, KnobPosition::LeftBottom | KnobPosition::LeftMiddle | KnobPosition::LeftTop)
}
}
impl From<LoupedeckKnob> for KnobPosition {
fn from(value: LoupedeckKnob) -> Self {
match value {

View file

@ -1,14 +1,15 @@
use std::borrow::ToOwned;
use std::collections::HashMap;
use std::sync::Arc;
use log::warn;
use once_cell::sync::Lazy;
use parse_display::Display;
use regex::Regex;
use serde::Deserialize;
use tokio::select;
use tokio::sync::broadcast;
use pa_volume_interface::{PaEntityState, PaVolumeInterface};
use pa_volume_interface::{PaEntityKind, PaEntityMetadata, PaEntityState, PaVolumeInterface};
use crate::model::knob_page::StyleByStateMap;
use crate::model::position::KnobPath;
@ -17,12 +18,8 @@ use crate::runner::command::IoWorkerCommand;
#[derive(Debug, Deserialize)]
pub struct Config {
#[serde(default = "default_delta")]
pub delta: f32,
#[serde(with = "serde_regex")]
pub regex: Regex,
#[serde(default)]
pub direction: Direction,
pub target: Target,
pub delta: Option<f32>,
#[serde(default)]
pub disable_press_to_mute: bool,
#[serde(default)]
@ -30,13 +27,52 @@ pub struct Config {
#[serde(default)]
pub muted_turn_action: MutedTurnAction,
#[serde(default)]
pub label: HashMap<State, String>,
#[serde(default)]
pub style: StyleByStateMap<State>,
}
fn default_delta() -> f32 {
0.05
#[derive(Debug, Eq, PartialEq, Deserialize, Display)]
#[serde(rename_all = "kebab-case")]
#[display(style = "kebab-case")]
pub enum TargetKind {
Input,
Output,
Application,
}
#[derive(Debug, Deserialize)]
pub struct Target {
#[serde(rename = "type")]
kind: TargetKind,
predicates: Vec<TargetPredicate>,
}
#[derive(Debug, Deserialize)]
pub struct TargetPredicate {
property: TargetPredicateProperty,
#[serde(flatten)]
pattern: TargetPredicatePattern,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum TargetPredicatePattern {
Static {
value: String,
},
Regex {
#[serde(with = "serde_regex")]
regex: Regex,
},
}
#[derive(Debug, Eq, PartialEq, Deserialize, Display)]
#[serde(rename_all = "kebab-case")]
#[display(style = "kebab-case")]
pub enum TargetPredicateProperty {
Description,
InternalName,
ApplicationName,
BinaryName,
}
#[derive(Debug, Default, Eq, PartialEq, Deserialize)]
@ -66,12 +102,54 @@ pub enum State {
static PA_VOLUME_INTERFACE: Lazy<PaVolumeInterface> = Lazy::new(|| PaVolumeInterface::spawn_thread("deckster".to_owned()));
fn get_volume_cv(channel_volumes: &Vec<f32>) -> f32 {
*channel_volumes.first().unwrap()
fn get_volume_cv(channel_volumes: &[f32]) -> f32 {
*channel_volumes.iter().max_by(|a, b| a.partial_cmp(b).unwrap()).unwrap()
}
fn get_volume_es(entity_state: &Option<Arc<PaEntityState>>) -> f32 {
entity_state.as_ref().map(|s| *s.channel_volumes().first().unwrap()).unwrap_or(0.0)
entity_state.as_ref().map(|s| get_volume_cv(&s.channel_volumes())).unwrap_or(0.0)
}
fn state_matches(target: &Target, state: &PaEntityState) -> bool {
if !match target.kind {
TargetKind::Input => state.kind() == PaEntityKind::Source,
TargetKind::Output => state.kind() == PaEntityKind::Sink,
TargetKind::Application => state.kind() == PaEntityKind::SinkInput,
} {
return false;
}
static EMPTY_STRING: String = String::new();
return target.predicates.iter().all(|p| {
let v = match (&p.property, state.metadata()) {
(TargetPredicateProperty::InternalName, PaEntityMetadata::Sink { name, .. }) => Some(name),
(TargetPredicateProperty::InternalName, PaEntityMetadata::Source { name, .. }) => Some(name),
(TargetPredicateProperty::InternalName, PaEntityMetadata::SinkInput { .. }) => None,
(TargetPredicateProperty::Description, PaEntityMetadata::Sink { description, .. }) => Some(description),
(TargetPredicateProperty::Description, PaEntityMetadata::Source { description, .. }) => Some(description),
(TargetPredicateProperty::Description, PaEntityMetadata::SinkInput { description, .. }) => Some(description),
(TargetPredicateProperty::ApplicationName, PaEntityMetadata::Sink { .. }) => None,
(TargetPredicateProperty::ApplicationName, PaEntityMetadata::Source { .. }) => None,
(TargetPredicateProperty::ApplicationName, PaEntityMetadata::SinkInput { application_name, .. }) => {
Some(application_name.as_ref().unwrap_or(&EMPTY_STRING))
}
(TargetPredicateProperty::BinaryName, PaEntityMetadata::Sink { .. }) => None,
(TargetPredicateProperty::BinaryName, PaEntityMetadata::Source { .. }) => None,
(TargetPredicateProperty::BinaryName, PaEntityMetadata::SinkInput { binary_name, .. }) => Some(binary_name.as_ref().unwrap_or(&EMPTY_STRING)),
};
if let Some(v) = v {
match &p.pattern {
TargetPredicatePattern::Static { value } => value == v,
TargetPredicatePattern::Regex { regex } => regex.is_match(v),
}
} else {
warn!("Property \"{}\" is not available for targets of type \"{}\"", &p.property, &target.kind);
false
}
});
}
pub async fn handle(path: KnobPath, config: Arc<Config>, mut events: broadcast::Receiver<(KnobPath, KnobEvent)>, commands: flume::Sender<IoWorkerCommand>) {
@ -84,8 +162,9 @@ pub async fn handle(path: KnobPath, config: Arc<Config>, mut events: broadcast::
entity_state = state
.entities_by_id()
.values()
.find(|entity| config.regex.is_match(entity.display_name()))
.find(|entity| state_matches(&config.target, &entity))
.map(Arc::clone);
commands
.send(IoWorkerCommand::SetKnobValue {
path: path.clone(),
@ -127,7 +206,7 @@ pub async fn handle(path: KnobPath, config: Arc<Config>, mut events: broadcast::
loop {
select! {
Ok(volume_state) = volume_states.recv() => {
entity_state = volume_state.entities_by_id().values().find(|entity| config.regex.is_match(entity.display_name())).map(Arc::clone);
entity_state = volume_state.entities_by_id().values().find(|entity| state_matches(&config.target, &entity)).map(Arc::clone);
update_style(&entity_state);
commands.send(IoWorkerCommand::SetKnobValue { path: path.clone(), value: get_volume_es(&entity_state) }).unwrap()
@ -147,7 +226,7 @@ pub async fn handle(path: KnobPath, config: Arc<Config>, mut events: broadcast::
};
let current_v = get_volume_cv(&entity_state.channel_volumes());
let v = (current_v + (factor * config.delta)).clamp(0.0, 1.0);
let v = (current_v + (factor * config.delta.unwrap_or(0.01))).clamp(0.0, 1.0);
pa_volume_interface.set_channel_volumes(*entity_state.id(), vec![v; entity_state.channel_volumes().len()]);
}
KnobEvent::Press => {

View file

@ -81,13 +81,23 @@ pub fn render_knob(context: &GraphicsContext, screen_size: IntSize, state: Optio
if let Some(indicators) = &style.indicators {
if let Some(bar) = indicators.bar.as_ref() {
let color = bar.color.unwrap_or(RGBA::new(0xff, 0xff, 0xff, 0x50).into());
const PADDING: f32 = 20.0;
let max_height: f32 = pixmap.height() as f32 - PADDING * 2.0;
const WIDTH: f32 = 5.0;
const PADDING_X: f32 = 5.0;
const PADDING_Y: f32 = 20.0;
let max_height: f32 = pixmap.height() as f32 - PADDING_Y * 2.0;
let height = state.value * max_height;
let y = pixmap.height() as f32 - PADDING - height;
let x = if state.path.position.is_left() {
PADDING_X
} else {
pixmap.width() as f32 - PADDING_X - WIDTH
};
let y = pixmap.height() as f32 - PADDING_Y - height;
pixmap.fill_rect(
Rect::from_xywh(pixmap.width() as f32 - 10.0, y, 5.0, height).unwrap(),
Rect::from_xywh(x, y, WIDTH, height).unwrap(),
&Paint {
shader: Shader::SolidColor(Color::from_rgba8(color.r, color.g, color.b, color.a)),
..Paint::default()

View file

@ -10,7 +10,6 @@ use libpulse_binding::context::introspect::{Introspector, SinkInfo, SinkInputInf
use libpulse_binding::context::subscribe::{Facility, InterestMaskSet};
use libpulse_binding::context::{subscribe, Context, FlagSet};
use libpulse_binding::def::Retval;
use libpulse_binding::mainloop::api::Mainloop as MainloopTrait;
use libpulse_binding::mainloop::standard::{IterateResult, Mainloop};
use libpulse_binding::operation::Operation;
use libpulse_binding::volume::{ChannelVolumes, Volume};
@ -23,17 +22,32 @@ pub type PaEntityId = u32;
pub enum PaEntityKind {
Source,
Sink,
Application,
SinkInput,
}
#[derive(Debug, Eq, PartialEq, Clone)]
pub enum PaEntityMetadata {
Source {
name: String,
description: String,
},
Sink {
name: String,
description: String,
},
SinkInput {
description: String,
binary_name: Option<String>,
application_name: Option<String>,
},
}
#[derive(Debug, Clone)]
pub struct PaEntityState {
id: PaEntityId,
kind: PaEntityKind,
name: String,
display_name: String,
channel_volumes: ChannelVolumes,
is_muted: bool,
metadata: PaEntityMetadata,
}
impl PaEntityState {
@ -41,16 +55,16 @@ impl PaEntityState {
&self.id
}
pub fn kind(&self) -> &PaEntityKind {
&self.kind
pub fn kind(&self) -> PaEntityKind {
match &self.metadata {
PaEntityMetadata::Source { .. } => PaEntityKind::Source,
PaEntityMetadata::Sink { .. } => PaEntityKind::Sink,
PaEntityMetadata::SinkInput { .. } => PaEntityKind::SinkInput,
}
}
pub fn name(&self) -> &String {
&self.name
}
pub fn display_name(&self) -> &String {
&self.display_name
pub fn metadata(&self) -> &PaEntityMetadata {
&self.metadata
}
pub fn channel_volumes(&self) -> Vec<f32> {
@ -67,10 +81,11 @@ impl From<&SourceInfo<'_>> for PaEntityState {
PaEntityState {
id: value.index as PaEntityId,
is_muted: value.mute,
name: value.name.clone().unwrap_or_default().into_owned(),
display_name: value.description.clone().unwrap_or_default().into_owned(),
kind: PaEntityKind::Source,
channel_volumes: value.volume,
metadata: PaEntityMetadata::Source {
name: value.name.clone().unwrap_or_default().into_owned(),
description: value.description.clone().unwrap_or_default().into_owned(),
},
}
}
}
@ -80,10 +95,11 @@ impl From<&SinkInfo<'_>> for PaEntityState {
PaEntityState {
id: value.index as PaEntityId,
is_muted: value.mute,
name: value.name.clone().unwrap_or_default().into_owned(),
display_name: value.description.clone().unwrap_or_default().into_owned(),
kind: PaEntityKind::Sink,
channel_volumes: value.volume,
metadata: PaEntityMetadata::Sink {
name: value.name.clone().unwrap_or_default().into_owned(),
description: value.description.clone().unwrap_or_default().into_owned(),
},
}
}
}
@ -93,10 +109,18 @@ impl From<&SinkInputInfo<'_>> for PaEntityState {
PaEntityState {
id: value.index as PaEntityId,
is_muted: value.mute,
name: value.name.clone().unwrap_or_default().into_owned(),
display_name: value.name.clone().unwrap_or_default().into_owned(),
kind: PaEntityKind::Application,
channel_volumes: value.volume,
metadata: PaEntityMetadata::SinkInput {
description: value.name.clone().unwrap_or_default().into_owned(),
application_name: value
.proplist
.get("application.name")
.map(|v| String::from_utf8_lossy(v).trim_end_matches(char::from(0)).to_owned()),
binary_name: value
.proplist
.get("application.process.binary")
.map(|v| String::from_utf8_lossy(v).trim_end_matches(char::from(0)).to_owned()),
},
}
}
}
@ -353,14 +377,14 @@ impl PaThread {
loop {
self.run_single_mainloop_iteration(false);
if let Ok(command) = self.commands_rx.try_recv() {
while let Ok(command) = self.commands_rx.try_recv() {
match command {
PaCommand::SetIsMuted { id, value } => {
if let Some(state) = PaThread::unwrap_state(&current_state).entities_by_id.get(&id) {
match state.kind {
match state.kind() {
PaEntityKind::Sink => self.introspector.borrow_mut().set_sink_mute_by_index(id, value, None),
PaEntityKind::Source => self.introspector.borrow_mut().set_source_mute_by_index(id, value, None),
PaEntityKind::Application => self.introspector.borrow_mut().set_sink_input_mute(id, value, None),
PaEntityKind::SinkInput => self.introspector.borrow_mut().set_sink_input_mute(id, value, None),
};
}
}
@ -371,10 +395,10 @@ impl PaThread {
value.set(i as u8, Volume((Volume::NORMAL.0 as f32 * v).floor() as u32));
}
match state.kind {
match state.kind() {
PaEntityKind::Sink => self.introspector.borrow_mut().set_sink_volume_by_index(id, &value, None),
PaEntityKind::Source => self.introspector.borrow_mut().set_source_volume_by_index(id, &value, None),
PaEntityKind::Application => self.introspector.borrow_mut().set_sink_input_volume(id, &value, None),
PaEntityKind::SinkInput => self.introspector.borrow_mut().set_sink_input_volume(id, &value, None),
};
}
}