hydra

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

lib.rs (8971B)


      1 //! Shared wire protocol for Hydra: the only crate both the daemon (`hydrad`) and the
      2 //! TUI/query binary (`hydra`) depend on. Deliberately has **zero** macOS / CoreAudio
      3 //! dependencies so the TUI can never accidentally link the audio engine.
      4 //!
      5 //! Transport is newline-delimited JSON (NDJSON) over a Unix-domain socket: one serde
      6 //! value per line, hand-debuggable with `nc`.
      7 
      8 use serde::{Deserialize, Serialize};
      9 use std::io::{self, BufRead, Write};
     10 use std::path::PathBuf;
     11 
     12 /// Bumped when the wire format changes incompatibly.
     13 pub const PROTOCOL_VERSION: u32 = 1;
     14 
     15 /// `~/Library/Application Support/hydra` on macOS. The daemon keeps its socket, config,
     16 /// and theme here. Falls back to `/tmp/hydra` if the data dir can't be resolved.
     17 pub fn runtime_dir() -> PathBuf {
     18     let mut p = dirs::data_dir().unwrap_or_else(|| PathBuf::from("/tmp"));
     19     p.push("hydra");
     20     p
     21 }
     22 
     23 /// Path to the daemon's control socket.
     24 pub fn socket_path() -> PathBuf {
     25     let mut p = runtime_dir();
     26     p.push("hydrad.sock");
     27     p
     28 }
     29 
     30 // ── Request / response ────────────────────────────────────────────────────────
     31 
     32 /// A request from a client (TUI or `hydra query`) to the daemon.
     33 #[derive(Debug, Clone, Serialize, Deserialize)]
     34 pub enum Command {
     35     /// Liveness check.
     36     Ping,
     37     /// Fetch the current routing snapshot.
     38     GetState,
     39     /// Enumerate the host's audio devices (hardware + virtual).
     40     ListDevices,
     41     /// Enumerate processes currently producing audio (capture targets).
     42     ListApps,
     43     /// Start monitoring one process's audio to an output device (P1).
     44     /// `output_uid = None` means the system default output.
     45     StartMonitor { pid: i32, output_uid: Option<String>, gain: f32 },
     46     /// Combine several processes' audio into a single route to one output (the Loopback
     47     /// "combine sources" feature). `output_uid = None` means the system default output.
     48     StartCombined { pids: Vec<i32>, output_uid: Option<String>, gain: f32 },
     49     /// Route a hardware INPUT device (mic / line-in, by UID) to an output. No tap/TCC.
     50     StartInput { input_uid: String, output_uid: Option<String>, gain: f32 },
     51     /// Tear down a route by id.
     52     StopRoute { id: String },
     53     /// Live-adjust a route's gain (linear, 0.0..~2.0).
     54     SetGain { id: String, gain: f32 },
     55     /// Live mute/unmute a route.
     56     SetMute { id: String, muted: bool },
     57     /// Rename the Hydra virtual device. Writes the driver manifest
     58     /// (/Library/Application Support/hydra/devices.json); the new name takes effect on the
     59     /// next coreaudiod restart (the driver reads the manifest at load).
     60     SetDeviceName { name: String },
     61     /// List saved preset names.
     62     ListPresets,
     63     /// Save the current routing setup as a named preset (replaces one of the same name).
     64     SavePreset { name: String },
     65     /// Replace all live routes with the named preset's routes. Apps not currently running
     66     /// are skipped (same as restore-on-startup).
     67     ApplyPreset { name: String },
     68     /// Delete a saved preset by name.
     69     DeletePreset { name: String },
     70     /// Start recording a route's audio to a WAV file. `path = None` ⇒ daemon picks a
     71     /// timestamped file in ~/Music/Hydra.
     72     StartRecording { id: String, path: Option<String> },
     73     /// Stop recording a route; reply carries the file path + duration.
     74     StopRecording { id: String },
     75     /// Opt this connection into the server-push event stream (state deltas, meters).
     76     Subscribe,
     77     /// Ask the daemon to exit.
     78     Shutdown,
     79 }
     80 
     81 /// The daemon's reply to a [`Command`].
     82 #[derive(Debug, Clone, Serialize, Deserialize)]
     83 pub enum Response {
     84     Pong { version: u32 },
     85     State(StateSnapshot),
     86     Devices(Vec<AudioDevice>),
     87     Apps(Vec<AudioApp>),
     88     /// A route was created; carries its assigned id.
     89     RouteStarted { id: String },
     90     /// Saved preset names.
     91     Presets(Vec<String>),
     92     /// A preset was applied; carries how many of its routes were (re)established.
     93     PresetApplied { restored: usize, total: usize },
     94     /// Recording started; carries the file path being written.
     95     RecordingStarted { path: String },
     96     /// Recording stopped; carries the file path + sample-frames written.
     97     RecordingStopped { path: String, frames: u64 },
     98     Ok,
     99     Error(String),
    100 }
    101 
    102 /// A host audio device (hardware or virtual), as seen by CoreAudio.
    103 #[derive(Debug, Clone, Serialize, Deserialize)]
    104 pub struct AudioDevice {
    105     pub uid: String,
    106     pub name: String,
    107     pub input_channels: u32,
    108     pub output_channels: u32,
    109     pub is_default_output: bool,
    110 }
    111 
    112 /// A process currently registered with CoreAudio as an audio producer.
    113 #[derive(Debug, Clone, Serialize, Deserialize)]
    114 pub struct AudioApp {
    115     pub pid: i32,
    116     pub name: String,
    117     pub bundle_id: Option<String>,
    118     /// Sort/priority rank: 0 = foreground Dock app, 1 = menu-bar/background app,
    119     /// 2 = plain process (system daemons, helpers). Lower = more likely a real target.
    120     #[serde(default)]
    121     pub kind: u8,
    122 }
    123 
    124 /// Server-initiated push messages (only sent after [`Command::Subscribe`]).
    125 #[derive(Debug, Clone, Serialize, Deserialize)]
    126 pub enum Event {
    127     StateChanged(StateSnapshot),
    128 }
    129 
    130 // ── Snapshot types ────────────────────────────────────────────────────────────
    131 
    132 /// A read-only view of the daemon's routing state, safe to render or print.
    133 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
    134 pub struct StateSnapshot {
    135     pub daemon_version: String,
    136     pub devices: Vec<DeviceSummary>,
    137     pub routes: Vec<RouteSummary>,
    138 }
    139 
    140 #[derive(Debug, Clone, Serialize, Deserialize)]
    141 pub struct DeviceSummary {
    142     pub uid: String,
    143     pub name: String,
    144     pub channels: u32,
    145 }
    146 
    147 #[derive(Debug, Clone, Serialize, Deserialize)]
    148 pub struct RouteSummary {
    149     pub id: String,
    150     /// Display label, e.g. "Swinsian → Scarlett 18i20".
    151     pub target: String,
    152     pub source_count: usize,
    153     pub active: bool,
    154     /// Linear gain (1.0 = unity).
    155     pub gain: f32,
    156     pub muted: bool,
    157     /// Most recent peak level (0.0..~1.0) for metering.
    158     pub peak: f32,
    159     /// Whether this route is currently being recorded to a file.
    160     #[serde(default)]
    161     pub recording: bool,
    162 }
    163 
    164 // ── NDJSON framing ────────────────────────────────────────────────────────────
    165 
    166 fn invalid_data<E: std::fmt::Display>(e: E) -> io::Error {
    167     io::Error::new(io::ErrorKind::InvalidData, e.to_string())
    168 }
    169 
    170 /// Serialize `msg` as a single JSON line and flush it to `w`.
    171 pub fn write_msg<W: Write, T: Serialize>(w: &mut W, msg: &T) -> io::Result<()> {
    172     let mut line = serde_json::to_vec(msg).map_err(invalid_data)?;
    173     line.push(b'\n');
    174     w.write_all(&line)?;
    175     w.flush()
    176 }
    177 
    178 /// Read one JSON line from `r`. Returns `Ok(None)` on clean EOF.
    179 pub fn read_msg<R: BufRead, T: for<'de> Deserialize<'de>>(r: &mut R) -> io::Result<Option<T>> {
    180     let mut line = String::new();
    181     if r.read_line(&mut line)? == 0 {
    182         return Ok(None);
    183     }
    184     let trimmed = line.trim_end();
    185     if trimmed.is_empty() {
    186         return Ok(None);
    187     }
    188     serde_json::from_str(trimmed).map(Some).map_err(invalid_data)
    189 }
    190 
    191 #[cfg(test)]
    192 mod tests {
    193     use super::*;
    194     use std::io::BufReader;
    195 
    196     #[test]
    197     fn command_round_trips() {
    198         let mut buf = Vec::new();
    199         write_msg(&mut buf, &Command::Ping).unwrap();
    200         let mut r = BufReader::new(&buf[..]);
    201         let got: Option<Command> = read_msg(&mut r).unwrap();
    202         assert!(matches!(got, Some(Command::Ping)));
    203     }
    204 
    205     #[test]
    206     fn snapshot_round_trips() {
    207         let snap = StateSnapshot {
    208             daemon_version: "0.1.0".into(),
    209             devices: vec![DeviceSummary { uid: "hydra:main".into(), name: "Main".into(), channels: 16 }],
    210             routes: vec![RouteSummary {
    211                 id: "r1".into(),
    212                 target: "hydra:main".into(),
    213                 source_count: 2,
    214                 active: true,
    215                 gain: 1.0,
    216                 muted: false,
    217                 peak: 0.0,
    218                 recording: false,
    219             }],
    220         };
    221         let mut buf = Vec::new();
    222         write_msg(&mut buf, &Response::State(snap)).unwrap();
    223         let mut r = BufReader::new(&buf[..]);
    224         let got: Option<Response> = read_msg(&mut r).unwrap();
    225         match got {
    226             Some(Response::State(s)) => {
    227                 assert_eq!(s.devices.len(), 1);
    228                 assert_eq!(s.routes[0].source_count, 2);
    229             }
    230             other => panic!("unexpected: {other:?}"),
    231         }
    232     }
    233 
    234     #[test]
    235     fn eof_returns_none() {
    236         let mut r = BufReader::new(&b""[..]);
    237         let got: Option<Command> = read_msg(&mut r).unwrap();
    238         assert!(got.is_none());
    239     }
    240 }