crates/veilvoice-gui/src/group.rs

what this file is for · veilvoice-gui · 2110 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
//! Group mode: several people in one recording, each with a name and a colour.
//!
//! The engine has handled several speakers since `veilvoice-conversation`
//! existed. This is the part of it a person can see: who is in the recording,
//! what each of them is called, what colour each is drawn in, and which
//! destination voice each becomes.
//!
//! # The toggle does not persist, and the tick that makes it persist is separate
//!
//! Group mode changes what a recording is *treated as*. A mode that survives a
//! restart is a mode somebody eventually forgets is on, and for this tool that
//! means a single-speaker recording rendered against a plan that does not
//! describe it, which silences everything the plan does not claim. So the
//! toggle is per-run and off by default, and there is a second, explicit tick
//! for "always start in group mode" which is the only thing written to disk.
//!
//! Two controls where one would do, deliberately. They answer two different
//! questions: *is this recording a group recording* and *are most of my
//! recordings group recordings*.
//!
//! # Colour is assigned, never guessed from the voice
//!
//! A speaker's colour is a function of their **slot**, exactly as their
//! destination voice is, and for the same reason: anything chosen by measuring
//! the input would make an output property a function of the input speaker,
//! which is the linkage this whole project exists to destroy. Slot 0 and slot 1
//! are the furthest-apart pair in the table because two people is the common
//! case; every slot after that is the colour whose nearest neighbour among the
//! ones already used is furthest away. That order was computed rather than
//! judged. See `veilvoice_video::palette`.
//!
//! A colour can be **overridden** per speaker, from any colour in any of the
//! nine palettes the website offers. An override is a person's choice about
//! their own recording, made after the fact, and carries none of the linkage
//! problem above.
//!
//! # Colour is never the only signal
//!
//! The name is drawn beside every circle, in the list, and in the subtitles.
//! Somebody who cannot separate two of these colours has the name, everywhere.
//! A panel that distinguished speakers by colour alone would be one about eight
//! per cent of men could not use.
//!
//! # In plain words
//!
//! The panel for a recording with several people in it.
//!
//! Each person gets a name and a colour, so you can see at a glance who is who.
//! The colours are chosen to be as different from each other as possible, and you
//! can pick your own from any of the palettes the website offers.
//!
//! Group mode is off when the application opens unless you have said otherwise,
//! and turning it on lasts only for that run. A mode that quietly survived a
//! restart is a mode somebody eventually forgets is on.

use crate::theme::palette as p;
use eframe::egui::{self, Color32, RichText, Ui};
use std::path::PathBuf;
use std::sync::mpsc;
use veilvoice_conversation::mode::{self as voice_mode, VoiceMode};
use veilvoice_conversation::render;
use veilvoice_conversation::{Conversation, Speaker};
use veilvoice_core::voices::{self, MAX_VOICES};
use veilvoice_core::DeidConfig;
use veilvoice_policy::workspace::{Member, Workspace};
use veilvoice_video::palette::Palette;


/// One person, as the panel holds them.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Person {
    /// What they are called. Carried into the audio's subtitles as typed.
    pub name: String,
    /// A colour chosen by hand, or `None` for the one their slot is given.
    pub colour: Option<Color32>,
}


impl Person {

    /// A person with the default name for their slot.
    fn at(slot: usize) -> Self {
        Self {
            name: format!("Speaker {}", slot + 1),
            colour: None,
        }
    }

}


/// What comes out of a group render.
///
/// All three by default. The request was "video + audio + combined unless the
/// user restricts it", and a default that produces less than was asked for is
/// a default that gets discovered after the recording has been deleted.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Outputs {
    /// The veiled audio.
    pub audio: bool,
    /// WebVTT and SubRip beside it.
    pub subtitles: bool,
    /// The self-contained page that plays all of it together.
    pub page: bool,
    /// **Roadmap item 139.** The video file, with the picture drawn rather than black.
    ///
    /// Off by default, and the only one of the four that is. The other three
    /// are files this program writes on its own; this one needs `ffmpeg`, which
    /// VeilVoice does not ship and will not install without being asked, and a
    /// default that silently depends on a tool the machine may not have is a
    /// default that fails on somebody else's computer.
    pub video: bool,
}


impl Default for Outputs {

    fn default() -> Self {
        Self {
            audio: true,
            subtitles: true,
            page: true,
            video: false,
        }
    }

}

impl Outputs {

    /// Whether anything at all would be written.
    pub fn any(&self) -> bool {
        self.audio || self.subtitles || self.page || self.video
    }



    /// The ticked ones, by name, for a project file.
    pub fn names(&self) -> Vec<String> {
        let mut out = Vec::new();
        if self.audio {
            out.push("audio".to_string());
        }
        if self.subtitles {
            out.push("subtitles".to_string());
        }
        if self.page {
            out.push("page".to_string());
        }
        if self.video {
            out.push("video".to_string());
        }
        out
    }



    /// Read back from a project file.
    ///
    /// An unrecognised name is ignored rather than refused: the file's own
    /// parser has already refused anything structurally strange, and an output
    /// this build cannot write is a thing it simply does not write.
    pub fn from_names(names: &[String]) -> Self {
        Self {
            audio: names.iter().any(|n| n == "audio"),
            subtitles: names.iter().any(|n| n == "subtitles"),
            page: names.iter().any(|n| n == "page"),
            video: names.iter().any(|n| n == "video"),
        }
    }

}


/// An egui colour as `#rrggbb`.
fn hex_of(colour: Color32) -> String {
    format!("#{:02x}{:02x}{:02x}", colour.r(), colour.g(), colour.b())
}



/// `#rrggbb` as an egui colour, or `None` for anything that is not one.
fn colour_of(text: &str) -> Option<Color32> {
    veilvoice_video::palette::rgb(text).map(|(r, g, b)| Color32::from_rgb(r, g, b))
}



/// The group-mode panel's state.
pub struct Group {
    /// Whether group mode is on **for this run**. Never written to disk.
    pub enabled: bool,
    /// The people in the recording.
    pub people: Vec<Person>,
    /// Which speaker's colour picker is open, if any.
    picking: Option<usize>,
    /// What a render would write.
    pub outputs: Outputs,
    /// The last thing that went wrong, to show rather than to swallow.
    pub notice: Option<String>,

    /// The recording.
    pub input: Option<PathBuf>,
    /// The plan holding who speaks when. Without it nothing can be rendered:
    /// this panel knows *who*, and a plan is the only honest source of *when*.
    pub plan: Option<PathBuf>,
    /// A title for the page, if the plan does not carry one.
    pub title: String,
    /// Which palette a page is drawn in. Tokyo Night unless changed, and
    /// **not persisted** -- the same shape as the mode toggle above it.
    pub theme: &'static Palette,
    /// A voice each, or one voice between everybody.
    pub voices: VoiceMode,
    /// Which named way of working this panel is set up as.
    pub profile: String,
    /// Where this project was last saved or loaded, if anywhere.
    pub project: Option<PathBuf>,
    /// The four file pickers this panel can have open.
    ///
    /// One each rather than one shared, because they land in different places
    /// and a single pending answer would have to remember which -- which is
    /// what the verify tab does, and only because its three slots are
    /// genuinely the same kind of thing.
    opening_project: crate::dialog::Pending,
    /// Where to save the project.
    saving_project: crate::dialog::Pending,
    /// The recording.
    choosing_input: crate::dialog::Pending,
    /// The plan.
    choosing_plan: crate::dialog::Pending,

    /// The engine settings in force, copied from the application each frame.
    ///
    /// F-67: this panel used [`DeidConfig::default`] everywhere -- for the
    /// voice limit it shows, for the mode it allows, and for the render itself.
    /// So somebody who set the strength to its highest and turned accent
    /// neutralisation on got a group render at the *default* strength with the
    /// accent work off, and nothing said so. The controls were on another tab,
    /// which is exactly why it went unnoticed.
    ///
    /// It matters twice over. The render is weaker than asked for, which is the
    /// serious half. And [`voices::clear_voices`] depends on the frame grid, so
    /// a configuration that collapses two registers onto each other makes the
    /// panel's "8 of 10" wrong in the direction that lets two people share a
    /// voice.
    pub config: DeidConfig,

    /// The worker, while a render is running.
    job: Option<mpsc::Receiver<Result<Vec<PathBuf>, String>>>,
    /// What that worker is doing, while it does it. **Roadmap item 133.**
    ///
    /// Shared with the render threads, which write to it, and read here every
    /// frame while the render is running. `None` when nothing is rendering.
    watching: Option<std::sync::Arc<render::Progress>>,
    /// The last render's result, kept until another is started.
    report: Option<Result<Vec<PathBuf>, String>>,
}


impl Default for Group {

    fn default() -> Self {
        Self {
            // Off. Always off, whatever was on last time -- see the module
            // note. `start_from` is what turns it on at launch.
            enabled: false,
            people: vec![Person::at(0), Person::at(1)],
            picking: None,
            watching: None,
            outputs: Outputs::default(),
            notice: None,
            input: None,
            plan: None,
            title: String::new(),
            theme: veilvoice_video::palette::default_palette(),
            voices: VoiceMode::default(),
            profile: veilvoice_policy::workspace::default_profile()
                .id
                .to_string(),
            project: None,
            opening_project: crate::dialog::Pending::new(),
            saving_project: crate::dialog::Pending::new(),
            choosing_input: crate::dialog::Pending::new(),
            choosing_plan: crate::dialog::Pending::new(),
            // Replaced every frame by the application's own settings. The
            // default is only what a panel built in a test starts from.
            config: DeidConfig::default(),
            job: None,
            report: None,
        }
    }

}

impl Group {

    /// The panel as it should open, given the saved preference.
    ///
    /// The *only* path by which group mode is on before anybody has touched
    /// anything.
    pub fn start_from(always: bool) -> Self {
        Self {
            enabled: always,
            ..Self::default()
        }
    }



    /// How many people are in the recording.
    pub fn len(&self) -> usize {
        self.people.len()
    }



    /// Whether there is nobody in it.
    pub fn is_empty(&self) -> bool {
        self.people.is_empty()
    }



    /// The colour a slot is drawn in: the override, or the one it is given.
    pub fn colour(&self, slot: usize) -> Color32 {
        self.people
            .get(slot)
            .and_then(|person| person.colour)
            .unwrap_or_else(|| assigned_colour(slot))
    }



    /// Add a person, if there is room for one.
    ///
    /// Ten is the engine's limit, not this panel's: there are ten destination
    /// voices, and an eleventh speaker would have to share one with somebody.
    /// Two people sharing a voice is a real collision, so the limit is stated
    /// rather than wrapped around.
    pub fn add(&mut self) {
        // The limit is whatever the *mode* can carry, and for a voice each that
        // is how many voices are far enough apart to be told apart -- measured
        // under the engine's configuration, not a number typed here.
        if let Err(why) = voice_mode::check(self.people.len() + 1, self.voices, &self.config) {
            self.notice = Some(why.to_string());
            return;
        }
        self.people.push(Person::at(self.people.len()));
        self.notice = None;
    }



    /// How many people this panel can hold in its current mode.
    pub fn limit(&self) -> usize {
        self.voices.speaker_limit(&self.config)
    }



    /// Remove one person, keeping at least two.
    ///
    /// One speaker is not a group, and a panel that let you get there would
    /// leave group mode on with nothing for it to do.
    pub fn remove(&mut self, slot: usize) {
        if self.people.len() <= 2 || slot >= self.people.len() {
            return;
        }
        self.people.remove(slot);
        self.picking = None;
    }



    /// Build a plan from the panel: names in slot order, no turns.
    ///
    /// Turns come from a plan file or from one microphone per person. This
    /// makes the half the panel knows about, and says what is missing rather
    /// than inventing it: a plan with no turns claims no audio, and every
    /// second of a recording rendered against it would be silenced.
    pub fn to_plan(&self, title: Option<&str>) -> Result<Conversation, String> {
        let mut plan = Conversation::new();
        plan.title = title.map(|t| t.to_string());
        for person in &self.people {
            plan.add_speaker(Speaker::named(&person.name))
                .map_err(|error| error.to_string())?;
        }
        Ok(plan)
    }



    /// The whole panel.
    ///
    /// `settings` is here for one tick: "always start in group mode" is the
    /// only thing on this panel that outlives the run, and it belongs beside
    /// the toggle it modifies rather than on a settings page three clicks away.
    /// The two controls only make sense read together.
    pub fn tab(&mut self, ui: &mut Ui, settings: &mut crate::settings::Settings) {
        // The panel is taller than the window the moment the colour picker is
        // open -- 108 swatches in nine named groups -- and without a scroller
        // the picker simply is not reachable. Found by opening it in the
        // running application and looking, which is also how the speaker strip
        // turned out to be five lines of wrapped text at 96 pixels wide.
        // The application scrolls every tab in one place; a second scroller
        // here would trap the wheel in whichever the pointer was over.
        self.body(ui, settings);
    }



    /// Collect whatever the open pickers have answered.
    ///
    /// Called once per frame, before anything is drawn, so a file chosen while
    /// the reader was browsing is in place by the time the panel that shows it
    /// is painted -- the same reason F-61 moved the dropped-file read to the
    /// top of `update`.
    fn collect_dialogs(&mut self) {
        if let Some(path) = self.opening_project.taken() {
            match veilvoice_policy::workspace::Workspace::load(&path) {
                Ok(work) => {
                    self.from_workspace(&work);
                    self.project = Some(path);
                }
                Err(why) => self.notice = Some(why.to_string()),
            }
        }
        if let Some(path) = self.saving_project.taken() {
            match self.to_workspace().save(&path) {
                Ok(()) => {
                    self.project = Some(path);
                    self.notice = None;
                }
                Err(why) => self.notice = Some(why.to_string()),
            }
        }
        if let Some(path) = self.choosing_input.taken() {
            self.input = Some(path);
        }
        if let Some(path) = self.choosing_plan.taken() {
            self.plan = Some(path);
        }
    }



    /// Everything inside the scroller.
    fn body(&mut self, ui: &mut Ui, settings: &mut crate::settings::Settings) {
        self.collect_dialogs();
        ui.heading(RichText::new("Group mode").color(p::blue()));
        ui.add_space(4.0);
        ui.label(
            RichText::new(
                "One recording with several people in it. Each gets a different \
                 destination voice, and every voiceprint is destroyed just as thoroughly \
                 as one speaker's would be.",
            )
            .color(p::muted()),
        );
        ui.add_space(10.0);

        self.profile_controls(ui);
        ui.add_space(10.0);
        self.mode_controls(ui, settings);
        ui.add_space(10.0);
        ui.separator();
        ui.add_space(10.0);

        if !self.enabled {
            ui.label(
                RichText::new(
                    "Group mode is off. A recording is treated as one speaker, which is \
                     what `anonymise file` does.",
                )
                .color(p::muted()),
            );
            return;
        }

        self.voice_mode_controls(ui);
        ui.add_space(12.0);
        self.strip(ui);
        ui.add_space(12.0);
        self.people_list(ui);
        ui.add_space(12.0);
        self.output_controls(ui);
        ui.add_space(12.0);
        self.files_and_theme(ui);
        ui.add_space(12.0);
        self.render_controls(ui);

        if let Some(notice) = &self.notice {
            ui.add_space(8.0);
            ui.label(RichText::new(notice).color(p::yellow()));
        }

        ui.add_space(12.0);
        ui.label(
            RichText::new(
                "Names are not veiled by anything. They are typed by you and they go into \
                 the subtitles as typed.",
            )
            .color(p::yellow()),
        );
        ui.add_space(4.0);
        ui.label(
            RichText::new(
                "This panel holds who is in the recording. When each turn starts and ends \
                 comes from a plan file or from one microphone per person. VeilVoice \
                 does not guess who is speaking, and audio no turn claims is silenced \
                 rather than passed through.",
            )
            .color(p::muted()),
        );
    }



    /// The named ways of working, and the project this panel came from.
    ///
    /// A profile is a *starting point*, not a lock: picking one sets the
    /// controls below and then leaves them alone. Anything else would mean a
    /// preset quietly overriding a choice somebody made after picking it, and
    /// they would find that out in the output.
    fn profile_controls(&mut self, ui: &mut Ui) {
        ui.label(RichText::new("How you are working").color(p::blue()));
        ui.add_space(4.0);

        ui.horizontal_wrapped(|ui| {
            for one in veilvoice_policy::workspace::BUILT_IN {
                let chosen = self.profile == one.id;
                if ui.selectable_label(chosen, one.name).clicked() && !chosen {
                    self.apply_profile(one);
                }
            }
        });

        if let Some(one) = veilvoice_policy::workspace::profile(&self.profile) {
            ui.label(RichText::new(one.note).color(p::muted()).small());
        } else {
            ui.label(
                RichText::new(format!(
                    "This project was saved under a profile called {:?}, which this \
                     build does not have.",
                    self.profile
                ))
                .color(p::yellow())
                .small(),
            );
        }

        ui.add_space(8.0);
        ui.horizontal(|ui| {
            if ui.button("open project…").clicked() {
                self.opening_project
                    .start(crate::dialog::Ask::open_filtered(
                        "VeilVoice project",
                        &["veilwork", "txt"],
                    ));
            }
            if ui.button("save project…").clicked() {
                self.saving_project.start(crate::dialog::Ask::save_filtered(
                    "project.veilwork",
                    "VeilVoice project",
                    &["veilwork"],
                ));
            }
            ui.label(
                RichText::new(match &self.project {
                    Some(path) => path
                        .file_name()
                        .map(|n| n.to_string_lossy().into_owned())
                        .unwrap_or_else(|| path.display().to_string()),
                    None => "not saved".to_string(),
                })
                .color(p::muted())
                .small(),
            );
        });
        ui.label(
            RichText::new(
                "  A project holds where your files are, who is in the recording and what \
                 you called them. It holds no audio and no passwords, so it is safe to \
                 keep beside the recording, but it does hold the names you typed.",
            )
            .color(p::muted())
            .small(),
        );
    }



    /// Set the controls this profile names, and change nothing else.
    fn apply_profile(&mut self, one: &veilvoice_policy::workspace::Profile) {
        self.profile = one.id.to_string();
        self.enabled = one.group;
        // A profile that asks for a voice each cannot be applied to more people
        // than there are voices. Said rather than trimming somebody out of the
        // group to make the preset fit.
        match voice_mode::check(self.people.len(), one.voices, &self.config) {
            Ok(()) => {
                self.voices = one.voices;
                self.notice = None;
            }
            Err(why) => {
                self.notice = Some(format!(
                    "{}. The rest of the profile has been applied.",
                    why
                ));
            }
        }
    }



    /// The two controls that decide whether group mode is on.
    fn mode_controls(&mut self, ui: &mut Ui, settings: &mut crate::settings::Settings) {
        ui.horizontal(|ui| {
            ui.checkbox(&mut self.enabled, "group mode")
                .on_hover_text("For this run only. Nothing here is written to disk.");
            ui.add_space(12.0);
            ui.label(
                RichText::new(if self.enabled {
                    "on, for this run"
                } else {
                    "off"
                })
                .color(if self.enabled { p::green() } else { p::muted() })
                .small(),
            );
        });
        ui.label(
            RichText::new(
                "  For this run. Closing the app turns it off again, so a recording of \
                 one person is never rendered against a plan describing several.",
            )
            .color(p::muted())
            .small(),
        );

        ui.add_space(6.0);
        let mut always = settings.always_group();
        if ui
            .checkbox(&mut always, "always start in group mode")
            .on_hover_text("The one thing on this panel that is written to disk")
            .changed()
        {
            settings.set_always_group(always);
        }
        ui.label(
            RichText::new(
                "  Remembered. It decides what the app opens in; the toggle above still \
                 turns it off for a run.",
            )
            .color(p::muted())
            .small(),
        );
        if let Some(error) = settings.save_error() {
            ui.label(
                RichText::new(format!("  {error}"))
                    .color(p::yellow())
                    .small(),
            );
        }
    }



    /// A voice each, or one voice between everybody.
    ///
    /// The second is more private and is not the default, which is the honest
    /// way round: it removes a real trace -- *which* speaker somebody was --
    /// and it costs the ability to follow the recording by ear. Most people
    /// want the first, and the ones who want the second want it for a reason
    /// they already know.
    fn voice_mode_controls(&mut self, ui: &mut Ui) {
        ui.label(RichText::new("Voices").color(p::blue()));
        ui.add_space(4.0);
        ui.horizontal(|ui| {
            for mode in [VoiceMode::Distinct, VoiceMode::Uniform] {
                if ui
                    .selectable_label(self.voices == mode, mode.label())
                    .clicked()
                    && self.voices != mode
                {
                    // Switching *to* a mode that cannot carry this many people
                    // is refused, with the same words the engine would use.
                    match voice_mode::check(self.people.len(), mode, &self.config) {
                        Ok(()) => {
                            self.voices = mode;
                            self.notice = None;
                        }
                        Err(why) => self.notice = Some(why.to_string()),
                    }
                }
            }
        });
        ui.label(RichText::new(self.voices.note()).color(p::muted()).small());

        let clear = voices::clear_voices(&self.config);
        ui.add_space(4.0);
        ui.label(
            RichText::new(format!(
                "  {clear} of the {MAX_VOICES} destination voices are far enough apart to \
                 be told apart by ear, measured rather than chosen. Past that, one voice for \
                 everybody is the honest option.",
            ))
            .color(p::muted())
            .small(),
        );

        // What the finished recording gives away about **who** was speaking,
        // beside the control that decides it.
        //
        // Not a measure of how well any voice is disguised: that is the
        // engine's, it is the same for one person and for eight, and a number
        // here that moved with the group size would be read as saying otherwise.
        // This counts one specific thing, which is how much of the shape of the
        // conversation a listener gets for free.
        let seen = voice_mode::Exposure::of(self.voices, self.people.len(), &self.config);
        ui.add_space(8.0);
        ui.horizontal(|ui| {
            ui.label(RichText::new("who was speaking").color(p::muted()).small());
            // Green at the top, yellow in the middle, red where most of the
            // conversation's structure is on the surface. The thresholds are
            // the two the note's wording already turns on.
            let colour = if seen.score >= 70 {
                p::green()
            } else if seen.score >= 35 {
                p::yellow()
            } else {
                p::red()
            };
            ui.label(
                RichText::new(format!("{}%", seen.score))
                    .color(colour)
                    .strong(),
            );
            ui.label(
                RichText::new(format!(
                    "{:.1} bits per turn, {} voice{} a listener can separate",
                    seen.bits,
                    seen.classes,
                    if seen.classes == 1 { "" } else { "s" }
                ))
                .color(p::muted())
                .small(),
            );
        });
        ui.label(
            RichText::new(format!("  {}", seen.note()))
                .color(p::muted())
                .small(),
        );
        if seen.crowded() {
            // The other half of the trade, and it moves the opposite way: a
            // crowded table gives less away and is harder to follow. Said as
            // its own line so it is not read as making the score worse.
            ui.label(
                RichText::new(format!(
                    "  The closest pair of these voices is {:.2}, under the {:.2} a \
                     listener needs. That makes the recording give *less* away and \
                     harder to follow: two of these people will sound like one.",
                    seen.crowding,
                    veilvoice_core::voices::CLEAR_SEPARATION,
                ))
                .color(p::yellow())
                .small(),
            );
        }
    }



    /// The picture: a circle per person, in their colour, with their name.
    ///
    /// This is the part the request was actually about. A mode you can only
    /// tell is on by reading a checkbox is a mode that gets left on.
    fn strip(&mut self, ui: &mut Ui) {
        egui::Frame::new()
            .fill(p::bg_dark())
            .stroke(egui::Stroke::new(1.0, p::border()))
            .corner_radius(8)
            .inner_margin(egui::Margin::symmetric(14, 12))
            .show(ui, |ui| {
                ui.horizontal_wrapped(|ui| {
                    for slot in 0..self.people.len() {
                        let colour = self.colour(slot);
                        let name = self.people[slot].name.clone();
                        ui.vertical(|ui| {
                            // Wide enough for "high register, wide tract" on two
                            // lines. At 96 the voice description wrapped to five,
                            // which turned a row of circles into a wall of text --
                            // seen in a capture of the running window, not deduced.
                            ui.set_width(168.0);
                            let (rect, _) = ui
                                .allocate_exact_size(egui::vec2(168.0, 44.0), egui::Sense::hover());
                            ui.painter().circle_filled(rect.center(), 19.0, colour);
                            ui.vertical_centered(|ui| {
                                ui.label(RichText::new(&name).color(p::fg()).small());
                                ui.label(
                                    RichText::new(self.voices.voice_for(slot).describe())
                                        .color(p::muted())
                                        .small(),
                                );
                            });
                        });
                    }
                });
            });
    }



    /// One row per person: colour, name, and a way to remove them.
    fn people_list(&mut self, ui: &mut Ui) {
        let count = self.people.len();
        let mut remove = None;
        for slot in 0..count {
            ui.horizontal(|ui| {
                let colour = self.colour(slot);
                let (rect, response) =
                    ui.allocate_exact_size(egui::vec2(22.0, 22.0), egui::Sense::click());
                ui.painter().rect_filled(rect, 5.0, colour);
                ui.painter().rect_stroke(
                    rect,
                    5.0,
                    egui::Stroke::new(1.0, p::border()),
                    egui::StrokeKind::Inside,
                );
                if response.clicked() {
                    self.picking = if self.picking == Some(slot) {
                        None
                    } else {
                        Some(slot)
                    };
                }
                response.on_hover_text("Choose a colour, from any of the palettes");

                ui.add(
                    egui::TextEdit::singleline(&mut self.people[slot].name)
                        .desired_width(180.0)
                        .hint_text("a name"),
                );

                if self.people[slot].colour.is_some()
                    && ui
                        .small_button("automatic")
                        .on_hover_text("Go back to the colour this slot is given")
                        .clicked()
                {
                    self.people[slot].colour = None;
                }

                if count > 2 && ui.small_button("remove").clicked() {
                    remove = Some(slot);
                }
            });

            if self.picking == Some(slot) {
                self.palette_picker(ui, slot);
            }
        }

        if let Some(slot) = remove {
            self.remove(slot);
        }

        ui.add_space(6.0);
        ui.horizontal(|ui| {
            if ui.button("add a person").clicked() {
                self.add();
            }
            ui.label(
                RichText::new(format!("{} of {}", self.people.len(), self.limit()))
                    .color(p::muted())
                    .small(),
            );
        });
    }



    /// Every colour in every palette, as swatches.
    ///
    /// The whole set rather than a colour wheel: these are the colours the rest
    /// of this application and the website are drawn in, so a speaker picked
    /// from them looks like part of the same thing. Grouped by palette and
    /// named, because "the third blue" is not a thing anybody can ask for.
    fn palette_picker(&mut self, ui: &mut Ui, slot: usize) {
        egui::Frame::new()
            .fill(p::surface())
            .stroke(egui::Stroke::new(1.0, p::border()))
            .corner_radius(6)
            .inner_margin(egui::Margin::same(10))
            .show(ui, |ui| {
                ui.label(
                    RichText::new("the ten a slot can be given")
                        .color(p::muted())
                        .small(),
                );
                ui.horizontal_wrapped(|ui| {
                    for index in 0..MAX_VOICES {
                        self.swatch(ui, slot, assigned_colour(index));
                    }
                });
                ui.add_space(8.0);
                for theme in crate::theme::themes() {
                    ui.label(RichText::new(theme.name).color(p::muted()).small());
                    ui.horizontal_wrapped(|ui| {
                        for colour in [
                            theme.accent,
                            theme.accent_2,
                            theme.cyan,
                            theme.ok,
                            theme.warn,
                            theme.err,
                            theme.fg,
                            theme.muted,
                        ] {
                            self.swatch(ui, slot, colour);
                        }
                    });
                }
            });
    }



    /// One clickable colour.
    fn swatch(&mut self, ui: &mut Ui, slot: usize, colour: Color32) {
        let (rect, response) = ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::click());
        ui.painter().rect_filled(rect, 4.0, colour);
        let chosen = self.people[slot].colour == Some(colour);
        ui.painter().rect_stroke(
            rect,
            4.0,
            egui::Stroke::new(if chosen { 2.0 } else { 1.0 }, {
                if chosen {
                    p::fg()
                } else {
                    p::border()
                }
            }),
            egui::StrokeKind::Inside,
        );
        if response.clicked() {
            self.people[slot].colour = Some(colour);
            self.picking = None;
        }
    }



    /// What a render writes.
    fn output_controls(&mut self, ui: &mut Ui) {
        ui.label(RichText::new("What a render writes").color(p::blue()));
        ui.add_space(4.0);
        ui.horizontal(|ui| {
            ui.checkbox(&mut self.outputs.audio, "audio");
            ui.checkbox(&mut self.outputs.subtitles, "subtitles");
            ui.checkbox(&mut self.outputs.page, "page");
            // **Roadmap item 139.** Off unless asked, because this is the one output
            // that needs a tool VeilVoice does not ship.
            ui.checkbox(&mut self.outputs.video, "video");
        });
        if !self.outputs.any() {
            ui.label(
                RichText::new("Nothing is ticked, so a render would write nothing.")
                    .color(p::yellow())
                    .small(),
            );
        } else {
            ui.label(
                RichText::new("All three by default. Untick what you do not want.")
                    .color(p::muted())
                    .small(),
            );
        }
    }



    /// This panel as a saveable project.
    pub fn to_workspace(&self) -> Workspace {
        let mut work = Workspace::new();
        work.title = (!self.title.trim().is_empty()).then(|| self.title.trim().to_string());
        work.input = self.input.clone();
        work.plan = self.plan.clone();
        work.profile = self.profile.clone();
        work.theme = self.theme.id.to_string();
        work.outputs = self.outputs.names();
        work.members = self
            .people
            .iter()
            .map(|person| Member {
                name: person.name.clone(),
                colour: person.colour.map(hex_of),
            })
            .collect();
        work
    }



    /// Put a saved project back.
    ///
    /// Anything this build does not recognise -- a profile from a newer
    /// version, a palette that has been renamed -- is **reported and left
    /// alone** rather than quietly replaced. Loading a project and silently
    /// getting different settings is the failure worth avoiding here: the whole
    /// point of the file is that it puts things back where they were.
    pub fn from_workspace(&mut self, work: &Workspace) {
        let mut notes: Vec<String> = Vec::new();

        self.title = work.title.clone().unwrap_or_default();
        self.input = work.input.clone();
        self.plan = work.plan.clone();

        match veilvoice_policy::workspace::profile(&work.profile) {
            Some(found) => {
                self.profile = found.id.to_string();
                self.voices = found.voices;
                self.enabled = found.group;
            }
            None => notes.push(format!(
                "this project was saved under a profile called {:?}, which this build \
                 does not have. The settings it named have been left as they are.",
                work.profile
            )),
        }

        match veilvoice_video::palette::by_id(&work.theme) {
            Some(found) => self.theme = found,
            None => notes.push(format!(
                "this project's palette, {:?}, is not one this build has.",
                work.theme
            )),
        }

        self.outputs = Outputs::from_names(&work.outputs);

        if !work.members.is_empty() {
            self.people = work
                .members
                .iter()
                .map(|member| Person {
                    name: member.name.clone(),
                    colour: member.colour.as_deref().and_then(colour_of),
                })
                .collect();
        }

        // A saved project may hold more people than the current mode can carry
        // -- it was saved under a different one, or by a build with a different
        // measured limit. Said, not silently trimmed.
        if let Err(why) = voice_mode::check(self.people.len(), self.voices, &self.config) {
            notes.push(why.to_string());
        }

        self.notice = (!notes.is_empty()).then(|| notes.join(" "));
    }



    /// Whether a render is running, so the window keeps repainting.
    pub fn is_busy(&self) -> bool {
        self.job.is_some()
    }



    /// Take the worker's answer if it has one. Called once a frame; never waits.
    pub fn drain(&mut self) {
        let Some(rx) = &self.job else { return };
        match rx.try_recv() {
            Ok(done) => {
                self.report = Some(done);
                self.job = None;
                // The bars go with the render they were about. Left up, they
                // would describe a render that has finished as though it were
                // still running, which is the one thing a progress bar must
                // not do.
                self.watching = None;
            }
            Err(mpsc::TryRecvError::Empty) => {}
            Err(mpsc::TryRecvError::Disconnected) => {
                self.report = Some(Err("the render stopped without finishing".into()));
                self.job = None;
                self.watching = None;
            }
        }
    }



    /// The recording, the plan, the title and the palette.
    fn files_and_theme(&mut self, ui: &mut Ui) {
        ui.label(RichText::new("The recording").color(p::blue()));
        ui.add_space(4.0);

        ui.horizontal(|ui| {
            if ui.button("choose recording…").clicked() {
                self.choosing_input.start(crate::dialog::Ask::open_filtered(
                    "audio",
                    &["wav", "mp3", "flac", "ogg", "m4a", "aac"],
                ));
            }
            ui.label(
                RichText::new(match &self.input {
                    Some(path) => file_name(path),
                    None => "no recording chosen".to_string(),
                })
                .color(if self.input.is_some() {
                    p::fg()
                } else {
                    p::muted()
                })
                .small(),
            );
        });

        ui.horizontal(|ui| {
            if ui.button("choose plan…").clicked() {
                self.choosing_plan
                    .start(crate::dialog::Ask::open_filtered("plan", &["txt"]));
            }
            ui.label(
                RichText::new(match &self.plan {
                    Some(path) => file_name(path),
                    None => "no plan chosen".to_string(),
                })
                .color(if self.plan.is_some() {
                    p::fg()
                } else {
                    p::muted()
                })
                .small(),
            );
        });

        // Said here rather than discovered afterwards. This panel knows who is
        // in the recording; only a plan knows when each of them speaks, and
        // audio no turn claims is silenced rather than passed through.
        ui.label(
            RichText::new(
                "  The plan says when each person speaks. Without one there is nothing to \
                 render against, and audio no turn claims is silenced rather than passed \
                 through, so a missing plan would produce a silent file, not a veiled \
                 one. `veilvoice conversation inspect` describes a plan you already have.",
            )
            .color(p::muted())
            .small(),
        );

        ui.add_space(8.0);
        ui.horizontal(|ui| {
            ui.label(RichText::new("title").color(p::muted()).small());
            ui.add(
                egui::TextEdit::singleline(&mut self.title)
                    .desired_width(240.0)
                    .hint_text("taken from the plan if left empty"),
            );
        });

        ui.add_space(8.0);
        ui.horizontal(|ui| {
            ui.label(RichText::new("page palette").color(p::muted()).small());
            // The same nine the website and this application offer, and the
            // same identifiers, so a page rendered here and a page rendered by
            // the command line in the same theme are the same picture.
            egui::ComboBox::from_id_salt("group-theme")
                .selected_text(self.theme.name)
                .show_ui(ui, |ui| {
                    for palette in veilvoice_video::palette::PALETTES {
                        ui.selectable_value(&mut self.theme, palette, palette.name);
                    }
                });
            ui.label(
                RichText::new("for this run; the app opens in Tokyo Night")
                    .color(p::muted())
                    .small(),
            );
        });
    }



    /// The button, and what came of pressing it.
    fn render_controls(&mut self, ui: &mut Ui) {
        let ready = self.input.is_some() && self.plan.is_some() && self.outputs.any();
        ui.horizontal(|ui| {
            let busy = self.is_busy();
            if ui
                .add_enabled(ready && !busy, egui::Button::new("render"))
                .clicked()
            {
                self.start();
            }
            if busy {
                ui.spinner();
                ui.label(RichText::new("rendering…").color(p::muted()).small());
            } else if !ready {
                ui.label(
                    RichText::new(if !self.outputs.any() {
                        "nothing is ticked to write"
                    } else if self.input.is_none() {
                        "choose a recording"
                    } else {
                        "choose a plan"
                    })
                    .color(p::muted())
                    .small(),
                );
            }
        });

        // **Roadmap item 133.** Two bars per speaker while the render walks the file:
        // what went in, and what the engine produced from it. One bar answers
        // "is something being written" and not "is this person being veiled",
        // which is the question somebody rendering an interview is asking.
        //
        // Drawn from the most recently finished turn rather than smoothed the
        // way the live meters are. There is nothing to smooth: a render is not
        // a stream, and a bar that eased toward a value the render had already
        // moved past would be an animation rather than a reading.
        //
        // The limit is under them, in the same words the live meters use. Two
        // bars that differ show the engine is between them; they cannot show
        // that the voice is unrecoverable, and nothing on a screen can.
        if let Some(watching) = self.watching.clone() {
            ui.add_space(8.0);
            for (slot, person) in self.people.iter().enumerate() {
                let Some((went_in, came_out)) = watching.levels(slot) else {
                    continue;
                };
                ui.horizontal(|ui| {
                    // The colour is the one this speaker is drawn in
                    // everywhere else, and the name is beside it, because a
                    // panel that told people apart by colour alone would be one
                    // about eight per cent of men could not use.
                    ui.label(RichText::new("●").color(self.colour(slot)));
                    ui.label(RichText::new(&person.name).color(p::fg()).small());
                    ui.label(
                        RichText::new(format!(
                            "{:.0}% of their turns, {:.1}s",
                            watching.done(slot) * 100.0,
                            watching.seconds(slot)
                        ))
                        .color(p::muted())
                        .small(),
                    );
                });
                crate::monitor::meter(ui, "in ", went_in, went_in);
                crate::monitor::meter(ui, "out", came_out, came_out);
            }
            ui.label(
                RichText::new(
                    "  What went into each turn and what came out of it. Two that move \
                     differently show the engine is between them; neither can show that \
                     the voice cannot be recovered.",
                )
                .small()
                .color(p::muted()),
            );
        }

        match &self.report {
            None => {}
            Some(Ok(written)) => {
                ui.add_space(6.0);
                for path in written {
                    ui.label(RichText::new(format!("wrote {}", path.display())).color(p::green()));
                }
                ui.label(
                    RichText::new(
                        "These files are not encrypted. The subtitles hold the names you \
                         typed, and nothing veils a name.",
                    )
                    .color(p::yellow())
                    .small(),
                );
            }
            Some(Err(why)) => {
                ui.add_space(6.0);
                ui.label(RichText::new(why).color(p::red()));
            }
        }
    }



    /// Start a render on a thread of its own.
    ///
    /// `update()` may read, paint and start work; it may never wait for any.
    /// A render reads a whole recording and runs the engine over it, which is
    /// seconds at best and the length of the file at worst.
    fn start(&mut self) {
        let (Some(input), Some(plan_path)) = (self.input.clone(), self.plan.clone()) else {
            return;
        };
        let names: Vec<String> = self.people.iter().map(|one| one.name.clone()).collect();
        let title = self.title.trim().to_string();
        let outputs = self.outputs;
        let theme = self.theme;
        let voices = self.voices;
        // Copied, not read from `self` on the worker: the settings that were in
        // force when the button was pressed are the ones this render was asked
        // for, whatever gets moved on another tab while it runs.
        let config = self.config;

        let job = Job {
            input,
            plan_path,
            names,
            title,
            outputs,
            theme,
            voices,
            config,
        };

        // **Roadmap item 133.** One slot per person the panel is showing. The plan
        // comes off disk and may name more; `render_watched` drops what this
        // cannot hold rather than refusing to render, because a bar with
        // nowhere to go is not worth a failed render.
        let watching = std::sync::Arc::new(render::Progress::for_speakers(job.names.len()));
        self.watching = Some(std::sync::Arc::clone(&watching));

        let (tx, rx) = mpsc::channel();
        self.job = Some(rx);
        self.report = None;
        std::thread::spawn(move || {
            let _ = tx.send(render_now(&job, &watching));
        });
    }

}


/// The last component of a path, for showing beside a button.
fn file_name(path: &std::path::Path) -> String {
    path.file_name()
        .map(|name| name.to_string_lossy().into_owned())
        .unwrap_or_else(|| path.display().to_string())
}



/// Everything one render needs, taken from the panel at the moment the button
/// was pressed.
///
/// Named fields rather than eight positional arguments: four of them are
/// interchangeable at the type level, and F-67 was a value that never reached
/// this function at all. A struct makes both mistakes visible at the call site.
struct Job {
    input: PathBuf,
    plan_path: PathBuf,
    names: Vec<String>,
    title: String,
    outputs: Outputs,
    theme: &'static Palette,
    voices: VoiceMode,
    /// The engine settings in force when this was started -- not the defaults,
    /// and not whatever the window is set to by the time it finishes.
    config: DeidConfig,
}



/// Do the render. Runs on a worker thread and touches no interface state.
///
/// The names come from the panel and the turns come from the plan, and the two
/// have to agree about how many people there are. A plan naming three speakers
/// rendered against a panel holding two would put one person's audio in
/// somebody else's voice, which is the one mistake here that cannot be heard in
/// the result -- so it is refused rather than reconciled.
fn render_now(job: &Job, watching: &render::Progress) -> Result<Vec<PathBuf>, String> {
    let Job {
        input,
        plan_path,
        names,
        title,
        outputs,
        theme,
        voices,
        config,
    } = job;
    let (outputs, theme, voices) = (*outputs, *theme, *voices);
    use veilvoice_conversation::render::Settings;
    use veilvoice_conversation::subtitles::{self, Format};

    let mut plan = Conversation::load(plan_path)
        .map_err(|error| format!("{}: {error}", plan_path.display()))?;
    if plan.len() != names.len() {
        return Err(format!(
            "the plan names {} speaker(s) and this panel holds {}. Rendering one against \
             the other would put somebody's audio in the wrong voice.",
            plan.len(),
            names.len()
        ));
    }
    // The panel's names win: they are what the person just typed, and the plan
    // may have been written before they renamed anybody. The *turns* are the
    // plan's and are untouched.
    plan.rename_speakers(names)
        .map_err(|error| error.to_string())?;
    // Set before anything is decoded: the refusal is cheap and it names the
    // way out, and there is no reason to read a whole recording first.
    plan.set_mode(voices, config)
        .map_err(|error| error.to_string())?;
    if !title.is_empty() {
        plan.title = Some(title.to_string());
    }

    let audio = veilvoice_audio::io::load(input).map_err(|error| error.to_string())?;
    // The application's settings, not the defaults -- see the note on
    // [`Group::config`]. The sample rate is the one exception: it comes from
    // the recording that was actually opened, not from a control.
    let settings = Settings {
        config: DeidConfig {
            sample_rate: audio.sample_rate as f32,
            ..*config
        },
        ..Settings::default()
    };
    let rendered = render::render_watched(&plan, &audio.samples, &settings, None, watching)
        .map_err(|error| error.to_string())?;

    let mut base = input.to_path_buf();
    base.set_extension("veiled.wav");
    let mut written = Vec::new();

    let veiled = veilvoice_audio::io::Audio {
        samples: rendered.samples,
        sample_rate: audio.sample_rate,
    };
    if outputs.audio || outputs.page {
        // The page plays the audio, so it is written whenever the page is --
        // a player pointing at a file that was never written is worse than no
        // player.
        write_private(
            &base,
            &veilvoice_audio::io::wav_bytes(&veiled).map_err(|error| error.to_string())?,
        )?;
        written.push(base.clone());
    }

    let vtt = with_extension(&base, "vtt");
    if outputs.subtitles || outputs.page {
        write_private(&vtt, subtitles::write(&plan, Format::WebVtt).as_bytes())?;
        written.push(vtt.clone());
        let srt = with_extension(&base, "srt");
        write_private(&srt, subtitles::write(&plan, Format::SubRip).as_bytes())?;
        written.push(srt);
    }

    if outputs.page || outputs.video {
        use veilvoice_video::{page, waveform};
        let look = page::Look::default().themed(theme);
        // The veiled audio's waveform, not the input's: a picture of the
        // original signal beside a file whose point is that the original is
        // gone would be the wrong picture.
        //
        // Read once and given to both, because the page and the video are two
        // drawings of one recording and an envelope computed twice is two
        // chances for them to be drawings of different ones.
        let envelope = waveform::envelope(&veiled.samples, 640);

        if outputs.page {
            let drawn = page::player(&plan, &envelope, &look, &file_name(&base), &file_name(&vtt))
                .map_err(|error| error.to_string())?;
            let html = with_extension(&base, "html");
            write_private(&html, drawn.markup.as_bytes())?;
            written.push(html);
        }

        if outputs.video {
            written.extend(render_video(&plan, &envelope, &look, &base)?);
        }
    }

    Ok(written)
}



/// **Roadmap item 139.** Draw the frames, then have `ffmpeg` make the video of them.
///
/// The pictures go into a directory beside the output and are **left there**
/// rather than cleaned up. Two reasons, and the second is the one that decided
/// it: a render that failed at the encode should not throw away the hours of
/// drawing that preceded it, and somebody who wants a different encoding of the
/// same conversation should not have to draw it again.
///
/// Without `ffmpeg` the frames and the list are still written and the command
/// is returned in the error, which is the same bargain the rest of this program
/// makes with tools it does not ship: prepare everything, name the one step
/// that is not ours, and let the person run it.
fn render_video(
    plan: &veilvoice_conversation::Conversation,
    envelope: &veilvoice_video::waveform::Envelope,
    look: &veilvoice_video::page::Look,
    base: &std::path::Path,
) -> Result<Vec<PathBuf>, String> {
    use veilvoice_video::{ffmpeg, frames, size};

    let encoding = ffmpeg::Encoding::default();
    let directory = with_extension(base, "frames");
    let written_frames = frames::write(
        plan,
        envelope,
        look,
        &size::Plan::default(),
        &directory,
        // No progress reporting from here: this runs on the render thread and
        // the caller already has a bar for the render as a whole. A second bar
        // that only moved during the drawing would read as the render stalling
        // whenever it was not.
        |_, _| {},
    )
    .map_err(|error| error.to_string())?;

    let video = with_extension(base, "mp4");
    let argv = ffmpeg::concat_command(&written_frames.list, base, &video, encoding);

    let Some(program) = ffmpeg::found() else {
        return Err(format!(
            "The {} pictures and their list are written, in {}. `ffmpeg` is not on \
             this machine, so the last step is yours:\n\n    {}\n\nThe Setup tab \
             lists `ffmpeg` under companion software, with the install command for \
             this system and a button to run it.",
            written_frames.files,
            directory.display(),
            ffmpeg::command_line(&argv)
        ));
    };

    let output = std::process::Command::new(program)
        .args(argv.iter().skip(1))
        .output()
        .map_err(|error| error.to_string())?;
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        let last = stderr.lines().rev().find(|line| !line.trim().is_empty());
        return Err(format!(
            "`ffmpeg` refused: {}",
            last.unwrap_or("it gave no reason").trim()
        ));
    }

    Ok(vec![video])
}



/// Replace the last extension, keeping any `.veiled` before it.
/// Write a file only this account can read, naming it if that fails.
///
/// The same fix as the command line's, in the other half of the program. All
/// four of these used the ordinary `fs::write`, which is 0644, while the file
/// tab wrote its result 0600 even where somebody had turned encryption off on
/// purpose. The group output is the one holding the names and the words as
/// typed, so the more revealing file had the weaker permission, and that was
/// not a decision: one path called the hardened write, the other the default.
fn write_private(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> {
    veilvoice_crypto::privatefile::write_owner_only(path, bytes)
        .map_err(|error| format!("{}: {error}", path.display()))
}



fn with_extension(path: &std::path::Path, extension: &str) -> PathBuf {
    let mut out = path.to_path_buf();
    out.set_extension(extension);
    out
}



/// The colour a slot is given, as an egui colour.
///
/// One table, shared with the video crate rather than copied here, so a circle
/// in this panel and the same speaker's circle in a rendered page cannot drift
/// apart. `unwrap_or` rather than `expect`: a malformed entry would be a bug in
/// that table, and a panel is the wrong place to find out about it.
pub fn assigned_colour(slot: usize) -> Color32 {
    let hex = veilvoice_video::palette::speaker(slot);
    match veilvoice_video::palette::rgb(hex) {
        Some((r, g, b)) => Color32::from_rgb(r, g, b),
        None => Color32::GRAY,
    }
}


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

    #[test]
    fn group_mode_is_off_unless_the_saved_preference_says_otherwise() {
        assert!(!Group::default().enabled);
        assert!(!Group::start_from(false).enabled);
        assert!(Group::start_from(true).enabled);
    }

    /// The whole reason the mode is not persisted: it must not survive a
    /// restart on its own. This is the test that would fail if somebody added
    /// `enabled` to `Prefs`.
    #[test]
    fn the_default_panel_never_opens_in_group_mode() {
        let turned_on = Group {
            enabled: true,
            ..Group::default()
        };
        assert!(turned_on.enabled, "turning it on works within a run");
        // A fresh panel, as a restart produces.
        assert!(!Group::default().enabled);
    }

    #[test]
    fn a_slot_gets_the_colour_its_slot_is_given_until_one_is_chosen() {
        let mut group = Group::default();
        assert_eq!(group.colour(0), assigned_colour(0));
        assert_eq!(group.colour(1), assigned_colour(1));
        group.people[0].colour = Some(Color32::from_rgb(1, 2, 3));
        assert_eq!(group.colour(0), Color32::from_rgb(1, 2, 3));
        // The override is one speaker's, not everybody's.
        assert_eq!(group.colour(1), assigned_colour(1));
    }

    /// The score sits beside the control that decides it.
    ///
    /// It answers "what does this recording give away about who was speaking",
    /// and the two things that decide the answer are the voice mode and the
    /// number of people. Somewhere else in the window it would be a number
    /// without the two controls that move it.
    #[test]
    fn what_the_recording_gives_away_is_shown_beside_the_choice_that_sets_it() {
        let source = std::fs::read_to_string("src/group.rs").expect("its own source");
        let at = source
            .find("fn voice_mode_controls")
            .expect("the voice mode controls");
        let end = source[at..]
            .find("\n    fn ")
            .map(|o| at + o)
            .unwrap_or(source.len());
        let body = &source[at..end];
        assert!(
            body.contains("Exposure::of"),
            "the score is no longer drawn beside the mode and the speaker count"
        );
        assert!(
            body.contains("seen.crowded()"),
            "the crowding is not shown, so the trade looks like it only goes one way"
        );
    }

    /// Two speakers is the common case, and slots 0 and 1 are the furthest
    /// apart pair in the table. If that ever stops being true the panel is
    /// drawing two people in two colours somebody cannot tell apart.
    #[test]
    fn the_first_two_colours_are_the_furthest_apart_pair() {
        let table = veilvoice_video::palette::SPEAKERS;
        let first = veilvoice_video::palette::distance(table[0], table[1]);
        for (i, a) in table.iter().enumerate() {
            for b in table.iter().skip(i + 1) {
                assert!(
                    veilvoice_video::palette::distance(a, b) <= first,
                    "{a} and {b} are further apart than slots 0 and 1"
                );
            }
        }
    }

    /// The limit is the *measured* one -- how many voices can be told apart --
    /// not the ten the table holds.
    ///
    /// Written as a bounded loop rather than `while len < MAX_VOICES`, which is
    /// what it was: `add` now refuses at nine, so that loop never terminated
    /// and the test suite hung rather than failed. A test that cannot fail
    /// cannot pass either.
    #[test]
    fn a_ninth_speaker_is_refused_and_the_refusal_names_the_way_out() {
        let mut group = Group::default();
        for _ in 0..MAX_VOICES + 4 {
            group.add();
        }
        let clear = voices::clear_voices(&DeidConfig::default());
        assert_eq!(clear, 8, "the measured clear limit");
        assert_eq!(group.len(), clear, "the panel stops where the voices do");

        let notice = group.notice.clone().expect("the refusal must be explained");
        assert!(notice.contains("one voice for everybody"), "{notice}");
    }

    /// And switching to one voice lifts it, which is the point of the refusal
    /// naming that mode.
    #[test]
    fn one_voice_for_everybody_carries_more_people() {
        let mut group = Group {
            voices: VoiceMode::Uniform,
            ..Group::default()
        };
        for _ in 0..MAX_VOICES + 4 {
            group.add();
        }
        assert_eq!(group.len(), MAX_VOICES, "up to what a plan can hold");
        assert_eq!(group.limit(), MAX_VOICES);
    }

    /// Switching *back* with too many people is refused rather than silently
    /// dropping somebody or handing two people one voice.
    #[test]
    fn switching_back_to_a_voice_each_is_refused_when_there_are_too_many() {
        let config = DeidConfig::default();
        let mut group = Group {
            voices: VoiceMode::Uniform,
            ..Group::default()
        };
        for _ in 0..MAX_VOICES + 4 {
            group.add();
        }
        assert!(voice_mode::check(group.len(), VoiceMode::Distinct, &config).is_err());
    }

    /// One speaker is not a group. Removing past two would leave the mode on
    /// with nothing for it to do.
    #[test]
    fn removing_stops_at_two() {
        let mut group = Group::default();
        group.add();
        assert_eq!(group.len(), 3);
        group.remove(0);
        assert_eq!(group.len(), 2);
        group.remove(0);
        assert_eq!(group.len(), 2, "two is the floor");
    }

    #[test]
    fn a_plan_carries_the_names_in_slot_order() {
        let mut group = Group::default();
        group.people[0].name = "Alex".into();
        group.people[1].name = "Sam".into();
        let plan = group.to_plan(Some("A chat")).unwrap();
        assert_eq!(plan.title.as_deref(), Some("A chat"));
        assert_eq!(plan.speakers()[0].name, "Alex");
        assert_eq!(plan.speakers()[1].name, "Sam");
    }

    /// A name with a line break in it could forge a record in the plan file.
    /// The conversation crate refuses it; this checks the panel surfaces that
    /// rather than producing a plan with a hole in it.
    #[test]
    fn a_name_with_a_line_break_is_refused_by_the_plan_rather_than_written() {
        let mut group = Group::default();
        group.people[0].name = "Alex\nspeaker  9  Mallory".into();
        let error = group.to_plan(None).expect_err("a line break is refused");
        assert!(error.contains("line break"), "{error}");
    }

    /// **Roadmap item 133.** Two bars per speaker, drawn while the render runs.
    ///
    /// Read out of the source rather than driven, for the reason every guard in
    /// this crate is: drawing a panel needs a window, and a test that opened one
    /// is what F-162, F-163 and F-165 all were. What is worth keeping is that
    /// there are two of them, that they are the shared meter rather than a
    /// second bar that would drift from it, and that the limit is printed with
    /// them.
    #[test]
    fn the_panel_draws_both_bars_for_every_speaker_while_rendering() {
        let source = include_str!("group.rs").replace("\r\n", "\n");
        let body = source
            .split("fn render_controls(&mut self, ui: &mut Ui) {")
            .nth(1)
            .and_then(|rest| rest.split("\n    fn ").next())
            .expect("the render controls have to be findable");

        assert!(
            body.contains(r#"meter(ui, "in ""#),
            "the render draws no input bar, so it cannot show what went in"
        );
        assert!(
            body.contains(r#"meter(ui, "out""#),
            "the render draws no output bar, so one bar is answering the wrong \
             question"
        );
        assert!(
            body.contains("crate::monitor::meter"),
            "the render draws its own meter instead of the shared one, and two \
             bars for one measurement drift apart"
        );
        assert!(
            body.contains("neither can show that"),
            "the bars are drawn without the limit that is printed beside every \
             other meter in this application"
        );
        // Every speaker, not the first: the question is whether *each* guest is
        // being veiled, and a panel that metered one of them would answer it
        // for one of them.
        assert!(
            body.contains("for (slot, person) in self.people.iter().enumerate()"),
            "the bars are not drawn per speaker"
        );
    }

    /// Nothing a group render writes is readable by another account.
    ///
    /// The same check as the command line's, on the other half of the program,
    /// because the same defect was in both and a fix to one would not have
    /// found the other.
    #[cfg(unix)]
    #[test]
    fn everything_a_group_render_writes_is_readable_only_by_this_account() {
        use std::os::unix::fs::PermissionsExt;
        let dir = tempfile::tempdir().unwrap();
        let input = dir.path().join("talk.wav");
        veilvoice_audio::io::save_wav(
            &input,
            &veilvoice_audio::io::Audio {
                samples: (0..16000).map(|n| (n as f32 / 200.0).sin() * 0.4).collect(),
                sample_rate: 16000,
            },
        )
        .unwrap();
        let plan_path = dir.path().join("plan.txt");
        std::fs::write(
            &plan_path,
            concat!(
                "VEILCONV1\n",
                "speaker  0  Ada\n",
                "speaker  1  Bram\n",
                "turn  0.000  0.500  0  hello\n",
                "turn  0.500  1.000  1  hello back\n",
            ),
        )
        .unwrap();

        let job = Job {
            input,
            plan_path,
            names: vec!["Ada".into(), "Bram".into()],
            title: "A test".into(),
            outputs: Outputs {
                audio: true,
                subtitles: true,
                page: true,
                video: false,
            },
            theme: veilvoice_video::palette::default_palette(),
            voices: VoiceMode::Distinct,
            config: DeidConfig::default(),
        };
        // Watched, and the bars checked below: a render the window ran without
        // the panel's progress is not the render the window runs.
        let watching = render::Progress::for_speakers(2);
        let written = render_now(&job, &watching).expect("the render should succeed");
        for slot in 0..2 {
            assert_eq!(
                watching.done(slot),
                1.0,
                "speaker {slot} was not watched to the end of their turns"
            );
        }
        assert!(!written.is_empty(), "a render that wrote nothing");
        for path in &written {
            let mode = std::fs::metadata(path).unwrap().permissions().mode() & 0o777;
            assert_eq!(
                mode,
                0o600,
                "{} is {mode:o}, so another account on this machine can read it",
                path.display()
            );
        }
    }

    /// A render cannot start without both files and something to write. This
    /// is the state the button is disabled in, asserted rather than trusted to
    /// the interface.
    #[test]
    fn a_render_needs_a_recording_a_plan_and_an_output() {
        let mut group = Group::default();
        assert!(group.input.is_none() && group.plan.is_none());
        group.start();
        assert!(!group.is_busy(), "nothing to render");

        group.input = Some(PathBuf::from("talk.wav"));
        group.start();
        assert!(!group.is_busy(), "still no plan");
    }

    /// A worker that dies without answering must not leave the panel saying
    /// "rendering" forever.
    #[test]
    fn a_render_that_stops_without_finishing_is_reported() {
        let (tx, rx) = mpsc::channel::<Result<Vec<PathBuf>, String>>();
        drop(tx);
        let mut group = Group {
            job: Some(rx),
            ..Group::default()
        };
        group.drain();
        assert!(!group.is_busy());
        match group.report {
            Some(Err(ref why)) => assert!(why.contains("without finishing"), "{why}"),
            other => panic!("expected a reported failure, got {other:?}"),
        }
    }

    /// The palette is Tokyo Night unless changed, and is not persisted -- the
    /// same shape as the mode toggle above it.
    #[test]
    fn the_page_palette_starts_at_tokyo_night_every_time() {
        assert_eq!(Group::default().theme.id, "tokyo-night");
        assert_eq!(Group::start_from(true).theme.id, "tokyo-night");
    }

    /// A plan and a panel that disagree about how many people there are is
    /// refused, because the result would be somebody's audio in the wrong
    /// voice and nobody could hear it.
    #[test]
    fn rendering_against_a_plan_with_a_different_count_is_refused() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("plan.txt");
        let mut plan = Conversation::new();
        plan.add_speaker(Speaker::named("A")).unwrap();
        plan.add_speaker(Speaker::named("B")).unwrap();
        plan.add_speaker(Speaker::named("C")).unwrap();
        plan.save(&path).unwrap();

        let error = render_now(
            &Job {
                input: PathBuf::from("nothing.wav"),
                plan_path: path.clone(),
                names: vec!["one".to_string(), "two".to_string()],
                title: String::new(),
                outputs: Outputs::default(),
                theme: veilvoice_video::palette::default_palette(),
                voices: VoiceMode::default(),
                config: DeidConfig::default(),
            },
            &render::Progress::default(),
        )
        .expect_err("three against two");
        assert!(error.contains("wrong voice"), "{error}");
        assert!(error.contains('3') && error.contains('2'), "{error}");
    }

    /// F-67. The panel's answers have to come from the application's settings,
    /// not from the defaults, because the controls that change them are on
    /// another tab and nothing here would say otherwise.
    ///
    /// Checked by moving the configuration to something that changes the
    /// answer: a coarser frame puts the destination pitches on a wider grid,
    /// registers collapse onto each other, and fewer voices stay clearly
    /// apart. If the panel were still reading the defaults, the limit would not
    /// move.
    #[test]
    fn the_limit_follows_the_settings_rather_than_the_defaults() {
        let coarse = DeidConfig {
            frame_size: 256,
            ..DeidConfig::default()
        };
        let fewer = voices::clear_voices(&coarse);
        let usual = voices::clear_voices(&DeidConfig::default());
        assert!(
            fewer < usual,
            "a coarser grid should separate fewer voices, not {fewer} against {usual}"
        );

        let mut panel = Group::default();
        assert_eq!(panel.limit(), usual, "the default panel");
        panel.config = coarse;
        assert_eq!(
            panel.limit(),
            fewer,
            "the panel must answer from the settings in force"
        );

        // And it stops adding people at the number it is now showing, rather
        // than at the one it used to show.
        while panel.people.len() < fewer {
            panel.add();
        }
        assert_eq!(panel.people.len(), fewer);
        panel.add();
        assert_eq!(panel.people.len(), fewer, "past the limit it printed");
        let notice = panel.notice.clone().expect("a refusal, not silence");
        assert!(notice.contains(&fewer.to_string()), "{notice}");
    }

    /// The whole point of a project file: what you had is what you get back.
    #[test]
    fn a_panel_round_trips_through_a_project_file() {
        let mut group = Group {
            enabled: true,
            title: "Two people".into(),
            input: Some(PathBuf::from("talk.wav")),
            plan: Some(PathBuf::from("plan.txt")),
            theme: veilvoice_video::palette::by_id("gruvbox").unwrap(),
            voices: VoiceMode::Uniform,
            profile: veilvoice_policy::workspace::GROUP_ONE_VOICE.id.to_string(),
            outputs: Outputs {
                audio: true,
                subtitles: false,
                page: true,
                video: false,
            },
            ..Group::default()
        };
        group.people[0].name = "Alex".into();
        group.people[1].name = "Sam".into();
        group.people[1].colour = Some(Color32::from_rgb(0x73, 0xda, 0xca));

        let text = group.to_workspace().to_text();
        let read_back = Workspace::parse(&text).expect("should parse");

        let mut restored = Group::default();
        restored.from_workspace(&read_back);

        assert_eq!(restored.title, "Two people");
        assert_eq!(restored.input, group.input);
        assert_eq!(restored.plan, group.plan);
        assert_eq!(restored.theme.id, "gruvbox");
        assert_eq!(restored.voices, VoiceMode::Uniform);
        assert_eq!(restored.profile, group.profile);
        assert_eq!(restored.outputs, group.outputs);
        assert_eq!(restored.people.len(), 2);
        assert_eq!(restored.people[0].name, "Alex");
        assert_eq!(restored.people[1].name, "Sam");
        assert_eq!(restored.people[1].colour, group.people[1].colour);
        assert!(restored.enabled, "a group profile opens in group mode");
    }

    /// A colour chosen by hand survives; one left automatic stays automatic
    /// rather than being frozen into whatever it happened to look like.
    #[test]
    fn an_automatic_colour_is_saved_as_automatic() {
        let group = Group::default();
        assert!(group.people[0].colour.is_none());
        let work = group.to_workspace();
        assert!(work.members[0].colour.is_none());

        let mut restored = Group::default();
        restored.from_workspace(&work);
        assert!(
            restored.people[0].colour.is_none(),
            "it must come back automatic, not pinned to today's table"
        );
    }

    /// A profile this build does not have is reported and the settings left
    /// alone. Silently opening under different settings is the failure worth
    /// avoiding: the point of the file is that it puts things back.
    #[test]
    fn an_unknown_profile_is_reported_rather_than_silently_replaced() {
        let mut work = Workspace::new();
        work.profile = "from-a-newer-build".into();
        let mut group = Group::default();
        let before = group.voices;
        group.from_workspace(&work);
        assert_eq!(group.voices, before, "nothing may have changed");
        let notice = group.notice.expect("it has to say so");
        assert!(notice.contains("from-a-newer-build"), "{notice}");
    }

    /// And an unknown palette likewise.
    #[test]
    fn an_unknown_palette_is_reported_rather_than_silently_replaced() {
        let mut work = Workspace::new();
        work.theme = "solarised".into();
        let mut group = Group::default();
        group.from_workspace(&work);
        assert_eq!(group.theme.id, "tokyo-night");
        assert!(group.notice.unwrap().contains("solarised"));
    }

    /// A project saved with more people than the current mode can carry is
    /// loaded and *reported*, not trimmed. Dropping somebody out of a group to
    /// make a preset fit is a thing nobody would notice until the render.
    #[test]
    fn a_project_with_too_many_people_for_the_mode_is_reported_not_trimmed() {
        let mut work = Workspace::new();
        work.profile = veilvoice_policy::workspace::GROUP_VOICES.id.to_string();
        work.members = (0..9)
            .map(|n| veilvoice_policy::workspace::Member {
                name: format!("P{n}"),
                colour: None,
            })
            .collect();

        let mut group = Group::default();
        group.from_workspace(&work);
        assert_eq!(group.people.len(), 9, "everybody is still here");
        let notice = group.notice.expect("it has to say so");
        assert!(notice.contains("one voice for everybody"), "{notice}");
    }

    /// Picking a profile sets what it names and leaves everything else. A
    /// preset that overrode a later choice would be found out in the output.
    #[test]
    fn a_profile_sets_what_it_names_and_nothing_else() {
        let mut group = Group {
            title: "kept".into(),
            ..Group::default()
        };
        group.people[0].name = "Alex".into();

        group.apply_profile(&veilvoice_policy::workspace::GROUP_ONE_VOICE);
        assert_eq!(group.voices, VoiceMode::Uniform);
        assert!(group.enabled);
        assert_eq!(group.title, "kept", "a profile is not a reset");
        assert_eq!(group.people[0].name, "Alex");
    }

    #[test]
    fn outputs_survive_being_named_and_read_back() {
        for outputs in [
            Outputs::default(),
            Outputs {
                audio: true,
                subtitles: false,
                page: false,
                video: false,
            },
            Outputs {
                audio: false,
                subtitles: false,
                page: false,
                video: false,
            },
        ] {
            assert_eq!(Outputs::from_names(&outputs.names()), outputs);
        }
    }

    /// Everything this program can write on its own is on by default.
    ///
    /// **The video is not**, and it is the only one. The other three are files
    /// VeilVoice writes itself; the video needs `ffmpeg`, which it does not
    /// ship and will not install without being asked, and a default that
    /// silently depends on a tool the machine may not have is one that fails on
    /// somebody else's computer rather than on the machine it was chosen on.
    #[test]
    fn everything_we_can_write_ourselves_is_on_by_default_and_the_video_is_not() {
        let outputs = Outputs::default();
        assert!(outputs.audio && outputs.subtitles && outputs.page);
        assert!(
            !outputs.video,
            "the video is on by default, so a render fails wherever ffmpeg is absent"
        );
        assert!(outputs.any());
        assert!(!Outputs {
            audio: false,
            subtitles: false,
            page: false,
            video: false
        }
        .any());
        // And asking for only the video is still asking for something.
        assert!(Outputs {
            audio: false,
            subtitles: false,
            page: false,
            video: true
        }
        .any());
    }

    /// A project file remembers the video choice like the other three.
    ///
    /// `names` and `from_names` are two halves of one format, and a field added
    /// to one and not the other is a setting that silently resets every time a
    /// project is opened.
    #[test]
    fn every_output_survives_a_project_file() {
        for outputs in [
            Outputs::default(),
            Outputs {
                audio: true,
                subtitles: true,
                page: true,
                video: true,
            },
            Outputs {
                audio: false,
                subtitles: false,
                page: false,
                video: true,
            },
        ] {
            let back = Outputs::from_names(&outputs.names());
            assert_eq!(back.audio, outputs.audio, "audio");
            assert_eq!(back.subtitles, outputs.subtitles, "subtitles");
            assert_eq!(back.page, outputs.page, "page");
            assert_eq!(back.video, outputs.video, "video");
        }
    }
}