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