crates/veilvoice-cli/src/meter.rs
what this file is for · veilvoice-cli · 259 lines · the same file on GitHub
The file as it is in the tree, in the colours you chose. A line number is a link, and so is every box in this file’s diagram: it opens here with the function it names marked.
// SPDX-License-Identifier: GPL-3.0-or-later
//! Level meters for `veilvoice live`, on a scale that means something.
//!
//! # Why the old one was wrong
//!
//! The first meter was linear: a peak of 0.5 filled half the bar. That is
//! arithmetically fine and useless as a meter, because loudness is not linear.
//! Ordinary speech recorded at a sensible level peaks around **-12 dBFS**,
//! which is 0.25 linear, so three of twelve blocks. Somebody speaking normally
//! saw a meter that looked like near-silence, and the only way to fill the bar
//! was to be clipping.
//!
//! Every real meter is logarithmic for that reason, and this one is too:
//! -60 dBFS at the left, 0 dBFS at the right. Speech now sits in the middle of
//! the bar where a person can see it move.
//!
//! # What it measures, and what it does not
//!
//! **Sample peak, since the last read.** `veilvoice-audio` keeps the largest
//! absolute sample seen since the meter was last looked at and resets it on
//! read, so nothing between two reads is missed.
//!
//! It is **not** a loudness meter. RMS, LUFS and everything else that
//! correlates with how loud a thing *sounds* need a window and a weighting
//! curve, and they answer a different question: this one is for "am I being
//! recorded, and am I clipping", which is a peak question.
//!
//! It also cannot see an **inter-sample peak**, a waveform that passes above
//! full scale between two samples and clips in a converter or an encoder
//! without any single sample exceeding 1.0. Catching those needs oversampling.
//! The meter says `CLIP` when a sample actually reaches full scale, and says
//! nothing about the ones it cannot see, which is the honest half of a true
//! peak meter rather than a claim to be one.
//!
//! # Peak hold
//!
//! A bar that only shows the current moment cannot show a transient: the loud
//! syllable is gone before a human eye finishes moving. The highest level of
//! the last [`HOLD`] is kept and drawn as a single marker, and it decays rather
//! than sticking, so the bar stays honest about what is happening *now* while
//! still showing what just happened.
//!
//! # In plain words
//!
//! Draws the input and output level meters during a live session.
//!
//! The bar and the decibel number beside it are worked out from the same piece of
//! arithmetic the window uses, so the two halves of VeilVoice cannot disagree
//! about the same reading. They did once, and a meter you have caught contradicting
//! itself is a meter you stop believing.
// The scale lives in `veilvoice-audio`, beside the thing that produces the
// peaks, because the desktop application draws the same readings and the two
// were drawing them differently -- one linear bar with a decibel number printed
// next to it, which is a meter arguing with itself. This file owns how it looks
// in a terminal and nothing else.
use crate::theme::{colour, paint};
use std::time::{Duration, Instant};
use veilvoice_audio::meter::{clipping, dbfs, position, CLIP_DB, FLOOR_DB};
/// How long a peak marker is held before it falls back.
pub const HOLD: Duration = Duration::from_millis(1500);
/// The eighth-block characters, so a bar of `n` characters has `8n` steps.
///
/// Twelve characters at one step each is a meter that moves in jumps of five
/// decibels, which reads as broken rather than as coarse. The same twelve
/// characters at eighths move in jumps of well under one.
const EIGHTHS: [&str; 9] = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"];
/// One meter: the bar, the peak marker, and the number.
///
/// `width` is in characters, and does not include the number after it.
pub fn render(peak: f32, hold: f32, width: usize) -> String {
let filled = position(peak) * width as f32;
let whole = filled.floor() as usize;
let part = ((filled - filled.floor()) * 8.0).round() as usize;
let mut bar = String::new();
for index in 0..width {
// The held peak is drawn as a marker in the empty part of the bar, and
// is simply invisible inside the filled part -- where it would be
// saying the same thing as the fill.
let marker = hold > 0.0 && (position(hold) * width as f32).floor() as usize == index;
if index < whole {
bar.push_str(EIGHTHS[8]);
} else if index == whole && part > 0 {
bar.push_str(EIGHTHS[part.min(8)]);
} else if marker {
bar.push('╵');
} else {
bar.push('·');
}
}
let db = dbfs(peak);
let shade = if db >= CLIP_DB {
colour::RED
} else if db >= -6.0 {
colour::YELLOW
} else if db >= -40.0 {
colour::GREEN
} else {
// Below -40 the signal is room tone, not speech. Drawn muted so a quiet
// room does not read as a working microphone.
colour::MUTED
};
let number = if db <= FLOOR_DB {
" -inf".to_string()
} else {
format!("{db:6.1}")
};
format!(
"{} {}",
paint(shade, &bar),
paint(
if db >= CLIP_DB {
colour::RED
} else {
colour::MUTED
},
&number
)
)
}
/// One channel's meter, keeping the peak between reads.
pub struct Channel {
hold: f32,
since: Instant,
clipped: bool,
}
impl Default for Channel {
fn default() -> Self {
Self {
hold: 0.0,
since: Instant::now(),
clipped: false,
}
}
}
impl Channel {
/// Take a new reading and give back the meter to print.
pub fn update(&mut self, peak: f32, width: usize) -> String {
// NaN only. Infinity is left alone so it reaches `dbfs`, which pins it
// to full scale -- reading it as silence here would have contradicted
// the reasoning three functions up, quietly, in the one place that
// decides what the user actually sees.
let peak = if peak.is_nan() { 0.0 } else { peak.max(0.0) };
if peak >= self.hold || self.since.elapsed() >= HOLD {
self.hold = peak;
self.since = Instant::now();
}
if clipping(peak) {
self.clipped = true;
}
render(peak, self.hold, width)
}
/// Whether this channel has clipped at any point in the session.
///
/// Sticky on purpose. Clipping is destructive and it is over in a
/// millisecond; a warning that disappears before the person looks up is a
/// warning that was never given.
pub fn has_clipped(&self) -> bool {
self.clipped
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_bar_is_the_width_it_was_asked_for() {
for peak in [0.0f32, 0.001, 0.25, 0.5, 0.9, 1.0, 2.0, -1.0, f32::NAN] {
for width in [1usize, 8, 12, 40] {
let text = render(peak, 0.0, width);
let bar: String = text.chars().filter(|c| !c.is_ascii()).collect();
assert_eq!(
bar.chars().count(),
width,
"peak {peak} at width {width} drew {bar:?}"
);
}
}
}
#[test]
fn full_scale_fills_it_and_silence_empties_it() {
let full = render(1.0, 0.0, 12);
assert_eq!(full.matches('█').count(), 12);
let quiet = render(0.0, 0.0, 12);
assert_eq!(quiet.matches('·').count(), 12);
assert!(quiet.contains("-inf"));
}
/// A held peak is drawn as a marker, and only where the bar is empty --
/// inside the fill it would be saying what the fill already says.
#[test]
fn a_held_peak_shows_as_a_marker_beyond_the_current_level() {
let text = render(0.01, 0.9, 12);
assert!(text.contains('╵'), "the held peak should be marked: {text}");
let covered = render(0.9, 0.9, 12);
assert!(
!covered.contains('╵'),
"no marker inside the fill: {covered}"
);
}
/// The hold falls back rather than sticking, or the meter slowly becomes a
/// picture of the loudest thing that ever happened.
#[test]
fn the_hold_rises_at_once_and_falls_back_after_a_while() {
let mut channel = Channel::default();
channel.update(0.9, 12);
assert!((channel.hold - 0.9).abs() < 1e-6);
// A quieter reading does not move it...
channel.update(0.1, 12);
assert!((channel.hold - 0.9).abs() < 1e-6);
// ...until the hold has expired.
channel.since = Instant::now() - HOLD - Duration::from_millis(1);
channel.update(0.1, 12);
assert!((channel.hold - 0.1).abs() < 1e-6);
}
/// Clipping is sticky: it is over in a millisecond and it is destructive.
#[test]
fn clipping_is_remembered_for_the_session() {
let mut channel = Channel::default();
assert!(!channel.has_clipped());
channel.update(0.5, 12);
assert!(!channel.has_clipped(), "-6 dBFS is not clipping");
channel.update(0.95, 12);
assert!(!channel.has_clipped(), "-0.45 dBFS is not clipping either");
channel.update(1.0, 12);
assert!(channel.has_clipped());
channel.update(0.0, 12);
assert!(
channel.has_clipped(),
"a warning that vanishes was never given"
);
}
#[test]
fn nothing_here_panics_on_anything() {
for peak in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -5.0, 1e30, 0.0] {
for hold in [f32::NAN, 0.0, 1.0, -1.0] {
let _ = render(peak, hold, 12);
}
let mut channel = Channel::default();
let _ = channel.update(peak, 12);
}
let _ = render(0.5, 0.5, 0);
}
}