hydra

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

commit f617db31be4e553bf588eee355d3a0039dc523ee
parent 374286c5193145a18b81f79a35b976b602e4ff93
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date:   Mon,  1 Jun 2026 14:04:23 -0500

fix: Hydra device defaults to 2ch (one-ear bug in Vesktop) + record backend

ONE-EAR FIX: routing app→Hydra→Vesktop sent only one ear to the other side.
Controlled comparison: normal mic (2ch) ✓, Loopback device (2ch) ✓, Hydra
(16ch) ✗. Measured Hydra's own capture with an equal-L/R tone → LEFT 0.0600
RIGHT 0.0600 (perfectly balanced), so the imbalance is downstream: WebRTC/
StereoMic downmixing a 16ch device (signal only on ch0/1, ch2-15 silent) to
stereo doesn't map cleanly. Fix = match the working reference: default the
driver to 2 channels (HYDRA_DRIVER_CHANNELS overrides for DAW use). Rebuild +
reinstall the driver to apply.

RECORD-TO-FILE BACKEND (UX3, daemon side — TUI key still pending):
- tap_shim.m: lock-free SPSC ring in HydraParams (IOProc writes raw pre-gain
  tap, drops+counts on overrun, never blocks the audio thread); publishes tap
  channels + sample rate. hydra_params_size() for a Rust/C layout assertion.
- shim.rs: Recorder with a drain thread → 32-bit-float WAV (header patched on
  finalize); start/stop join the thread + disarm the IOProc before params free.
  Runtime size assert guards struct-layout drift.
- engine + IPC (StartRecording/StopRecording, RouteSummary.recording) + daemon
  (default path ~/Music/Hydra/hydra-<route>-<ts>.wav).
VERIFIED end-to-end: recorded an afplay route → afinfo reports valid WAVE,
2ch/44100/Float32/2.008s/88576 frames; samples have real signal (peak 0.06).

23 tests green, 0 warnings.

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

Diffstat:
MCargo.lock | 1+
Mcrates/hydra-core/src/engine.rs | 28++++++++++++++++++++++++++++
Mcrates/hydra-core/src/ffi/shim.rs | 215++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/hydra-core/src/ffi/tap_shim.m | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/hydra-core/src/model.rs | 1+
Mcrates/hydra-ipc/src/lib.rs | 13+++++++++++++
Mcrates/hydra/src/query.rs | 1+
Mcrates/hydrad/Cargo.toml | 1+
Mcrates/hydrad/src/server.rs | 34++++++++++++++++++++++++++++++++++
Mscripts/build-driver.sh | 6+++++-
10 files changed, 355 insertions(+), 3 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -332,6 +332,7 @@ dependencies = [ name = "hydrad" version = "0.1.0" dependencies = [ + "dirs", "hydra-core", "hydra-ipc", "serde_json", diff --git a/crates/hydra-core/src/engine.rs b/crates/hydra-core/src/engine.rs @@ -111,6 +111,33 @@ impl Engine { } } + /// Start recording route `id` to `path`. Err if no such route or the route refuses + /// (already recording / tap format not ready). + pub fn start_recording(&mut self, id: &str, path: std::path::PathBuf) -> Result<()> { + match self.routes.get_mut(id) { + Some(e) => e.route.start_recording(path), + None => anyhow::bail!("no such route: {id}"), + } + } + + /// Stop recording route `id`; returns frames written. + pub fn stop_recording(&mut self, id: &str) -> Result<u64> { + match self.routes.get_mut(id) { + Some(e) => e.route.stop_recording(), + None => anyhow::bail!("no such route: {id}"), + } + } + + /// Whether route `id` is currently recording. + pub fn is_recording(&self, id: &str) -> bool { + self.routes.get(id).map(|e| e.route.is_recording()).unwrap_or(false) + } + + /// The file route `id` is recording to, as a display string (empty if not recording). + pub fn recording_path(&self, id: &str) -> Option<String> { + self.routes.get(id).and_then(|e| e.route.recording_path()).map(|p| p.display().to_string()) + } + /// Project current routes into a wire snapshot. Meters are sampled live here. pub fn snapshot(&self) -> StateSnapshot { let mut routes: Vec<RouteSummary> = self @@ -124,6 +151,7 @@ impl Engine { gain: e.route.gain(), muted: e.route.muted(), peak: e.route.peak(), + recording: e.route.is_recording(), }) .collect(); routes.sort_by(|a, b| a.id.cmp(&b.id)); diff --git a/crates/hydra-core/src/ffi/shim.rs b/crates/hydra-core/src/ffi/shim.rs @@ -6,15 +6,20 @@ use std::ffi::{c_void, CString}; use std::os::raw::c_char; +use std::path::PathBuf; use std::ptr; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU32, AtomicU64, Ordering}; +use std::sync::Arc; 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`. +/// Shared realtime parameters. Layout MUST match `HydraParams` in `tap_shim.m` exactly — +/// C `_Atomic int`/`unsigned int`/`unsigned long long` map to `AtomicI32`/`AtomicU32`/ +/// `AtomicU64` (same size/align), and `float*` to `*mut f32`. A runtime size assertion in +/// `MonitorRoute::start_combined` guards against drift. #[repr(C)] struct HydraParams { gain: f32, @@ -22,6 +27,17 @@ struct HydraParams { peak: [f32; 8], running: i32, callbacks: u64, + // recording ring (SPSC) + rec_on: AtomicI32, + rec_buf: *mut f32, + rec_cap: u32, + rec_channels: u32, + rec_write: AtomicU64, + rec_read: AtomicU64, + rec_overruns: AtomicU64, + // tap format, published by the IOProc / start + fmt_channels: AtomicU32, + fmt_sample_rate: AtomicU32, } /// Opaque-to-Rust handle the shim fills in. Layout must match `HydraRoute`. @@ -42,6 +58,8 @@ extern "C" { out_route: *mut HydraRoute, ) -> i32; fn hydra_monitor_stop(route: *mut HydraRoute); + /// `sizeof(HydraParams)` as the C compiler sees it — for the layout assertion. + fn hydra_params_size() -> usize; } static NEXT_ID: AtomicU64 = AtomicU64::new(1); @@ -54,8 +72,26 @@ pub struct MonitorRoute { raw: HydraRoute, /// Heap-stable params the C IOProc reads; we keep it alive for the route's lifetime. params: Box<HydraParams>, + /// Active recording, if any. Always stopped (thread joined) before `params` drops. + recorder: Option<Recorder>, } +/// A live recording: owns the ring storage the IOProc writes into and the drain thread that +/// flushes it to a WAV file. The IOProc holds a raw pointer into `ring`/`params`; this struct +/// guarantees the drain thread is joined and `rec_on` cleared before either is freed. +struct Recorder { + path: PathBuf, + stop: Arc<AtomicBool>, + handle: Option<std::thread::JoinHandle<std::io::Result<u64>>>, + /// Kept alive so the raw `rec_buf` pointer the IOProc holds stays valid. + _ring: Box<[f32]>, +} + +/// Wrap a raw params pointer so it can cross into the drain thread. Safe because the params +/// box outlives the thread (joined in `Recorder::stop`) and we only touch atomics + the ring. +struct ParamsPtr(*const HydraParams); +unsafe impl Send for ParamsPtr {} + // 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 {} @@ -76,12 +112,30 @@ impl MonitorRoute { let proc_objs: Vec<AudioObjectID> = pids.iter().map(|&p| process_object_for_pid(p)).collect::<Result<_>>()?; + // Guard against the Rust/C struct layouts drifting apart (this struct is shared with + // the realtime IOProc; a mismatch would corrupt audio-thread memory). + const _: () = (); + assert_eq!( + std::mem::size_of::<HydraParams>(), + unsafe { hydra_params_size() }, + "HydraParams layout mismatch between Rust and tap_shim.m" + ); + let mut params = Box::new(HydraParams { gain, muted: 0, peak: [0.0; 8], running: 0, callbacks: 0, + rec_on: AtomicI32::new(0), + rec_buf: ptr::null_mut(), + rec_cap: 0, + rec_channels: 0, + rec_write: AtomicU64::new(0), + rec_read: AtomicU64::new(0), + rec_overruns: AtomicU64::new(0), + fmt_channels: AtomicU32::new(0), + fmt_sample_rate: AtomicU32::new(0), }); let mut raw = HydraRoute { tap: 0, aggregate: 0, ioproc: ptr::null_mut(), params: ptr::null_mut() }; @@ -106,6 +160,7 @@ impl MonitorRoute { pids: pids.to_vec(), raw, params, + recorder: None, }) } @@ -142,15 +197,171 @@ impl MonitorRoute { pub fn peak(&self) -> f32 { unsafe { ptr::read_volatile(&self.params.peak[0]) } } + + pub fn is_recording(&self) -> bool { + self.recorder.is_some() + } + + /// The file currently being recorded to, if any. + pub fn recording_path(&self) -> Option<&std::path::Path> { + self.recorder.as_ref().map(|r| r.path.as_path()) + } + + /// Begin recording the tapped audio to a WAV file at `path`. The IOProc fills an SPSC + /// ring; a drain thread writes the file. Errors if already recording or if the tap + /// format isn't known yet (no callback has fired — start playback first). + pub fn start_recording(&mut self, path: PathBuf) -> Result<()> { + if self.recorder.is_some() { + bail!("already recording"); + } + let channels = self.params.fmt_channels.load(Ordering::Acquire); + let sample_rate = self.params.fmt_sample_rate.load(Ordering::Acquire); + if channels == 0 || sample_rate == 0 { + bail!("tap format not ready yet — start audio in the app first, then record"); + } + + // ~4 seconds of ring headroom; the drain thread empties it continuously. + let cap = (sample_rate as usize * channels as usize * 4).next_power_of_two(); + let mut ring = vec![0.0f32; cap].into_boxed_slice(); + + // Point the IOProc at the ring and arm it. Order matters: set buf/cap/channels and + // reset indices BEFORE flipping rec_on, so the audio thread never sees a half-armed ring. + self.params.rec_buf = ring.as_mut_ptr(); + self.params.rec_cap = cap as u32; + self.params.rec_channels = channels; + self.params.rec_read.store(0, Ordering::Relaxed); + self.params.rec_write.store(0, Ordering::Relaxed); + self.params.rec_overruns.store(0, Ordering::Relaxed); + self.params.rec_on.store(1, Ordering::Release); + + let stop = Arc::new(AtomicBool::new(false)); + let stop_t = Arc::clone(&stop); + let pp = ParamsPtr(self.params.as_ref() as *const HydraParams); + let out_path = path.clone(); + let handle = std::thread::spawn(move || drain_to_wav(pp, stop_t, &out_path, channels, sample_rate)); + + self.recorder = Some(Recorder { path, stop, handle: Some(handle), _ring: ring }); + Ok(()) + } + + /// Stop recording: disarm the IOProc, join the drain thread, finalize the WAV. Returns + /// the number of sample-frames written. + pub fn stop_recording(&mut self) -> Result<u64> { + let Some(mut rec) = self.recorder.take() else { + bail!("not recording"); + }; + // Disarm the audio thread first so it stops touching the ring, then drain+join. + self.params.rec_on.store(0, Ordering::Release); + rec.stop.store(true, Ordering::Release); + let frames = match rec.handle.take().map(|h| h.join()) { + Some(Ok(Ok(floats))) => floats / self.params.rec_channels.max(1) as u64, + Some(Ok(Err(e))) => bail!("recording write error: {e}"), + Some(Err(_)) => bail!("recording thread panicked"), + None => 0, + }; + // ring (rec._ring) frees here, after the thread is joined and the IOProc disarmed. + Ok(frames) + } } impl Drop for MonitorRoute { fn drop(&mut self) { + // Stop recording (joins the drain thread, disarms the IOProc) BEFORE tearing down + // CoreAudio and freeing params — otherwise the audio thread could touch freed memory. + if self.recorder.is_some() { + let _ = self.stop_recording(); + } unsafe { hydra_monitor_stop(&mut self.raw) }; // `params` Box is freed here, after the IOProc is guaranteed gone. } } +/// Drain the SPSC ring to a 32-bit-float WAV until `stop` is set and the ring is empty. +/// Runs on its own thread; the only shared state is atomics + the ring buffer. +fn drain_to_wav( + pp: ParamsPtr, + stop: Arc<AtomicBool>, + path: &std::path::Path, + channels: u32, + sample_rate: u32, +) -> std::io::Result<u64> { + use std::io::Write; + let params = unsafe { &*pp.0 }; + let cap = params.rec_cap as usize; + + let file = std::fs::File::create(path)?; + let mut w = std::io::BufWriter::new(file); + write_wav_header(&mut w, channels, sample_rate)?; + + let mut total_floats: u64 = 0; + let mut scratch: Vec<f32> = Vec::with_capacity(cap); + loop { + let write = params.rec_write.load(Ordering::Acquire); + let read = params.rec_read.load(Ordering::Relaxed); + let avail = (write - read) as usize; + if avail > 0 { + scratch.clear(); + for i in 0..avail { + scratch.push(params.rec_buf_get((read as usize + i) % cap)); + } + let mut bytes = Vec::with_capacity(avail * 4); + for &s in &scratch { + bytes.extend_from_slice(&s.to_le_bytes()); + } + w.write_all(&bytes)?; + params.rec_read.store(write, Ordering::Release); + total_floats += avail as u64; + } else if stop.load(Ordering::Acquire) { + break; + } else { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + } + w.flush()?; + finalize_wav(w.into_inner().map_err(|e| e.into_error())?, total_floats)?; + Ok(total_floats) +} + +impl HydraParams { + /// Read one float from the recording ring (drain thread only). + fn rec_buf_get(&self, idx: usize) -> f32 { + unsafe { *self.rec_buf.add(idx) } + } +} + +/// Write a canonical 44-byte WAV header for 32-bit IEEE float PCM, with placeholder sizes +/// (patched by [`finalize_wav`] once the total is known). +fn write_wav_header<W: std::io::Write>(w: &mut W, channels: u32, sample_rate: u32) -> std::io::Result<()> { + let ch = channels as u16; + let byte_rate = sample_rate * channels * 4; + let block_align = (channels * 4) as u16; + w.write_all(b"RIFF")?; + w.write_all(&0u32.to_le_bytes())?; // RIFF size — patched later + w.write_all(b"WAVE")?; + w.write_all(b"fmt ")?; + w.write_all(&16u32.to_le_bytes())?; // fmt chunk size + w.write_all(&3u16.to_le_bytes())?; // format 3 = IEEE float + w.write_all(&ch.to_le_bytes())?; + w.write_all(&sample_rate.to_le_bytes())?; + w.write_all(&byte_rate.to_le_bytes())?; + w.write_all(&block_align.to_le_bytes())?; + w.write_all(&32u16.to_le_bytes())?; // bits per sample + w.write_all(b"data")?; + w.write_all(&0u32.to_le_bytes())?; // data size — patched later + Ok(()) +} + +/// Patch the RIFF + data sizes in a finished WAV now that the float count is known. +fn finalize_wav(mut file: std::fs::File, total_floats: u64) -> std::io::Result<()> { + use std::io::{Seek, SeekFrom, Write}; + let data_bytes = (total_floats * 4) as u32; + file.seek(SeekFrom::Start(4))?; + file.write_all(&(36 + data_bytes).to_le_bytes())?; // RIFF size = 36 + data + file.seek(SeekFrom::Start(40))?; + file.write_all(&data_bytes.to_le_bytes())?; // data chunk size + file.flush() +} + /// 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 { diff --git a/crates/hydra-core/src/ffi/tap_shim.m b/crates/hydra-core/src/ffi/tap_shim.m @@ -70,6 +70,23 @@ typedef struct { float peak[8]; // per-channel peak, written by IOProc int running; // 1 while the IOProc is installed unsigned long long callbacks; // IOProc invocation count (liveness diagnostic) + + // ── Recording ring (SPSC): IOProc is the sole writer, the Rust drain thread the sole + // reader. Lock-free + allocation-free on the audio thread — we only ever memcpy into a + // pre-allocated buffer and bump an atomic write index. `rec_on` gates capture; on + // overrun (reader too slow) we drop samples and bump rec_overruns rather than block. + _Atomic int rec_on; // 1 while recording + float *rec_buf; // ring storage (rec_cap floats), owned by Rust + unsigned int rec_cap; // capacity in floats (must be > 0 when rec_on) + unsigned int rec_channels; // channels the writer interleaves (set when armed) + _Atomic unsigned long long rec_write; // total floats written (monotonic; & cap for pos) + _Atomic unsigned long long rec_read; // total floats consumed by the drain thread + _Atomic unsigned long long rec_overruns; // count of dropped floats (ring was full) + + // Tap format, published by the IOProc on its first run so Rust can write a correct WAV + // header (0 until the first callback fires). + _Atomic unsigned int fmt_channels; + _Atomic unsigned int fmt_sample_rate; } HydraParams; typedef struct { @@ -79,6 +96,9 @@ typedef struct { HydraParams *params; } HydraRoute; +// Exposed so Rust can assert its mirrored struct matches this compiler's layout. +size_t hydra_params_size(void) { return sizeof(HydraParams); } + static AudioObjectID hydra_default_output(void) { AudioObjectID dev = 0; UInt32 sz = sizeof(dev); @@ -203,6 +223,30 @@ OSStatus hydra_monitor_start(const AudioObjectID *procObjs, const UInt32 tapCh = tap ? tap->mNumberChannels : 0; const UInt32 tapFrames = (tapData && tapCh) ? (tap->mDataByteSize / sizeof(float) / tapCh) : 0; + // Publish the tap channel count once known, so the recorder can header the WAV. + if (tapCh && __c11_atomic_load(&P->fmt_channels, __ATOMIC_RELAXED) == 0) { + __c11_atomic_store(&P->fmt_channels, tapCh, __ATOMIC_RELEASE); + } + + // Recording: copy the raw (pre-gain) tap into the SPSC ring. We record the + // source as captured, independent of the monitor gain knob. Lock-free; on a + // full ring we drop and count rather than block the audio thread. + if (__c11_atomic_load(&P->rec_on, __ATOMIC_ACQUIRE) && tapData && P->rec_buf && P->rec_cap) { + const unsigned int total = tapFrames * tapCh; + unsigned long long w = __c11_atomic_load(&P->rec_write, __ATOMIC_RELAXED); + unsigned long long r = __c11_atomic_load(&P->rec_read, __ATOMIC_ACQUIRE); + const unsigned int cap = P->rec_cap; + unsigned int freeSpace = (unsigned int)(cap - (w - r)); + if (total <= freeSpace) { + for (unsigned int i = 0; i < total; i++) { + P->rec_buf[(w + i) % cap] = tapData[i]; + } + __c11_atomic_store(&P->rec_write, w + total, __ATOMIC_RELEASE); + } else { + __c11_atomic_fetch_add(&P->rec_overruns, total, __ATOMIC_RELAXED); + } + } + float peak = 0.0f; for (UInt32 ob = 0; ob < nout; ob++) { float *out = (float *)outData->mBuffers[ob].mData; @@ -235,6 +279,20 @@ OSStatus hydra_monitor_start(const AudioObjectID *procObjs, return st != noErr ? st : kAudioHardwareUnspecifiedError; } + // Publish the aggregate's nominal sample rate for the WAV header. + { + Float64 sr = 0; + UInt32 srSize = sizeof(sr); + AudioObjectPropertyAddress srAddr = { + kAudioDevicePropertyNominalSampleRate, + kAudioObjectPropertyScopeGlobal, + kAudioObjectPropertyElementMain + }; + if (AudioObjectGetPropertyData(agg, &srAddr, 0, NULL, &srSize, &sr) == noErr && sr > 0) { + __c11_atomic_store(&P->fmt_sample_rate, (unsigned int)sr, __ATOMIC_RELEASE); + } + } + st = AudioDeviceStart(agg, procID); if (st != noErr) { AudioDeviceDestroyIOProcID(agg, procID); diff --git a/crates/hydra-core/src/model.rs b/crates/hydra-core/src/model.rs @@ -51,6 +51,7 @@ impl RoutingState { gain: 1.0, muted: false, peak: 0.0, + recording: false, }) .collect(), } diff --git a/crates/hydra-ipc/src/lib.rs b/crates/hydra-ipc/src/lib.rs @@ -65,6 +65,11 @@ pub enum Command { ApplyPreset { name: String }, /// Delete a saved preset by name. DeletePreset { name: String }, + /// Start recording a route's audio to a WAV file. `path = None` ⇒ daemon picks a + /// timestamped file in ~/Music/Hydra. + StartRecording { id: String, path: Option<String> }, + /// Stop recording a route; reply carries the file path + duration. + StopRecording { id: String }, /// Opt this connection into the server-push event stream (state deltas, meters). Subscribe, /// Ask the daemon to exit. @@ -84,6 +89,10 @@ pub enum Response { Presets(Vec<String>), /// A preset was applied; carries how many of its routes were (re)established. PresetApplied { restored: usize, total: usize }, + /// Recording started; carries the file path being written. + RecordingStarted { path: String }, + /// Recording stopped; carries the file path + sample-frames written. + RecordingStopped { path: String, frames: u64 }, Ok, Error(String), } @@ -145,6 +154,9 @@ pub struct RouteSummary { pub muted: bool, /// Most recent peak level (0.0..~1.0) for metering. pub peak: f32, + /// Whether this route is currently being recorded to a file. + #[serde(default)] + pub recording: bool, } // ── NDJSON framing ──────────────────────────────────────────────────────────── @@ -201,6 +213,7 @@ mod tests { gain: 1.0, muted: false, peak: 0.0, + recording: false, }], }; let mut buf = Vec::new(); diff --git a/crates/hydra/src/query.rs b/crates/hydra/src/query.rs @@ -106,6 +106,7 @@ mod tests { gain: 1.0, muted, peak, + recording: false, } } diff --git a/crates/hydrad/Cargo.toml b/crates/hydrad/Cargo.toml @@ -12,3 +12,4 @@ path = "src/main.rs" hydra-core = { path = "../hydra-core" } hydra-ipc = { path = "../hydra-ipc" } serde_json.workspace = true +dirs.workspace = true diff --git a/crates/hydrad/src/server.rs b/crates/hydrad/src/server.rs @@ -14,6 +14,19 @@ use hydra_ipc::{read_msg, write_msg, Command, Response, PROTOCOL_VERSION}; type SharedEngine = Arc<Mutex<Engine>>; +/// Default recording destination: `~/Music/Hydra/hydra-<route>-<unixsecs>.wav`. +fn default_recording_path(route_id: &str) -> std::path::PathBuf { + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let mut dir = dirs::audio_dir().unwrap_or_else(|| std::path::PathBuf::from("/tmp")); + dir.push("Hydra"); + let _ = std::fs::create_dir_all(&dir); + dir.push(format!("hydra-{route_id}-{secs}.wav")); + dir +} + /// Resolve an app bundle id to a currently-running audio-process PID, or None if it isn't /// playing. Shared by startup-restore and preset-apply. fn resolve_bundle_to_pid(bundle_id: &str) -> Option<i32> { @@ -175,6 +188,27 @@ fn dispatch( Response::Error(format!("no preset named {name:?}")) } } + Command::StartRecording { id, path } => { + let path = path.map(std::path::PathBuf::from).unwrap_or_else(|| default_recording_path(&id)); + let resp = match engine.lock().unwrap().start_recording(&id, path.clone()) { + Ok(()) => Response::RecordingStarted { path: path.display().to_string() }, + Err(e) => Response::Error(e.to_string()), + }; + notify_route_change(); + resp + } + Command::StopRecording { id } => { + let path = engine.lock().unwrap().recording_path(&id); + let resp = match engine.lock().unwrap().stop_recording(&id) { + Ok(frames) => Response::RecordingStopped { + path: path.unwrap_or_default(), + frames, + }, + Err(e) => Response::Error(e.to_string()), + }; + notify_route_change(); + resp + } // Server-push subscription lands in P4; acknowledge for now. Command::Subscribe => Response::Ok, Command::Shutdown => { diff --git a/scripts/build-driver.sh b/scripts/build-driver.sh @@ -23,7 +23,11 @@ APP="$OUT/Hydra.driver" DRIVER_NAME="Hydra" BUNDLE_ID="com.ganten.hydra.driver" -CHANNELS="${HYDRA_DRIVER_CHANNELS:-16}" +# Default 2ch (stereo): matches a normal mic / Loopback's device, so WebRTC apps +# (Discord/Vesktop, Zoom) downmix cleanly to L+R. A 16ch device made Vesktop send only +# one ear — the stereo capture path doesn't map 16→2 cleanly when only ch 0/1 carry signal. +# Override for multi-channel DAW routing with HYDRA_DRIVER_CHANNELS=N. +CHANNELS="${HYDRA_DRIVER_CHANNELS:-2}" MANUFACTURER="Ganten" SIGN_ID="${HYDRA_SIGN_ID:--}" # '-' = ad-hoc