hydra

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

commit c68822d831f1cf8cba9dd9474c5a0bfc25fd86a0
parent 483a422c8e124775b8da9584de2cbb0bf4bedd60
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date:   Mon,  1 Jun 2026 15:27:56 -0500

UX4: dB-scaled meters with peak-hold

Linear meters are near-useless for audio — a -20 dB signal (peak 0.1) barely
moved one block. Now:
- peak_to_fraction(): maps peak → fill on a dBFS scale (-54..0 dB), so quiet-but-
  present audio reads clearly (-20 dB ≈ 63% fill). Unit-tested.
- peak-hold tick: App tracks a decaying per-route held peak (snap up, gentle
  decay ~0.88/refresh); drawn as an amber ┃ above the fill, like a hardware VU.
- headroom colour: green < -6 dB, amber approaching, red within ~1 dB of clip.

27 tests green, 0 warnings.

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

Diffstat:
Mcrates/hydra/src/app.rs | 22++++++++++++++++++++++
Mcrates/hydra/src/ui.rs | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
2 files changed, 77 insertions(+), 12 deletions(-)

diff --git a/crates/hydra/src/app.rs b/crates/hydra/src/app.rs @@ -43,6 +43,9 @@ pub struct App { /// PIDs the user has marked (space) to combine into one route. pub marked: std::collections::BTreeSet<i32>, pub routes: Vec<RouteSummary>, + /// Decaying peak-hold per route id, for the meter's "peak hold" tick. Updated each + /// refresh: jump up to a new peak instantly, decay slowly otherwise. + pub peak_hold: std::collections::HashMap<String, f32>, /// Output devices a route can target (output_channels > 0), e.g. speakers or "Hydra". pub outputs: Vec<AudioDevice>, /// Index into `outputs` of the currently chosen route target. @@ -66,6 +69,7 @@ impl App { show_all_apps: false, marked: std::collections::BTreeSet::new(), routes: Vec::new(), + peak_hold: std::collections::HashMap::new(), outputs: Vec::new(), output_sel: 0, focus: Focus::Apps, @@ -234,11 +238,29 @@ impl App { } if let Ok(Response::State(snap)) = client::request(Command::GetState) { self.routes = snap.routes; + self.update_peak_hold(); } self.clamp_selection(); } + /// Update the per-route peak-hold: snap up to any new peak, otherwise decay toward the + /// current level so the held tick drifts down. Drop holds for routes that no longer exist. + fn update_peak_hold(&mut self) { + const DECAY: f32 = 0.88; // ~per-refresh; gentle fall like a hardware peak meter + let live: std::collections::HashSet<&str> = self.routes.iter().map(|r| r.id.as_str()).collect(); + self.peak_hold.retain(|id, _| live.contains(id.as_str())); + for r in &self.routes { + let held = self.peak_hold.entry(r.id.clone()).or_insert(0.0); + *held = if r.peak >= *held { r.peak } else { (*held * DECAY).max(r.peak) }; + } + } + + /// The held peak for a route (0.0 if unknown). + pub fn peak_hold_for(&self, id: &str) -> f32 { + self.peak_hold.get(id).copied().unwrap_or(0.0) + } + /// Keep only output-capable devices; default the selection to the system default output. fn set_outputs(&mut self, devices: Vec<AudioDevice>) { let prev_uid = self.outputs.get(self.output_sel).map(|d| d.uid.clone()); diff --git a/crates/hydra/src/ui.rs b/crates/hydra/src/ui.rs @@ -194,7 +194,7 @@ fn draw_routes(f: &mut Frame, area: Rect, app: &App, theme: &Theme) { Span::styled(format!("{} ", r.target), Style::default().fg(theme.fg)), Span::styled(format!("{:>3.0}% ", r.gain * 100.0), Style::default().fg(theme.accent)), ]; - spans.extend(peak_bar(r.peak, 12, theme)); + spans.extend(peak_bar(r.peak, app.peak_hold_for(&r.id), 12, theme)); if r.recording { spans.push(Span::styled(" ⏺ REC", Style::default().fg(theme.danger).add_modifier(Modifier::BOLD))); } @@ -279,20 +279,63 @@ fn selection_style(focused: bool, theme: &Theme) -> Style { } } -/// A small level meter: filled blocks scaled by `peak` (0.0..~1.0). -fn peak_bar(peak: f32, width: usize, theme: &Theme) -> Vec<Span<'static>> { - let filled = ((peak.clamp(0.0, 1.0)) * width as f32).round() as usize; - let color = if peak > 0.9 { - theme.danger - } else if peak > 0.6 { - theme.warning +/// Map a linear peak (0..1) to a meter fill fraction (0..1) on a dBFS scale spanning +/// [`METER_FLOOR_DB`, 0]. Linear meters are near-useless for audio — a −20 dB signal +/// (peak 0.1) would barely move — so we scale by loudness as the ear hears it. +const METER_FLOOR_DB: f32 = -54.0; +fn peak_to_fraction(peak: f32) -> f32 { + let p = peak.clamp(0.0, 1.0); + if p <= 0.0 { + return 0.0; + } + let db = 20.0 * p.log10(); // ≤ 0 + ((db - METER_FLOOR_DB) / -METER_FLOOR_DB).clamp(0.0, 1.0) +} + +/// A dB-scaled level meter with a held-peak marker. `peak` is the instantaneous level, +/// `hold` the decaying recent maximum (drawn as a bright tick). Colour reflects headroom: +/// green low, amber approaching, red near clip. +fn peak_bar(peak: f32, hold: f32, width: usize, theme: &Theme) -> Vec<Span<'static>> { + let frac = peak_to_fraction(peak); + let filled = (frac * width as f32).round() as usize; + let hold_pos = (peak_to_fraction(hold) * width as f32).round() as usize; + let color = if peak > 0.89 { + theme.danger // within ~1 dB of full scale + } else if peak > 0.5 { + theme.warning // ~ -6 dB and up } else { theme.ghost }; - vec![ - Span::styled("█".repeat(filled), Style::default().fg(color)), - Span::styled("·".repeat(width - filled), Style::default().fg(theme.border)), - ] + + let mut spans = Vec::with_capacity(width); + for i in 0..width { + if i < filled { + spans.push(Span::styled("█", Style::default().fg(color))); + } else if hold_pos > 0 && i == hold_pos.min(width - 1) && hold_pos >= filled { + // Held-peak tick sits above the current fill — the classic VU "peak hold". + spans.push(Span::styled("┃", Style::default().fg(theme.accent))); + } else { + spans.push(Span::styled("·", Style::default().fg(theme.border))); + } + } + spans +} + +#[cfg(test)] +mod tests { + use super::peak_to_fraction; + + #[test] + fn db_scale_is_useful_for_quiet_signals() { + // Silence floors, full-scale fills. + assert_eq!(peak_to_fraction(0.0), 0.0); + assert!((peak_to_fraction(1.0) - 1.0).abs() < 1e-6); + // A -20 dB signal (0.1) should read well above the linear 10% — proves the point. + let f = peak_to_fraction(0.1); // -20 dB on a -54 dB floor ≈ 0.63 + assert!(f > 0.55 && f < 0.7, "got {f}"); + // -40 dB still visible (not floored to nothing). + assert!(peak_to_fraction(0.01) > 0.2); + } } fn key(k: &str, theme: &Theme) -> Span<'static> {