hydra

Terminal replacement for Loopback — virtual audio devices and routing on macOS, from a ratatui TUI.
Log | Files | Refs | README | LICENSE

commit f81577cea054c009d7545b755b1f119e34f55668
parent 95cb7f1878f3ea08e22d39f18fb2989ca4ecc1e4
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date:   Sun, 31 May 2026 11:13:34 -0500

P1: CoreAudio engine — device/app enumeration, per-app tap→monitor, signing

hydra-core gains a macOS CoreAudio layer (gated, FFI in src/ffi):
- hal.rs: device enumeration (uid/name/in+out channels/default) via hand-defined
  HAL property selectors over coreaudio-sys — verified against real hardware
  (Scarlett 18i20, built-ins, even the running Loopback virtual mic).
- process.rs: audio-process enumeration + PID→process-object translation
  (macOS 14.4+ tap targets); live count tracks afplay starting/stopping.
- tap_shim.m + build.rs: an Obj-C shim builds the CATapDescription + private
  aggregate (output sub-device + tap list) + IOProc that copies tapped audio to
  the output with live gain/mute and per-channel peak. coreaudio-sys lacks the
  14.4 tap symbols, so the shim owns that surface against the real SDK.
- shim.rs + engine.rs: MonitorRoute (RAII teardown in the order coreaudiod needs)
  and an Engine the daemon drives.

IPC: ListDevices/ListApps/StartMonitor/StopRoute/SetGain/SetMute + AudioDevice/
AudioApp/RouteSummary(gain,muted,peak). Daemon wires them to the engine.

TUI: two-pane browser (audio apps | live routes), ⏎ to monitor, m/+/-/d controls,
peak meters, 500ms refresh, Navi-themed.

Signing: scripts/bundle.sh builds+signs Hydra.app (Info.plist with
NSAudioCaptureUsageDescription, audio-input entitlement); install-agent.sh runs it
as a GUI LaunchAgent. Required because process taps need a TCC-authorizable identity.

Enumeration + the full control path are verified over the socket. Actual capture
audio is gated on the user approving the one-time Audio Recording prompt, which
needs the signed bundle (a bare cargo binary can't be authorized).

Builds clean (0 warnings), tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Diffstat:
M.gitignore | 1+
MCargo.lock | 178+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCargo.toml | 1+
Mcrates/hydra-core/Cargo.toml | 13++++++++++---
Acrates/hydra-core/build.rs | 18++++++++++++++++++
Acrates/hydra-core/src/engine.rs | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/hydra-core/src/ffi/hal.rs | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/hydra-core/src/ffi/mod.rs | 123+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/hydra-core/src/ffi/process.rs | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/hydra-core/src/ffi/shim.rs | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/hydra-core/src/ffi/tap_shim.m | 214+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/hydra-core/src/lib.rs | 12+++++++++---
Mcrates/hydra-core/src/model.rs | 3+++
Mcrates/hydra-ipc/src/lib.rs | 51++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/hydrad/src/main.rs | 15+++++++--------
Mcrates/hydrad/src/server.rs | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
16 files changed, 1042 insertions(+), 27 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -1,3 +1,4 @@ /target **/*.rs.bk .DS_Store +/dist diff --git a/Cargo.lock b/Cargo.lock @@ -3,12 +3,45 @@ version = 4 [[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] name = "allocator-api2" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn", +] + +[[package]] name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -30,12 +63,42 @@ dependencies = [ ] [[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] name = "compact_str" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -50,6 +113,31 @@ dependencies = [ ] [[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "coreaudio-sys" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6" +dependencies = [ + "bindgen", +] + +[[package]] name = "crossterm" version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -152,6 +240,12 @@ dependencies = [ ] [[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -169,6 +263,12 @@ dependencies = [ ] [[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -208,6 +308,11 @@ dependencies = [ name = "hydra-core" version = "0.1.0" dependencies = [ + "anyhow", + "cc", + "core-foundation", + "core-foundation-sys", + "coreaudio-sys", "dirs", "hydra-ipc", "serde", @@ -292,6 +397,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] name = "libredox" version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -337,6 +452,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] name = "mio" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -349,6 +470,16 @@ dependencies = [ ] [[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -443,6 +574,41 @@ dependencies = [ ] [[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] name = "rustix" version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -526,6 +692,18 @@ dependencies = [ ] [[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] name = "signal-hook" version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -12,3 +12,4 @@ authors = ["Ganten"] serde = { version = "1", features = ["derive"] } serde_json = "1" dirs = "5" +anyhow = "1" diff --git a/crates/hydra-core/Cargo.toml b/crates/hydra-core/Cargo.toml @@ -9,7 +9,14 @@ hydra-ipc = { path = "../hydra-ipc" } serde.workspace = true serde_json.workspace = true dirs.workspace = true +anyhow.workspace = true -# CoreAudio FFI (process taps, aggregate devices, IOProcs) lands in P1. The engine -# modules will be gated behind #[cfg(target_os = "macos")] so the pure model/config -# layer stays portable and unit-testable. +# CoreAudio FFI lives here and only here. Gated to macOS so the pure model/config +# layer stays portable + unit-testable on any host. +[target.'cfg(target_os = "macos")'.dependencies] +coreaudio-sys = "0.2" +core-foundation = "0.10" +core-foundation-sys = "0.8" + +[target.'cfg(target_os = "macos")'.build-dependencies] +cc = "1" diff --git a/crates/hydra-core/build.rs b/crates/hydra-core/build.rs @@ -0,0 +1,18 @@ +//! Compiles the Objective-C CoreAudio tap shim and links the frameworks it needs. +//! macOS-only; a no-op elsewhere so the pure model layer still builds anywhere. + +fn main() { + #[cfg(target_os = "macos")] + { + cc::Build::new() + .file("src/ffi/tap_shim.m") + .flag("-fobjc-arc") + .flag("-Wno-unused-parameter") + .compile("hydra_tap_shim"); + + println!("cargo:rerun-if-changed=src/ffi/tap_shim.m"); + println!("cargo:rustc-link-lib=framework=Foundation"); + println!("cargo:rustc-link-lib=framework=CoreFoundation"); + println!("cargo:rustc-link-lib=framework=CoreAudio"); + } +} diff --git a/crates/hydra-core/src/engine.rs b/crates/hydra-core/src/engine.rs @@ -0,0 +1,86 @@ +//! The live routing engine: owns every active [`MonitorRoute`] and projects them into +//! wire snapshots. The daemon holds one of these behind a `Mutex`. macOS-only. + +use std::collections::HashMap; + +use anyhow::Result; +use hydra_ipc::{RouteSummary, StateSnapshot}; + +use crate::ffi::shim::MonitorRoute; + +struct Entry { + route: MonitorRoute, + /// Human-readable "app → output" label for display. + label: String, +} + +#[derive(Default)] +pub struct Engine { + routes: HashMap<String, Entry>, +} + +impl Engine { + pub fn new() -> Self { + Self::default() + } + + /// Start monitoring `pid` to `output_uid` (default output if `None`). `label` is the + /// display string the TUI/snapshot shows. Returns the new route's id. + pub fn start_monitor( + &mut self, + pid: i32, + output_uid: Option<&str>, + gain: f32, + label: String, + ) -> Result<String> { + let route = MonitorRoute::start(pid, output_uid, gain)?; + let id = route.id().to_string(); + self.routes.insert(id.clone(), Entry { route, label }); + Ok(id) + } + + /// Stop and tear down a route. Returns whether it existed. + pub fn stop(&mut self, id: &str) -> bool { + self.routes.remove(id).is_some() + } + + pub fn set_gain(&mut self, id: &str, gain: f32) -> bool { + match self.routes.get_mut(id) { + Some(e) => { + e.route.set_gain(gain); + true + } + None => false, + } + } + + pub fn set_muted(&mut self, id: &str, muted: bool) -> bool { + match self.routes.get_mut(id) { + Some(e) => { + e.route.set_muted(muted); + true + } + None => false, + } + } + + /// Project current routes into a wire snapshot. Meters are sampled live here. + pub fn snapshot(&self) -> StateSnapshot { + let mut routes: Vec<RouteSummary> = self + .routes + .values() + .map(|e| RouteSummary { + id: e.route.id().to_string(), + target: e.label.clone(), + source_count: 1, + active: true, + gain: e.route.gain(), + muted: e.route.muted(), + peak: e.route.peak(), + }) + .collect(); + routes.sort_by(|a, b| a.id.cmp(&b.id)); + + StateSnapshot { daemon_version: crate::VERSION.to_string(), devices: Vec::new(), routes } + } +} diff --git a/crates/hydra-core/src/ffi/hal.rs b/crates/hydra-core/src/ffi/hal.rs @@ -0,0 +1,75 @@ +//! Device enumeration. Read-only HAL queries — no special entitlement or TCC consent +//! required, so this works the moment the daemon starts. + +use anyhow::Result; +use hydra_ipc::AudioDevice; + +use super::{addr, get_array, get_cfstring, get_scalar, scope, sel, AudioObjectID, ELEMENT_MAIN, SYSTEM_OBJECT}; + +/// The system's current default output device, or `Ok(0)` if none is set. +pub fn default_output_device() -> AudioObjectID { + unsafe { get_scalar(SYSTEM_OBJECT, &addr(sel::DEFAULT_OUTPUT, scope::GLOBAL, ELEMENT_MAIN)).unwrap_or(0) } +} + +/// Enumerate every audio device CoreAudio knows about. +pub fn list_devices() -> Result<Vec<AudioDevice>> { + let default_out = default_output_device(); + let ids: Vec<AudioObjectID> = + unsafe { get_array(SYSTEM_OBJECT, &addr(sel::DEVICES, scope::GLOBAL, ELEMENT_MAIN))? }; + + let mut devices = Vec::with_capacity(ids.len()); + for id in ids { + let name = device_name(id); + let uid = device_uid(id); + devices.push(AudioDevice { + uid, + name, + input_channels: channel_count(id, scope::INPUT), + output_channels: channel_count(id, scope::OUTPUT), + is_default_output: id == default_out, + }); + } + Ok(devices) +} + +/// A device's human-readable name (falls back to a placeholder). +pub fn device_name(id: AudioObjectID) -> String { + unsafe { get_cfstring(id, &addr(sel::NAME, scope::GLOBAL, ELEMENT_MAIN)) } + .unwrap_or_else(|_| format!("device {id}")) +} + +/// A device's persistent UID (stable across reboots; used to re-resolve devices). +pub fn device_uid(id: AudioObjectID) -> String { + unsafe { get_cfstring(id, &addr(sel::DEVICE_UID, scope::GLOBAL, ELEMENT_MAIN)) }.unwrap_or_default() +} + +/// Total channels a device exposes in the given scope (`scope::INPUT` / `scope::OUTPUT`), +/// summed across all its streams. Returns 0 if the device has none. +pub fn channel_count(id: AudioObjectID, scope: u32) -> u32 { + use std::ptr; + let a = addr(sel::STREAM_CONFIG, scope, ELEMENT_MAIN); + unsafe { + let size = match super::data_size(id, &a) { + Ok(s) if s > 0 => s, + _ => return 0, + }; + let mut buf = vec![0u8; size]; + let mut io = size as u32; + let st = coreaudio_sys::AudioObjectGetPropertyData( + id, + &a, + 0, + ptr::null(), + &mut io, + buf.as_mut_ptr() as *mut _, + ); + if st != 0 { + return 0; + } + // Reinterpret the bytes as an AudioBufferList and sum each buffer's channels. + let abl = buf.as_ptr() as *const coreaudio_sys::AudioBufferList; + let n = (*abl).mNumberBuffers as usize; + let first = (*abl).mBuffers.as_ptr(); + (0..n).map(|i| (*first.add(i)).mNumberChannels).sum() + } +} diff --git a/crates/hydra-core/src/ffi/mod.rs b/crates/hydra-core/src/ffi/mod.rs @@ -0,0 +1,123 @@ +//! Thin, safe-ish wrappers over the CoreAudio HAL C API. +//! +//! We lean on `coreaudio-sys` for the stable base types/functions and `core-foundation` +//! for CFString/CFDictionary, but spell out the property selectors ourselves as +//! four-char-codes — that keeps us independent of whatever the pinned SDK happens to +//! export, which matters for the macOS 14.4+ process-tap selectors. + +pub mod hal; +pub mod process; +pub mod shim; + +use anyhow::{bail, Result}; +use coreaudio_sys as sys; +use std::mem; +use std::ptr; + +pub use sys::AudioObjectID; + +/// The well-known system object that owns the device/process lists. +pub const SYSTEM_OBJECT: AudioObjectID = sys::kAudioObjectSystemObject as AudioObjectID; + +/// Build a four-char-code constant the way CoreAudio defines its selectors. +pub const fn fourcc(s: &[u8; 4]) -> u32 { + ((s[0] as u32) << 24) | ((s[1] as u32) << 16) | ((s[2] as u32) << 8) | (s[3] as u32) +} + +/// Property selectors (verified four-char-codes from `AudioHardware.h`). +pub mod sel { + use super::fourcc; + pub const DEVICES: u32 = fourcc(b"dev#"); + pub const DEFAULT_OUTPUT: u32 = fourcc(b"dOut"); + pub const DEFAULT_INPUT: u32 = fourcc(b"dIn "); + pub const TRANSLATE_UID_TO_DEVICE: u32 = fourcc(b"uidd"); + pub const DEVICE_UID: u32 = fourcc(b"uid "); + pub const NAME: u32 = fourcc(b"lnam"); + pub const STREAM_CONFIG: u32 = fourcc(b"slay"); + pub const NOMINAL_SAMPLE_RATE: u32 = fourcc(b"nsrt"); + // Process taps (macOS 14.4+). + pub const PROCESS_LIST: u32 = fourcc(b"prs#"); + pub const TRANSLATE_PID_TO_PROCESS: u32 = fourcc(b"id2p"); + pub const PROCESS_PID: u32 = fourcc(b"ppid"); + pub const PROCESS_BUNDLE_ID: u32 = fourcc(b"pbid"); +} + +/// Property scopes. +pub mod scope { + use super::fourcc; + pub const GLOBAL: u32 = fourcc(b"glob"); + pub const INPUT: u32 = fourcc(b"inpt"); + pub const OUTPUT: u32 = fourcc(b"outp"); +} + +/// The "main"/master element. +pub const ELEMENT_MAIN: u32 = 0; + +/// Construct a property address (the most common shape: global scope, main element). +pub fn addr(selector: u32, scope: u32, element: u32) -> sys::AudioObjectPropertyAddress { + sys::AudioObjectPropertyAddress { mSelector: selector, mScope: scope, mElement: element } +} + +/// Byte size CoreAudio reports for a property. +/// +/// # Safety +/// `obj` must be a valid AudioObjectID and `a` a valid address. +pub unsafe fn data_size(obj: AudioObjectID, a: &sys::AudioObjectPropertyAddress) -> Result<usize> { + let mut size: u32 = 0; + let st = sys::AudioObjectGetPropertyDataSize(obj, a, 0, ptr::null(), &mut size); + if st != 0 { + bail!("AudioObjectGetPropertyDataSize(sel={:#x}) -> OSStatus {st}", a.mSelector); + } + Ok(size as usize) +} + +/// Read a property whose value is an array of POD `T` (e.g. a list of object IDs). +/// +/// # Safety +/// `T` must be a plain-old-data type matching the property's element layout. +pub unsafe fn get_array<T: Copy>(obj: AudioObjectID, a: &sys::AudioObjectPropertyAddress) -> Result<Vec<T>> { + let size = data_size(obj, a)?; + let count = size / mem::size_of::<T>(); + let mut buf: Vec<T> = Vec::with_capacity(count); + let mut io = size as u32; + let st = sys::AudioObjectGetPropertyData(obj, a, 0, ptr::null(), &mut io, buf.as_mut_ptr() as *mut _); + if st != 0 { + bail!("AudioObjectGetPropertyData(sel={:#x}) -> OSStatus {st}", a.mSelector); + } + buf.set_len(io as usize / mem::size_of::<T>()); + Ok(buf) +} + +/// Read a fixed-size scalar property (e.g. a `u32`, `f64`, or an `AudioObjectID`). +/// +/// # Safety +/// `T` must match the property's exact value layout. +pub unsafe fn get_scalar<T: Copy>(obj: AudioObjectID, a: &sys::AudioObjectPropertyAddress) -> Result<T> { + let mut val: mem::MaybeUninit<T> = mem::MaybeUninit::zeroed(); + let mut size = mem::size_of::<T>() as u32; + let st = sys::AudioObjectGetPropertyData(obj, a, 0, ptr::null(), &mut size, val.as_mut_ptr() as *mut _); + if st != 0 { + bail!("AudioObjectGetPropertyData(sel={:#x}) -> OSStatus {st}", a.mSelector); + } + Ok(val.assume_init()) +} + +/// Read a CFString property and return it as an owned Rust `String`. +/// +/// # Safety +/// `a` must address a property whose value is a `CFStringRef`. +pub unsafe fn get_cfstring(obj: AudioObjectID, a: &sys::AudioObjectPropertyAddress) -> Result<String> { + use core_foundation::base::TCFType; + use core_foundation::string::CFString; + use core_foundation_sys::string::CFStringRef; + + let mut s: CFStringRef = ptr::null(); + let mut size = mem::size_of::<CFStringRef>() as u32; + let st = sys::AudioObjectGetPropertyData(obj, a, 0, ptr::null(), &mut size, &mut s as *mut _ as *mut _); + if st != 0 || s.is_null() { + bail!("AudioObjectGetPropertyData(cfstring, sel={:#x}) -> OSStatus {st}", a.mSelector); + } + // CoreAudio follows the create rule for returned CFStrings: we own this reference. + let cf = CFString::wrap_under_create_rule(s); + Ok(cf.to_string()) +} diff --git a/crates/hydra-core/src/ffi/process.rs b/crates/hydra-core/src/ffi/process.rs @@ -0,0 +1,67 @@ +//! Process enumeration — the candidate capture targets for per-app routing. +//! +//! CoreAudio exposes a "process object" per app that has touched audio. Reading the +//! list and each process's PID/bundle-id needs no capture permission; only *tapping* +//! one (P1's engine) requires TCC consent. + +use anyhow::Result; +use hydra_ipc::AudioApp; + +use super::{addr, get_array, get_cfstring, get_scalar, scope, sel, AudioObjectID, ELEMENT_MAIN, SYSTEM_OBJECT}; + +/// Enumerate processes registered with CoreAudio as audio producers. +/// +/// Returns an empty list (not an error) on macOS older than 14.4, where the process +/// object list selector doesn't exist. +pub fn list_audio_processes() -> Vec<AudioApp> { + let ids: Vec<AudioObjectID> = + match unsafe { get_array(SYSTEM_OBJECT, &addr(sel::PROCESS_LIST, scope::GLOBAL, ELEMENT_MAIN)) } { + Ok(ids) => ids, + Err(_) => return Vec::new(), + }; + + let mut apps = Vec::with_capacity(ids.len()); + for id in ids { + let pid: i32 = + unsafe { get_scalar(id, &addr(sel::PROCESS_PID, scope::GLOBAL, ELEMENT_MAIN)).unwrap_or(-1) }; + let bundle_id = unsafe { get_cfstring(id, &addr(sel::PROCESS_BUNDLE_ID, scope::GLOBAL, ELEMENT_MAIN)) } + .ok() + .filter(|s| !s.is_empty()); + let name = friendly_name(pid, bundle_id.as_deref()); + apps.push(AudioApp { pid, name, bundle_id }); + } + apps +} + +/// Resolve a PID to its CoreAudio process object (needed before creating a tap). +/// +/// Uses `kAudioHardwarePropertyTranslatePIDToProcessObject`, which takes the PID as +/// qualifier data and returns the matching object ID. +pub fn process_object_for_pid(pid: i32) -> Result<AudioObjectID> { + let a = addr(sel::TRANSLATE_PID_TO_PROCESS, scope::GLOBAL, ELEMENT_MAIN); + let mut obj: AudioObjectID = 0; + let mut io = std::mem::size_of::<AudioObjectID>() as u32; + let mut pid_q = pid; + let st = unsafe { + coreaudio_sys::AudioObjectGetPropertyData( + SYSTEM_OBJECT, + &a, + std::mem::size_of::<i32>() as u32, + &mut pid_q as *mut _ as *const _, + &mut io, + &mut obj as *mut _ as *mut _, + ) + }; + if st != 0 || obj == 0 { + anyhow::bail!("translate pid {pid} -> process object failed (OSStatus {st})"); + } + Ok(obj) +} + +/// Best-effort display name: bundle-id tail if present, else `pid N`. +fn friendly_name(pid: i32, bundle_id: Option<&str>) -> String { + match bundle_id { + Some(b) => b.rsplit('.').next().filter(|s| !s.is_empty()).unwrap_or(b).to_string(), + None => format!("pid {pid}"), + } +} diff --git a/crates/hydra-core/src/ffi/shim.rs b/crates/hydra-core/src/ffi/shim.rs @@ -0,0 +1,135 @@ +//! Safe Rust wrapper over the Objective-C tap shim (`tap_shim.m`). +//! +//! [`MonitorRoute`] owns a live monitor: a process tap feeding a private aggregate +//! device whose IOProc copies the tapped audio to a hardware output. Dropping it tears +//! the CoreAudio objects down in the correct order. + +use std::ffi::{c_void, CString}; +use std::os::raw::c_char; +use std::ptr; +use std::sync::atomic::{AtomicU64, Ordering}; + +use anyhow::{bail, Result}; + +use super::process::process_object_for_pid; +use super::AudioObjectID; + +/// Shared realtime parameters. Layout must match `HydraParams` in `tap_shim.m`. +#[repr(C)] +struct HydraParams { + gain: f32, + muted: i32, + peak: [f32; 8], + running: i32, +} + +/// Opaque-to-Rust handle the shim fills in. Layout must match `HydraRoute`. +#[repr(C)] +struct HydraRoute { + tap: AudioObjectID, + aggregate: AudioObjectID, + ioproc: *mut c_void, + params: *mut HydraParams, +} + +extern "C" { + fn hydra_monitor_start( + proc_objs: *const AudioObjectID, + n_procs: i32, + output_uid: *const c_char, + params: *mut HydraParams, + out_route: *mut HydraRoute, + ) -> i32; + fn hydra_monitor_stop(route: *mut HydraRoute); +} + +static NEXT_ID: AtomicU64 = AtomicU64::new(1); + +/// A running monitor route. Tearing down on `Drop` is what keeps coreaudiod from wedging. +pub struct MonitorRoute { + id: String, + pid: i32, + raw: HydraRoute, + /// Heap-stable params the C IOProc reads; we keep it alive for the route's lifetime. + params: Box<HydraParams>, +} + +// The CoreAudio handles are just IDs; the only pointer is into our own boxed params, +// which outlives any access. Safe to move between threads (the daemon holds it behind a Mutex). +unsafe impl Send for MonitorRoute {} + +impl MonitorRoute { + /// Tap `pid` and monitor it to `output_uid` (or the default output if `None`). + pub fn start(pid: i32, output_uid: Option<&str>, gain: f32) -> Result<Self> { + let proc_obj = process_object_for_pid(pid)?; + let mut params = Box::new(HydraParams { gain, muted: 0, peak: [0.0; 8], running: 0 }); + let mut raw = HydraRoute { tap: 0, aggregate: 0, ioproc: ptr::null_mut(), params: ptr::null_mut() }; + + let c_uid = output_uid.map(|s| CString::new(s)).transpose()?; + let uid_ptr = c_uid.as_ref().map_or(ptr::null(), |c| c.as_ptr()); + let procs = [proc_obj]; + + let st = unsafe { + hydra_monitor_start(procs.as_ptr(), 1, uid_ptr, params.as_mut() as *mut _, &mut raw) + }; + if st != 0 { + bail!("starting monitor for pid {pid} failed: {}", os_status(st)); + } + + Ok(Self { id: format!("r{}", NEXT_ID.fetch_add(1, Ordering::Relaxed)), pid, raw, params }) + } + + pub fn id(&self) -> &str { + &self.id + } + + pub fn pid(&self) -> i32 { + self.pid + } + + pub fn set_gain(&mut self, gain: f32) { + unsafe { ptr::write_volatile(&mut self.params.gain, gain) }; + } + + pub fn set_muted(&mut self, muted: bool) { + unsafe { ptr::write_volatile(&mut self.params.muted, muted as i32) }; + } + + pub fn gain(&self) -> f32 { + unsafe { ptr::read_volatile(&self.params.gain) } + } + + pub fn muted(&self) -> bool { + unsafe { ptr::read_volatile(&self.params.muted) != 0 } + } + + /// Highest per-channel peak written by the last IOProc callback (0.0..~1.0). + pub fn peak(&self) -> f32 { + let mut m = 0.0f32; + for i in 0..2 { + let p = unsafe { ptr::read_volatile(&self.params.peak[i]) }; + if p > m { + m = p; + } + } + m + } +} + +impl Drop for MonitorRoute { + fn drop(&mut self) { + unsafe { hydra_monitor_stop(&mut self.raw) }; + // `params` Box is freed here, after the IOProc is guaranteed gone. + } +} + +/// Render an OSStatus as a four-char-code if printable, else a number — CoreAudio errors +/// are usually FourCCs (e.g. `!pri` = no permission, `!dev` = bad device). +fn os_status(st: i32) -> String { + let b = (st as u32).to_be_bytes(); + if b.iter().all(|&c| c.is_ascii_graphic() || c == b' ') { + format!("'{}{}{}{}' ({st})", b[0] as char, b[1] as char, b[2] as char, b[3] as char) + } else { + st.to_string() + } +} diff --git a/crates/hydra-core/src/ffi/tap_shim.m b/crates/hydra-core/src/ffi/tap_shim.m @@ -0,0 +1,214 @@ +// Hydra CoreAudio tap shim. +// +// The process-tap API (macOS 14.4+) centers on CATapDescription, an Objective-C class +// that the Rust `coreaudio-sys` bindings don't cover. Rather than reconstruct it through +// the obj-c runtime from Rust, we build the whole monitor route here against the real SDK +// headers — guaranteeing correct API usage — and expose a tiny C surface to Rust. +// +// A "monitor route" = tap one or more processes, fold the tap into a private aggregate +// device that also contains a hardware output, and run an IOProc that copies the tapped +// audio to that output with a live gain/mute. Per-buffer peak is written back for meters. +// +// Realtime contract: the IOProc reads `gain`/`muted` and writes `peak[]` on the audio +// thread. Rust owns the HydraParams allocation and accesses the same fields with volatile +// loads/stores. These are word-sized scalars where a torn read costs at most one stale +// buffer of gain — acceptable for a control parameter, and lock-free by construction. + +#import <Foundation/Foundation.h> +#import <CoreAudio/CoreAudio.h> +#import <CoreAudio/CATapDescription.h> +#import <math.h> + +// The process-tap entry points (macOS 14.4+) aren't pulled in by the CoreAudio umbrella +// header on every SDK, so declare them explicitly. Stable C ABI; resolved at link time +// against the CoreAudio framework. +extern OSStatus AudioHardwareCreateProcessTap(CATapDescription *inDescription, AudioObjectID *outTapID); +extern OSStatus AudioHardwareDestroyProcessTap(AudioObjectID inTapID); + +typedef struct { + float gain; // linear, read by IOProc + int muted; // 0/1, read by IOProc + float peak[8]; // per-channel peak, written by IOProc + int running; // 1 while the IOProc is installed +} HydraParams; + +typedef struct { + AudioObjectID tap; + AudioObjectID aggregate; + AudioDeviceIOProcID ioproc; + HydraParams *params; +} HydraRoute; + +static AudioObjectID hydra_default_output(void) { + AudioObjectID dev = 0; + UInt32 sz = sizeof(dev); + AudioObjectPropertyAddress a = { + kAudioHardwarePropertyDefaultOutputDevice, + kAudioObjectPropertyScopeGlobal, + kAudioObjectPropertyElementMain + }; + AudioObjectGetPropertyData(kAudioObjectSystemObject, &a, 0, NULL, &sz, &dev); + return dev; +} + +static AudioObjectID hydra_device_for_uid(const char *uid) { + CFStringRef cf = CFStringCreateWithCString(NULL, uid, kCFStringEncodingUTF8); + AudioObjectID dev = kAudioObjectUnknown; + UInt32 sz = sizeof(dev); + AudioObjectPropertyAddress a = { + kAudioHardwarePropertyTranslateUIDToDevice, + kAudioObjectPropertyScopeGlobal, + kAudioObjectPropertyElementMain + }; + AudioObjectGetPropertyData(kAudioObjectSystemObject, &a, sizeof(cf), &cf, &sz, &dev); + CFRelease(cf); + return dev; +} + +// Returns a +1 retained NSString (caller owns); nil on failure. +static NSString *hydra_device_uid(AudioObjectID dev) { + CFStringRef uid = NULL; + UInt32 sz = sizeof(uid); + AudioObjectPropertyAddress a = { + kAudioDevicePropertyDeviceUID, + kAudioObjectPropertyScopeGlobal, + kAudioObjectPropertyElementMain + }; + if (AudioObjectGetPropertyData(dev, &a, 0, NULL, &sz, &uid) != noErr || uid == NULL) { + return nil; + } + return (__bridge_transfer NSString *)uid; +} + +// Build tap + aggregate + IOProc and start it. Returns an OSStatus (noErr on success). +OSStatus hydra_monitor_start(const AudioObjectID *procObjs, + int nProcs, + const char *outputUID, // nullable -> default output + HydraParams *params, + HydraRoute *outRoute) { + if (!procObjs || nProcs <= 0 || !params || !outRoute) { + return kAudio_ParamError; + } + @autoreleasepool { + // 1. Tap description over the requested processes (stereo mixdown). + NSMutableArray<NSNumber *> *procs = [NSMutableArray arrayWithCapacity:nProcs]; + for (int i = 0; i < nProcs; i++) { + [procs addObject:@(procObjs[i])]; + } + CATapDescription *desc = [[CATapDescription alloc] initStereoMixdownOfProcesses:procs]; + desc.name = @"Hydra Tap"; + desc.UUID = [NSUUID UUID]; + desc.muteBehavior = CATapUnmuted; // keep the app audible while we tap it + + // 2. Create the process tap. + AudioObjectID tapID = 0; + OSStatus st = AudioHardwareCreateProcessTap(desc, &tapID); + if (st != noErr || tapID == 0) { + return st != noErr ? st : kAudioHardwareUnspecifiedError; + } + + // 3. Resolve the output device + its UID for the aggregate's sub-device list. + AudioObjectID outDev = (outputUID && outputUID[0]) ? hydra_device_for_uid(outputUID) + : hydra_default_output(); + NSString *outUID = hydra_device_uid(outDev); + if (outUID == nil) { + AudioHardwareDestroyProcessTap(tapID); + return kAudioHardwareBadDeviceError; + } + + // 4. Private aggregate: hardware output as sub-device, tap folded in as input. + // The aggregate dictionary keys are C-string #defines, so box them with @(...). + NSString *aggUID = [[NSUUID UUID] UUIDString]; + NSDictionary *description = @{ + @(kAudioAggregateDeviceNameKey): @"Hydra Monitor", + @(kAudioAggregateDeviceUIDKey): aggUID, + @(kAudioAggregateDeviceIsPrivateKey): @YES, + @(kAudioAggregateDeviceMainSubDeviceKey): outUID, + @(kAudioAggregateDeviceSubDeviceListKey): @[ + @{ @(kAudioSubDeviceUIDKey): outUID } + ], + @(kAudioAggregateDeviceTapListKey): @[ + @{ @(kAudioSubTapUIDKey): desc.UUID.UUIDString } + ], + @(kAudioAggregateDeviceTapAutoStartKey): @YES, + }; + + AudioObjectID agg = 0; + st = AudioHardwareCreateAggregateDevice((__bridge CFDictionaryRef)description, &agg); + if (st != noErr || agg == 0) { + AudioHardwareDestroyProcessTap(tapID); + return st != noErr ? st : kAudioHardwareUnspecifiedError; + } + + // 5. IOProc: copy tap input -> device output with live gain/mute, write peaks. + HydraParams *P = params; + AudioDeviceIOProcID procID = NULL; + st = AudioDeviceCreateIOProcIDWithBlock(&procID, agg, NULL, + ^(const AudioTimeStamp *now, const AudioBufferList *inData, + const AudioTimeStamp *inTime, AudioBufferList *outData, + const AudioTimeStamp *outTime) { + (void)now; (void)inTime; (void)outTime; + const float g = P->muted ? 0.0f : P->gain; + const UInt32 nin = inData ? inData->mNumberBuffers : 0; + const UInt32 nout = outData ? outData->mNumberBuffers : 0; + for (UInt32 ob = 0; ob < nout; ob++) { + float *out = (float *)outData->mBuffers[ob].mData; + UInt32 outN = outData->mBuffers[ob].mDataByteSize / sizeof(float); + if (!out) continue; + if (ob < nin && inData->mBuffers[ob].mData) { + const float *in = (const float *)inData->mBuffers[ob].mData; + UInt32 inN = inData->mBuffers[ob].mDataByteSize / sizeof(float); + UInt32 n = outN < inN ? outN : inN; + float peak = 0.0f; + for (UInt32 i = 0; i < n; i++) { + float v = in[i] * g; + out[i] = v; + float av = fabsf(v); + if (av > peak) peak = av; + } + for (UInt32 i = n; i < outN; i++) out[i] = 0.0f; + if (ob < 8) P->peak[ob] = peak; + } else { + memset(out, 0, outData->mBuffers[ob].mDataByteSize); + if (ob < 8) P->peak[ob] = 0.0f; + } + } + }); + if (st != noErr || procID == NULL) { + AudioHardwareDestroyAggregateDevice(agg); + AudioHardwareDestroyProcessTap(tapID); + return st != noErr ? st : kAudioHardwareUnspecifiedError; + } + + st = AudioDeviceStart(agg, procID); + if (st != noErr) { + AudioDeviceDestroyIOProcID(agg, procID); + AudioHardwareDestroyAggregateDevice(agg); + AudioHardwareDestroyProcessTap(tapID); + return st; + } + + P->running = 1; + outRoute->tap = tapID; + outRoute->aggregate = agg; + outRoute->ioproc = procID; + outRoute->params = P; + return noErr; + } +} + +// Tear down in the order that keeps coreaudiod happy: stop IO, remove the IOProc, destroy +// the aggregate (which references the tap), then destroy the tap. +void hydra_monitor_stop(HydraRoute *r) { + if (!r) return; + if (r->aggregate && r->ioproc) { + AudioDeviceStop(r->aggregate, r->ioproc); + AudioDeviceDestroyIOProcID(r->aggregate, r->ioproc); + } + if (r->aggregate) AudioHardwareDestroyAggregateDevice(r->aggregate); + if (r->tap) AudioHardwareDestroyProcessTap(r->tap); + if (r->params) r->params->running = 0; + r->tap = 0; + r->aggregate = 0; + r->ioproc = NULL; +} diff --git a/crates/hydra-core/src/lib.rs b/crates/hydra-core/src/lib.rs @@ -1,10 +1,16 @@ -//! Hydra's routing core: the domain model, config persistence, and (from P1 onward) -//! the CoreAudio engine. Only the daemon (`hydrad`) links this crate; the TUI talks to -//! the daemon over [`hydra_ipc`] and never touches CoreAudio. +//! Hydra's routing core: the domain model, config persistence, and the CoreAudio +//! engine. Only the daemon (`hydrad`) links this crate; the TUI talks to the daemon +//! over [`hydra_ipc`] and never touches CoreAudio. pub mod model; pub use model::RoutingState; +/// CoreAudio FFI + the live routing engine. macOS-only; the model layer stays portable. +#[cfg(target_os = "macos")] +pub mod ffi; +#[cfg(target_os = "macos")] +pub mod engine; + /// Daemon/engine version, surfaced to clients in snapshots. pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/crates/hydra-core/src/model.rs b/crates/hydra-core/src/model.rs @@ -48,6 +48,9 @@ impl RoutingState { target: r.target_device_uid.clone(), source_count: 0, active: r.active, + gain: 1.0, + muted: false, + peak: 0.0, }) .collect(), } diff --git a/crates/hydra-ipc/src/lib.rs b/crates/hydra-ipc/src/lib.rs @@ -36,6 +36,19 @@ pub enum Command { Ping, /// Fetch the current routing snapshot. GetState, + /// Enumerate the host's audio devices (hardware + virtual). + ListDevices, + /// Enumerate processes currently producing audio (capture targets). + ListApps, + /// Start monitoring one process's audio to an output device (P1). + /// `output_uid = None` means the system default output. + StartMonitor { pid: i32, output_uid: Option<String>, gain: f32 }, + /// Tear down a route by id. + StopRoute { id: String }, + /// Live-adjust a route's gain (linear, 0.0..~2.0). + SetGain { id: String, gain: f32 }, + /// Live mute/unmute a route. + SetMute { id: String, muted: bool }, /// Opt this connection into the server-push event stream (state deltas, meters). Subscribe, /// Ask the daemon to exit. @@ -47,10 +60,32 @@ pub enum Command { pub enum Response { Pong { version: u32 }, State(StateSnapshot), + Devices(Vec<AudioDevice>), + Apps(Vec<AudioApp>), + /// A route was created; carries its assigned id. + RouteStarted { id: String }, Ok, Error(String), } +/// A host audio device (hardware or virtual), as seen by CoreAudio. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AudioDevice { + pub uid: String, + pub name: String, + pub input_channels: u32, + pub output_channels: u32, + pub is_default_output: bool, +} + +/// A process currently registered with CoreAudio as an audio producer. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AudioApp { + pub pid: i32, + pub name: String, + pub bundle_id: Option<String>, +} + /// Server-initiated push messages (only sent after [`Command::Subscribe`]). #[derive(Debug, Clone, Serialize, Deserialize)] pub enum Event { @@ -77,9 +112,15 @@ pub struct DeviceSummary { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RouteSummary { pub id: String, + /// Display label, e.g. "Swinsian → Scarlett 18i20". pub target: String, pub source_count: usize, pub active: bool, + /// Linear gain (1.0 = unity). + pub gain: f32, + pub muted: bool, + /// Most recent peak level (0.0..~1.0) for metering. + pub peak: f32, } // ── NDJSON framing ──────────────────────────────────────────────────────────── @@ -128,7 +169,15 @@ mod tests { let snap = StateSnapshot { daemon_version: "0.1.0".into(), devices: vec![DeviceSummary { uid: "hydra:main".into(), name: "Main".into(), channels: 16 }], - routes: vec![RouteSummary { id: "r1".into(), target: "hydra:main".into(), source_count: 2, active: true }], + routes: vec![RouteSummary { + id: "r1".into(), + target: "hydra:main".into(), + source_count: 2, + active: true, + gain: 1.0, + muted: false, + peak: 0.0, + }], }; let mut buf = Vec::new(); write_msg(&mut buf, &Response::State(snap)).unwrap(); diff --git a/crates/hydrad/src/main.rs b/crates/hydrad/src/main.rs @@ -1,7 +1,6 @@ -//! `hydrad` — the Hydra daemon. It owns all CoreAudio state (from P1) and is the only -//! process that touches the audio engine. Clients connect over a Unix-domain socket. -//! -//! P0 scope: bind the socket, answer `Ping`/`GetState`, hold an (empty) routing state. +//! `hydrad` — the Hydra daemon. It owns all CoreAudio state (taps, aggregate devices, +//! IOProcs) and is the only process that touches the audio engine. Clients connect over +//! a Unix-domain socket. mod server; @@ -11,7 +10,7 @@ use std::os::unix::fs::PermissionsExt; use std::os::unix::net::UnixListener; use std::sync::{Arc, Mutex}; -use hydra_core::RoutingState; +use hydra_core::engine::Engine; fn main() -> Result<(), Box<dyn Error>> { let sock = hydra_ipc::socket_path(); @@ -25,14 +24,14 @@ fn main() -> Result<(), Box<dyn Error>> { fs::set_permissions(&sock, fs::Permissions::from_mode(0o600))?; eprintln!("hydrad {} listening on {}", hydra_core::VERSION, sock.display()); - let state = Arc::new(Mutex::new(RoutingState::default())); + let engine = Arc::new(Mutex::new(Engine::new())); for conn in listener.incoming() { match conn { Ok(stream) => { - let state = Arc::clone(&state); + let engine = Arc::clone(&engine); std::thread::spawn(move || { - if let Err(e) = server::handle(stream, state) { + if let Err(e) = server::handle(stream, engine) { eprintln!("connection error: {e}"); } }); diff --git a/crates/hydrad/src/server.rs b/crates/hydrad/src/server.rs @@ -6,26 +6,79 @@ use std::io::{BufReader, BufWriter}; use std::os::unix::net::UnixStream; use std::sync::{Arc, Mutex}; -use hydra_core::RoutingState; +use hydra_core::engine::Engine; +use hydra_core::ffi::{hal, process}; use hydra_ipc::{read_msg, write_msg, Command, Response, PROTOCOL_VERSION}; -pub fn handle(stream: UnixStream, state: Arc<Mutex<RoutingState>>) -> Result<(), Box<dyn Error>> { +type SharedEngine = Arc<Mutex<Engine>>; + +pub fn handle(stream: UnixStream, engine: SharedEngine) -> Result<(), Box<dyn Error>> { let mut reader = BufReader::new(stream.try_clone()?); let mut writer = BufWriter::new(stream); while let Some(cmd) = read_msg::<_, Command>(&mut reader)? { - let resp = match cmd { - Command::Ping => Response::Pong { version: PROTOCOL_VERSION }, - Command::GetState => Response::State(state.lock().unwrap().snapshot()), - // Server-push subscription lands in P4; acknowledge for now. - Command::Subscribe => Response::Ok, - Command::Shutdown => { - write_msg(&mut writer, &Response::Ok)?; - std::process::exit(0); - } - }; + let resp = dispatch(cmd, &engine, &mut writer)?; write_msg(&mut writer, &resp)?; } Ok(()) } + +fn dispatch( + cmd: Command, + engine: &SharedEngine, + writer: &mut BufWriter<UnixStream>, +) -> Result<Response, Box<dyn Error>> { + Ok(match cmd { + Command::Ping => Response::Pong { version: PROTOCOL_VERSION }, + Command::GetState => Response::State(engine.lock().unwrap().snapshot()), + Command::ListDevices => match hal::list_devices() { + Ok(devices) => Response::Devices(devices), + Err(e) => Response::Error(format!("device enumeration failed: {e}")), + }, + Command::ListApps => Response::Apps(process::list_audio_processes()), + Command::StartMonitor { pid, output_uid, gain } => { + let label = route_label(pid, output_uid.as_deref()); + match engine.lock().unwrap().start_monitor(pid, output_uid.as_deref(), gain, label) { + Ok(id) => Response::RouteStarted { id }, + Err(e) => Response::Error(e.to_string()), + } + } + Command::StopRoute { id } => ok_or_missing(engine.lock().unwrap().stop(&id), &id), + Command::SetGain { id, gain } => ok_or_missing(engine.lock().unwrap().set_gain(&id, gain), &id), + Command::SetMute { id, muted } => ok_or_missing(engine.lock().unwrap().set_muted(&id, muted), &id), + // Server-push subscription lands in P4; acknowledge for now. + Command::Subscribe => Response::Ok, + Command::Shutdown => { + write_msg(writer, &Response::Ok)?; + std::process::exit(0); + } + }) +} + +fn ok_or_missing(found: bool, id: &str) -> Response { + if found { + Response::Ok + } else { + Response::Error(format!("no such route: {id}")) + } +} + +/// Build the "app → output" display label for a new route. +fn route_label(pid: i32, output_uid: Option<&str>) -> String { + let app = process::list_audio_processes() + .into_iter() + .find(|a| a.pid == pid) + .map(|a| a.name) + .unwrap_or_else(|| format!("pid {pid}")); + + let output = match output_uid { + Some(uid) => hal::list_devices() + .ok() + .and_then(|ds| ds.into_iter().find(|d| d.uid == uid).map(|d| d.name)) + .unwrap_or_else(|| uid.to_string()), + None => "Default Output".to_string(), + }; + + format!("{app} → {output}") +}