From e6d07010afc1f8666cde5c58078c68b62c9393af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jalil=20David=20Salam=C3=A9=20Messina?= Date: Sat, 17 May 2025 14:13:57 +0200 Subject: [PATCH] feat: rewrite audiomenu in Python There is no reason for it to be a Rust program --- scripts/audiomenu/.envrc | 2 + scripts/audiomenu/.gitignore | 2 +- scripts/audiomenu/.python-version | 1 + scripts/audiomenu/Cargo.lock | 553 ------------------------------ scripts/audiomenu/Cargo.toml | 18 - scripts/audiomenu/README.md | 0 scripts/audiomenu/audiomenu.py | 238 +++++++++++++ scripts/audiomenu/package.nix | 28 +- scripts/audiomenu/pyproject.toml | 9 + scripts/audiomenu/src/main.rs | 231 ------------- scripts/audiomenu/uv.lock | 34 ++ scripts/default.nix | 33 +- 12 files changed, 292 insertions(+), 857 deletions(-) create mode 100644 scripts/audiomenu/.envrc create mode 100644 scripts/audiomenu/.python-version delete mode 100644 scripts/audiomenu/Cargo.lock delete mode 100644 scripts/audiomenu/Cargo.toml create mode 100644 scripts/audiomenu/README.md create mode 100644 scripts/audiomenu/audiomenu.py create mode 100644 scripts/audiomenu/pyproject.toml delete mode 100644 scripts/audiomenu/src/main.rs create mode 100644 scripts/audiomenu/uv.lock diff --git a/scripts/audiomenu/.envrc b/scripts/audiomenu/.envrc new file mode 100644 index 0000000..729d54f --- /dev/null +++ b/scripts/audiomenu/.envrc @@ -0,0 +1,2 @@ +source_up +source .venv/bin/activate diff --git a/scripts/audiomenu/.gitignore b/scripts/audiomenu/.gitignore index ea8c4bf..1d17dae 100644 --- a/scripts/audiomenu/.gitignore +++ b/scripts/audiomenu/.gitignore @@ -1 +1 @@ -/target +.venv diff --git a/scripts/audiomenu/.python-version b/scripts/audiomenu/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/scripts/audiomenu/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/scripts/audiomenu/Cargo.lock b/scripts/audiomenu/Cargo.lock deleted file mode 100644 index c65db92..0000000 --- a/scripts/audiomenu/Cargo.lock +++ /dev/null @@ -1,553 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" - -[[package]] -name = "anstream" -version = "0.6.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" - -[[package]] -name = "anstyle-parse" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" -dependencies = [ - "windows-sys", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" -dependencies = [ - "anstyle", - "once_cell", - "windows-sys", -] - -[[package]] -name = "audiomenu" -version = "0.1.0" -dependencies = [ - "clap", - "duct", - "miette", - "serde", - "serde_json", -] - -[[package]] -name = "backtrace" -version = "0.3.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets", -] - -[[package]] -name = "backtrace-ext" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" -dependencies = [ - "backtrace", -] - -[[package]] -name = "bitflags" -version = "2.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" - -[[package]] -name = "cfg-if" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" - -[[package]] -name = "clap" -version = "4.5.38" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.5.38" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.5.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" - -[[package]] -name = "colorchoice" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" - -[[package]] -name = "duct" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6ce170a0e8454fa0f9b0e5ca38a6ba17ed76a50916839d217eb5357e05cdfde" -dependencies = [ - "libc", - "os_pipe", - "shared_child", - "shared_thread", -] - -[[package]] -name = "errno" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" -dependencies = [ - "libc", - "windows-sys", -] - -[[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "is_ci" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" - -[[package]] -name = "itoa" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" - -[[package]] -name = "libc" -version = "0.2.172" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" - -[[package]] -name = "linux-raw-sys" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" - -[[package]] -name = "memchr" -version = "2.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" - -[[package]] -name = "miette" -version = "7.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" -dependencies = [ - "backtrace", - "backtrace-ext", - "cfg-if", - "miette-derive", - "owo-colors", - "supports-color", - "supports-hyperlinks", - "supports-unicode", - "terminal_size", - "textwrap", - "unicode-width 0.1.14", -] - -[[package]] -name = "miette-derive" -version = "7.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "miniz_oxide" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" -dependencies = [ - "adler2", -] - -[[package]] -name = "object" -version = "0.36.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" -dependencies = [ - "memchr", -] - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "os_pipe" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ffd2b0a5634335b135d5728d84c5e0fd726954b87111f7506a61c502280d982" -dependencies = [ - "libc", - "windows-sys", -] - -[[package]] -name = "owo-colors" -version = "4.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26995317201fa17f3656c36716aed4a7c81743a9634ac4c99c0eeda495db0cec" - -[[package]] -name = "proc-macro2" -version = "1.0.95" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "rustc-demangle" -version = "0.1.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" - -[[package]] -name = "rustix" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", -] - -[[package]] -name = "ryu" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" - -[[package]] -name = "serde" -version = "1.0.219" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.219" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.140" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" -dependencies = [ - "itoa", - "memchr", - "ryu", - "serde", -] - -[[package]] -name = "shared_child" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e297bd52991bbe0686c086957bee142f13df85d1e79b0b21630a99d374ae9dc" -dependencies = [ - "libc", - "windows-sys", -] - -[[package]] -name = "shared_thread" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a6f98357c6bb0ebace19b22220e5543801d9de90ffe77f8abb27c056bac064" - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "supports-color" -version = "3.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" -dependencies = [ - "is_ci", -] - -[[package]] -name = "supports-hyperlinks" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f44ed3c63152de6a9f90acbea1a110441de43006ea51bcce8f436196a288b" - -[[package]] -name = "supports-unicode" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" - -[[package]] -name = "syn" -version = "2.0.101" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "terminal_size" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed" -dependencies = [ - "rustix", - "windows-sys", -] - -[[package]] -name = "textwrap" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" -dependencies = [ - "unicode-linebreak", - "unicode-width 0.2.0", -] - -[[package]] -name = "unicode-ident" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" - -[[package]] -name = "unicode-linebreak" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" - -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - -[[package]] -name = "unicode-width" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/scripts/audiomenu/Cargo.toml b/scripts/audiomenu/Cargo.toml deleted file mode 100644 index b4e0aed..0000000 --- a/scripts/audiomenu/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "audiomenu" -description = "fuzzel script to select the default audio device for pipewire+wireplumber" -version = "0.1.0" -edition = "2021" - -[dependencies] -clap = { version = "4.5.23", features = ["derive", "env"] } -duct = "1.0.0" -miette = { version = "7.4.0", features = ["fancy"] } -serde = { version = "1.0.215", features = ["derive"] } -serde_json = "1.0.133" - -[profile.release] -lto = true -opt-level = 's' -panic = "abort" -strip = true diff --git a/scripts/audiomenu/README.md b/scripts/audiomenu/README.md new file mode 100644 index 0000000..e69de29 diff --git a/scripts/audiomenu/audiomenu.py b/scripts/audiomenu/audiomenu.py new file mode 100644 index 0000000..f9ffe9d --- /dev/null +++ b/scripts/audiomenu/audiomenu.py @@ -0,0 +1,238 @@ +# pyright: strict, reportAny=false +from dataclasses import dataclass +import json +import subprocess +from typing import Self +import typing +import click + + +def expect[T](typ: type[T], value: object) -> T: + if not isinstance(value, typ): + raise ValueError( + f"expected value to be of type {typ} but was of type {type(value)}" + ) + return value + + +@dataclass(slots=True) +class PWNodeProps: + object_id: int + node_description: str + node_name: str + media_class: str + + @classmethod + def from_json(cls, data: dict[str, object]) -> Self: + return cls( + object_id=expect(int, data["object.id"]), + node_description=expect(str, data.get("node.description", "(unknown)")), + node_name=expect(str, data["node.name"]), + media_class=expect(str, data.get("media.class", "(unknown)")), + ) + + +@dataclass(slots=True) +class PWNodeInfo: + props: PWNodeProps + + @classmethod + def from_json(cls, data: dict[str, object]) -> Self: + props = typing.cast(dict[str, object], expect(dict, data["props"])) + return cls(PWNodeProps.from_json(props)) + + +@dataclass(slots=True) +class PWNode: + node_type: str + info: PWNodeInfo | None + + @classmethod + def from_json(cls, data: dict[str, object]) -> Self: + info = data.get("info", None) + if info is not None: + info = PWNodeInfo.from_json( + typing.cast(dict[str, object], expect(dict, info)) + ) + return cls(node_type=expect(str, data["type"]), info=info) + + +@dataclass(slots=True) +class AudioDevice: + id: int + name: str + volume: float + muted: bool + default: bool + + @staticmethod + def get_volume(id: int | str) -> tuple[float, bool]: + wpctl_output = subprocess.run( + ["wpctl", "get-volume", str(id)], + encoding="UTF-8", + check=True, + capture_output=True, + ) + match wpctl_output.stdout.strip().split(sep=" "): + case ["Volume:", value]: + return (float(value), False) + case ["Volume:", value, "[MUTED]"]: + return (float(value), True) + case _: + raise ValueError(f"Unexpected wpctl output: {wpctl_output.stdout}") + + @classmethod + def from_pw_node(cls, node: PWNode, default: str) -> Self: + if node.info is None: + raise ValueError(f"Node is not a valid audio device {node}") + + id = node.info.props.object_id + volume, muted = cls.get_volume(id) + + return cls( + id=id, + name=node.info.props.node_description, + volume=volume, + muted=muted, + default=node.info.props.node_name == default, + ) + + def menu_item(self) -> str: + id = f"id={self.id:<3}" + + if self.default: + id = f"[{id}]" + else: + id = f" {id} " + + if self.muted: + return f"{id} {self.volume:>4.0%} [MUTED] {self.name}" + else: + return f"{id} {self.volume:>4.0%} {self.name}" + + +def get_nodes(data: list[dict[str, object]]) -> list[PWNode]: + def is_audio_node(node: object) -> bool: + if not isinstance(node, dict): + return False + + node = typing.cast(dict[str, object], node) + if node["type"] != "PipeWire:Interface:Node": + return False + info = node.get("info", None) + if info is None or not isinstance(info, dict): + return False + info = typing.cast(dict[str, object], info) + props = info.get("props", None) + if props is None or not isinstance(props, dict): + return False + props = typing.cast(dict[str, object], props) + if (media_class := props.get("media.class", None)) is not None: + return isinstance(media_class, str) and media_class.startswith("Audio") + return False + + return [ + PWNode.from_json(typing.cast(dict[str, object], expect(dict, node))) + for node in data + if is_audio_node(node) + ] + + +def pw_dump() -> list[dict[str, object]]: + dump_output = subprocess.run( + ["pw-dump"], encoding="UTF-8", check=True, capture_output=True + ) + data = json.loads(dump_output.stdout) + return typing.cast(list[dict[str, object]], expect(list, data)) + + +def get_defaults_metadata(data: list[dict[str, object]]) -> list[dict[str, object]]: + return typing.cast( + list[dict[str, object]], + expect( + list, + next( + node + for node in data + if node["type"] == "PipeWire:Interface:Metadata" + and expect(dict, node["props"])["metadata.name"] == "default" + )["metadata"], + ), + ) + + +def get_sinks() -> list[AudioDevice]: + data = pw_dump() + default = next( + typing.cast(dict[str, str], expect(dict, data["value"]))["name"] + for data in get_defaults_metadata(data) + if data["key"] == "default.audio.sink" + ) + return [ + AudioDevice.from_pw_node(node, default) + for node in get_nodes(data) + if node.info is not None and node.info.props.media_class == "Audio/Sink" + ] + + +def get_sources() -> list[AudioDevice]: + data = pw_dump() + default = next( + typing.cast(dict[str, str], expect(dict, data["value"]))["name"] + for data in get_defaults_metadata(data) + if data["key"] == "default.audio.source" + ) + return [ + AudioDevice.from_pw_node(node, default) + for node in get_nodes(data) + if node.info is not None and node.info.props.media_class == "Audio/Source" + ] + + +@click.group(name="audiomenu") +def main() -> None: + pass + + +def select(options: list[str], prompt: str) -> int | None: + menu_output = subprocess.run( + ["fuzzel", "--dmenu", f"--prompt={prompt}"], + input="\n".join(options), + encoding="UTF-8", + capture_output=True, + ) + if menu_output.returncode == 2: + return None + menu_output.check_returncode() + selected = menu_output.stdout.rstrip() + return options.index(selected) + + +@main.command() +def select_sink() -> None: + devices = get_sinks() + selected = select([device.menu_item() for device in devices], prompt="Select Sink>") + if selected is None: + click.echo("No sink selected") + return + + device = devices[selected] + _ = subprocess.run(["wpctl", "set-default", str(device.id)], check=True) + + +@main.command() +def select_source() -> None: + devices = get_sources() + selected = select( + [device.menu_item() for device in devices], prompt="Select Source>" + ) + if selected is None: + click.echo("No source selected") + return + + device = devices[selected] + _ = subprocess.run(["wpctl", "set-default", str(device.id)], check=True) + + +if __name__ == "__main__": + main() diff --git a/scripts/audiomenu/package.nix b/scripts/audiomenu/package.nix index ceb495b..265c3f5 100644 --- a/scripts/audiomenu/package.nix +++ b/scripts/audiomenu/package.nix @@ -1,22 +1,6 @@ -{ - lib, - rustPlatform, - cleanRustSrc, -}: -let - cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml); - inherit (cargoToml.package) name version description; - pname = name; - src = cleanRustSrc ./.; -in -rustPlatform.buildRustPackage { - inherit pname version src; - cargoLock.lockFile = ./Cargo.lock; - useNextest = true; - meta = { - inherit description; - license = lib.licenses.mit; - homepage = "https://github.com/jalil-salame/configuration.nix"; - mainProgram = name; - }; -} +{ writers, python3Packages }: +writers.writePython3 "audiomenu" { + libraries = [ python3Packages.click ]; + # line too long, but I like my code well documented + flakeIgnore = [ "E501" ]; +} ./audiomenu.py diff --git a/scripts/audiomenu/pyproject.toml b/scripts/audiomenu/pyproject.toml new file mode 100644 index 0000000..6d992a3 --- /dev/null +++ b/scripts/audiomenu/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "audiomenu" +version = "0.1.0" +description = "fuzzel script to select the default audio device for pipewire+wireplumber" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "click>=8.1.7", +] diff --git a/scripts/audiomenu/src/main.rs b/scripts/audiomenu/src/main.rs deleted file mode 100644 index a542fd4..0000000 --- a/scripts/audiomenu/src/main.rs +++ /dev/null @@ -1,231 +0,0 @@ -use std::{ - fmt::{Display, Write as _}, - io::{Read, Write as _}, - process::{Command, Stdio}, -}; - -use clap::Parser; -use duct::cmd; -use miette::{bail, Context, IntoDiagnostic, Result}; -use serde::Deserialize; - -fn main() -> Result<()> { - miette::set_panic_hook(); - Opts::parse().run() -} - -/// fuzzel script to select the default audio device for pipewire+wireplumber -#[derive(Debug, Parser)] -struct Opts { - #[clap(subcommand)] - cmd: Cmd, -} - -impl Opts { - fn run(self) -> Result<()> { - self.cmd.run() - } -} - -#[derive(Debug, clap::Subcommand)] -enum Cmd { - /// Select audio sink (speakers/headphones) - SelectSink, - /// Select audio source (microphone) - SelectSource, -} - -impl Cmd { - fn run(self) -> Result<()> { - let id = match self { - Cmd::SelectSink => { - let devices = get_sinks().wrap_err("failed to get sinks")?; - let selected = select( - devices.iter().map(|dev| dev.name.as_ref()), - Some("Select input>"), - ) - .wrap_err("failed to select a default sink")?; - if selected.is_empty() { - eprintln!("did not select a device"); - return Ok(()); - } - let Some(dev) = devices.into_iter().find(|dev| dev.name == selected) else { - bail!("couldn't find a device matching `{selected}`"); - }; - dev.id - } - Cmd::SelectSource => { - let devices = get_sources().wrap_err("failed to get sinks")?; - let selected = select( - devices.iter().map(|dev| dev.name.as_ref()), - Some("Select output>"), - ) - .wrap_err("failed to select a default source")?; - if selected.is_empty() { - eprintln!("did not select a device"); - return Ok(()); - } - let Some(dev) = devices.into_iter().find(|dev| dev.name == selected) else { - bail!("couldn't find a device matching `{selected}`"); - }; - dev.id - } - }; - cmd!("wpctl", "set-default", id.to_string()) - .run() - .map(drop) - .into_diagnostic() - .wrap_err("failed to set default input") - } -} - -#[derive(Debug, Deserialize)] -struct PWNode { - #[serde(rename = "type")] - node_type: Box, - #[serde(default)] - info: PWNodeInfo, - // json ignores the rest of the fields by default -} - -#[derive(Debug, Deserialize, Default)] -struct PWNodeInfo { - props: PWNodeProps, - // json ignores the rest of the fields by default -} - -#[derive(Debug, Deserialize, Default)] -struct PWNodeProps { - #[serde(rename = "object.id")] - object_id: u32, - #[serde(rename = "node.description", default)] - node_description: Box, - #[serde(rename = "media.class", default)] - media_class: Box, - // json ignores the rest of the fields by default -} - -struct AudioDevice { - id: u32, - name: Box, - _side: S, -} - -/// Output (e.g. speakers) -struct AudioSink; - -/// Input (e.g. microphone) -struct AudioSource; - -fn get_sinks() -> Result>> { - get_devices() -} - -fn get_sources() -> Result>> { - get_devices() -} - -fn get_devices() -> Result>> -where - AudioDevice: TryFrom, -{ - Ok(get_nodes()? - .into_iter() - .filter_map(|node| AudioDevice::::try_from(node).ok()) - .collect()) -} - -impl TryFrom for AudioDevice { - type Error = miette::Report; - - fn try_from(value: PWNode) -> std::result::Result { - if value.node_type.as_ref() != "PipeWire:Interface:Node" { - bail!( - "invalid type: `{}`, expected `PipeWire:Interface:Node`", - value.node_type - ) - } - let class = value.info.props.media_class; - match class.as_ref() { - "Audio/Source" => Ok(Self { - id: value.info.props.object_id, - name: value.info.props.node_description, - _side: AudioSource, - }), - _ => bail!("invalid media.class: `{class}`, expected `Audio/Source`"), - } - } -} - -impl TryFrom for AudioDevice { - type Error = miette::Report; - - fn try_from(value: PWNode) -> std::result::Result { - if value.node_type.as_ref() != "PipeWire:Interface:Node" { - bail!( - "invalid type: `{}`, expected `PipeWire:Interface:Node`", - value.node_type - ) - } - let class = value.info.props.media_class; - match class.as_ref() { - "Audio/Sink" => Ok(Self { - id: value.info.props.object_id, - name: value.info.props.node_description, - _side: AudioSink, - }), - _ => bail!("invalid media.class: `{class}`, expected `Audio/Sink`"), - } - } -} - -fn get_nodes() -> Result> { - let dump = cmd!("pw-dump") - .read() - .into_diagnostic() - .wrap_err("failed to get devices with pw-dump")?; - serde_json::from_str(&dump) - .into_diagnostic() - .wrap_err("failed to parse pw-dump output") -} - -fn select(options: It, prompt: Option<&str>) -> Result> -where - T: Display, - It: IntoIterator, -{ - let append_line = |mut s: String, it| { - writeln!(s, "{it}").unwrap(); - s - }; - let options = options.into_iter().fold(String::new(), append_line); - let mut menu = Command::new("fuzzel"); - menu.arg("--dmenu"); - if let Some(prompt) = prompt { - menu.arg(format!("--prompt={prompt}")); - } - Ok(pipe_to_stdin_and_return_stdout(&mut menu, options)? - .trim() - .into()) -} - -fn pipe_to_stdin_and_return_stdout(cmd: &mut Command, data: impl Display) -> Result { - let mut child = cmd - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .spawn() - .into_diagnostic() - .wrap_err_with(|| format!("failed to run {cmd:?}"))?; - let mut stdin = child.stdin.take().expect("stdin not piped"); - write!(stdin, "{data}") - .into_diagnostic() - .wrap_err("failed to send data to process' stdin")?; - drop(stdin); - let mut stdout = child.stdout.take().expect("stdout not piped"); - let mut buf = String::new(); - stdout - .read_to_string(&mut buf) - .into_diagnostic() - .wrap_err("failed to retrieve output from process")?; - Ok(buf) -} diff --git a/scripts/audiomenu/uv.lock b/scripts/audiomenu/uv.lock new file mode 100644 index 0000000..1876983 --- /dev/null +++ b/scripts/audiomenu/uv.lock @@ -0,0 +1,34 @@ +version = 1 +requires-python = ">=3.12" + +[[package]] +name = "audiomenu" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "click" }, +] + +[package.metadata] +requires-dist = [{ name = "click", specifier = ">=8.1.7" }] + +[[package]] +name = "click" +version = "8.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "platform_system == 'Windows'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/0f/62ca20172d4f87d93cf89665fbaedcd560ac48b465bd1d92bfc7ea6b0a41/click-8.2.0.tar.gz", hash = "sha256:f5452aeddd9988eefa20f90f05ab66f17fce1ee2a36907fd30b05bbb5953814d", size = 235857 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/58/1f37bf81e3c689cc74ffa42102fa8915b59085f54a6e4a80bc6265c0f6bf/click-8.2.0-py3-none-any.whl", hash = "sha256:6b303f0b2aa85f1cb4e5303078fadcbcd4e476f114fab9b5007005711839325c", size = 102156 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] diff --git a/scripts/default.nix b/scripts/default.nix index 0152c68..5cde95c 100644 --- a/scripts/default.nix +++ b/scripts/default.nix @@ -1,38 +1,7 @@ -{ lib, ... }: let - # Clean the package source leaving only the relevant rust files - cleanRustSrc = - pname: src: - lib.cleanSourceWith { - inherit src; - name = "${pname}-source"; - # Adapted from - # no need to pull in crane for just this - filter = - orig_path: type: - let - path_str = toString orig_path; - base = baseNameOf path_str; - parentDir = baseNameOf (dirOf path_str); - matchesSuffix = lib.any (suffix: lib.hasSuffix suffix base) [ - # Rust sources - ".rs" - # TOML files are often used to configure cargo based tools (e.g. .cargo/config.toml) - ".toml" - ]; - isCargoLock = base == "Cargo.lock"; - # .cargo/config.toml is captured above - isOldStyleCargoConfig = parentDir == ".cargo" && base == "config"; - in - type == "directory" || matchesSuffix || isCargoLock || isOldStyleCargoConfig; - }; - # callPackage but for my rust Packages - callRustPackage = - pkgs: pname: nixSrc: - pkgs.callPackage nixSrc { cleanRustSrc = cleanRustSrc pname; }; packages = pkgs: { jpassmenu = pkgs.callPackage ./jpassmenu/package.nix { }; - audiomenu = callRustPackage pkgs "audiomenu" ./audiomenu/package.nix; + audiomenu = pkgs.callPackage ./audiomenu/package.nix { }; }; in {