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:
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