hydra

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

commit 57f248df365636f86328bb8cb584c9e34a4ca735
parent 0901577694888493cfc9dc03110f5399c1a186dc
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date:   Mon,  1 Jun 2026 13:14:53 -0500

docs: log P3b multi-device plan + why it's branch-only

Captures the measured scope (78+41 static object-id refs, 22 channel-count
sites incl. ring buffer + realtime path), the refactor plan, and the reason it
stays off main: driver changes can't be behavior-tested without sudo install +
coreaudiod restart, so the refactor lives on p3b-multi-device and merges only
after a real installed multi-device round-trip passes.

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

Diffstat:
Adocs/P3b-multi-device.md | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 54 insertions(+), 0 deletions(-)

diff --git a/docs/P3b-multi-device.md b/docs/P3b-multi-device.md @@ -0,0 +1,54 @@ +# P3b — multiple dynamic devices (deferred, branch: `p3b-multi-device`) + +## Why this is on a branch, not main + +`main` is a working single-device Loopback replacement (rename + capture + combine + +monitor, all verified). P3b is the one piece that requires rewriting BlackHole's **static +object model**, and driver changes **cannot be behavior-tested without a sudo install + +coreaudiod restart** — i.e. I can only build-check them, not run them. Half-building a +4600-line C refactor blind is the failure mode that bit this project earlier (committing +non-compiling / wrong-behaviour trees). So: the refactor lives on `p3b-multi-device`, main +stays shippable, and we only merge once it's been installed-and-tested for real. + +## The scope (measured, not guessed) + +In `driver/upstream/BlackHole/BlackHole.c` (v0.6.1, 4620 lines): +- **78** references to `kObjectID_Device`, **41** to `kObjectID_Device2` +- A fixed object-ID enum: `Box=2, Device=3, Stream_Input=4, Volume_Input=5, Mute_Input=6, + Stream_Output=7, Volume_Output=8, Mute_Output=9, Pitch=10, Clock=11, Device2=12` +- **22** uses of `kNumber_Of_Channels`, including ring-buffer `calloc` and the realtime IO + path (`vDSP_*`, `memcpy`) — so channel count is compile-time-baked into buffer math +- One global `gRingBuffer`; per-device IO-running counters are hand-duplicated + (`gDevice_IOIsRunning`, `gDevice2_IOIsRunning`) + +## The refactor plan + +1. Replace the fixed object-ID enum with a **runtime registry**: each manifest device owns a + contiguous block of object IDs (device + its streams/volume/mute/clock controls), assigned + at init from the manifest. +2. Replace the single-device globals with an **array of device structs** (`name`, `uid`, + `channels`, `gRingBuffer`, `gIOIsRunning`, sample-rate, volume state). +3. In **every** property handler (`*_HasProperty / IsPropertySettable / GetPropertyDataSize / + GetPropertyData / SetPropertyData`) and `DoIOOperation`, switch on `objectID` → resolve to + the owning device struct instead of comparing against `kObjectID_Device`/`Device2`. +4. Make `kNumber_Of_Channels`-derived sizing **per-device** (ring buffer, ASBD, channel + layout, bytes-per-frame). +5. Publish the device list from the manifest at `BlackHole_Initialize`; the daemon already + writes `devices.json` and `hydra_cfg.c` already parses it (extend the reader from + first-device-name to the full array). +6. Daemon side: `AddDevice` / `RemoveDevice` / `SetChannels` IPC + TUI affordances; rewrite + `devices.json` and trigger a reload. + +## Verification loop (the reason it's branch-only) + +Each iteration must: `./scripts/build-driver.sh` → `./scripts/install-driver.sh` (sudo + +`killall coreaudiod`) → confirm in `system_profiler SPAudioDataType` / the TUI that the +expected devices appear and route audio. There is no headless substitute. Budget for the +single-device assumption being pervasive — expect this to be the largest single chunk of +work in the project, and merge to main only after a real multi-device round-trip passes. + +## Lower-risk prerequisite already done + +P3a (manifest-driven **device name**) is on main and verified end-to-end on the host. It +proves the daemon→manifest→driver-reader pipe works; P3b extends the reader from one name to +the full device array.