hydra

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

commit 483a422c8e124775b8da9584de2cbb0bf4bedd60
parent 3b772e3d1d8a9921257acd0d64170fa236196394
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date:   Mon,  1 Jun 2026 15:21:33 -0500

UX3 complete: record-to-file TUI binding

Wires the (already-verified) recording backend into the TUI: 'R' on a selected
route toggles recording to ~/Music/Hydra/hydra-<route>-<ts>.wav; the route shows
a red ⏺ REC indicator while recording; status line reports the saved path +
duration on stop. Footer hint added. Backend (lock-free ring → WAV drain thread)
was verified earlier via afinfo (valid 2ch/44.1k/Float32 WAV with real signal).

26 tests green, 0 warnings.

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

Diffstat:
Mcrates/hydra/src/app.rs | 23+++++++++++++++++++++++
Mcrates/hydra/src/main.rs | 1+
Mcrates/hydra/src/ui.rs | 5+++++
3 files changed, 29 insertions(+), 0 deletions(-)

diff --git a/crates/hydra/src/app.rs b/crates/hydra/src/app.rs @@ -374,6 +374,29 @@ impl App { self.refresh(); } + /// Start or stop recording the selected route to a WAV file (daemon picks the path in + /// ~/Music/Hydra). Toggles based on the route's current recording state. + pub fn toggle_record_selected(&mut self) { + let Some(route) = self.routes.get(self.route_sel) else { return }; + let id = route.id.clone(); + let cmd = if route.recording { + Command::StopRecording { id } + } else { + Command::StartRecording { id, path: None } + }; + match client::request(cmd) { + Ok(Response::RecordingStarted { path }) => self.status = format!("● recording → {path}"), + Ok(Response::RecordingStopped { path, frames }) => { + let secs = frames as f64 / 44100.0; + self.status = format!("saved {path} ({secs:.1}s)"); + } + Ok(Response::Error(e)) => self.status = format!("record: {e}"), + Ok(other) => self.status = format!("unexpected: {other:?}"), + Err(e) => self.status = format!("record failed: {e}"), + } + self.refresh(); + } + /// Scale the selected route's gain by `factor` (multiplicative, so a few presses span /// the whole range). `up=true` multiplies, `up=false` divides. Clamped to 0..=16. /// Multiplicative because Core Audio process taps attenuate ~-20 dB, so useful makeup diff --git a/crates/hydra/src/main.rs b/crates/hydra/src/main.rs @@ -105,6 +105,7 @@ fn handle_key(app: &mut App, code: KeyCode) { KeyCode::Char('p') => app.open_presets(), KeyCode::Char('P') => app.begin_save_preset(), KeyCode::Char('m') => app.toggle_mute_selected(), + KeyCode::Char('R') => app.toggle_record_selected(), KeyCode::Char('d') | KeyCode::Char('x') => app.stop_selected(), KeyCode::Char('+') | KeyCode::Char('=') => app.adjust_gain(true), KeyCode::Char('-') | KeyCode::Char('_') => app.adjust_gain(false), diff --git a/crates/hydra/src/ui.rs b/crates/hydra/src/ui.rs @@ -195,6 +195,9 @@ fn draw_routes(f: &mut Frame, area: Rect, app: &App, theme: &Theme) { Span::styled(format!("{:>3.0}% ", r.gain * 100.0), Style::default().fg(theme.accent)), ]; spans.extend(peak_bar(r.peak, 12, theme)); + if r.recording { + spans.push(Span::styled(" ⏺ REC", Style::default().fg(theme.danger).add_modifier(Modifier::BOLD))); + } ListItem::new(Line::from(spans)) }) .collect(); @@ -249,6 +252,8 @@ fn draw_footer(f: &mut Frame, area: Rect, app: &App, theme: &Theme) { hint(" mute ", theme), key("+/-", theme), hint(" gain ", theme), + key("R", theme), + hint(" record ", theme), key("d", theme), hint(" stop ", theme), key("⇥", theme),