crates/veilvoice-gui/src/monitor.rs

what this file is for · veilvoice-gui · 579 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
//! The live monitor: what is going in, and what is coming out, wherever you are.
//!
//! # Why this is not just the meters on the Studio tab
//!
//! The Studio has drawn an input and an output meter for some time, and they
//! are the right meters. What they were not was *visible*: they are inside one
//! panel, and the moment somebody switched to Group to set up an interview, or
//! to Settings, or to Monitor, the only picture of what their microphone was
//! doing went off screen while the audio carried on.
//!
//! That is the wrong way round for this feature in particular. Live scramble is
//! the mode where the thing being protected is happening *now*, in real time,
//! and where the two questions a person actually has are "is it hearing me" and
//! "is anything coming out". A meter you have to navigate to in order to answer
//! them is a meter that answers them late.
//!
//! So the monitor rides the window. It is on by default, it shows on every tab,
//! and it shows exactly two things plus their state: the level going in, and
//! the level coming out.
//!
//! # Two places it can sit, and one way to switch it off
//!
//! [`Style::Toolbar`] docks it to the bottom of the window, where it takes a
//! strip of height and never covers anything. [`Style::Overlay`] floats it over
//! the panel, bottom right, for somebody who would rather keep the full height
//! for the panel and accept that it sits on top of a corner of it.
//! [`Style::Off`] is offered because a strip somebody does not want is a strip
//! they will resent, and the Studio still has the full meters either way.
//!
//! The overlay is deliberately **not** click-through and **not** draggable: a
//! floating thing that moves is a floating thing somebody loses behind the
//! window edge, and this one has a close button that sets the preference
//! instead.
//!
//! # What it does not claim
//!
//! It shows levels. A level is not proof that the voice is being changed: a
//! working meter and a bypassed engine look identical, and saying so is the
//! difference between a monitor and a reassurance. What tells you the engine is
//! running is that the output is a voice that is not yours, which is what the
//! preview in the Studio is for.
//!
//! # In plain words
//!
//! A small strip along the bottom of the window showing how loud your voice is
//! going in and how loud the veiled voice is coming out, while live scramble is
//! running.
//!
//! It follows you around the application, because the moment you want it is the
//! moment you are doing something else and are not sure the microphone is still
//! working.
//!
//! It cannot tell you that the disguise is working. It can tell you that sound
//! is arriving and sound is leaving, which is the thing that usually goes
//! wrong.

use crate::theme::palette as p;
use egui::RichText;


/// Where the monitor sits, or whether it is shown at all.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Style {
    /// Docked along the bottom of the window. Covers nothing.
    #[default]
    Toolbar,
    /// Floating over the bottom right of the panel.
    Overlay,
    /// A small window of its own, kept above other windows.
    ///
    /// **Roadmap item 150.** The other two are inside the VeilVoice window, which is
    /// behind whatever you are talking into. Somebody on a call or streaming
    /// has the meeting or the streaming software in front, and the only
    /// picture of what their microphone is doing is covered by it exactly when
    /// they need it.
    OnTop,
    /// Not shown. The Studio still has the full meters.
    Off,
}


impl Style {

    /// A short name, for a picker.
    pub fn label(self) -> &'static str {
        match self {
            Self::Toolbar => "a strip along the bottom",
            Self::Overlay => "a card floating in the corner",
            Self::OnTop => "a small window kept above everything",
            Self::Off => "only on the Studio tab",
        }
    }



    /// What this choice costs and buys, in the words a front end should show.
    pub fn note(self) -> &'static str {
        match self {
            Self::Toolbar => {
                "A strip docked to the bottom of the window while live scramble \
                 is running, on every tab. It takes a little height and it \
                 covers nothing."
            }
            Self::Overlay => {
                "A small card floating over the bottom right corner while live \
                 scramble is running. Keeps the full height for the panel and \
                 sits on top of a corner of it."
            }
            Self::OnTop => {
                "A small window of its own that stays above other windows, so \
                 the meters are still visible with a call or streaming \
                 software in front. Move it where you like; closing it brings \
                 the strip back."
            }
            Self::Off => {
                "The monitor is not shown. The Studio still has the full \
                 meters, so this means you see them when you are looking at \
                 that tab and not otherwise."
            }
        }
    }



    /// Every style, in the order a picker should offer them.
    pub const ALL: &'static [Style] = &[Style::Toolbar, Style::Overlay, Style::OnTop, Style::Off];



    /// The identifier written to the settings file.
    pub fn key(self) -> &'static str {
        match self {
            Self::Toolbar => "toolbar",
            Self::Overlay => "overlay",
            Self::OnTop => "on-top",
            Self::Off => "off",
        }
    }



    /// Read a style back.
    ///
    /// An unrecognised value is the default rather than an error, and the
    /// default *shows* the monitor. Of the two ways to be wrong about a
    /// settings file this build cannot read, hiding the only picture of a live
    /// microphone is the worse one.
    pub fn from_key(key: &str) -> Style {
        Self::ALL
            .iter()
            .copied()
            .find(|style| style.key() == key)
            .unwrap_or_default()
    }

}


/// The smoothed levels the monitor and the Studio both draw.
///
/// One copy, updated once a frame from the session, because two copies is two
/// bars that disagree by a frame and one of them is always the one somebody is
/// looking at.
#[derive(Clone, Copy, Debug, Default)]
pub struct Levels {
    /// Smoothed input peak, 0 to 1.
    pub input: f32,
    /// Smoothed output peak, 0 to 1.
    pub output: f32,
    /// The highest input of the last moment or so.
    pub hold_input: f32,
    /// The highest output of the last moment or so.
    pub hold_output: f32,
    /// When the hold was last taken.
    hold_since: Option<std::time::Instant>,
    /// Whether either side has clipped since the session started.
    ///
    /// Sticky on purpose. Clipping is destructive and is over in a
    /// millisecond, so a warning that has gone before the person looks up was
    /// never given.
    pub clipped: bool,
}



/// How long a held peak stays up before it falls back to the current level.
const HOLD: std::time::Duration = std::time::Duration::from_millis(1500);


impl Levels {

    /// Take a new reading.
    pub fn update(&mut self, input_peak: f32, output_peak: f32) {
        use veilvoice_audio::meter;

        // Fall smoothly rather than flickering with every frame.
        self.input = (self.input * 0.7).max(input_peak);
        self.output = (self.output * 0.7).max(output_peak);

        if meter::clipping(input_peak) || meter::clipping(output_peak) {
            self.clipped = true;
        }

        // The hold rises at once and falls back after a second and a half,
        // rather than sticking: otherwise the mark slowly becomes a picture of
        // the loudest thing that ever happened.
        let expired = self
            .hold_since
            .map(|at| at.elapsed() >= HOLD)
            .unwrap_or(true);
        if expired || self.input >= self.hold_input || self.output >= self.hold_output {
            if expired {
                self.hold_input = self.input;
                self.hold_output = self.output;
            } else {
                self.hold_input = self.hold_input.max(self.input);
                self.hold_output = self.hold_output.max(self.output);
            }
            self.hold_since = Some(std::time::Instant::now());
        }
    }



    /// Back to nothing, for when a session stops.
    pub fn clear(&mut self) {
        *self = Self::default();
    }

}


/// A compact bar, for the strip. The full-height one lives on the Studio tab.
///
/// Same scale as `veilvoice_audio::meter`, so this bar, the Studio's bar and
/// the one `veilvoice live` draws in a terminal are the same bar at three
/// sizes. A monitor that used a scale of its own would be a fourth opinion
/// about the same number.
fn bar(ui: &mut egui::Ui, label: &str, peak: f32, hold: f32) {
    use veilvoice_audio::meter;

    ui.label(RichText::new(label).color(p::muted()).small());
    let (rect, _) = ui.allocate_exact_size(egui::vec2(120.0, 10.0), egui::Sense::hover());
    let painter = ui.painter();
    painter.rect_filled(rect, 2.0, p::bg_dark());

    let db = meter::dbfs(peak);
    let colour = if meter::clipping(peak) {
        p::red()
    } else if db >= -6.0 {
        p::yellow()
    } else if db >= -40.0 {
        p::green()
    } else {
        // Below -40 is room tone rather than speech. Muted, so a quiet room
        // does not read as a working microphone.
        p::muted()
    };
    let mut filled = rect;
    filled.set_width(rect.width() * meter::position(peak));
    painter.rect_filled(filled, 2.0, colour);

    if meter::position(hold) > meter::position(peak) {
        let x = rect.left() + rect.width() * meter::position(hold);
        painter.line_segment(
            [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())],
            egui::Stroke::new(1.5, p::fg()),
        );
    }
    painter.rect_stroke(
        rect,
        2.0,
        egui::Stroke::new(1.0, p::border()),
        egui::StrokeKind::Inside,
    );

    let text = if db <= meter::FLOOR_DB {
        " -inf".to_string()
    } else {
        format!("{db:>5.1}")
    };
    ui.label(
        RichText::new(text)
            .color(if meter::clipping(peak) {
                p::red()
            } else {
                p::muted()
            })
            .small(),
    );
}



/// What the reader did with the monitor this frame.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
    /// Nothing.
    None,
    /// The close button: switch the monitor off and remember it.
    Dismiss,
}



/// Draw the row itself. Shared by both styles so they cannot drift apart.
///
/// `preview` changes the word and the colour, and it is not cosmetic. A
/// preview goes to this machine's own output and a live session goes to
/// whatever is listening on the cable, and somebody who has those two the wrong
/// way round is either talking to a call in their own voice or talking to
/// nobody. The strip is the thing on screen, so the strip has to say which.
fn row(ui: &mut egui::Ui, levels: &Levels, preview: bool, closable: bool) -> Action {
    let mut action = Action::None;
    ui.horizontal(|ui| {
        if preview {
            ui.label(RichText::new("preview").color(p::yellow()).small())
                .on_hover_text("Going to this machine's output only. Nobody on a call hears this.");
        } else {
            ui.label(RichText::new("live").color(p::green()).small());
        }
        ui.add_space(6.0);
        bar(ui, "in", levels.input, levels.hold_input);
        ui.add_space(10.0);
        bar(ui, "out", levels.output, levels.hold_output);
        if levels.clipped {
            ui.add_space(8.0);
            ui.label(RichText::new("CLIPPED").color(p::red()).small())
                .on_hover_text(
                    "The signal reached full scale and was cut off. Turn the input \
                     level down; clipping cannot be undone afterwards.",
                );
        }
        if closable {
            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
                if ui
                    .small_button("close")
                    .on_hover_text(
                        "Hide this strip. The Studio keeps its meters, and \
                         Settings brings the strip back.",
                    )
                    .clicked()
                {
                    action = Action::Dismiss;
                }
            });
        }
    });
    action
}



/// Draw the monitor for this frame.
///
/// Call once per frame from the shell, after the tab strip and before the
/// panel, whether or not a session is running: this returns immediately when
/// there is nothing to show, so the caller has one line rather than a
/// condition it can get wrong in one place and not the other.
pub fn show(
    root: &mut egui::Ui,
    style: Style,
    running: bool,
    preview: bool,
    levels: &Levels,
) -> Action {
    if !running || style == Style::Off {
        return Action::None;
    }
    let mut action = Action::None;
    match style {
        Style::Toolbar => {
            egui::Panel::bottom("live_monitor").show(root, |ui| {
                ui.add_space(4.0);
                action = row(ui, levels, preview, true);
                ui.add_space(4.0);
            });
        }
        Style::Overlay => {
            egui::Area::new(egui::Id::new("live_monitor_overlay"))
                .anchor(egui::Align2::RIGHT_BOTTOM, egui::vec2(-16.0, -16.0))
                .order(egui::Order::Foreground)
                .show(root.ctx(), |ui| {
                    egui::Frame::new()
                        .fill(p::surface())
                        .stroke(egui::Stroke::new(1.0, p::border()))
                        .corner_radius(8)
                        .inner_margin(egui::Margin::symmetric(12, 8))
                        .show(ui, |ui| {
                            action = row(ui, levels, preview, true);
                        });
                });
        }
        Style::OnTop => {
            // Roadmap item 150. A window of its own, above the others.
            //
            // Immediate rather than deferred, because a deferred viewport's
            // closure has to be `'static` and these levels are borrowed from
            // the Studio for exactly this frame. Immediate draws it inside
            // this pass, with the same numbers the rest of the window is
            // drawing, which is the whole point: two copies of a meter are two
            // meters that disagree by a frame.
            //
            // Where the platform cannot give a second window, `egui` embeds it
            // in this one instead and says so through `ViewportClass`, which
            // is a floating card over the panel: the same thing `Overlay`
            // does, which is the right thing to fall back to.
            let viewport = egui::ViewportBuilder::default()
                .with_title("VeilVoice monitor")
                .with_inner_size([300.0, 96.0])
                .with_min_inner_size([240.0, 80.0])
                .with_always_on_top()
                // Off the task bar: this is an instrument that rides above a
                // call, not a second application somebody alt-tabs to.
                .with_taskbar(false)
                .with_resizable(true);
            root.ctx().show_viewport_immediate(
                egui::ViewportId::from_hash_of("veilvoice-monitor"),
                viewport,
                |ui, _class| {
                    ui.add_space(6.0);
                    // Not closable from inside: the window's own close button
                    // is the obvious way out, and two ways to dismiss one
                    // thing is one of them somebody has to discover.
                    action = row(ui, levels, preview, false);
                    ui.add_space(4.0);
                    ui.label(
                        RichText::new("levels only, not proof the voice is changed")
                            .small()
                            .color(p::muted()),
                    );
                    if ui.input(|i| i.viewport().close_requested()) {
                        action = Action::Dismiss;
                    }
                },
            );
        }
        Style::Off => {}
    }
    action
}



/// One level meter: a bar on the decibel scale, and the number beside it.
///
/// This was a **linear** bar with a decibel number printed next to it, which is
/// a meter arguing with itself: the number said -12 dB and the bar showed a
/// quarter. Ordinary speech at a sensible recording level peaks near -12 dBFS,
/// so the bar read as near-silence and the only way to fill it was to clip.
///
/// The scale now comes from `veilvoice_audio::meter`, which is where the peaks
/// come from, so this bar and the terminal's are the same bar. `hold` is the
/// highest level of the last moment or so, drawn as a mark: a transient is over
/// before an eye finishes moving, and a bar showing only *now* cannot show one.
///
/// # Why it lives here
///
/// It was in `app.rs` and private there, so when the Studio needed to meter
/// a take as it recorded there were two choices: reach into a private module,
/// or draw a second bar that would slowly stop looking like the first. It
/// belongs here, beside [`Levels`], which is the thing that smooths what it
/// draws.
///
/// Below -40 dBFS the colour goes muted rather than green, so a quiet room does
/// not read as a working microphone.
pub fn meter(ui: &mut egui::Ui, label: &str, peak: f32, hold: f32) {
    use veilvoice_audio::meter;

    ui.horizontal(|ui| {
        ui.label(RichText::new(label).color(p::muted()));
        let (rect, _) = ui.allocate_exact_size(egui::vec2(280.0, 12.0), egui::Sense::hover());
        let painter = ui.painter();
        painter.rect_filled(rect, 2.0, p::bg_dark());

        let db = meter::dbfs(peak);
        let colour = if meter::clipping(peak) {
            p::red()
        } else if db >= -6.0 {
            p::yellow()
        } else if db >= -40.0 {
            p::green()
        } else {
            // Below -40 the signal is room tone rather than speech. Drawn muted,
            // so a quiet room does not read as a working microphone.
            p::muted()
        };
        let mut filled = rect;
        filled.set_width(rect.width() * meter::position(peak));
        painter.rect_filled(filled, 2.0, colour);

        // The held peak, as a hairline. Only where the bar is empty: inside the
        // fill it would be saying what the fill already says.
        if meter::position(hold) > meter::position(peak) {
            let x = rect.left() + rect.width() * meter::position(hold);
            painter.line_segment(
                [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())],
                egui::Stroke::new(1.5, p::fg()),
            );
        }

        painter.rect_stroke(
            rect,
            2.0,
            egui::Stroke::new(1.0, p::border()),
            egui::StrokeKind::Inside,
        );

        let text = if db <= meter::FLOOR_DB {
            "  -inf dBFS".to_string()
        } else {
            format!("{db:>6.1} dBFS")
        };
        ui.label(RichText::new(text).color(if meter::clipping(peak) {
            p::red()
        } else {
            p::muted()
        }));
    });
}


#[cfg(test)]
mod tests {
    use super::*;

    /// **Roadmap item 150.** The always-on-top window is offered and is not the
    /// default.
    ///
    /// Not the default because a window that puts itself above everything is
    /// a thing somebody should ask for. Offered because the other two live
    /// inside the VeilVoice window, and somebody on a call has the call in
    /// front of that window.
    #[test]
    fn the_monitor_can_be_kept_above_other_windows() {
        assert!(Style::ALL.contains(&Style::OnTop));
        assert_ne!(Style::default(), Style::OnTop);
        assert_eq!(Style::from_key("on-top"), Style::OnTop);
        assert_eq!(Style::OnTop.key(), "on-top");
        // It has to say what it is for, in the words a picker shows, and the
        // words have to name the thing that makes it different.
        assert!(Style::OnTop.note().contains("above other windows"));
        assert!(
            Style::OnTop.note().contains("closing it"),
            "somebody pressing the window's close button must be told what \
             that does, because it is not the same as turning the monitor off"
        );
    }

    /// Every style survives a round trip through the settings file.
    #[test]
    fn a_style_reads_back_as_itself() {
        for style in Style::ALL {
            assert_eq!(Style::from_key(style.key()), *style);
        }
    }

    /// An unreadable setting shows the monitor rather than hiding it.
    ///
    /// The same rule the notification style follows, for the same reason: of
    /// the two ways to be wrong about a file this build cannot parse, the one
    /// that hides the only picture of a live microphone is the worse one.
    #[test]
    fn an_unknown_setting_still_shows_something() {
        for nonsense in ["", "TOOLBAR", "left", "off ", "1"] {
            assert_eq!(Style::from_key(nonsense), Style::Toolbar, "{nonsense:?}");
        }
        // And the one value that does mean off still means off.
        assert_eq!(Style::from_key("off"), Style::Off);
    }

    /// Every style says what it is and what it costs.
    #[test]
    fn every_style_explains_itself() {
        for style in Style::ALL {
            assert!(!style.label().is_empty());
            assert!(style.note().len() > 40, "{}", style.key());
        }
    }

    /// Clipping stays reported once it has happened.
    #[test]
    fn clipping_is_sticky_because_it_is_over_in_a_millisecond() {
        let mut levels = Levels::default();
        levels.update(1.0, 0.1);
        assert!(levels.clipped);
        for _ in 0..100 {
            levels.update(0.01, 0.01);
        }
        assert!(
            levels.clipped,
            "a clip that scrolls past was never reported"
        );
        levels.clear();
        assert!(!levels.clipped, "stopping the session starts again");
    }

    /// The held peak is at least the current level, always.
    #[test]
    fn the_hold_never_sits_below_the_bar_it_marks() {
        let mut levels = Levels::default();
        for peak in [0.1f32, 0.9, 0.2, 0.05, 0.7] {
            levels.update(peak, peak / 2.0);
            assert!(levels.hold_input >= levels.input, "{levels:?}");
            assert!(levels.hold_output >= levels.output, "{levels:?}");
        }
    }
}