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:
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}")
+}