crates/veilvoice-gui/src/studio.rs

what this file is for · veilvoice-gui · 2406 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 Recording Studio and the Recording Browser.
//!
//! # Two tabs, one vault
//!
//! The Studio records; the Browser is what is in the vault afterwards. They are
//! one module because they are one vault, and a vault opened in two places is
//! two chances to get the unlocking wrong.
//!
//! # Roadmap item 130: this is also where veiling as it runs happens
//!
//! Live scramble was a tab of its own, and it did not need to be. The Studio
//! has always recorded through the same [`veilvoice_audio::LiveSession`] that
//! tab ran, with the same engine and the same routing, so the two screens were
//! one act performed in two rooms: pick the devices over there, come here,
//! press record.
//!
//! Two sessions was the part that was actually wrong. Veiling on one tab and
//! recording on the other opened the same microphone twice, and on the
//! platforms that allow that at all the second stream gets a copy of the input
//! nobody asked for. [`Studio::start_session`] is the only starter now, and a
//! test reads this crate's source and fails if a second one appears.
//!
//! The voice half of the tab is drawn by the window rather than here, because
//! the device lists and the engine settings belong to the window. What lives
//! in this module is the session those controls drive, and everything about
//! the vault.
//!
//! **The voice half works with the vault shut.** Veiling a call has never
//! needed a recording vault, and making somebody set one up before they could
//! disguise their voice on a call would be a worse program than the one that
//! had two tabs.
//!
//! # The vault needs both passphrases, and asks for both here
//!
//! [`veilvoice_crypto::studio::StudioKey`] is derived from the app lock **and**
//! the at-rest passphrase, and from neither alone. That is the whole point of
//! it: a laptop stolen while VeilVoice is unlocked opens nothing, because the
//! at-rest passphrase was never typed.
//!
//! So this tab asks for both, every time, rather than reaching into whatever
//! the rest of the application happens to be holding. That is a deliberate
//! inconvenience:
//!
//! - The app-lock secret is only kept for the session when
//!   [`Sealing::AppLock`](crate::security::Sealing::AppLock) is chosen. Reading
//!   it when it happens to be there, and prompting when it is not, would make
//!   the vault's strength depend on an unrelated setting, and nobody would
//!   know which they had.
//! - Prompting for both, always, is the only version of this whose security is
//!   the same on every run.
//!
//! Neither passphrase is stored. Both typing buffers are wiped the moment the
//! key is derived, and the derived key lives in page-locked memory for as long
//! as the vault is open. Locking the window closes the vault.
//!
//! # What is recorded is what comes out, unless it was asked to be otherwise
//!
//! The Studio records through
//! `veilvoice_audio::LiveSession::start_recording`,
//! which is the same path the command line uses, so the samples that reach the
//! recorder are the **veiled** ones. That is the default and it is what
//! [`Keep::Veiled`] means.
//!
//! **Roadmap item 131** adds the other two. The microphone can be kept as well, or
//! instead, and the reasoning for allowing it at all is on [`Keep`]: refusing
//! would not stop somebody who needs the real recording, it would move them to
//! a phone on the table, which is a plaintext file on a device with none of
//! this. What matters is that it is asked for rather than arrived at.
//!
//! So it is a choice made **before** the button, never remembered between runs,
//! reset when the window locks, and stated in the same words the plaintext path
//! uses. The default is the safe one, and every path that has not been asked
//! for the microphone passes `None` where it would go.
//!
//! Each recording is assembled inside a `veilvoice_crypto::Secret` and handed
//! straight to the vault to be sealed. Neither is ever a plain file, not even
//! briefly: an unveiled take is a recording of a real voice, and it is sealed
//! exactly as strongly as a veiled one.
//!
//! # In plain words
//!
//! Record here, and what you record is kept locked up. Opening the cupboard
//! needs both of your passwords at once, every time, which is what makes it
//! worth having.
//!
//! What gets recorded is the disguised voice. You can ask for your real one as
//! well, or instead, and the screen tells you what that means before you start:
//! anybody who can open the cupboard can then hear who was talking.

use egui::{Color32, RichText, Ui};
use veilvoice_core::DeidConfig;
use veilvoice_crypto::studio::{Entry, Studio as Vault, StudioKey};
use veilvoice_crypto::Secret;
use zeroize::Zeroize;

use crate::theme::palette as p;


/// Move a typed passphrase into page-locked storage and wipe the buffer.
///
/// The same shape as [`crate::security`]'s, and for the same reason: a text
/// widget owns a `String`, so the passphrase exists as ordinary heap bytes
/// while it is being typed. This shortens that window; nothing can close it.
fn into_secret(typed: &mut String) -> Secret {
    let mut bytes = typed.as_bytes().to_vec();
    let secret = Secret::new(&mut bytes);
    typed.zeroize();
    secret
}



/// Where the vaults live: beside the lock file, in this platform's config
/// directory. `None` when the environment does not say where that is, in which
/// case the Studio says so rather than inventing a location.
///
/// A folder of vaults rather than a vault. The real one and any decoys made
/// beside it are directories in here with opaque names, and which of them is
/// real is a question only the pair of passphrases answers. A real vault at a
/// fixed name would be told from a decoy by reading the name, which would make
/// the decoys worthless.
pub fn default_dir() -> Option<std::path::PathBuf> {
    veilvoice_crypto::lock::default_path().map(|lock| lock.with_file_name("studio"))
}



/// What the Studio is doing.
#[derive(PartialEq, Eq)]
enum Phase {
    /// The vault is shut. Both passphrases are wanted.
    Shut,
    /// Open, and not recording.
    Idle,
    /// Recording.
    Recording,
}



/// Who is speaking into a session.
///
/// **Roadmap item 147.** One microphone or several, and the choice is this enum
/// rather than a pair of fields, so "never both" is a thing that cannot be
/// written rather than a thing to remember.
#[derive(Clone)]
enum Who {
    /// One person, on the named microphone or on this machine's default.
    One(Option<String>),
    /// A room: one microphone per guest, as [`Studio::room_guests`] lists them.
    ///
    /// The list is deliberately **not** copied in here. It is the Studio's, the
    /// controls that edit it are disabled while a room is running, and a copy
    /// would be a second answer to "who is in this room" that could disagree
    /// with the one on screen.
    Room,
}



/// What a live session was started with.
///
/// Held for as long as one is running, because sinks cannot be attached to a
/// stream that has already started: beginning a take and ending one each
/// restart the session, and it has to come back with the devices and the engine
/// settings it was running with rather than with whatever the window happens to
/// be set to a minute later.
#[derive(Clone)]
struct Setup {
    /// The engine settings this session is running.
    config: DeidConfig,
    /// Whether one microphone is open or a room of them.
    who: Who,
    /// The chosen output device, or `None` for the default.
    output: Option<String>,
    /// Whether this is a preview: the veiled voice goes to this machine's own
    /// output and the chosen one is ignored. See [`Studio::start_session`].
    preview: bool,
}



/// The session the Studio has open, and there is at most one.
///
/// **Roadmap item 147.** Two fields would be two things to clear, and a room left
/// running beside a single session is two streams on one output with every
/// guest's voice arriving twice. This is one field, so the invariant holds by
/// construction rather than by every path remembering to clear the other.
enum Running {
    /// One person, through [`veilvoice_audio::LiveSession`].
    One(veilvoice_audio::LiveSession),
    /// A room, through [`veilvoice_audio::RoomSession`].
    Room(veilvoice_audio::RoomSession),
}



/// One guest in a room: the name their take is filed under, and the microphone
/// they speak into.
///
/// **Roadmap item 147.** A room is a list of these. It is edited while nothing is
/// running and read when a session starts.
#[derive(Clone, Default)]
pub struct RoomGuest {
    /// What their take is called, and what their bars are labelled with. Blank
    /// is allowed while it is being typed and becomes "guest 3" when it is
    /// used: see [`RoomGuest::called`].
    pub name: String,
    /// The microphone they speak into, or `None` for this machine's default.
    pub device: Option<String>,
}


impl RoomGuest {

    /// What to call this guest in slot `slot`, filling in a blank name.
    ///
    /// A name is what tells four recordings apart afterwards, so an empty one
    /// becomes the slot rather than an empty file name.
    pub fn called(&self, slot: usize) -> String {
        let typed = self.name.trim();
        if typed.is_empty() {
            format!("guest {}", slot + 1)
        } else {
            typed.to_string()
        }
    }

}


/// Which guests are sharing a microphone, and the sentence to say about it.
///
/// `None` means everybody has their own. Two guests on one device is refused,
/// because one microphone carrying two people is one signal and nothing in this
/// program can separate it again: veiling it would give both of them the same
/// voice, which is the exact thing a room exists to avoid.
///
/// Two guests on the default device are the same case. `None` is a device, not
/// an absence, and it took saying so to notice that a list of guests nobody had
/// picked a microphone for was a room of one microphone opened several times.
///
/// Pure, and separate from starting, so it can be tested on a machine with no
/// sound card: F-163 and F-165 are why nothing here opens a device to answer a
/// question that does not need one.
pub fn sharing_a_microphone(guests: &[RoomGuest]) -> Option<String> {
    for (first, guest) in guests.iter().enumerate() {
        for (second, other) in guests.iter().enumerate().skip(first + 1) {
            if guest.device != other.device {
                continue;
            }
            let where_it_is = match &guest.device {
                Some(name) => format!("{name:?}"),
                None => "this machine's default microphone".to_string(),
            };
            return Some(format!(
                "{} and {} are both on {}. One microphone carrying two people is one \
                 signal, and nothing here can separate it again: give them a device \
                 each, or record them as one person.",
                guest.called(first),
                other.called(second),
                where_it_is,
            ));
        }
    }
    None
}



/// Whose voice one recording of a take is.
///
/// **Roadmap item 147.** A take used to be one recording, or two when the real voice
/// was kept as well. A room take is the mix plus one or two per guest, which is
/// up to seventeen recordings landing in one vault under one take name, and the
/// only thing telling them apart is what they are called.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Whose<'a> {
    /// The one person a single-microphone take recorded.
    Only,
    /// One guest of a room.
    Guest(&'a str),
    /// The mix: the one recording with the whole room in it.
    Everybody,
}



/// What one recording of a take is called in the vault.
///
/// Pure, and the **one** place a take name is built. A take can now produce
/// seventeen recordings, stored from three loops, and a suffix added in two of
/// them would leave an entry that is somebody's real voice looking exactly like
/// the veiled one beside it. Written once here, and a test reads this module to
/// check the suffix appears nowhere else.
pub fn take_name(take: &str, whose: Whose<'_>, veiled: bool) -> String {
    let mut name = take.to_string();
    match whose {
        Whose::Only => {}
        Whose::Guest(guest) => {
            name.push_str(" - ");
            name.push_str(guest);
        }
        // Named for what it holds rather than numbered, because in a Browser
        // full of one take's recordings this is the one somebody wants first.
        Whose::Everybody => name.push_str(" (everybody)"),
    }
    if !veiled {
        // The one thing telling an unveiled recording from a veiled one in the
        // Browser. Everything else about the two entries is identical.
        name.push_str(" (unveiled)");
    }
    name
}



/// What is being kept for one guest while a room take runs.
struct GuestTake {
    /// The guest's name at the moment the take started.
    ///
    /// Copied rather than looked up when the take is stored, because the list
    /// can be edited between takes and a recording is filed under the name it
    /// was made with.
    name: String,
    /// Their veiled voice, when it is being kept.
    veiled: Option<veilvoice_audio::record::Recorder>,
    /// Their real voice, when it is being kept. Roadmap item 131's warning applies
    /// once per guest.
    plain: Option<veilvoice_audio::record::Recorder>,
}



/// What a running session last reported about itself.
///
/// **Roadmap item 147.** One microphone reports [`veilvoice_audio::LiveStats`] and a
/// room reports [`veilvoice_audio::RoomStats`], which is a different shape
/// because it has one entry per guest. The window matches on this rather than
/// being handed a single-microphone reading a room would have to be flattened
/// into, because flattening it is exactly what loses the per-guest bars the
/// roadmap item asked for.
pub enum Reading {
    /// One person.
    One(veilvoice_audio::LiveStats),
    /// A room.
    Room(veilvoice_audio::RoomStats),
}



/// The Studio and the Browser.
///
/// Every field's default is the shut, empty state, so this derives rather
/// than being written out: a hand-written `Default` here would be a second
/// place to remember a new field, and forgetting one would leave it carrying
/// whatever the last session put in it.
#[derive(Default)]
pub struct Studio {
    /// The open vault. `None` is the shut state, and is the default.
    vault: Option<Vault>,
    /// The listing, read when the vault opens and after every change rather
    /// than every frame: a frame is 16 milliseconds and this decrypts a file.
    entries: Vec<Entry>,

    // --- the unlock form ---
    app_entry: String,
    rest_entry: String,

    // --- veiling, and recording ---
    /// The running session: the veiled voice going out, whether or not a take
    /// is being kept from it. One microphone or a room, never both.
    running: Option<Running>,
    /// What that session was started with, and `None` when none is running.
    setup: Option<Setup>,
    /// The last thing the platform said about either stream, and how many it
    /// has said. **Roadmap item 132.**
    trouble: Option<veilvoice_audio::Interference>,
    /// How many interruptions this session has reported, so a new one is
    /// noticed rather than the same one being shown again every frame.
    troubles_seen: u64,
    /// How many of them happened while the current take was running.
    take_troubles: u64,
    /// Other programs that took the microphone while the current take was
    /// running. Names rather than processes, because Windows reports the use
    /// per application and gives no process to compare.
    intruders: std::collections::BTreeSet<String>,
    recorder: Option<veilvoice_audio::record::Recorder>,
    /// What the next take will be called.
    take_name: String,
    /// Which side of the engine the next take keeps.
    ///
    /// Defaults to the veiled voice, and the default is the point: nothing here
    /// reaches a recording of somebody's real voice without being asked for.
    keep: Keep,
    /// The second recorder, when the real voice is being kept as well.
    plain: Option<veilvoice_audio::record::Recorder>,

    // --- the room ---
    /// **Roadmap item 147.** Whether the form is set to a room rather than to one
    /// microphone. Beside the guest list rather than in the window, because the
    /// two are one answer and splitting them would let the window ask for a
    /// room of nobody.
    room_wanted: bool,
    /// Who is in the room. Empty until the form is switched to a room, which
    /// seeds it: a room of one person is one microphone, which is the other
    /// half of this tab.
    guests: Vec<RoomGuest>,
    /// What is being kept per guest while a room take runs, in the order the
    /// guests were given.
    room_takes: Vec<GuestTake>,
    /// The mix everybody in a room hears, when a take is keeping the veiled
    /// side. The one recording that has the whole conversation in it.
    mixed: Option<veilvoice_audio::record::Recorder>,
    /// Smoothed bars, one pair per guest, sized when a room starts.
    ///
    /// Separate from [`Studio::levels`], which is the one pair the monitor
    /// strip draws on every tab. Sharing them would mean a room drawn as one
    /// bar, which is the reading that cannot say which microphone is dead.
    guest_levels: Vec<crate::monitor::Levels>,

    // --- the browser ---
    /// Which take is selected, by identifier.
    selected: Option<String>,
    /// The take being renamed, and the name being typed for it.
    renaming: Option<(String, String)>,
    /// The take a removal is waiting to be confirmed for.
    confirm_remove: Option<String>,
    /// Smoothed input and output peaks, for the bars drawn while recording.
    levels: crate::monitor::Levels,
    /// The take being played, and which one it is.
    ///
    /// Held as a pair so the row that started it can show its own controls. One
    /// at a time: two takes playing over each other is not a feature, and the
    /// second would decrypt a second recording into memory while the first was
    /// still there.
    playing: Option<(String, veilvoice_audio::playback::Playing)>,
    /// The folder picker, while it is open.
    picker: crate::dialog::Pending,
    /// What the picker is open for: which take, and what to make of it.
    choosing: Option<(String, Render)>,

    // --- decoys ---
    /// How many decoys the slider is on.
    decoys_wanted: usize,
    /// Free space where the vaults live, measured when the vault opens and
    /// again after decoys are made.
    ///
    /// Cached rather than read while drawing: measuring it starts a process,
    /// and a frame is sixteen milliseconds. `None` is "the system would not
    /// say", which the panel reports as such.
    free: Option<u64>,
    /// How many vault-shaped directories are in the folder, real and decoy
    /// together. Counted at the same moments as the free space, and for the
    /// same reason.
    vaults: usize,

    /// The last thing worth saying, and the colour to say it in.
    message: Option<(String, Color32)>,
}


impl Studio {

    /// Whether the vault is open.
    pub fn is_open(&self) -> bool {
        self.vault.is_some()
    }



    /// Whether a take is being kept.
    ///
    /// The window asks, so that closing it, or locking, does not silently
    /// abandon a recording somebody is in the middle of making. A session
    /// running with nothing attached to it is not a recording: since roadmap item 130
    /// the Studio veils whether or not it is keeping anything, and treating
    /// those as the same thing would refuse to close a window over a call
    /// nobody was recording.
    pub fn is_recording(&self) -> bool {
        self.recorder.is_some()
            || self.plain.is_some()
            || self.mixed.is_some()
            || !self.room_takes.is_empty()
    }



    /// Whether the veiled voice is going out.
    pub fn is_veiling(&self) -> bool {
        self.running.is_some()
    }



    /// Whether the form is set to a room. **Roadmap item 147.**
    pub fn wants_a_room(&self) -> bool {
        self.room_wanted
    }



    /// Switch the form between one microphone and a room.
    ///
    /// Switching to a room with nobody in it seeds two guests, because a room
    /// of one is one microphone and this tab already has that. Switching away
    /// keeps the list: somebody who ticked the box to look at it and untucked
    /// it again has not asked for the names they typed to be thrown away.
    pub fn want_a_room(&mut self, yes: bool) {
        self.room_wanted = yes;
        if yes && self.guests.is_empty() {
            self.guests = vec![RoomGuest::default(), RoomGuest::default()];
        }
    }



    /// Who is in the room, in the order they were added.
    pub fn room_guests(&self) -> &[RoomGuest] {
        &self.guests
    }



    /// One guest, to be edited by the controls that draw them.
    pub fn room_guest_mut(&mut self, index: usize) -> Option<&mut RoomGuest> {
        self.guests.get_mut(index)
    }



    /// Add a guest, up to [`veilvoice_audio::MAX_GUESTS`].
    ///
    /// The bound is the audio layer's and is checked here as well, so the
    /// button stops adding rather than the session refusing afterwards.
    pub fn add_guest(&mut self) {
        if self.guests.len() < veilvoice_audio::MAX_GUESTS {
            self.guests.push(RoomGuest::default());
        }
    }



    /// Take a guest out of the room.
    pub fn remove_guest(&mut self, index: usize) {
        if index < self.guests.len() {
            self.guests.remove(index);
        }
    }



    /// The smoothed bars for the room, one pair per guest.
    ///
    /// Empty when what is running is one microphone, which is what
    /// [`Studio::levels`] is for.
    pub fn guest_levels(&self) -> &[crate::monitor::Levels] {
        &self.guest_levels
    }



    /// Whether what is going out is a preview to this machine's own output
    /// rather than to the chosen one.
    pub fn is_previewing(&self) -> bool {
        self.setup.as_ref().is_some_and(|setup| setup.preview)
    }



    /// The smoothed levels, for the monitor strip and for this tab.
    pub fn levels(&self) -> &crate::monitor::Levels {
        &self.levels
    }



    /// What the audio path last reported about itself, and whether it is new.
    ///
    /// **Roadmap item 132.** The platform reports a stream error on a callback of
    /// its own; before this the only thing done with one was a print to a
    /// console the window does not have. Now the window asks, once a frame,
    /// and says so.
    pub fn trouble(&self) -> Option<&veilvoice_audio::Interference> {
        self.trouble.as_ref()
    }



    /// The programs that took the microphone while this take has been running.
    ///
    /// Empty is the ordinary case and means nothing else asked for it.
    pub fn intruders(&self) -> impl Iterator<Item = &str> {
        self.intruders.iter().map(String::as_str)
    }



    /// Record which programs are holding the microphone right now.
    ///
    /// **Roadmap item 132.** Called by the window only while a take is running:
    /// outside one this is the Monitor tab's question and the safety catch's,
    /// and building a list every frame for a question nobody is asking is what
    /// roadmap item 126 is about.
    ///
    /// VeilVoice itself is holding the microphone whenever this is called, so
    /// it is not an intruder in its own recording. It is matched by name
    /// rather than by process because Windows reports device use per
    /// application and gives no process to compare with.
    pub fn note_microphone_holders<'a>(&mut self, holders: impl Iterator<Item = &'a str>) {
        for holder in holders {
            if holder.to_ascii_lowercase().contains("veilvoice") {
                continue;
            }
            if !self.intruders.contains(holder) {
                self.intruders.insert(holder.to_owned());
            }
        }
    }



    /// Read the session's counters, once a frame, and move the levels on.
    ///
    /// Called from the window rather than from this tab, because the monitor
    /// strip is drawn on every tab and this tab is drawn on one. Reading the
    /// session only while the Studio was on screen would freeze the strip the
    /// moment somebody navigated away, which is the exact moment it exists for.
    pub fn tick(&mut self) -> Option<Reading> {
        let reading = match self.running.as_ref()? {
            Running::One(session) => Reading::One(session.stats()),
            Running::Room(room) => Reading::Room(room.stats()),
        };

        // The strip on every tab is one pair of bars, and a room has one pair
        // per guest, so the strip gets the loudest microphone in the room and
        // the mix. That answers "is anything arriving and is anything leaving",
        // which is what the strip is for; which guest is silent is a question
        // the per-guest bars answer and this one deliberately does not.
        //
        // Which numbers is decided here and the bars are moved on **once**,
        // below. Two calls, one per arm, would be two readers of a counter that
        // resets as it is read, and only one of them would run per frame, which
        // is the kind of thing that is true until somebody adds a third arm.
        let (input, output, interfered) = match &reading {
            Reading::One(stats) => (stats.input_peak, stats.output_peak, stats.interfered),
            Reading::Room(stats) => {
                // Sized here rather than assumed: a room whose guest count
                // changed would otherwise draw a guest's bar against somebody
                // else's numbers for a frame.
                if self.guest_levels.len() != stats.guests.len() {
                    self.guest_levels = vec![crate::monitor::Levels::default(); stats.guests.len()];
                }
                for (levels, guest) in self.guest_levels.iter_mut().zip(&stats.guests) {
                    levels.update(guest.input_peak, guest.output_peak);
                }
                let loudest = stats
                    .guests
                    .iter()
                    .fold(0.0f32, |most, guest| most.max(guest.input_peak));
                // The mix peak **before** clipping, which is what every other
                // bar in this program shows and is the one that can say the
                // room went past full scale.
                (loudest, stats.mix_peak, stats.interfered)
            }
        };
        self.levels.update(input, output);

        // **Roadmap item 132.** The count is a number and is read every frame; the
        // report itself holds a string and is asked for only when the count
        // has moved.
        if interfered > self.troubles_seen {
            if self.is_recording() {
                self.take_troubles += interfered - self.troubles_seen;
            }
            self.troubles_seen = interfered;
            self.trouble = match self.running.as_ref() {
                Some(Running::One(session)) => session.interference(),
                Some(Running::Room(room)) => room.interference(),
                None => None,
            };
            self.catch_a_fault();
        }
        Some(reading)
    }



    /// **Roadmap item 145.** The Studio's own failsafe, and what it is for.
    ///
    /// Separate from [`veilvoice_guard::failsafe`], which is the application's and is
    /// about *other programs* taking the microphone. This one is about this
    /// tab: a fault in the Studio stops the Studio rather than the recording.
    ///
    /// # The fault it catches
    ///
    /// The device a take is being recorded from stops existing: unplugged,
    /// switched away by the operating system, taken by something with more
    /// authority. Roadmap item 132 made that visible, and visible was as far as it
    /// went: the take carried on, recording silence, until somebody looked at
    /// the screen. A recording that continues after there is nothing to record
    /// is worse than one that stops, because it looks like it worked.
    ///
    /// # What it does, and what it deliberately does not
    ///
    /// It **stores** what was captured and stops. It does not discard, retry,
    /// or switch to another device.
    ///
    /// Not discard, for the reason locking the window does not: everything up
    /// to the fault is a real recording of something somebody said, and
    /// throwing it away because the end of it is missing would be the worst
    /// thing this tab could do.
    ///
    /// Not retry, and not switch: the person chose that microphone. Moving a
    /// recording onto a different one because the first went away is a program
    /// deciding, on its own, to record somebody through a device they did not
    /// pick. On a machine where the default input is a laptop's built-in
    /// microphone, that is exactly the wrong answer.
    ///
    /// Only a device that has gone. Anything else the platform reports is
    /// shown and left alone, because "the mixer said something" is not a
    /// reason to end a recording somebody is making.
    fn catch_a_fault(&mut self) {
        let gone = self.trouble.as_ref().is_some_and(|t| t.device_gone);
        if !gone {
            return;
        }
        let recording = self.is_recording();
        // The session is over either way: a device that has gone does not come
        // back, and the streams on it are not going to produce another sample.
        self.stop_veiling();
        if !recording {
            self.message = Some((
                "The device this was using is gone, so the veiling has stopped.".to_string(),
                p::red(),
            ));
            return;
        }
        // `stop_veiling` sealed the take on the way through and left its own
        // message about what was stored. This says why it stopped, in front of
        // it, because the reason is the part somebody has to know.
        let stored = self
            .message
            .take()
            .map(|(said, _)| said)
            .unwrap_or_default();
        self.message = Some((
            format!(
                "The device this was recording from is gone, so the take was stopped and \
                 stored rather than left running on nothing. {stored}"
            )
            .trim_end()
            .to_string(),
            p::red(),
        ));
    }



    /// What phase the take half of the tab is in.
    fn phase(&self) -> Phase {
        match (&self.vault, self.is_recording()) {
            (None, _) => Phase::Shut,
            (Some(_), false) => Phase::Idle,
            (Some(_), true) => Phase::Recording,
        }
    }



    /// Start veiling, keeping nothing.
    ///
    /// `preview` sends it to this machine's own output instead of the chosen
    /// one, which is how somebody hears themselves veiled without whatever is
    /// listening on the virtual cable hearing it too.
    pub fn start_veiling(
        &mut self,
        config: DeidConfig,
        input: Option<&str>,
        output: Option<&str>,
        preview: bool,
    ) {
        let setup = Setup {
            config,
            who: Who::One(input.map(str::to_owned)),
            output: output.map(str::to_owned),
            preview,
        };
        self.start_session(setup, veilvoice_audio::Keeping::default());
    }



    /// **Roadmap item 147.** Start veiling a room, keeping nothing.
    ///
    /// One microphone per guest, each veiled into a voice of their own and the
    /// results mixed into `output`. The guests are [`Studio::room_guests`]
    /// rather than an argument, for the reason [`Who::Room`] gives.
    pub fn start_room(&mut self, config: DeidConfig, output: Option<&str>, preview: bool) {
        let setup = Setup {
            config,
            who: Who::Room,
            output: output.map(str::to_owned),
            preview,
        };
        self.start_session(setup, veilvoice_audio::Keeping::default());
    }



    /// Stop the audio. A take still running is stored first, never discarded.
    pub fn stop_veiling(&mut self) {
        if self.is_recording() {
            self.finish_take();
        }
        self.running = None;
        self.setup = None;
        // The bars go back to nothing rather than freezing at the last peak,
        // which would read as a level still arriving.
        self.levels.clear();
        self.guest_levels.clear();
    }



    /// Shut the vault and forget the key.
    ///
    /// Called when the window locks. A recording in progress is stopped first
    /// and **kept**, not discarded: the vault is still open at that moment, and
    /// throwing away a recording because the idle timer fired would be the
    /// worst thing this tab could do.
    pub fn close(&mut self) {
        // Everything the audio path is doing, including a take in progress.
        self.stop_veiling();
        // Back to the safe side. A choice that survived a lock would be a
        // choice somebody made before lunch deciding what is recorded after it.
        self.keep = Keep::default();
        self.plain = None;
        self.mixed = None;
        self.room_takes.clear();
        self.vault = None;
        self.entries.clear();
        self.selected = None;
        self.renaming = None;
        self.confirm_remove = None;
        // A picker still open belongs to a vault that is now shut. Its answer
        // must not arrive later and export from a vault nobody opened.
        self.choosing = None;
        // And a take still playing is a decrypted recording in memory. The
        // window is locking; it goes with the vault.
        self.playing = None;
        // Measurements of a folder this no longer has open. Kept, they would
        // be shown beside the next vault as though they described it.
        self.free = None;
        self.vaults = 0;
        self.app_entry.zeroize();
        self.rest_entry.zeroize();
    }



    /// Derive the key from both entries and open the vault.
    fn unlock(&mut self) {
        let Some(dir) = default_dir() else {
            self.message = Some((
                "This system does not say where an application should keep its \
                 files, so there is nowhere to put a vault."
                    .into(),
                p::red(),
            ));
            return;
        };

        // Both buffers are consumed and wiped here whatever happens next,
        // including the failure paths below.
        let app = into_secret(&mut self.app_entry);
        let rest = into_secret(&mut self.rest_entry);

        let key = match StudioKey::derive(&app, &rest) {
            Ok(key) => key,
            Err(error) => {
                self.message = Some((error.to_string(), p::red()));
                return;
            }
        };

        match veilvoice_crypto::studio::find_or_make(&dir, key) {
            Ok(vault) => match vault.list() {
                Ok(entries) => {
                    let count = entries.len();
                    self.entries = entries;
                    self.vault = Some(vault);
                    self.measure(&dir);
                    self.message = Some((
                        match count {
                            0 => "Vault open. Nothing in it yet.".to_string(),
                            1 => "Vault open. One recording.".to_string(),
                            n => format!("Vault open. {n} recordings."),
                        },
                        p::green(),
                    ));
                }
                // The index would not open. Almost always the wrong pair of
                // passphrases, and said that way round rather than as a
                // cryptographic verdict, because that is what it usually means.
                Err(_) => {
                    self.message = Some((
                        "That pair did not open this vault. Both passphrases have \
                         to be the ones it was made with, and either one being \
                         wrong looks exactly like this."
                            .into(),
                        p::red(),
                    ));
                }
            },
            Err(error) => self.message = Some((error.to_string(), p::red())),
        }
    }



    /// Read the folder the vaults are in: how much room is free, and how many
    /// vaults are already there.
    ///
    /// Both start a little work, so this is called when something changes
    /// rather than while drawing. Neither is an error worth reporting: a folder
    /// that will not list and a system that will not say how much is free both
    /// mean the panel offers a starting point instead of a measurement, and it
    /// says which.
    fn measure(&mut self, dir: &std::path::Path) {
        self.free = veilvoice_setup::space::free_bytes(dir);
        self.vaults = veilvoice_crypto::studio::vault_dirs(dir)
            .map(|v| v.len())
            .unwrap_or(0);
    }



    /// Make `count` decoys beside the open vault.
    ///
    /// Sized from the vault that is open, so they cannot be told from it by
    /// size, and named the way it is named, so they cannot be told from it by
    /// name. The key each is filled under is made and dropped inside
    /// `make_decoy_in`; nothing here ever holds it.
    fn make_decoys(&mut self, count: usize) {
        let Some(vault) = &self.vault else { return };
        let Some(parent) = vault.dir().parent().map(std::path::Path::to_path_buf) else {
            return;
        };

        let shape = match veilvoice_crypto::studio::Shape::of(vault) {
            Ok(shape) => shape,
            Err(error) => {
                self.message = Some((error.to_string(), p::red()));
                return;
            }
        };

        for made in 0..count {
            if let Err(error) = veilvoice_crypto::studio::make_decoy_in(&parent, shape) {
                // Said with the number that did get made. Stopping quietly
                // after three of eight would leave somebody believing they had
                // eight, which is worse than the failure itself.
                self.message = Some((
                    format!(
                        "{made} of {count} were made, and then this stopped: {error}. \
                         The ones already made are decoys and are staying."
                    ),
                    p::red(),
                ));
                self.measure(&parent);
                return;
            }
        }

        self.measure(&parent);
        self.message = Some((
            format!(
                "{} made. This folder now holds {} and only the pair of passphrases says \
                 which one is yours.",
                counted_decoys(count),
                self.vaults
            ),
            p::green(),
        ));
    }



    /// Start recording into the vault's holding area.
    fn start_take(&mut self, setup: Setup) {
        // One recorder per side that is being kept, and neither exists unless
        // it was asked for. The session builds them, at the rate the device
        // agreed to: this used to build them here from `config.sample_rate`,
        // which is the rate that was *asked* for, and F-166 is what the two
        // disagreeing costs.
        let keeping = veilvoice_audio::Keeping {
            veiled: self.keep.wants_veiled(),
            plain: self.keep.wants_plain(),
        };
        self.start_session(setup, keeping);
    }



    /// Start, or restart, the live session `setup` describes.
    ///
    /// **The one place in the window a session is started.** Roadmap item 130 moved
    /// live scramble here, and one starter is most of what that is worth: two
    /// of them meant two opens of the same microphone, and on the platforms
    /// that allow that at all the second stream gets a copy of the input
    /// nobody asked for.
    ///
    /// `keeping` decides whether this session keeps anything. Sinks cannot be
    /// attached to a running stream, so starting and ending a take each restart
    /// the session, which costs a short gap in the outgoing voice. That is said
    /// on screen rather than hidden: a gap somebody can see explained is better
    /// than one they cannot.
    fn start_session(&mut self, setup: Setup, keeping: veilvoice_audio::Keeping) {
        use veilvoice_audio::devices::Direction;

        // The running one goes first, and before the devices are opened rather
        // than after: a second stream on the same microphone would exist for as
        // long as the open took.
        self.running = None;
        self.guest_levels.clear();

        // A preview goes to this machine's own output and the chosen output is
        // deliberately ignored: a preview sent to the virtual cable would be
        // heard by whatever is listening on it, which is the one place somebody
        // checking their setup does not want it to go. `None` asks the audio
        // layer for the default device.
        let chosen = if setup.preview {
            None
        } else {
            setup.output.as_deref()
        };
        let output = match veilvoice_audio::devices::open(Direction::Output, chosen) {
            Ok(device) => device,
            Err(error) => {
                self.setup = None;
                self.message = Some((error.to_string(), p::red()));
                return;
            }
        };

        // Whichever kind this is, it ends as one session, one set of recorders
        // and one `Kept`-shaped answer. The two arms differ in how many
        // microphones they open and in nothing else, which is why the state
        // they leave behind is set once, below both of them.
        let started = match &setup.who {
            Who::One(name) => {
                let input = match veilvoice_audio::devices::open(Direction::Input, name.as_deref())
                {
                    Ok(device) => device,
                    Err(error) => {
                        self.setup = None;
                        self.message = Some((error.to_string(), p::red()));
                        return;
                    }
                };
                veilvoice_audio::LiveSession::start_recording(
                    &input,
                    &output,
                    setup.config,
                    keeping,
                )
                .map(|(session, kept)| {
                    (
                        Running::One(session),
                        kept.veiled,
                        kept.plain,
                        None,
                        Vec::new(),
                    )
                })
            }
            Who::Room => {
                // Refused here rather than by the audio layer, because the
                // audio layer would open one device twice and succeed: it
                // cannot tell that two of its `Guest`s are the same machine.
                let list = self.guests.clone();
                if let Some(said) = sharing_a_microphone(&list) {
                    self.setup = None;
                    self.message = Some((said, p::red()));
                    return;
                }

                // Every microphone is opened before any `Guest` is built,
                // because a `Guest` borrows its device and both have to outlive
                // the call that starts the room.
                let mut devices = Vec::with_capacity(list.len());
                for (slot, guest) in list.iter().enumerate() {
                    match veilvoice_audio::devices::open(Direction::Input, guest.device.as_deref())
                    {
                        Ok(device) => devices.push(device),
                        Err(error) => {
                            self.setup = None;
                            // Named, because a room has several microphones and
                            // "a device would not open" does not say which
                            // person to go and look at.
                            self.message =
                                Some((format!("{}: {error}", guest.called(slot)), p::red()));
                            return;
                        }
                    }
                }

                // A voice each, from the same table a group *render* hands out,
                // in the same order. Two guests on one voice would be two
                // people who cannot be told apart by ear, which is what opening
                // a microphone each was for.
                let guests: Vec<veilvoice_audio::Guest<'_>> = devices
                    .iter()
                    .enumerate()
                    .map(|(slot, device)| {
                        let mut config = setup.config;
                        config.accent =
                            veilvoice_core::voices::voice(slot).applied_to(config.accent);
                        veilvoice_audio::Guest {
                            device,
                            config,
                            keeping,
                        }
                    })
                    .collect();

                // The mix is kept when the veiled side is: it is the veiled
                // recording of the whole conversation. Keeping only the
                // microphones asks for everybody's real voice and for no mix,
                // which is what it says.
                veilvoice_audio::RoomSession::start(&guests, &output, keeping.veiled).map(
                    |(session, kept)| {
                        let takes = kept
                            .guests
                            .into_iter()
                            .enumerate()
                            .map(|(slot, one)| GuestTake {
                                name: list[slot].called(slot),
                                veiled: one.veiled,
                                plain: one.plain,
                            })
                            .collect();
                        (Running::Room(session), None, None, kept.mixed, takes)
                    },
                )
            }
        };

        match started {
            Ok((session, veiled, plain, mixed, takes)) => {
                self.running = Some(session);
                self.recorder = veiled;
                self.plain = plain;
                self.mixed = mixed;
                self.room_takes = takes;
                self.setup = Some(setup);
                self.message = None;
                // A new session reports on itself. The counter belongs to the
                // `Shared` that went with the old one, so carrying either
                // across would attribute the last stream's trouble to this one.
                self.troubles_seen = 0;
                self.trouble = None;
                if keeping.is_anything() {
                    self.take_troubles = 0;
                    self.intruders.clear();
                }
            }
            Err(error) => {
                self.setup = None;
                self.message = Some((error.to_string(), p::red()));
            }
        }
    }



    /// Stop keeping, seal what was captured, and carry on veiling.
    ///
    /// Veiling continues deliberately. Somebody on a call who has just ended a
    /// take has not asked to be heard in their own voice again, and a stop
    /// button that unveiled them mid-sentence would be the worst control in
    /// this window. The session comes back without recorders attached, which
    /// costs the short gap [`Self::start_session`] describes.
    fn stop_take(&mut self) {
        self.finish_take();
        if let Some(setup) = self.setup.clone() {
            self.start_session(setup, veilvoice_audio::Keeping::default());
        }
    }



    /// Stop recording and seal what was captured into the vault.
    ///
    /// The audio stops with it. Callers that mean to carry on veiling use
    /// [`Self::stop_take`], which restarts it; callers that are shutting
    /// everything down use [`Self::stop_veiling`], which does not.
    fn finish_take(&mut self) {
        // The audio stops first. Sealing takes a noticeable moment, and samples
        // arriving during it would be dropped rather than kept.
        self.running = None;
        // The bars go back to nothing rather than freezing at the last peak,
        // which would read as a level still arriving.
        self.levels.clear();
        self.guest_levels.clear();

        let name = if self.take_name.trim().is_empty() {
            "untitled".to_string()
        } else {
            self.take_name.trim().to_string()
        };

        // Both recorders are taken before either is stored, so a failure
        // sealing the first does not leave the second holding audio.
        let veiled = self.recorder.take();
        let plain = self.plain.take();
        // **Roadmap item 147.** A room's mix and its guests, taken before any of them
        // is stored, for the reason the pair above is: a failure sealing the
        // first must not leave the rest holding audio.
        let mixed = self.mixed.take();
        let room = std::mem::take(&mut self.room_takes);

        let mut said = Vec::new();
        let mut trouble = false;
        // The veiled take first, so that when both were kept the one in the
        // message and the one selected in the Browser is the safe one. In a
        // room the mix takes that place, and is named for what it holds: it is
        // the only recording with the whole conversation in it.
        for (recorder, whose, is_veiled) in [
            (veiled, Whose::Only, true),
            (plain, Whose::Only, false),
            (mixed, Whose::Everybody, true),
        ] {
            let Some(recorder) = recorder else { continue };
            match self.store_take(recorder, &take_name(&name, whose, is_veiled)) {
                Ok(line) => said.push(line),
                Err(line) => {
                    said.push(line);
                    trouble = true;
                }
            }
        }
        // Then every guest, under their own name. Four people is four files
        // somebody can tell apart afterwards, which is the whole reason each of
        // them was given a microphone.
        for guest in room {
            for (recorder, is_veiled) in [(guest.veiled, true), (guest.plain, false)] {
                let Some(recorder) = recorder else { continue };
                let called = take_name(&name, Whose::Guest(&guest.name), is_veiled);
                match self.store_take(recorder, &called) {
                    Ok(line) => said.push(line),
                    Err(line) => {
                        said.push(line);
                        trouble = true;
                    }
                }
            }
        }

        if said.is_empty() {
            // Nothing was being kept, which `start_take` does not allow and
            // which would otherwise end in silence.
            return;
        }

        // **Roadmap item 132.** What happened to the audio while this was being
        // recorded, said with the take rather than left in a log. A recording
        // made while the microphone was taken away, or while another program
        // was also holding it, is a recording somebody should know that about
        // before they rely on it.
        //
        // Said even when it changes nothing about the file, because "nothing
        // was noticed" and "nothing happened" are different sentences and only
        // one of them is this program's to say.
        let mut warned = false;
        if self.take_troubles > 0 {
            warned = true;
            said.push(format!(
                "The audio path reported {} while this was recording, so it may be short \
                 or may have gaps.",
                counted_interruptions(self.take_troubles)
            ));
        }
        if !self.intruders.is_empty() {
            warned = true;
            let names: Vec<&str> = self.intruders.iter().map(String::as_str).collect();
            said.push(format!(
                "Something else was holding the microphone while this was recording: {}. \
                 That program heard what you said in your own voice.",
                names.join(", ")
            ));
        }
        self.take_troubles = 0;
        self.intruders.clear();

        self.take_name.clear();
        self.message = Some((
            said.join(" "),
            if trouble {
                p::red()
            } else if warned {
                p::yellow()
            } else {
                p::green()
            },
        ));
    }



    /// Seal one recorder's audio into the vault under `name`.
    ///
    /// Returns the line to say either way. Split out of [`Self::finish_take`]
    /// because a take can now produce two recordings and the sealing is
    /// identical for both: what differs is only the name and, for the person
    /// reading the message, which side it came from.
    fn store_take(
        &mut self,
        mut recorder: veilvoice_audio::record::Recorder,
        name: &str,
    ) -> Result<String, String> {
        recorder.drain();
        if recorder.samples() == 0 {
            return Err(format!(
                "Nothing was captured for {name:?}, so nothing was stored. \
                 Check the input device is the one you are speaking into."
            ));
        }

        let seconds = recorder.seconds();
        let dropped = recorder.dropped();
        let wav = recorder.wav().map_err(|e| e.to_string())?;

        let Some(vault) = &self.vault else {
            // The vault shut while a recording was running. The recording is
            // still in locked memory here and there is nowhere safe to put it,
            // so say so plainly rather than writing it somewhere it does not
            // belong.
            return Err(
                "The vault closed while this was recording, so there is nowhere \
                 to put it. Open the vault and record again."
                    .to_string(),
            );
        };

        let made = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_secs() as i64)
            .unwrap_or(0);

        match vault.store(name, made, wav.expose()) {
            Ok(entry) => {
                self.selected = Some(entry.id.clone());
                self.entries.push(entry);
                let mut said = format!("Stored {name:?}, {}.", length(seconds as f64));
                if dropped > 0 {
                    // Never hidden. A recording that is quietly short is the
                    // failure this whole path is built to avoid.
                    said.push_str(&format!(
                        " {dropped} samples were dropped, so it is slightly short."
                    ));
                }
                Ok(said)
            }
            Err(error) => Err(error.to_string()),
        }
    }



    /// Play a take, straight out of the vault and out of locked memory.
    ///
    /// # Nothing is written
    ///
    /// The obvious way to hear a WAV is to put it somewhere and hand the path
    /// to something that plays files. That would leave an unencrypted recording
    /// on the disk, which is what the vault exists to prevent, and it would
    /// leave it there until somebody remembered to shred it.
    ///
    /// So the samples go from the sealed record, through
    /// [`Secret`](veilvoice_crypto::Secret), to the audio device. The take is
    /// decrypted whole rather than in pieces, because the container is
    /// authenticated as one piece and an AEAD that let you open the first
    /// second of it would not be authenticating anything. What that buys is the
    /// thing that matters, which is no plaintext file at any point; what it
    /// does not buy is a footprint smaller than the recording, and that is said
    /// here rather than implied.
    fn play(&mut self, id: &str) {
        // Whatever was playing stops first, and its samples go with it. Two
        // takes decrypted at once is twice as much of somebody's voice in
        // memory as the reason for it.
        self.playing = None;

        let Some(vault) = &self.vault else {
            return;
        };
        let wav = match vault.load(id) {
            Ok(wav) => wav,
            Err(error) => {
                self.message = Some((error.to_string(), p::red()));
                return;
            }
        };
        let Some((rate, _seconds)) = wav_shape(wav.expose()) else {
            self.message = Some((
                "That recording does not have a WAV header this can read, so \
                 there is no way to know what rate to play it at."
                    .into(),
                p::red(),
            ));
            return;
        };

        match veilvoice_audio::playback::start(pcm16(wav.expose()), rate, None) {
            Ok(playing) => {
                self.playing = Some((id.to_string(), playing));
                self.message = None;
            }
            Err(error) => self.message = Some((error.to_string(), p::red())),
        }
    }



    /// Turn a take into a page, a video, or both, in `into`.
    ///
    /// # Leaving the vault is the point, and is said out loud
    ///
    /// Everything written here is **outside** the vault and is not sealed. That
    /// is not a defect: a video nobody can open is not a video. It is the one
    /// thing somebody doing this needs to have understood, so the tab says it
    /// before the button is pressed rather than in a note afterwards.
    ///
    /// The audio is still veiled, because it was veiled before it was ever
    /// stored. What leaves is a recording of a voice that is not anybody's.
    fn export(&mut self, id: &str, what: Render, into: &std::path::Path) {
        let Some(vault) = &self.vault else {
            return;
        };
        let Some(entry) = self.entries.iter().find(|e| e.id == id).cloned() else {
            self.message = Some((
                "That recording is not in the listing any more.".into(),
                p::red(),
            ));
            return;
        };

        let wav = match vault.load(id) {
            Ok(wav) => wav,
            Err(error) => {
                self.message = Some((error.to_string(), p::red()));
                return;
            }
        };
        let Some((_rate, seconds)) = wav_shape(wav.expose()) else {
            self.message = Some((
                "That recording does not have a WAV header this can read, so its \
                 length is unknown and nothing was written."
                    .into(),
                p::red(),
            ));
            return;
        };

        let stem = safe_stem(&entry.name);
        let audio_path = into.join(format!("{stem}.wav"));
        let plan = match plan_for(&entry.name, seconds) {
            Ok(plan) => plan,
            Err(why) => {
                self.message = Some((why, p::red()));
                return;
            }
        };

        // The audio first, because both outputs need it and neither is worth
        // writing without it.
        if let Err(error) =
            veilvoice_crypto::privatefile::write_owner_only(&audio_path, wav.expose())
        {
            self.message = Some((error.to_string(), p::red()));
            return;
        }

        let mut wrote = vec![audio_path.clone()];

        if what.wants_page() {
            match self.write_page(&plan, wav.expose(), &stem, into, &audio_path) {
                Ok(mut paths) => wrote.append(&mut paths),
                Err(why) => {
                    self.message = Some((why, p::red()));
                    return;
                }
            }
        }

        if what.wants_video() {
            let video = into.join(format!("{stem}.mp4"));
            match veilvoice_video::ffmpeg::found() {
                Some(_) => match run_ffmpeg(&audio_path, &video) {
                    Ok(()) => wrote.push(video),
                    Err(why) => {
                        self.message = Some((why, p::red()));
                        return;
                    }
                },
                // The same answer the command line gives: the exact command,
                // rather than an offer to fetch a program this does not ship.
                None => {
                    let argv = veilvoice_video::ffmpeg::black_command(
                        &audio_path,
                        &video,
                        veilvoice_video::ffmpeg::Encoding::default(),
                    );
                    self.message = Some((
                        format!(
                            "The audio and the page are written. `ffmpeg` is not on this \
                             machine, so the video is not.\n\nThe Setup tab lists \
                             `ffmpeg` under companion software, with the install command \
                             for this system and a button to run it. Or run this yourself, \
                             which is the same command:\n\n{}",
                            veilvoice_video::ffmpeg::command_line(&argv)
                        ),
                        p::yellow(),
                    ));
                    return;
                }
            }
        }

        let names: Vec<String> = wrote
            .iter()
            .filter_map(|path| path.file_name().map(|n| n.to_string_lossy().into_owned()))
            .collect();
        self.message = Some((
            format!(
                "Wrote {} into {}. None of it is sealed: what leaves the vault is \
                 an ordinary file, and the voice in it is still a voice nobody owns.",
                names.join(", "),
                into.display()
            ),
            p::green(),
        ));
    }



    /// The player page, its subtitles, and the drawing they sit in.
    fn write_page(
        &self,
        plan: &veilvoice_conversation::Conversation,
        wav: &[u8],
        stem: &str,
        into: &std::path::Path,
        audio: &std::path::Path,
    ) -> Result<Vec<std::path::PathBuf>, String> {
        use veilvoice_conversation::subtitles::{self, Format};
        use veilvoice_video::{page, waveform};

        let samples = pcm16(wav);
        let envelope = waveform::envelope(&samples, 900);
        let vtt = subtitles::write(plan, Format::WebVtt);

        let audio_name = audio
            .file_name()
            .map(|n| n.to_string_lossy().into_owned())
            .unwrap_or_default();

        let drawn = page::player(
            plan,
            &envelope,
            &page::Look::default(),
            &audio_name,
            // Carried in the page rather than fetched: a browser treats every
            // `file:` URL as its own origin, so a track read from the file
            // beside the page is refused and the captions silently do not
            // appear.
            &page::inline_vtt(&vtt),
        )
        .map_err(|error| error.to_string())?;

        let html = into.join(format!("{stem}.html"));
        let vtt_path = into.join(format!("{stem}.vtt"));
        veilvoice_crypto::privatefile::write_owner_only(&html, drawn.markup.as_bytes())
            .map_err(|error| error.to_string())?;
        veilvoice_crypto::privatefile::write_owner_only(&vtt_path, vtt.as_bytes())
            .map_err(|error| error.to_string())?;
        Ok(vec![html, vtt_path])
    }



    /// Re-read the listing from the vault.
    fn refresh(&mut self) {
        if let Some(vault) = &self.vault {
            match vault.list() {
                Ok(entries) => self.entries = entries,
                Err(error) => self.message = Some((error.to_string(), p::red())),
            }
        }
    }

}

impl Studio {

    /// The take half of the Recording Studio tab.
    ///
    /// The voice half, which is the devices, the engine settings, the meters
    /// and the buttons that start and stop the veiling, is drawn above this by
    /// the window: roadmap item 130 moved live scramble into this tab, and the device
    /// lists and the settings widgets it needs are the window's rather than
    /// this module's. What is here is everything to do with the vault.
    ///
    /// `config` is the engine setting the rest of the window is showing, so a
    /// take is recorded at the strength on screen rather than at a default this
    /// tab chose for itself.
    pub fn tab(
        &mut self,
        ui: &mut Ui,
        config: DeidConfig,
        input: Option<&str>,
        output: Option<&str>,
    ) {
        ui.add_space(4.0);

        match self.phase() {
            Phase::Shut => self.shut_panel(ui),
            Phase::Idle => {
                self.take_form(ui);
                ui.add_space(10.0);
                self.keep_form(ui);
                ui.add_space(12.0);
                if ui
                    .button(RichText::new("  start recording  ").strong())
                    .clicked()
                {
                    // The routing the person is already hearing is kept: a take
                    // started while previewing stays on the headphones rather
                    // than being moved onto the cable by the act of recording.
                    // The same kind of session, and the same devices, that
                    // are already running. A take that started a room because
                    // the box above happened to be ticked would be this tab
                    // opening three more microphones on its own.
                    let who = match self.setup.as_ref() {
                        Some(running) => running.who.clone(),
                        None if self.room_wanted => Who::Room,
                        None => Who::One(input.map(str::to_owned)),
                    };
                    self.start_take(Setup {
                        config,
                        who,
                        output: output.map(str::to_owned),
                        preview: self.is_previewing(),
                    });
                }
            }
            Phase::Recording => {
                // **Both** recorders, every frame, and the reason is the ring
                // rather than the clock. A recorder nobody drains fills up and
                // starts dropping samples, so draining only one of them would
                // have made the second take quietly short: the exact failure
                // `dropped` exists to report, arrived at by not asking.
                //
                // The counter is whichever is running, because keeping only the
                // microphone leaves no veiled recorder at all, and a clock that
                // sat at zero while a take ran would read as nothing being
                // recorded.
                let mut seconds = 0.0f32;
                let mut dropped = 0u64;
                // **Roadmap item 147.** A room take is the mix plus one or two
                // recorders per guest, and every one of them is on the same
                // rule: drained here or quietly short. Chained rather than
                // repeated, because the loop is what makes that true and a
                // second loop somewhere else is how one of them gets missed.
                let room = self
                    .room_takes
                    .iter_mut()
                    .flat_map(|take| [take.veiled.as_mut(), take.plain.as_mut()]);
                for recorder in [
                    self.recorder.as_mut(),
                    self.plain.as_mut(),
                    self.mixed.as_mut(),
                ]
                .into_iter()
                .chain(room)
                .flatten()
                {
                    recorder.drain();
                    seconds = seconds.max(recorder.seconds());
                    dropped = dropped.max(recorder.dropped());
                }

                ui.horizontal(|ui| {
                    ui.label(RichText::new("● recording").color(p::red()).strong());
                    ui.label(RichText::new(length(seconds as f64)).color(p::fg()));
                    // **Roadmap item 145**: whether what is being recorded is veiled,
                    // said *while* it is being recorded and not only before it
                    // started. The choice is made on the form above and then
                    // the form is gone, so a take that keeps somebody's real
                    // voice looked exactly like one that does not for the whole
                    // of the recording. It is drawn in the colour the choice
                    // carries: the safe one is not a warning.
                    ui.label(
                        RichText::new(format!("keeping {}", self.keep.label()))
                            .color(if self.keep.wants_plain() {
                                p::yellow()
                            } else {
                                p::muted()
                            })
                            .small(),
                    );
                });

                // The two bars used to be drawn here as well. They are not any
                // more, and nothing was lost: since roadmap item 130 the voice half
                // of this tab draws them whether or not a take is running, so
                // drawing them again under the clock would be the same meter
                // twice on one screen. The reading is taken once a frame by the
                // window, in `tick`, for the same reason: `stats` resets the
                // peaks as it reads them, so two readers would each see half
                // the level.
                if dropped > 0 {
                    ui.label(
                        RichText::new(format!(
                            "{dropped} samples dropped, so this will be slightly short"
                        ))
                        .color(p::yellow())
                        .small(),
                    );
                }
                ui.add_space(12.0);
                if ui
                    .button(RichText::new("  stop and store  ").strong())
                    .clicked()
                {
                    self.stop_take();
                }
                // Repainting while the counter is running, and only then.
                ui.ctx()
                    .request_repaint_after(std::time::Duration::from_millis(200));
            }
        }

        self.say(ui);
    }



    /// The Recording Browser tab.
    pub fn browser(&mut self, ui: &mut Ui) {
        ui.add_space(4.0);

        // A take that has reached its end releases its samples here rather
        // than waiting for somebody to press stop. The buffer is a decrypted
        // recording; it should not outlive the playing of it by however long
        // the window is left open.
        if self.playing.as_ref().is_some_and(|(_, p)| p.finished()) {
            self.playing = None;
        }
        if self.playing.is_some() {
            // Only while something is playing: the position moves, so the
            // window has to redraw, and the rest of the time it must not.
            ui.ctx()
                .request_repaint_after(std::time::Duration::from_millis(200));
        }

        // The folder picker's answer, if it has arrived. Polled rather than
        // waited for, so the window keeps running while it is open.
        if let Some(answer) = self.picker.poll() {
            if let Some((id, what)) = self.choosing.take() {
                match answer {
                    Some(into) => self.export(&id, what, &into),
                    // Cancelled. Not an error, and not worth a message.
                    None => self.message = None,
                }
            }
        }

        if self.vault.is_none() {
            self.shut_panel(ui);
            self.say(ui);
            return;
        }

        if self.entries.is_empty() {
            ui.label(
                RichText::new(
                    "Nothing in the vault yet. The Studio tab is where recordings are made.",
                )
                .color(p::muted()),
            );
            self.decoy_panel(ui);
            self.say(ui);
            return;
        }

        ui.label(
            RichText::new(format!("{} in the vault", counted(self.entries.len())))
                .color(p::blue())
                .small(),
        );
        ui.add_space(6.0);

        // Collected first: the row buttons borrow `self` mutably, and the list
        // they are drawn from is `self.entries`.
        let rows: Vec<Entry> = self.entries.clone();
        let mut act: Option<Act> = None;

        egui::ScrollArea::vertical()
            .max_height(320.0)
            .show(ui, |ui| {
                for entry in &rows {
                    let chosen = self.selected.as_deref() == Some(entry.id.as_str());
                    ui.horizontal(|ui| {
                        if ui
                            .selectable_label(chosen, RichText::new(&entry.name).strong())
                            .clicked()
                        {
                            act = Some(Act::Select(entry.id.clone()));
                        }
                        ui.label(RichText::new(size(entry.bytes)).color(p::muted()).small());
                        ui.label(RichText::new(made_on(entry.made)).color(p::muted()).small());
                    });

                    if chosen {
                        ui.indent(entry.id.as_str(), |ui| {
                            if let Some((id, typing)) = &mut self.renaming {
                                if id == &entry.id {
                                    ui.horizontal(|ui| {
                                        ui.add(
                                            egui::TextEdit::singleline(typing).desired_width(220.0),
                                        );
                                        if ui.button("save").clicked() {
                                            act =
                                                Some(Act::Rename(entry.id.clone(), typing.clone()));
                                        }
                                        if ui.button("cancel").clicked() {
                                            act = Some(Act::CancelRename);
                                        }
                                    });
                                    return;
                                }
                            }
                            if self.confirm_remove.as_deref() == Some(entry.id.as_str()) {
                                ui.label(
                                    RichText::new(
                                        "Remove this recording? It cannot be brought back.",
                                    )
                                    .color(p::yellow()),
                                );
                                ui.horizontal(|ui| {
                                    if ui
                                        .button(RichText::new("remove it").color(p::red()))
                                        .clicked()
                                    {
                                        act = Some(Act::Remove(entry.id.clone()));
                                    }
                                    if ui.button("keep it").clicked() {
                                        act = Some(Act::CancelRemove);
                                    }
                                });
                                return;
                            }
                            // Playing, and where it has got to.
                            //
                            // The progress is read rather than counted here:
                            // the callback knows how many samples it has
                            // actually handed the device, which is the only
                            // number that is true when the device is behind.
                            let this_is_playing =
                                self.playing.as_ref().is_some_and(|(id, _)| id == &entry.id);
                            ui.horizontal(|ui| {
                                if this_is_playing {
                                    if ui.button("  stop  ").clicked() {
                                        act = Some(Act::Stop);
                                    }
                                    if let Some((_, playing)) = &self.playing {
                                        ui.label(
                                            RichText::new(format!(
                                                "{} / {}",
                                                length(playing.position() as f64),
                                                length(playing.duration() as f64)
                                            ))
                                            .color(p::fg()),
                                        );
                                    }
                                } else if ui
                                    .button("  play  ")
                                    .on_hover_text(
                                        "Plays it out of locked memory. Nothing is \
                                         written to the disk, so there is no copy to \
                                         remember to shred afterwards.",
                                    )
                                    .clicked()
                                {
                                    act = Some(Act::Play(entry.id.clone()));
                                }
                            });
                            ui.add_space(4.0);
                            ui.horizontal(|ui| {
                                if ui.button("rename").clicked() {
                                    act = Some(Act::StartRename(
                                        entry.id.clone(),
                                        entry.name.clone(),
                                    ));
                                }
                                if ui.button("remove").clicked() {
                                    act = Some(Act::AskRemove(entry.id.clone()));
                                }
                            });
                            ui.add_space(4.0);
                            ui.horizontal(|ui| {
                                ui.label(RichText::new("take it out:").color(p::muted()).small());
                                if ui
                                    .button("preview page")
                                    .on_hover_text(
                                        "A page that plays it, draws the waveform and \
                                         carries its own captions. Opens in a browser \
                                         with nothing installed.",
                                    )
                                    .clicked()
                                {
                                    act = Some(Act::Export(entry.id.clone(), Render::Preview));
                                }
                                if ui
                                    .button("render video")
                                    .on_hover_text(
                                        "An MP4 with a black picture, for somewhere that \
                                         will not accept an audio file. Needs ffmpeg, \
                                         which VeilVoice does not ship: the Setup tab \
                                         can install it.",
                                    )
                                    .clicked()
                                {
                                    act = Some(Act::Export(entry.id.clone(), Render::Video));
                                }
                                if ui.button("both").clicked() {
                                    act = Some(Act::Export(entry.id.clone(), Render::Both));
                                }
                            });
                            ui.label(
                                RichText::new(
                                    "Anything taken out is written unsealed. The voice in \
                                     it is still veiled; the file is an ordinary file.",
                                )
                                .color(p::yellow())
                                .small(),
                            );
                        });
                    }
                }
            });

        if let Some(act) = act {
            self.apply(act);
        }

        ui.add_space(8.0);
        ui.label(
            RichText::new(
                "The names and dates above are sealed with the recordings. What a vault on a \
                 disk shows is how many files there are and roughly how large each one is, and \
                 nothing else.",
            )
            .color(p::muted())
            .small(),
        );

        self.decoy_panel(ui);
        self.say(ui);
    }



    /// The decoy panel, under the listing.
    ///
    /// Here rather than in the Studio tab because it is about the folder the
    /// vault is in rather than about making a recording, and this is the tab
    /// that already shows what is on the disk.
    fn decoy_panel(&mut self, ui: &mut Ui) {
        let Some(vault) = &self.vault else { return };
        let shape = match veilvoice_crypto::studio::Shape::of(vault) {
            Ok(shape) => shape,
            // The listing above would already have failed, so there is nothing
            // to add and no second red line worth printing.
            Err(_) => return,
        };

        ui.add_space(10.0);
        let mut wanted = self.decoys_wanted;
        let asked = crate::decoys::panel(ui, shape, self.free, self.vaults, &mut wanted);
        self.decoys_wanted = wanted;
        if let Some(count) = asked {
            self.make_decoys(count);
        }
    }



    /// The panel shown while the vault is shut, in both tabs.
    fn shut_panel(&mut self, ui: &mut Ui) {
        ui.label(RichText::new("The vault is shut").color(p::blue()).small());
        ui.add_space(4.0);
        ui.label(
            RichText::new(
                "It opens with both passphrases at once: the one on this application, and the \
                 one on your recordings. Neither alone opens it, which is what makes a stolen \
                 unlocked laptop useless here.",
            )
            .color(p::muted())
            .small(),
        );
        ui.add_space(10.0);

        let mut go = false;
        ui.horizontal(|ui| {
            ui.label("app lock  ");
            go |= ui
                .add(
                    egui::TextEdit::singleline(&mut self.app_entry)
                        .password(true)
                        .desired_width(200.0),
                )
                .lost_focus()
                && ui.input(|i| i.key_pressed(egui::Key::Enter));
        });
        ui.horizontal(|ui| {
            ui.label("at rest   ");
            go |= ui
                .add(
                    egui::TextEdit::singleline(&mut self.rest_entry)
                        .password(true)
                        .desired_width(200.0),
                )
                .lost_focus()
                && ui.input(|i| i.key_pressed(egui::Key::Enter));
        });

        ui.add_space(8.0);
        let ready = !self.app_entry.is_empty() && !self.rest_entry.is_empty();
        if ui
            .add_enabled(
                ready,
                egui::Button::new(RichText::new("  open the vault  ").strong()),
            )
            .clicked()
            || (go && ready)
        {
            self.unlock();
        }
        if !ready {
            ui.label(
                RichText::new("Both are needed. One on its own is refused rather than tried.")
                    .color(p::muted())
                    .small(),
            );
        }
    }



    /// The name field for the next take.
    fn take_form(&mut self, ui: &mut Ui) {
        ui.horizontal(|ui| {
            ui.label("call it  ");
            ui.add(
                egui::TextEdit::singleline(&mut self.take_name)
                    .hint_text("untitled")
                    .desired_width(240.0),
            );
        });
        ui.label(
            RichText::new(
                "A name is a label and nothing veils a name. It is sealed with the recording, \
                 so it is not readable from the disk, and it is still the thing that says who \
                 this is.",
            )
            .color(p::muted())
            .small(),
        );
    }



    /// Which side of the engine to keep, asked before anything starts.
    ///
    /// **Roadmap item 131.** Before the button rather than after it, because the
    /// answer cannot be changed once a take has been made: a recording of
    /// somebody's real voice is not something to discover having made.
    ///
    /// The safe choice is selected, and choosing either of the others puts what
    /// it costs on the screen in the same words the plaintext path uses. There
    /// is no tick that quietly remembers this between runs, for the reason
    /// group mode is not remembered either: a mode somebody forgets is on is a
    /// mode that eventually records what they did not mean to record.
    fn keep_form(&mut self, ui: &mut Ui) {
        ui.label(RichText::new("what to keep").color(p::blue()).small());
        ui.horizontal(|ui| {
            for choice in [Keep::Veiled, Keep::Both, Keep::Plain] {
                ui.selectable_value(&mut self.keep, choice, choice.label());
            }
        });
        ui.label(
            RichText::new(self.keep.cost())
                .color(if self.keep.wants_plain() {
                    p::yellow()
                } else {
                    p::muted()
                })
                .small(),
        );
        if self.keep == Keep::Both {
            ui.label(
                RichText::new(
                    "Two entries in the Browser, one of them ending in \
                     \"(unveiled)\". The name is the only thing telling them \
                     apart, so rename rather than deleting if you are not sure.",
                )
                .color(p::muted())
                .small(),
            );
        }
    }



    /// Show the last message, if there is one.
    fn say(&self, ui: &mut Ui) {
        if let Some((text, colour)) = &self.message {
            ui.add_space(10.0);
            ui.label(RichText::new(text).color(*colour));
        }
    }



    /// Carry out a row action.
    ///
    /// Separate from drawing because every one of these needs `&mut self` while
    /// the loop that produced it is borrowing `self.entries`.
    fn apply(&mut self, act: Act) {
        match act {
            Act::Select(id) => {
                self.selected = Some(id);
                self.renaming = None;
                self.confirm_remove = None;
            }
            Act::StartRename(id, name) => {
                self.confirm_remove = None;
                self.renaming = Some((id, name));
            }
            Act::CancelRename => self.renaming = None,
            Act::Rename(id, name) => {
                if let Some(vault) = &self.vault {
                    match vault.rename(&id, &name) {
                        Ok(()) => {
                            self.renaming = None;
                            self.message = Some((format!("Renamed to {name:?}."), p::green()));
                            self.refresh();
                        }
                        Err(error) => self.message = Some((error.to_string(), p::red())),
                    }
                }
            }
            Act::AskRemove(id) => {
                self.renaming = None;
                self.confirm_remove = Some(id);
            }
            Act::CancelRemove => self.confirm_remove = None,
            Act::Play(id) => self.play(&id),
            Act::Stop => {
                // Dropping it stops the audio and releases the samples. There
                // is no stop that keeps the buffer: a decrypted recording
                // outliving the reason it was decrypted is the leak the vault
                // exists to prevent.
                self.playing = None;
            }
            Act::Export(id, what) => {
                // Off the render loop. `rfd`'s blocking picker freezes the
                // window until it is answered, which `dialog` exists to avoid
                // and which a test in that module forbids.
                //
                // Asked for every time rather than remembered: this writes an
                // unsealed copy of something that is in a vault, and a
                // remembered folder is how the second one lands somewhere the
                // first was deliberately kept out of.
                self.choosing = Some((id, what));
                self.picker.start(crate::dialog::Ask::Folder);
            }
            Act::Remove(id) => {
                if let Some(vault) = &self.vault {
                    match vault.remove(&id) {
                        Ok(()) => {
                            self.confirm_remove = None;
                            if self.selected.as_deref() == Some(id.as_str()) {
                                self.selected = None;
                            }
                            self.message = Some(("Removed.".into(), p::green()));
                            self.refresh();
                        }
                        Err(error) => self.message = Some((error.to_string(), p::red())),
                    }
                }
            }
        }
    }

}


/// Which side of the engine a take keeps.
///
/// **Roadmap item 131.** Three, and the order they are written in is the order they
/// are offered: the safe one first, and the one that records the real voice
/// last.
///
/// # Why the plain voice is offered at all
///
/// Because somebody comparing the two needs both, and because an interview
/// whose consent covers the real recording is a real thing people do. Refusing
/// it would not stop that; it would move it to a phone on the table, which is a
/// plaintext recording on a device with none of this. What matters is that it
/// is asked for rather than arrived at.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Keep {
    /// The veiled voice only. The default, and what the Studio has always done.
    #[default]
    Veiled,
    /// Both, as two takes in the vault.
    Both,
    /// The microphone only, unveiled.
    Plain,
}


impl Keep {

    /// Whether the veiled voice is kept.
    pub fn wants_veiled(self) -> bool {
        matches!(self, Keep::Veiled | Keep::Both)
    }



    /// Whether the real voice is kept.
    ///
    /// The one question the warning hangs off, so it is asked once here rather
    /// than matched on in three places.
    pub fn wants_plain(self) -> bool {
        matches!(self, Keep::Plain | Keep::Both)
    }



    /// What this is called where it is chosen.
    pub fn label(self) -> &'static str {
        match self {
            Keep::Veiled => "the veiled voice",
            Keep::Both => "both",
            Keep::Plain => "the microphone, unveiled",
        }
    }



    /// What it costs, in the words the plaintext path uses.
    ///
    /// The wording matters and is deliberately the same shape as the warning on
    /// writing an unencrypted file: this is the one thing the Studio does that
    /// produces a recording of somebody's real voice, and it says so before it
    /// starts rather than after.
    pub fn cost(self) -> &'static str {
        match self {
            Keep::Veiled => {
                "The engine runs first and the recorder only ever sees what \
                 comes out of it. No recording of the real voice is made."
            }
            Keep::Both => {
                "Two takes, and one of them is the real voice. It is sealed in \
                 the vault like everything else, and it is still a recording of \
                 somebody that a veiled one is not: anybody who opens the vault \
                 can hear who was speaking."
            }
            Keep::Plain => {
                "The real voice, and nothing veiled. It is sealed in the vault \
                 like everything else, and it is still a recording of somebody \
                 that a veiled one is not: anybody who opens the vault can hear \
                 who was speaking. Nothing here removes that afterwards."
            }
        }
    }

}


/// What a take is to be turned into.
///
/// Three, because the two useful things are genuinely separate and doing both
/// is the common case: the page is something to look at now, the video is
/// something to send somewhere that will not take an audio file, and somebody
/// who wants the second usually wants to check the first.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Render {
    /// The self-contained player page, and the audio and subtitles beside it.
    Preview,
    /// An MP4, through `ffmpeg`.
    Video,
    /// Both.
    Both,
}


impl Render {

    fn wants_page(self) -> bool {
        matches!(self, Render::Preview | Render::Both)
    }



    fn wants_video(self) -> bool {
        matches!(self, Render::Video | Render::Both)
    }

}


/// The sample rate and frame count a canonical WAV header states.
///
/// Read from the recording's own header rather than assumed, because the
/// recorder writes the rate the **device agreed to**, which is not always the
/// rate that was asked for. A duration computed from the wrong rate puts every
/// subtitle in the wrong place, and the video would be the wrong length.
fn wav_shape(wav: &[u8]) -> Option<(u32, f64)> {
    if wav.len() < 44 || &wav[0..4] != b"RIFF" || &wav[8..12] != b"WAVE" {
        return None;
    }
    let rate = u32::from_le_bytes(wav[24..28].try_into().ok()?);
    let bytes_per_sample = u16::from_le_bytes(wav[34..36].try_into().ok()?) as u32 / 8;
    let data = u32::from_le_bytes(wav[40..44].try_into().ok()?) as f64;
    let channels = u16::from_le_bytes(wav[22..24].try_into().ok()?) as u32;
    let per_second = rate
        .checked_mul(bytes_per_sample.max(1))?
        .checked_mul(channels.max(1))?;
    if per_second == 0 {
        return None;
    }
    Some((rate, data / per_second as f64))
}



/// A one-speaker plan spanning a take.
///
/// A studio take is one person at a microphone, so the plan the renderer wants
/// is a single turn from nothing to the end. Built rather than stored: a plan
/// kept beside each take would be a second description of a fact the audio
/// already carries, and the two would disagree the first time a take was
/// trimmed.
fn plan_for(name: &str, seconds: f64) -> Result<veilvoice_conversation::Conversation, String> {
    use veilvoice_conversation::{Speaker, Turn};

    let mut plan = veilvoice_conversation::Conversation::new();
    plan.title = Some(name.to_string());
    plan.add_speaker(Speaker::named(name))
        .map_err(|e| e.to_string())?;
    plan.add_turn(Turn {
        start: 0.0,
        end: seconds,
        speaker: 0,
        text: None,
    })
    .map_err(|e| e.to_string())?;
    Ok(plan)
}



/// Something a browser row asked for.
#[derive(Debug, PartialEq)]
enum Act {
    Select(String),
    StartRename(String, String),
    CancelRename,
    Rename(String, String),
    AskRemove(String),
    CancelRemove,
    Remove(String),
    Export(String, Render),
    Play(String),
    Stop,
}



/// "one interruption" or "three interruptions".
///
/// Beside [`counted`] and [`counted_decoys`] for the reason those exist: a
/// message reading "1 interruptions" is a message written by a computer, and
/// this one is read at the moment somebody is deciding whether to trust a
/// recording.
pub fn counted_interruptions(n: u64) -> String {
    if n == 1 {
        "one interruption".to_string()
    } else {
        format!("{n} interruptions")
    }
}



/// "One decoy" or "four decoys", so the interface does not say "1 decoys".
pub fn counted_decoys(n: usize) -> String {
    match n {
        1 => "One decoy".to_string(),
        n => format!("{n} decoys"),
    }
}



/// "One recording" or "four recordings", so the interface does not say
/// "1 recordings".
pub fn counted(n: usize) -> String {
    match n {
        1 => "one recording".to_string(),
        n => format!("{n} recordings"),
    }
}



/// A Unix time as a date somebody reads.
///
/// Deliberately the date and not the time of day. A vault listing sitting open
/// on a screen in an office says enough by naming the recordings; the minute
/// each was made is detail nobody browsing needs and somebody looking over a
/// shoulder might.
pub fn made_on(unix: i64) -> String {
    // Civil date from a Unix day count, by the usual algorithm. No dependency
    // for this: the crate graph is read by people, and a date formatter is not
    // worth a line in it.
    let days = unix.div_euclid(86_400);
    let z = days + 719_468;
    let era = z.div_euclid(146_097);
    let doe = z.rem_euclid(146_097);
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = if m <= 2 { y + 1 } else { y };
    format!("{y:04}-{m:02}-{d:02}")
}



/// Sixteen-bit PCM from a WAV, as the waveform drawer wants it.
///
/// The header is skipped rather than parsed a second time: [`wav_shape`] has
/// already established this is a canonical 44-byte header, and a reader that
/// disagreed with it about where the data starts would draw a waveform offset
/// from the audio it is meant to describe.
fn pcm16(wav: &[u8]) -> Vec<f32> {
    wav.get(44..)
        .unwrap_or(&[])
        .chunks_exact(2)
        .map(|pair| i16::from_le_bytes([pair[0], pair[1]]) as f32 / 32_768.0)
        .collect()
}



/// A file name built from what somebody called a recording.
///
/// A name is whatever was typed, and it reaches a **path** here. Everything
/// that is not a letter, a digit, a dash or an underscore becomes a dash, so a
/// take called `../../etc/passwd` or `a/b` cannot write outside the folder that
/// was chosen. Empty after that, and it is `take`: a file called nothing is not
/// a file.
fn safe_stem(name: &str) -> String {
    let cleaned: String = name
        .chars()
        .map(|ch| {
            if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
                ch
            } else {
                '-'
            }
        })
        .collect();
    let trimmed = cleaned.trim_matches('-');
    if trimmed.is_empty() {
        "take".to_string()
    } else {
        // A long name makes a path some systems refuse, and the name is a
        // label rather than an identifier, so shortening loses nothing that
        // is not still in the vault.
        trimmed.chars().take(60).collect()
    }
}



/// Run `ffmpeg` to put the audio in a video with a black picture.
fn run_ffmpeg(audio: &std::path::Path, video: &std::path::Path) -> Result<(), String> {
    let argv = veilvoice_video::ffmpeg::black_command(
        audio,
        video,
        veilvoice_video::ffmpeg::Encoding::default(),
    );
    let Some(program) = veilvoice_video::ffmpeg::found() else {
        return Err("`ffmpeg` went away between the check and the run.".into());
    };
    let output = std::process::Command::new(program)
        .args(argv.iter().skip(1))
        .output()
        .map_err(|error| error.to_string())?;
    if output.status.success() {
        return Ok(());
    }
    // The last line of ffmpeg's complaint, which is the one that says what
    // was wrong. The whole of it is pages of build configuration.
    let stderr = String::from_utf8_lossy(&output.stderr);
    let last = stderr.lines().rev().find(|line| !line.trim().is_empty());
    Err(format!(
        "`ffmpeg` refused: {}",
        last.unwrap_or("it gave no reason").trim()
    ))
}



/// A length in seconds, as `m:ss`, for somewhere a person reads.
pub fn length(seconds: f64) -> String {
    let whole = seconds.max(0.0) as u64;
    format!("{}:{:02}", whole / 60, whole % 60)
}



/// A size in bytes, rounded to something a person can compare.
pub fn size(bytes: usize) -> String {
    const MIB: f64 = 1024.0 * 1024.0;
    if bytes as f64 >= MIB {
        format!("{:.1} MiB", bytes as f64 / MIB)
    } else {
        format!("{:.0} KiB", (bytes as f64 / 1024.0).max(1.0))
    }
}


#[cfg(test)]
#[path = "studio/tests.rs"]
mod tests;