Skip to main content

max / audiofiles

Describe the sample editor The thirteenth port, and the widest screen the app has: nine operations dispatched from one 760-line window. What the description deletes is twelve knobs on EditUiState -- both trim bounds, gain, both normalise fields, three fade fields, both silence spans and both remove bounds. Every one is a buffer for a control being adjusted, read only by the apply_* the button beside it calls, which is the deletion bulk.rs already made of BulkModal. It fixes the piggyback on the way. The batch buttons read the single-sample sliders -- batch_gain takes state.edit.gain_db -- which was caught once as M-14 and answered by baking the number into the label. Batch operations are forms with their own fields here, so there is nothing to piggyback on. The result prompt is a second shape at /edit rather than an overlay: the shipped panel draws it in place, so nothing is trying to raise itself. This is deliberately not another consumer of unprompted-overlay. FadeCurve gains as_value/from_value/label/all in audiofiles-core, the pairing EditResultMode and InstrumentMode already carry, so the value a control submits is the variant the audio pipeline matches on. A curve the app cannot read back is refused rather than defaulted. Filed: quasi:vocabulary:undo-outlives-the-answer. Response::undoable hangs off the answer that made it, and an audiofiles edit finishes on a worker -- there is no answer being made when the result lands, and Screen::notices holds Nodes, which carry no action. The standing act the shipped panel draws is what a description can say, and it is a weaker claim. Second consumer for 91114ff1 (trim is an interval; the route refuses the inverted pair the shipped panel silently rewrites every frame). Third for 5672cad4 (the clipping warning is a valid answer that costs something, so it is a fragment beside the field rather than a member of it). Consumer for 28a777df (the prompt is a form, not the shipped three buttons, because an act cannot carry the Remember checkbox's value). Not described: the waveform, its wash, its drag handles and click-to-seek. 516 tests pass with the feature on, clippy clean, fmt clean.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-17 20:17 UTC
Signed with PGP, not checked
Commit: 9dde498b51a7c677aa82250727754418d917b9e3
Parent: e47053e
7 files changed, +1357 insertions, -21 deletions
M Cargo.lock +10 -10
@@ -7544,15 +7544,7 @@
7544 7544 ]
7545 7545
7546 7546 [[patch.unused]]
7547 - name = "kberg"
7548 - version = "0.1.0"
7549 -
7550 - [[patch.unused]]
7551 - name = "ops-status"
7552 - version = "0.1.0"
7553 -
7554 - [[patch.unused]]
7555 - name = "painhours"
7547 + name = "quasi-type"
7556 7548 version = "0.1.0"
7557 7549
7558 7550 [[patch.unused]]
@@ -7580,5 +7572,13 @@
7580 7572 version = "0.19.0"
7581 7573
7582 7574 [[patch.unused]]
7583 - name = "quasi-type"
7575 + name = "kberg"
7576 + version = "0.1.0"
7577 +
7578 + [[patch.unused]]
7579 + name = "ops-status"
7580 + version = "0.1.0"
7581 +
7582 + [[patch.unused]]
7583 + name = "painhours"
7584 7584 version = "0.1.0"
@@ -238,6 +238,14 @@
238 238 edit_panel::draw_edit_window(ctx, state);
239 239 }
240 240
241 + // The described editor, beside the shipped one and on the same condition:
242 + // it is a window the app opens for a sample rather than one with a toggle,
243 + // so there is no second flag to keep in step.
244 + #[cfg(feature = "quasi")]
245 + if state.edit.show_window {
246 + crate::quasi::panel::draw_edit(ctx, state);
247 + }
248 +
241 249 // Floating sample forge window
242 250 if state.forge.show_window {
243 251 forge_panel::draw_forge_window(ctx, state);
@@ -75,6 +75,7 @@
75 75
76 76 pub mod bulk;
77 77 pub mod detail;
78 + pub mod edit;
78 79 pub mod export;
79 80 pub mod files;
80 81 pub mod help;
@@ -510,7 +511,10 @@
510 511 /// the panel applies it afterwards. `SettingsUiState::pending_action` is the
511 512 /// same pattern, already in this app and documented as "set by the UI, consumed
512 513 /// by the app layer each frame".
513 - #[derive(Debug, Clone, PartialEq, Eq)]
514 + ///
515 + /// `PartialEq` but not `Eq`: the editor's intents carry the numbers a control
516 + /// submitted, and a gain is an `f64`.
517 + #[derive(Debug, Clone, PartialEq)]
514 518 pub enum Intent {
515 519 /// Select a row.
516 520 Open(i64),
@@ -602,6 +606,75 @@
602 606 PurgeLooseFiles,
603 607 /// Ask the host for a folder to look for the missing files in.
604 608 LocateLooseFiles,
609 + /// Cut the edited sample down to this span.
610 + EditTrim {
611 + /// Where the kept part starts, as a fraction of the whole.
612 + start: f32,
613 + /// Where it ends.
614 + end: f32,
615 + },
616 + /// Change the edited sample's level by this many dB.
617 + EditGain(f64),
618 + /// Normalise it to this target, by peak or by loudness.
619 + EditNormalize {
620 + /// True for peak, false for LUFS.
621 + peak: bool,
622 + /// The target, in whichever unit that is.
623 + target: f64,
624 + },
625 + /// Play it backwards.
626 + EditReverse,
627 + /// Fade it in or out, this long, on this curve.
628 + EditFade {
629 + /// True to fade in, false to fade out.
630 + fading_in: bool,
631 + /// How long the fade runs, in milliseconds.
632 + ms: f64,
633 + /// The curve, as `FadeCurve::as_value` writes it.
634 + curve: String,
635 + },
636 + /// Put silence in at this point.
637 + EditInsertSilence {
638 + /// Where it goes, in milliseconds.
639 + at: f64,
640 + /// How much, in milliseconds.
641 + ms: f64,
642 + },
643 + /// Take this span out.
644 + EditRemoveRange {
645 + /// Where it starts, in milliseconds.
646 + from: f64,
647 + /// Where it ends.
648 + to: f64,
649 + },
650 + /// Give up on the edit that is running.
651 + EditCancel,
652 + /// Audition the sample being edited, or pause it.
653 + EditPlay,
654 + /// Remember this as the standing answer to what happens to an edit.
655 + EditRemember(String),
656 + /// Answer the question a finished edit is waiting on.
657 + EditChoose {
658 + /// Replace or sibling, as `EditResultMode::as_value` writes it.
659 + mode: String,
660 + /// Whether to keep this as the standing answer.
661 + remember: bool,
662 + },
663 + /// Throw the finished edit away.
664 + EditDiscard,
665 + /// Put the last edit back.
666 + EditUndo,
667 + /// Normalise every chosen sample.
668 + BatchNormalize {
669 + /// True for peak, false for LUFS.
670 + peak: bool,
671 + /// The target, in whichever unit that is.
672 + target: f64,
673 + },
674 + /// Change every chosen sample's level.
675 + BatchGain(f64),
676 + /// Reverse every chosen sample.
677 + BatchReverse,
605 678 }
606 679
607 680 /// The app's file list, as the narrow thing a described screen borrows.
@@ -2614,6 +2687,226 @@
2614 2687 }
2615 2688 }
2616 2689
2690 + /// The sample being edited, as much as the editor needs to say about it.
2691 + ///
2692 + /// What is **not** here is the eleven knobs `EditUiState` carries — trim bounds,
2693 + /// gain, normalise target and mode, fade shape and length, the two silence
2694 + /// spans. See [`edit`]'s header: those are a buffer for what is being typed,
2695 + /// which is a `Runtime`'s `View`, and the same deletion [`bulk`] made of
2696 + /// `BulkModal`'s eleven fields.
2697 + #[derive(Debug, Clone, PartialEq)]
2698 + pub struct Editing {
2699 + /// What the sample is called.
2700 + pub name: String,
2701 + /// Its sample rate, in Hz.
2702 + pub sample_rate: u32,
2703 + /// How long it runs, in seconds, where analysis has said.
2704 + pub duration: Option<f64>,
2705 + /// Its peak, in dBFS, where analysis has said.
2706 + pub peak_db: Option<f64>,
2707 + /// Whether this sample is the preview that is playing.
2708 + pub playing: bool,
2709 + /// Whether an edit is being applied right now.
2710 + pub working: bool,
2711 + /// Whether the app is waiting to be told what to do with a finished edit.
2712 + pub asking: bool,
2713 + /// The standing answer to that question, as [`EditResultMode::as_value`]
2714 + /// writes it.
2715 + ///
2716 + /// [`EditResultMode::as_value`]: crate::state::EditResultMode::as_value
2717 + pub result: Option<String>,
2718 + /// How many samples are chosen, which is what makes the batch section a
2719 + /// section rather than nothing.
2720 + pub chosen: usize,
2721 + /// The last edit, while it is still reversible, by the name it goes under.
2722 + pub undoing: Option<String>,
2723 + }
2724 +
2725 + /// The sample editor, as much as a described screen needs.
2726 + ///
2727 + /// The twelfth narrow trait and the widest, at eighteen methods, and the width
2728 + /// is the screen's rather than the trait's: the shipped editor is one window
2729 + /// with seven sections and every one of them dispatches its own operation. What
2730 + /// it does *not* have is a way to read a knob back, which is the deletion.
2731 + pub trait Edit {
2732 + /// What is being edited, if anything is.
2733 + fn subject(&self) -> Option<Editing>;
2734 +
2735 + /// Cut the sample down to this span, as fractions of its length.
2736 + fn trim(&self, start: f32, end: f32);
2737 +
2738 + /// Change its level by this many dB.
2739 + fn gain(&self, db: f64);
2740 +
2741 + /// Normalise it to this target, by peak or by loudness.
2742 + fn normalize(&self, peak: bool, target: f64);
2743 +
2744 + /// Play it backwards.
2745 + fn reverse(&self);
2746 +
2747 + /// Fade it in or out, this long, on this curve.
2748 + fn fade(&self, fading_in: bool, ms: f64, curve: &str);
2749 +
2750 + /// Put this much silence in at this point.
2751 + fn insert_silence(&self, at: f64, ms: f64);
2752 +
2753 + /// Take this span out.
2754 + fn remove_range(&self, from: f64, to: f64);
2755 +
2756 + /// Give up on the edit that is running.
2757 + fn cancel(&self);
2758 +
2759 + /// Audition it, or stop auditioning it.
2760 + fn play(&self);
2761 +
2762 + /// Stop the preview.
2763 + fn stop(&self);
2764 +
2765 + /// Remember this as the standing answer to what happens to an edit.
2766 + fn remember(&self, mode: &str);
2767 +
2768 + /// Answer the question a finished edit is waiting on.
2769 + fn choose(&self, mode: &str, remember: bool);
2770 +
2771 + /// Throw the finished edit away.
2772 + fn discard(&self);
2773 +
2774 + /// Put the last edit back.
2775 + fn undo(&self);
2776 +
2777 + /// Normalise every chosen sample.
2778 + fn batch_normalize(&self, peak: bool, target: f64);
2779 +
2780 + /// Change every chosen sample's level.
2781 + fn batch_gain(&self, db: f64);
2782 +
2783 + /// Reverse every chosen sample.
2784 + fn batch_reverse(&self);
2785 + }
2786 +
2787 + /// The app's editor, as the narrow thing the described editor borrows.
2788 + pub struct FromEditor<'a> {
2789 + /// What the app is editing.
2790 + pub state: &'a crate::state::BrowserState,
2791 + /// What the described screen asked for, applied after the frame.
2792 + pub intents: &'a std::cell::RefCell<Vec<Intent>>,
2793 + }
2794 +
2795 + impl Edit for FromEditor<'_> {
2796 + fn subject(&self) -> Option<Editing> {
2797 + let hash = self.state.edit.hash.as_deref()?;
2798 + let analysis = self.state.detail.selected_analysis.as_ref();
2799 + Some(Editing {
2800 + name: self
2801 + .state
2802 + .selected_node()
2803 + .map(|node| node.node.name.clone())
2804 + .unwrap_or_default(),
2805 + sample_rate: analysis.map_or(44_100, |analysis| analysis.sample_rate),
2806 + duration: analysis.map(|analysis| analysis.duration),
2807 + peak_db: analysis.and_then(|analysis| analysis.peak_db),
2808 + playing: self.state.preview.previewing_hash.as_deref() == Some(hash)
2809 + && self.state.shared.preview.lock().playing,
2810 + working: self.state.edit.in_progress,
2811 + asking: self.state.edit.result_prompt,
2812 + result: self
2813 + .state
2814 + .edit
2815 + .result_mode
2816 + .map(|mode| mode.as_value().to_owned()),
2817 + chosen: self.state.selected_sample_hashes().len(),
2818 + undoing: self
2819 + .state
2820 + .edit
2821 + .last_undo
2822 + .as_ref()
2823 + .map(|entry| entry.op_name.clone()),
2824 + })
2825 + }
2826 +
2827 + fn trim(&self, start: f32, end: f32) {
2828 + self.push(Intent::EditTrim { start, end });
2829 + }
2830 +
2831 + fn gain(&self, db: f64) {
2832 + self.push(Intent::EditGain(db));
2833 + }
2834 +
2835 + fn normalize(&self, peak: bool, target: f64) {
2836 + self.push(Intent::EditNormalize { peak, target });
2837 + }
2838 +
2839 + fn reverse(&self) {
2840 + self.push(Intent::EditReverse);
2841 + }
2842 +
2843 + fn fade(&self, fading_in: bool, ms: f64, curve: &str) {
2844 + self.push(Intent::EditFade {
2845 + fading_in,
2846 + ms,
2847 + curve: curve.to_owned(),
2848 + });
2849 + }
2850 +
2851 + fn insert_silence(&self, at: f64, ms: f64) {
2852 + self.push(Intent::EditInsertSilence { at, ms });
2853 + }
2854 +
2855 + fn remove_range(&self, from: f64, to: f64) {
2856 + self.push(Intent::EditRemoveRange { from, to });
2857 + }
2858 +
2859 + fn cancel(&self) {
2860 + self.push(Intent::EditCancel);
2861 + }
2862 +
2863 + fn play(&self) {
2864 + self.push(Intent::EditPlay);
2865 + }
2866 +
2867 + fn stop(&self) {
2868 + self.push(Intent::StopPlayback);
2869 + }
2870 +
2871 + fn remember(&self, mode: &str) {
2872 + self.push(Intent::EditRemember(mode.to_owned()));
2873 + }
2874 +
2875 + fn choose(&self, mode: &str, remember: bool) {
2876 + self.push(Intent::EditChoose {
2877 + mode: mode.to_owned(),
2878 + remember,
2879 + });
2880 + }
2881 +
2882 + fn discard(&self) {
2883 + self.push(Intent::EditDiscard);
2884 + }
2885 +
2886 + fn undo(&self) {
2887 + self.push(Intent::EditUndo);
2888 + }
2889 +
2890 + fn batch_normalize(&self, peak: bool, target: f64) {
2891 + self.push(Intent::BatchNormalize { peak, target });
2892 + }
2893 +
2894 + fn batch_gain(&self, db: f64) {
2895 + self.push(Intent::BatchGain(db));
2896 + }
2897 +
2898 + fn batch_reverse(&self) {
2899 + self.push(Intent::BatchReverse);
2900 + }
2901 + }
2902 +
2903 + impl FromEditor<'_> {
2904 + /// Record what the described screen asked for.
2905 + fn push(&self, intent: Intent) {
2906 + self.intents.borrow_mut().push(intent);
2907 + }
2908 + }
2909 +
2617 2910 /// A theme the host resolved, as the description needs to name it.
2618 2911 ///
2619 2912 /// Three strings rather than the app's own `ThemeMeta`, so the described screen
@@ -2667,6 +2960,8 @@
2667 2960 pub importing: &'a dyn Importing,
2668 2961 /// The vault's health, for the loose-files warning.
2669 2962 pub integrity: &'a dyn Integrity,
2963 + /// The sample being edited, for the editor.
2964 + pub editor: &'a dyn Edit,
2670 2965 /// The themes on offer, resolved by the host at startup.
2671 2966 pub themes: &'a [ThemeChoice],
2672 2967 }
@@ -2677,9 +2972,11 @@
2677 2972 /// cost is nothing, and building it fresh is what lets the state borrow.
2678 2973 #[must_use]
2679 2974 pub fn router<'a>() -> Router<Panels<'a>> {
2680 - integrity::routes(importing::routes(naming::routes(toolbar::routes(
2681 - library::routes(shell::routes(help::routes(bulk::routes(detail::routes(
2682 - export::routes(files::routes(sync::routes(settings::routes(Router::new())))),
2975 + edit::routes(integrity::routes(importing::routes(naming::routes(
2976 + toolbar::routes(library::routes(shell::routes(help::routes(bulk::routes(
2977 + detail::routes(export::routes(files::routes(sync::routes(
2978 + settings::routes(Router::new()),
2979 + )))),
2683 2980 ))))),
2684 2981 ))))
2685 2982 }
@@ -33,9 +33,9 @@
33 33 use std::cell::RefCell;
34 34
35 35 use super::{
36 - FromBackend, FromBar, FromBulk, FromContents, FromExport, FromImport, FromIntegrity,
37 - FromLibrary, FromNaming, FromSelection, FromSyncManager, FromWindow, Intent, Panels, Setting,
38 - Sync, ThemeChoice, Unconfigured,
36 + FromBackend, FromBar, FromBulk, FromContents, FromEditor, FromExport, FromImport,
37 + FromIntegrity, FromLibrary, FromNaming, FromSelection, FromSyncManager, FromWindow, Intent,
38 + Panels, Setting, Sync, ThemeChoice, Unconfigured,
39 39 };
40 40 use crate::state::BrowserState;
41 41 use crate::ui::theme;
@@ -53,6 +53,7 @@
53 53 export: Option<Runtime>,
54 54 detail: Option<Runtime>,
55 55 shell: Option<Runtime>,
56 + edit: Option<Runtime>,
56 57 /// Whether the described main window is open.
57 58 pub show_shell: bool,
58 59 /// Whether the described detail panel is open.
@@ -266,6 +267,35 @@
266 267 }
267 268 }
268 269
270 + /// Draw the described sample editor, and act on whatever was pressed.
271 + ///
272 + /// **Refreshed unconditionally**, the fourth window to need it and the fourth
273 + /// reason: an edit runs on a worker, so the screen moves from `working` to
274 + /// `asking` with nothing pressed. Same clock the export flow reads.
275 + pub fn draw_edit(ctx: &egui::Context, state: &mut BrowserState) {
276 + let intents = RefCell::new(Vec::new());
277 + let mut runtime = state.described.edit.take();
278 + let host = Host {
279 + state,
280 + sync: None,
281 + themes: themes(),
282 + intents: &intents,
283 + };
284 + let closed = window(
285 + ctx,
286 + "Sample Editor (described)",
287 + &mut runtime,
288 + &host,
289 + "/edit",
290 + true,
291 + );
292 + state.described.edit = runtime;
293 + apply(ctx, state, intents.into_inner());
294 + if closed {
295 + state.described.edit = None;
296 + }
297 + }
298 +
269 299 /// Do what a described screen asked the app to do to itself.
270 300 ///
271 301 /// **The frame boundary.** A route holds `&BrowserState` and cannot select a
@@ -579,6 +609,87 @@
579 609 Intent::CancelImport => state.cancel_import_preflight(),
580 610 Intent::DismissLooseFiles => state.dismiss_loose_files_warning(),
581 611 Intent::PurgeLooseFiles => state.purge_missing_loose_files(),
612 + // The editor. Every one of these writes the value the described
613 + // control submitted into the knob the shipped panel keeps for it,
614 + // and then calls what the shipped button calls. That the knobs
615 + // still exist is the shipped panel's business: `edit`'s header is
616 + // about what the *description* no longer carries, and while both
617 + // windows are open both need somewhere to put a number.
618 + Intent::EditTrim { start, end } => {
619 + state.edit.trim_start = start;
620 + state.edit.trim_end = end;
621 + state.apply_edit_trim();
622 + }
623 + Intent::EditGain(db) => {
624 + state.edit.gain_db = db;
625 + state.apply_edit_gain();
626 + }
627 + Intent::EditNormalize { peak, target } => {
628 + state.edit.norm_peak = peak;
629 + state.edit.norm_target = target;
630 + state.apply_edit_normalize();
631 + }
632 + Intent::EditReverse => state.apply_edit_reverse(),
633 + Intent::EditFade {
634 + fading_in,
635 + ms,
636 + curve,
637 + } => {
638 + state.edit.fade_in = fading_in;
639 + state.edit.fade_duration_ms = ms;
640 + // The route already refused anything else, so an unreadable
641 + // curve here is one the app grew and the description has not
642 + // learned.
643 + if let Some(curve) = audiofiles_core::edit::FadeCurve::from_value(&curve) {
644 + state.edit.fade_curve = curve;
645 + }
646 + state.apply_edit_fade();
647 + }
648 + Intent::EditInsertSilence { at, ms } => {
649 + state.edit.silence_position_ms = at;
650 + state.edit.silence_duration_ms = ms;
651 + state.apply_edit_insert_silence();
652 + }
653 + Intent::EditRemoveRange { from, to } => {
654 + state.edit.remove_start_ms = from;
655 + state.edit.remove_end_ms = to;
656 + state.apply_edit_remove_range();
657 + }
658 + Intent::EditCancel => state.cancel_edit_operation(),
659 + // Play and pause are one control, which is the shipped transport's
660 + // own reading: pressing it while this sample is loaded toggles the
661 + // buffer, and pressing it while another is loaded starts this one.
662 + Intent::EditPlay => {
663 + if let Some(hash) = state.edit.hash.clone() {
664 + if state.preview.previewing_hash.as_deref() == Some(hash.as_str()) {
665 + let mut playback = state.shared.preview.lock();
666 + playback.playing = !playback.playing;
667 + } else {
668 + state.trigger_preview(&hash);
669 + }
670 + }
671 + }
672 + Intent::EditRemember(mode) => {
673 + if let Some(mode) = crate::state::EditResultMode::from_value(&mode) {
674 + state.set_edit_result_mode(mode);
675 + }
676 + }
677 + Intent::EditChoose { mode, remember } => {
678 + if let Some(mode) = crate::state::EditResultMode::from_value(&mode) {
679 + state.confirm_edit_result(mode, remember);
680 + }
681 + }
682 + Intent::EditDiscard => state.discard_edit_result(),
683 + Intent::EditUndo => state.undo_last_edit(),
684 + Intent::BatchNormalize { peak, target } => {
685 + if peak {
686 + state.batch_normalize_peak(target);
687 + } else {
688 + state.batch_normalize_lufs(target);
689 + }
690 + }
691 + Intent::BatchGain(db) => state.batch_gain(db),
692 + Intent::BatchReverse => state.batch_reverse(),
582 693 // The host act with no described step. See `integrity`'s header:
583 694 // fourth consumer of `quasi:vocabulary:host-save-location`.
584 695 Intent::LocateLooseFiles => {
@@ -919,6 +1030,7 @@
919 1030 let naming = FromNaming { state, intents };
920 1031 let importing = FromImport { state, intents };
921 1032 let integrity = FromIntegrity { state, intents };
1033 + let editor = FromEditor { state, intents };
922 1034 let panels = Panels {
923 1035 config: &config,
924 1036 sync,
@@ -932,6 +1044,7 @@
932 1044 naming: &naming,
933 1045 importing: &importing,
934 1046 integrity: &integrity,
1047 + editor: &editor,
935 1048 themes,
936 1049 };
937 1050 super::router()
@@ -13,10 +13,11 @@
13 13
14 14 use super::{
15 15 Analysed, Analysis, Bar, Bulk, Channels, Chosen, Collection, ColumnsShown, Config, Coverage,
16 - Crumb, Detail, Detailed, Export, Files, Filter, Focus, Folder, Format, Holding, Importing,
17 - Integrity, Library, Naming, Panel, Panels, Phase, Playing, Preflight, Pricing, ProfileChoice,
18 - Sample, Saying, Searching, Setting, Settings, Shared, Shell, Source, Spread, State, Status,
19 - Subject, Subscription, Suggested, Sync, Tagged, ThemeChoice, Vault, Where, router,
16 + Crumb, Detail, Detailed, Editing, Export, Files, Filter, Focus, Folder, Format, Holding,
17 + Importing, Integrity, Library, Naming, Panel, Panels, Phase, Playing, Preflight, Pricing,
18 + ProfileChoice, Sample, Saying, Searching, Setting, Settings, Shared, Shell, Source, Spread,
19 + State, Status, Subject, Subscription, Suggested, Sync, Tagged, ThemeChoice, Vault, Where,
20 + router,
20 21 };
21 22
22 23 /// A config store in memory.
@@ -221,6 +222,7 @@
221 222 naming: &Unnamed,
222 223 importing: &NoImport,
223 224 integrity: &Sound,
225 + editor: &Unedited,
224 226 themes: &themes,
225 227 };
226 228 router().handle(&state, request)
@@ -290,6 +292,7 @@
290 292 naming: &Unnamed,
291 293 importing: &NoImport,
292 294 integrity: &Sound,
295 + editor: &Unedited,
293 296 themes: &themes,
294 297 };
295 298 router().handle(&state, request)
@@ -443,6 +446,7 @@
443 446 naming: &Unnamed,
444 447 importing: &NoImport,
445 448 integrity: &Sound,
449 + editor: &Unedited,
446 450 themes: &themes,
447 451 };
448 452 let response = router()
@@ -496,6 +500,7 @@
496 500 naming: &Unnamed,
497 501 importing: &NoImport,
498 502 integrity: &Sound,
503 + editor: &Unedited,
499 504 themes: &themes,
500 505 };
501 506
@@ -543,6 +548,7 @@
543 548 naming: &Unnamed,
544 549 importing: &NoImport,
545 550 integrity: &Sound,
551 + editor: &Unedited,
546 552 themes: &themes,
547 553 };
548 554 let refused = router().handle(
@@ -576,6 +582,7 @@
576 582 naming: &Unnamed,
577 583 importing: &NoImport,
578 584 integrity: &Sound,
585 + editor: &Unedited,
579 586 themes: &themes,
580 587 };
581 588
@@ -632,6 +639,7 @@
632 639 naming: &Unnamed,
633 640 importing: &NoImport,
634 641 integrity: &Sound,
642 + editor: &Unedited,
635 643 themes: &themes,
636 644 };
637 645 let response = router()
@@ -684,6 +692,7 @@
684 692 naming: &Unnamed,
685 693 importing: &NoImport,
686 694 integrity: &Sound,
695 + editor: &Unedited,
687 696 themes: &themes,
688 697 };
689 698 let response = router()
@@ -842,6 +851,7 @@
842 851 naming: &Unnamed,
843 852 importing: &NoImport,
844 853 integrity: &Sound,
854 + editor: &Unedited,
845 855 themes: &themes,
846 856 };
847 857 router().handle(&state, request)
@@ -1861,6 +1871,7 @@
1861 1871 naming: &Unnamed,
1862 1872 importing: &NoImport,
1863 1873 integrity: &Sound,
1874 + editor: &Unedited,
1864 1875 themes: &themes,
1865 1876 };
1866 1877 router().handle(&state, request)
@@ -2394,6 +2405,7 @@
2394 2405 naming: &Unnamed,
2395 2406 importing: &NoImport,
2396 2407 integrity: &Sound,
2408 + editor: &Unedited,
2397 2409 themes: &themes,
2398 2410 };
2399 2411 router().handle(&state, request)
@@ -2767,6 +2779,7 @@
2767 2779 naming: &Unnamed,
2768 2780 importing: &NoImport,
2769 2781 integrity: &Sound,
2782 + editor: &Unedited,
2770 2783 themes: &themes,
2771 2784 };
2772 2785 router().handle(&state, request)
@@ -2820,6 +2833,7 @@
2820 2833 naming: &Unnamed,
2821 2834 importing: &NoImport,
2822 2835 integrity: &Sound,
2836 + editor: &Unedited,
2823 2837 themes: &themes,
2824 2838 };
2825 2839
@@ -3034,6 +3048,7 @@
3034 3048 naming: &Unnamed,
3035 3049 importing: &NoImport,
3036 3050 integrity: &Sound,
3051 + editor: &Unedited,
3037 3052 themes: &themes,
3038 3053 };
3039 3054 router().handle(&state, request)
@@ -3451,6 +3466,7 @@
3451 3466 naming: &Unnamed,
3452 3467 importing: &NoImport,
3453 3468 integrity: &Sound,
3469 + editor: &Unedited,
3454 3470 themes: &themes,
3455 3471 };
3456 3472 router().handle(&state, request)
@@ -3884,6 +3900,7 @@
3884 3900 naming: &Unnamed,
3885 3901 importing: &NoImport,
3886 3902 integrity: &Sound,
3903 + editor: &Unedited,
3887 3904 themes: &themes,
3888 3905 };
3889 3906 router().handle(&state, request)
@@ -4400,6 +4417,7 @@
4400 4417 naming,
4401 4418 importing: &NoImport,
4402 4419 integrity: &Sound,
4420 + editor: &Unedited,
4403 4421 themes: &themes,
4404 4422 };
4405 4423 router().handle(&state, request)
@@ -4555,6 +4573,7 @@
4555 4573 naming: &Unnamed,
4556 4574 importing,
4557 4575 integrity: &Sound,
4576 + editor: &Unedited,
4558 4577 themes: &themes,
4559 4578 };
4560 4579 router().handle(&state, request)
@@ -4699,6 +4718,7 @@
4699 4718 naming: &Unnamed,
4700 4719 importing: &NoImport,
4701 4720 integrity,
4721 + editor: &Unedited,
4702 4722 themes: &themes,
4703 4723 };
4704 4724 router().handle(&state, request)
@@ -4778,6 +4798,7 @@
4778 4798 naming: &Unnamed,
4779 4799 importing: &NoImport,
4780 4800 integrity: &vault,
4801 + editor: &Unedited,
4781 4802 themes: &themes,
4782 4803 };
4783 4804 let response = router().handle(&state, Request::get("/")).unwrap();
@@ -4791,3 +4812,520 @@
4791 4812 acts(screen)
4792 4813 );
4793 4814 }
4815 +
4816 + // --- The sample editor -------------------------------------------------------
4817 +
4818 + /// Nothing is being edited, for every test that is not about editing.
4819 + struct Unedited;
4820 +
4821 + impl super::Edit for Unedited {
4822 + fn subject(&self) -> Option<Editing> {
4823 + None
4824 + }
4825 +
4826 + fn trim(&self, _start: f32, _end: f32) {}
4827 + fn gain(&self, _db: f64) {}
4828 + fn normalize(&self, _peak: bool, _target: f64) {}
4829 + fn reverse(&self) {}
4830 + fn fade(&self, _fading_in: bool, _ms: f64, _curve: &str) {}
4831 + fn insert_silence(&self, _at: f64, _ms: f64) {}
4832 + fn remove_range(&self, _from: f64, _to: f64) {}
4833 + fn cancel(&self) {}
4834 + fn play(&self) {}
4835 + fn stop(&self) {}
4836 + fn remember(&self, _mode: &str) {}
4837 + fn choose(&self, _mode: &str, _remember: bool) {}
4838 + fn discard(&self) {}
4839 + fn undo(&self) {}
4840 + fn batch_normalize(&self, _peak: bool, _target: f64) {}
4841 + fn batch_gain(&self, _db: f64) {}
4842 + fn batch_reverse(&self) {}
4843 + }
4844 +
4845 + /// An editor in memory, recording what was asked of it.
4846 + struct FakeEditor {
4847 + subject: Option<Editing>,
4848 + asked: RefCell<Vec<String>>,
4849 + }
4850 +
4851 + impl FakeEditor {
4852 + fn editing() -> Self {
4853 + Self {
4854 + subject: Some(Editing {
4855 + name: "kick.wav".to_owned(),
4856 + sample_rate: 44_100,
4857 + duration: Some(1.5),
4858 + peak_db: Some(-2.0),
4859 + playing: false,
4860 + working: false,
4861 + asking: false,
4862 + result: None,
4863 + chosen: 1,
4864 + undoing: None,
4865 + }),
4866 + asked: RefCell::new(Vec::new()),
4867 + }
4868 + }
4869 +
4870 + fn with(mut self, change: impl FnOnce(&mut Editing)) -> Self {
4871 + if let Some(subject) = self.subject.as_mut() {
4872 + change(subject);
4873 + }
4874 + self
4875 + }
4876 +
4877 + fn asked(&self) -> Vec<String> {
4878 + self.asked.borrow().clone()
4879 + }
4880 +
4881 + fn note(&self, what: String) {
4882 + self.asked.borrow_mut().push(what);
4883 + }
4884 + }
4885 +
4886 + impl super::Edit for FakeEditor {
4887 + fn subject(&self) -> Option<Editing> {
4888 + self.subject.clone()
4889 + }
4890 +
4891 + fn trim(&self, start: f32, end: f32) {
4892 + self.note(format!("trim {start} {end}"));
4893 + }
4894 +
4895 + fn gain(&self, db: f64) {
4896 + self.note(format!("gain {db}"));
4897 + }
4898 +
4899 + fn normalize(&self, peak: bool, target: f64) {
4900 + self.note(format!("normalize peak={peak} {target}"));
4901 + }
4902 +
4903 + fn reverse(&self) {
4904 + self.note("reverse".to_owned());
4905 + }
4906 +
4907 + fn fade(&self, fading_in: bool, ms: f64, curve: &str) {
4908 + self.note(format!("fade in={fading_in} {ms} {curve}"));
4909 + }
4910 +
4911 + fn insert_silence(&self, at: f64, ms: f64) {
4912 + self.note(format!("insert {at} {ms}"));
4913 + }
4914 +
4915 + fn remove_range(&self, from: f64, to: f64) {
4916 + self.note(format!("remove {from} {to}"));
4917 + }
4918 +
4919 + fn cancel(&self) {
4920 + self.note("cancel".to_owned());
4921 + }
4922 +
4923 + fn play(&self) {
4924 + self.note("play".to_owned());
4925 + }
4926 +
4927 + fn stop(&self) {
4928 + self.note("stop".to_owned());
4929 + }
4930 +
4931 + fn remember(&self, mode: &str) {
4932 + self.note(format!("remember {mode}"));
4933 + }
4934 +
4935 + fn choose(&self, mode: &str, remember: bool) {
4936 + self.note(format!("choose {mode} remember={remember}"));
4937 + }
4938 +
4939 + fn discard(&self) {
4940 + self.note("discard".to_owned());
4941 + }
4942 +
4943 + fn undo(&self) {
4944 + self.note("undo".to_owned());
4945 + }
4946 +
4947 + fn batch_normalize(&self, peak: bool, target: f64) {
4948 + self.note(format!("batch normalize peak={peak} {target}"));
4949 + }
4950 +
4951 + fn batch_gain(&self, db: f64) {
4952 + self.note(format!("batch gain {db}"));
4953 + }
4954 +
4955 + fn batch_reverse(&self) {
4956 + self.note("batch reverse".to_owned());
4957 + }
4958 + }
4959 +
4960 + /// A router call against this editor.
4961 + fn editing(editor: &FakeEditor, request: Request) -> Result<Response, quasi_router::RouteError> {
4962 + let store = Store::default();
4963 + let sync = Offline;
4964 + let files = FakeFiles::default();
4965 + let themes = themes();
4966 + let state = Panels {
4967 + config: &store,
4968 + sync: &sync,
4969 + files: &files,
4970 + export: &Idle,
4971 + detail: &Unfocused,
4972 + bulk: &Unchosen,
4973 + shell: &Quiet,
4974 + library: &Empty,
4975 + bar: &Still,
4976 + naming: &Unnamed,
4977 + importing: &NoImport,
4978 + integrity: &Sound,
4979 + editor,
4980 + themes: &themes,
4981 + };
4982 + router().handle(&state, request)
4983 + }
4984 +
4985 + /// The editor screen.
4986 + fn edited(editor: &FakeEditor) -> Screen {
4987 + screen_of(&editing(editor, Request::get("/edit")).unwrap()).clone()
4988 + }
4989 +
4990 + #[test]
4991 + fn the_editor_refuses_to_exist_with_nothing_to_edit() {
4992 + // The shipped window is only open because something is being edited, so a
4993 + // screen for "no sample" would be a screen the app does not have.
4994 + assert!(
4995 + editing(
4996 + &FakeEditor {
4997 + subject: None,
4998 + asked: RefCell::new(Vec::new())
4999 + },
5000 + Request::get("/edit")
5001 + )
5002 + .is_err()
5003 + );
5004 + }
5005 +
5006 + #[test]
5007 + fn a_finished_edit_asks_at_the_same_address_it_was_started_from() {
5008 + // One route, two shapes: the prompt is a state the user arrived at, not a
5009 + // place they went. `sync`, `export` and `detail` settled this.
5010 + let quiet = FakeEditor::editing();
5011 + assert!(said(&edited(&quiet)).contains("kick.wav"));
5012 +
5013 + let asking = FakeEditor::editing().with(|subject| subject.asking = true);
5014 + let screen = edited(&asking);
5015 + let said = said(&screen);
5016 + assert!(
5017 + said.contains("How should the edited sample be handled?"),
5018 + "{said}"
5019 + );
5020 + // And the editor's own controls are gone, which is what the shipped panel's
5021 + // early return does.
5022 + assert!(
5023 + !acts(&screen).iter().any(|act| act == "Reverse"),
5024 + "{:?}",
5025 + acts(&screen)
5026 + );
5027 + }
5028 +
5029 + #[test]
5030 + fn trim_refuses_a_span_that_ends_before_it_starts() {
5031 + // The pair the description cannot state. The shipped panel keeps it true by
5032 + // writing one of the two every frame; an address reachable by typing needs
5033 + // the refusal as well. `91114ff1`, second consumer.
5034 + let editor = FakeEditor::editing();
5035 + assert!(
5036 + editing(
5037 + &editor,
5038 + posting(
5039 + "/edit/trim",
5040 + Params::new().with("start", "0.8").with("end", "0.2")
5041 + ),
5042 + )
5043 + .is_err()
5044 + );
5045 + assert!(editor.asked().is_empty());
5046 +
5047 + editing(
5048 + &editor,
5049 + posting(
5050 + "/edit/trim",
5051 + Params::new().with("start", "0.1").with("end", "0.9"),
5052 + ),
5053 + )
5054 + .unwrap();
5055 + assert_eq!(editor.asked(), ["trim 0.1 0.9"]);
5056 + }
5057 +
5058 + #[test]
5059 + fn a_position_outside_the_sample_is_refused() {
5060 + let editor = FakeEditor::editing();
5061 + for (start, end) in [("-0.5", "0.9"), ("0.1", "1.5"), ("nope", "0.9")] {
5062 + assert!(
5063 + editing(
5064 + &editor,
5065 + posting(
5066 + "/edit/trim",
5067 + Params::new().with("start", start).with("end", end)
5068 + ),
5069 + )
5070 + .is_err(),
5071 + "{start} {end}"
5072 + );
5073 + }
5074 + assert!(editor.asked().is_empty());
5075 + }
5076 +
5077 + #[test]
5078 + fn the_clipping_warning_moves_with_the_gain_and_leaves_the_screen_standing() {
5079 + // `5672cad4`, third consumer: a valid answer that costs something has no
5080 + // slot on the field, so it is a fragment beside it.
5081 + let editor = FakeEditor::editing();
5082 + let quiet = editing(
5083 + &editor,
5084 + posting("/edit/gain/preview", Params::new().with("gain", "1.0")),
5085 + )
5086 + .unwrap();
5087 +
5088 + let Outcome::Fragment { region, node } = &quiet.outcome else {
5089 + panic!("{quiet:?}");
5090 + };
5091 + assert_eq!(region, "edit-clipping");
5092 + // -2.0 + 1.0 is still under the ceiling, so it is a fact rather than a
5093 + // warning.
5094 + assert!(matches!(node, Node::Text { .. }), "{node:?}");
5095 +
5096 + let loud = editing(
5097 + &editor,
5098 + posting("/edit/gain/preview", Params::new().with("gain", "6.0")),
5099 + )
5100 + .unwrap();
5101 + let Outcome::Fragment { node, .. } = &loud.outcome else {
5102 + panic!("{loud:?}");
5103 + };
5104 + let Node::Notice { tone, text, .. } = node else {
5105 + panic!("{node:?}");
5106 + };
5107 + assert_eq!(*tone, quasi_router::layout::Tone::Danger);
5108 + assert!(text.contains("clips!"), "{text}");
5109 +
5110 + // A control mid-drag can send half a number, and that is not an error the
5111 + // user should see.
5112 + assert!(
5113 + editing(
5114 + &editor,
5115 + posting("/edit/gain/preview", Params::new().with("gain", "-")),
5116 + )
5117 + .is_ok()
5118 + );
5119 + // None of it applied anything.
5120 + assert!(editor.asked().is_empty());
5121 + }
5122 +
5123 + #[test]
5124 + fn a_normalize_target_is_checked_against_the_mode_it_was_chosen_for() {
5125 + // Peak runs to 0 dBFS and loudness stops at -6 LUFS. The description
5126 + // carries the wider of the two ranges, so the route holds the narrower.
5127 + let editor = FakeEditor::editing();
5128 +
5129 + editing(
5130 + &editor,
5131 + posting(
5132 + "/edit/normalize",
5133 + Params::new().with("mode", "peak").with("target", "-1"),
5134 + ),
5135 + )
5136 + .unwrap();
5137 + assert_eq!(editor.asked(), ["normalize peak=true -1"]);
5138 +
5139 + assert!(
5140 + editing(
5141 + &editor,
5142 + posting(
5143 + "/edit/normalize",
5144 + Params::new().with("mode", "lufs").with("target", "-1"),
5145 + ),
5146 + )
5147 + .is_err()
5148 + );
5149 + }
5150 +
5151 + #[test]
5152 + fn a_fade_curve_the_app_cannot_read_back_is_refused() {
5153 + // The pairing `FadeCurve::as_value`/`from_value` exists so the value a
5154 + // control submits is the variant the audio pipeline matches on.
5155 + let editor = FakeEditor::editing();
5156 + assert!(
Lines truncated
@@ -17,6 +17,49 @@
17 17 }
18 18
19 19 impl FadeCurve {
20 + /// The value a control submits for this curve.
21 + ///
22 + /// The pairing `EditResultMode` and `InstrumentMode` already carry, and it
23 + /// is here rather than in the UI crate because a described control's value
24 + /// and the variant the pipeline matches on are the same fact: a copy on the
25 + /// drawing side is a copy that can drift from the one that renders audio.
26 + #[must_use]
27 + pub const fn as_value(self) -> &'static str {
28 + match self {
29 + Self::Linear => "linear",
30 + Self::Logarithmic => "log",
31 + Self::SCurve => "s-curve",
32 + }
33 + }
34 +
35 + /// What this curve is called where a person reads it.
36 + #[must_use]
37 + pub const fn label(self) -> &'static str {
38 + match self {
39 + Self::Linear => "Linear",
40 + Self::Logarithmic => "Log",
41 + Self::SCurve => "S-Curve",
42 + }
43 + }
44 +
45 + /// [`as_value`](Self::as_value) the other way. `None` for anything else,
46 + /// which is what a submitted value the app does not know reads as.
47 + #[must_use]
48 + pub fn from_value(value: &str) -> Option<Self> {
49 + match value {
50 + "linear" => Some(Self::Linear),
51 + "log" => Some(Self::Logarithmic),
52 + "s-curve" => Some(Self::SCurve),
53 + _ => None,
54 + }
55 + }
56 +
57 + /// Every curve, in the order a picker offers them.
58 + #[must_use]
59 + pub const fn all() -> [Self; 3] {
60 + [Self::Linear, Self::Logarithmic, Self::SCurve]
61 + }
62 +
20 63 /// Compute the gain at position `t` (0.0 = start, 1.0 = end of fade region).
21 64 /// Returns a value in [0.0, 1.0].
22 65 ///
@@ -180,4 +223,16 @@
180 223 );
181 224 }
182 225 }
226 +
227 + #[test]
228 + fn every_offered_curve_reads_back_as_the_curve_it_names() {
229 + // The pairing a described picker rests on: what a control submits is
230 + // what the pipeline matches on, so a curve chosen in the editor is the
231 + // curve the audio gets.
232 + for curve in FadeCurve::all() {
233 + assert_eq!(FadeCurve::from_value(curve.as_value()), Some(curve));
234 + }
235 + assert!(FadeCurve::from_value("").is_none());
236 + assert!(FadeCurve::from_value("exponential").is_none());
237 + }
183 238 }
@@ -1,0 +1,808 @@
1 + //! The sample editor, described: seven operations on one sample, and three on
2 + //! everything chosen.
3 + //!
4 + //! The thirteenth audiofiles port, and the widest single screen the app has:
5 + //! `ui/edit_panel.rs` is 760 lines and dispatches nine distinct operations from
6 + //! one window.
7 + //!
8 + //! # What the description deletes: eleven knobs on `EditUiState`
9 + //!
10 + //! `trim_start`, `trim_end`, `gain_db`, `norm_peak`, `norm_target`, `fade_in`,
11 + //! `fade_duration_ms`, `fade_curve`, `silence_position_ms`,
12 + //! `silence_duration_ms`, `remove_start_ms`, `remove_end_ms`. Twelve, counting
13 + //! properly. Every one is a buffer for a control that is being adjusted, read
14 + //! only by the `apply_*` function the button beside it calls, and
15 + //! [`bulk`](super::bulk) already made this deletion once for `BulkModal`: what
16 + //! is being typed into a described screen is the runtime's, and it arrives with
17 + //! the submit that used it.
18 + //!
19 + //! What is left on the app's side is the *sample* — the hash, whether an edit is
20 + //! running, what a finished one is waiting for — which is the app's state and
21 + //! not a control's.
22 + //!
23 + //! It also fixes something on the way. The batch section's three buttons
24 + //! **piggyback on the single-sample sliders**: `batch_normalize_peak` takes
25 + //! `state.edit.norm_target`, `batch_gain` takes `state.edit.gain_db`. That was
26 + //! caught once already (M-14: "removes the silent-piggyback footgun where the
27 + //! user couldn't tell what value the batch button would use") and answered by
28 + //! baking the number into the button's label. Here the batch operations are
29 + //! forms with their own fields, so there is nothing to piggyback on and nothing
30 + //! to bake.
31 + //!
32 + //! # One address, two shapes, and why this one is not the overlay finding
33 + //!
34 + //! `state.edit.result_prompt` makes the shipped panel draw a different body:
35 + //! "How should the edited sample be handled?", with the editor's own controls
36 + //! gone. That is a state the user *arrived* at rather than a place they went, so
37 + //! it is a shape at `/edit` — the rule `sync`, `export` and `detail` settled.
38 + //!
39 + //! Worth saying because the pass before this filed
40 + //! `quasi:vocabulary:unprompted-overlay` for exactly the shape this looks like.
41 + //! It is not the same: the shipped app draws the prompt *in place*, not over
42 + //! what the user was doing, so nothing here is trying to raise itself.
43 + //!
44 + //! # THE FINDING: an undo offer cannot outlive the answer that raised it
45 + //!
46 + //! `Response::undoable` is the vocabulary's shape for "that happened, take it
47 + //! back", and its header is right that the timeout is renderer policy. It hangs
48 + //! off a `Response`'s notice, which means the offer exists only in the answer
49 + //! that made it.
50 + //!
51 + //! An audiofiles edit finishes on a worker thread. The answer that dispatched it
52 + //! was built and shown seconds earlier, and there is no answer being made at the
53 + //! moment the result lands — so the one place `undoable` can be attached is the
54 + //! one place nothing knows an undo is available yet. `Screen::notices` is a
55 + //! `Vec<Node>`, and `Node::Notice` carries no action, so a screen cannot say it
56 + //! either.
57 + //!
58 + //! What this port does instead is describe the standing affordance the shipped
59 + //! panel draws: the last edit's name, and an `Act` beside it. That works and it
60 + //! is not the same claim — an act is a control the user finds, and an undo offer
61 + //! is a consequence the app volunteers.
62 + //!
63 + //! Filed as `quasi:vocabulary:undo-outlives-the-answer`.
64 + //!
65 + //! # A third consumer for `5672cad4`, the valid answer that costs something
66 + //!
67 + //! "Peak: -2.0 dB -> 1.5 dB (clips!)" is not an error — the gain is a legal
68 + //! value and the edit will run — and it is not standing help either, because it
69 + //! depends on what has been typed. makeover-layout `5672cad4` is the gap that a
70 + //! `Field`'s two message slots are help and error with nothing between, and this
71 + //! is its third measured site after the export screen's re-encoding warning.
72 + //!
73 + //! The workaround is the rename preview's: `Field::changes` answers a fragment
74 + //! into a region beside the field. It costs a route and a region for what wants
75 + //! to be a member.
76 + //!
77 + //! # A second consumer for `91114ff1`, the interval
78 + //!
79 + //! Trim is one question with two values that constrain each other: start must be
80 + //! before end, and the shipped panel enforces it by writing one of them
81 + //! (`if trim_start >= trim_end { trim_start = trim_end - 0.001 }`) every frame.
82 + //! `FieldKind::Range` describes one value in an extent, so the description here
83 + //! is two ranges that do not know about each other and a route that refuses the
84 + //! inverted pair. Same gap audiofiles' filter panel is waiting on, from a second
85 + //! app surface.
86 + //!
87 + //! # What is deliberately not described
88 + //!
89 + //! - **The waveform**, its trim wash, its draggable handles and click-to-seek.
90 + //! Domain rendering: a click that maps a pixel to a frame and writes into a
91 + //! mutex an audio thread is filling is a host fact, not a fact about a sample.
92 + //! The numeric path *is* described, which is what the shipped panel calls the
93 + //! sliders beside it.
94 + //! - **The in-progress greying.** Every section disables itself while an edit
95 + //! runs, deliberately keeping the layout rather than collapsing to a spinner.
96 + //! Described as `Act::disabled` on what an edit would start, which says the
97 + //! same thing without naming a colour.
98 +
99 + use quasi_router::layout::{FieldKind, Notice, Tone};
100 + use quasi_router::{
101 + Act, Action, Choice, Field, Figure, Node, RegionKind, Request, Response, RouteError, Router,
102 + Screen, Slot,
103 + };
104 +
105 + use audiofiles_core::edit::FadeCurve;
106 +
107 + use super::{Editing, Panels};
108 + use crate::state::EditResultMode;
109 +
110 + /// The region the editor answers into.
111 + const BODY: &str = "edit-body";
112 +
113 + /// The region the clipping warning lands in.
114 + const CLIPPING: &str = "edit-clipping";
115 +
116 + /// The names a control submits under.
117 + const START: &str = "start";
118 + /// See [`START`].
119 + const END: &str = "end";
120 + /// See [`START`].
121 + const GAIN: &str = "gain";
122 + /// See [`START`].
123 + const MODE: &str = "mode";
124 + /// See [`START`].
125 + const TARGET: &str = "target";
126 + /// See [`START`].
127 + const CURVE: &str = "curve";
128 + /// See [`START`].
129 + const LENGTH: &str = "length";
130 + /// See [`START`].
131 + const AT: &str = "at";
132 + /// See [`START`].
133 + const FROM: &str = "from";
134 + /// See [`START`].
135 + const TO: &str = "to";
136 + /// See [`START`].
137 + const RESULT: &str = "result";
138 + /// See [`START`].
139 + const REMEMBER: &str = "remember";
140 +
141 + /// The value the peak/loudness choice submits for peak.
142 + const PEAK: &str = "peak";
143 +
144 + /// The value it submits for loudness.
145 + const LUFS: &str = "lufs";
146 +
147 + /// Where the fade direction's two answers are written.
148 + const FADE_IN: &str = "in";
149 +
150 + /// Register the editor's routes.
151 + pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
152 + router
153 + .get("/edit", screen)
154 + .post("/edit/trim", trim)
155 + .post("/edit/gain", gain)
156 + .post("/edit/gain/preview", clipping)
157 + .post("/edit/normalize", normalize)
158 + .post("/edit/reverse", reverse)
159 + .post("/edit/fade", fade)
160 + .post("/edit/silence/insert", insert_silence)
161 + .post("/edit/silence/remove", remove_range)
162 + .post("/edit/play", play)
163 + .post("/edit/stop", stop)
164 + .post("/edit/cancel", cancel)
165 + .post("/edit/undo", undo)
166 + .post("/edit/result", remember)
167 + .post("/edit/result/choose", choose)
168 + .post("/edit/result/discard", discard)
169 + .post("/edit/batch/normalize", batch_normalize)
170 + .post("/edit/batch/gain", batch_gain)
171 + .post("/edit/batch/reverse", batch_reverse)
172 + }
173 +
174 + /// `GET /edit`
175 + fn screen(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
176 + Ok(editor(state)?.into())
177 + }
178 +
179 + /// The whole editor, at whichever of its two shapes it is in.
180 + fn editor(state: &Panels<'_>) -> Result<Screen, RouteError> {
181 + let sample = subject(state)?;
182 + let body = if sample.asking {
183 + asking(&sample)
184 + } else {
185 + editing(&sample)
186 + };
187 + Ok(Screen::sidebar_content("Sample Editor").with(body))
188 + }
189 +
190 + /// What is being edited, or a refusal.
191 + ///
192 + /// Nothing is a `NotFound` rather than an empty editor: the shipped window is
193 + /// only open because something is being edited, and a described screen for "no
194 + /// sample" would be a screen the app does not have.
195 + fn subject(state: &Panels<'_>) -> Result<Editing, RouteError> {
196 + state
197 + .editor
198 + .subject()
199 + .ok_or_else(|| RouteError::not_found("nothing is being edited"))
200 + }
201 +
202 + /// The question a finished edit is waiting on.
203 + ///
204 + /// A form rather than the shipped three buttons, and the reason is a gap:
205 + /// "Replace Original" and "Create Sibling" each need to carry the answer to
206 + /// "Remember my choice", and an `Act` cannot carry a value another control is
207 + /// holding. That is makeover-layout `28a777df` (a control carries an action but
208 + /// no computed payload), and this is a consumer of it. Discard stays an act
209 + /// because it carries nothing.
210 + fn asking(sample: &Editing) -> Slot {
211 + Slot::new(BODY, RegionKind::Pane)
212 + .with(Node::page("Edit Result"))
213 + .with(Node::text("How should the edited sample be handled?"))
214 + .with(Node::Form {
215 + fields: vec![
216 + Field::radio(RESULT, "Result", result_modes()).value(
217 + sample
218 + .result
219 + .clone()
220 + .unwrap_or_else(|| EditResultMode::Sibling.as_value().to_owned()),
221 + ),
222 + // The shipped checkbox is ticked when a standing answer exists,
223 + // which is the same fact this reads.
224 + Field::new(FieldKind::Checkbox, REMEMBER, "Remember my choice")
225 + .value(if sample.result.is_some() { "on" } else { "" }),
226 + ],
227 + submit: "Use this".to_owned(),
228 + action: Action::post("/edit/result/choose"),
229 + })
230 + .with(Node::Act(
231 + Act::new("Discard edit", Action::post("/edit/result/discard"))
232 + .tone(Tone::Danger)
233 + .confirm("Throw this edit away?"),
234 + ))
235 + }
236 +
237 + /// The editor proper.
238 + fn editing(sample: &Editing) -> Slot {
239 + let mut body = Slot::new(BODY, RegionKind::Pane).with(Node::page(sample.name.clone()));
240 +
241 + // What the shipped info line says, as facts rather than one muted string.
242 + body = body.with(Node::Figure(Figure::new(
243 + sample.sample_rate.to_string(),
244 + "Hz",
245 + )));
246 + if let Some(duration) = sample.duration {
247 + body = body.with(Node::Figure(Figure::new(
248 + format!("{duration:.3}"),
249 + "seconds",
250 + )));
251 + }
252 + if let Some(peak) = sample.peak_db {
253 + body = body.with(Node::Figure(Figure::new(format!("{peak:.1}"), "dBFS")));
254 + }
255 +
256 + if sample.working {
257 + body = body
258 + .with(Node::Notice {
259 + kind: Notice::Banner,
260 + tone: Tone::Info,
261 + text: "Applying edit...".to_owned(),
262 + })
263 + .with(Node::Act(Act::new("Cancel", Action::post("/edit/cancel"))));
264 + }
265 +
266 + body = transport(body, sample);
267 + body = trim_section(body);
268 + body = levels(body, sample);
269 + body = transform(body, sample);
270 + body = silence(body, sample);
271 + body = result(body, sample);
272 + batch(body, sample)
273 + }
274 +
275 + /// Play, pause and stop, independent of the main list's selection.
276 + fn transport(body: Slot, sample: &Editing) -> Slot {
277 + body.with(Node::Act(
278 + Act::new(
279 + if sample.playing { "Pause" } else { "Play" },
280 + Action::post("/edit/play"),
281 + )
282 + .key("space"),
283 + ))
284 + .with(Node::Act(Act::new("Stop", Action::post("/edit/stop"))))
285 + }
286 +
287 + /// The span to keep.
288 + fn trim_section(body: Slot) -> Slot {
289 + body.with(Node::section("Trim")).with(Node::Form {
290 + // Two ranges over a fraction of the sample, which is what the shipped
291 + // sliders are. The seconds the shipped panel prints beside each is the
292 + // same number in the sample's units, and a description that carried both
293 + // would be describing a label. See the module header on the interval
294 + // gap: these two constrain each other and cannot say so.
295 + fields: vec![span(START, "Start"), span(END, "End").value("1")],
296 + submit: "Trim".to_owned(),
297 + action: Action::post("/edit/trim"),
298 + })
299 + }
300 +
301 + /// One end of the trim, as a fraction of the whole.
302 + fn span(name: &str, label: &str) -> Field {
303 + Field::range(name, label, "0", "1").step("0.001").value("0")
304 + }
305 +
306 + /// Gain and normalise.
307 + fn levels(body: Slot, sample: &Editing) -> Slot {
308 + let mut body = body.with(Node::section("Levels"));
309 +
310 + body = body
311 + .with(Node::Form {
312 + fields: vec![
313 + Field::range(GAIN, "Gain", "-24", "24")
314 + .step("0.1")
315 + .value("0")
316 + // The clipping consequence, which the field cannot carry
317 + // itself. See the module header, `5672cad4`.
318 + .changes(Action::post("/edit/gain/preview")),
319 + ],
320 + submit: "Apply gain".to_owned(),
321 + action: Action::post("/edit/gain"),
322 + })
323 + .with(Node::Region(
324 + Slot::new(CLIPPING, RegionKind::Group).with(clips(sample.peak_db, 0.0)),
325 + ));
326 +
327 + // Peak and LUFS have different ranges (-24..0 dBFS against -24..-6 LUFS) and
328 + // different defaults, and the shipped panel resets the target when the mode
329 + // changes because "the carried-over value is meaningless across modes". Both
330 + // facts are about the pair rather than about either control, and neither is
331 + // sayable: the widest range is described and the route refuses what falls
332 + // outside the chosen mode's half.
333 + body.with(Node::Form {
334 + fields: vec![
335 + Field::radio(
336 + MODE,
337 + "Normalize by",
338 + vec![
339 + Choice::new(PEAK, "Peak"),
340 + Choice::new(LUFS, "Loudness (LUFS)"),
341 + ],
342 + )
343 + .value(PEAK),
344 + Field::range(TARGET, "Target", "-24", "0")
345 + .step("0.1")
346 + .value("-1"),
347 + ],
348 + submit: "Normalize".to_owned(),
349 + action: Action::post("/edit/normalize"),
350 + })
351 + }
352 +
353 + /// What the gain about to be applied would do to the peak.
354 + ///
355 + /// Its own node so `Field::changes` can answer it as a fragment while the
356 + /// control is being moved, which is the rename preview's arrangement.
357 + fn clips(peak: Option<f64>, gain: f64) -> Node {
358 + let Some(peak) = peak else {
359 + return Node::empty("Peak unknown until this sample is analysed.");
360 + };
361 + let predicted = peak + gain;
362 + if predicted <= 0.0 {
363 + return Node::text(format!("Peak: {peak:.1} dB -> {predicted:.1} dB"));
364 + }
365 + Node::Notice {
366 + kind: Notice::Banner,
367 + tone: Tone::Danger,
368 + text: format!("Peak: {peak:.1} dB -> {predicted:.1} dB (clips!)"),
369 + }
370 + }
371 +
372 + /// Reverse and fade.
373 + fn transform(body: Slot, sample: &Editing) -> Slot {
374 + body.with(Node::section("Transform"))
375 + .with(Node::Act(disable_while(
376 + Act::new("Reverse", Action::post("/edit/reverse")),
377 + sample,
378 + )))
379 + .with(Node::Form {
380 + // The fade row was left out of the forms conversion as "a slider, a
381 + // chooser and an Apply composing one operation", which was the right
382 + // call about a *field* and is what a `Form` is for: several answers
383 + // and one submit that uses them together.
384 + fields: vec![
385 + Field::radio(
386 + FADE_IN,
387 + "Fade",
388 + vec![Choice::new("in", "In"), Choice::new("out", "Out")],
389 + )
390 + .value("in"),
391 + Field::range(LENGTH, "Length", "10", "10000")
392 + .step("10")
393 + .value("100"),
394 + Field::select(
395 + CURVE,
396 + "Curve",
397 + FadeCurve::all()
398 + .into_iter()
399 + .map(|curve| Choice::new(curve.as_value(), curve.label()))
400 + .collect(),
401 + )
402 + .value(FadeCurve::Linear.as_value()),
403 + ],
404 + submit: "Apply fade".to_owned(),
405 + action: Action::post("/edit/fade"),
406 + })
407 + }
408 +
409 + /// Insert and remove.
410 + fn silence(body: Slot, sample: &Editing) -> Slot {
411 + // The shipped drag values clamp to the sample's length where analysis has
412 + // said what it is. A described `max` says the same thing, and where the
413 + // length is unknown there is nothing to say rather than a made-up ceiling.
414 + let cap = sample.duration.map(|seconds| seconds * 1000.0);
415 +
416 + body.with(Node::section("Silence"))
417 + .with(Node::Form {
418 + fields: vec![
419 + milliseconds(AT, "Insert at", cap).value("0"),
420 + milliseconds(LENGTH, "Duration", Some(60_000.0)).value("100"),
421 + ],
422 + submit: "Insert".to_owned(),
423 + action: Action::post("/edit/silence/insert"),
424 + })
425 + .with(Node::Form {
426 + fields: vec![
427 + milliseconds(FROM, "Remove from", cap).value("0"),
428 + milliseconds(TO, "to", cap).value("0"),
429 + ],
430 + submit: "Remove".to_owned(),
431 + action: Action::post("/edit/silence/remove"),
432 + })
433 + }
434 +
435 + /// A number of milliseconds, bounded where the app knows the bound.
436 + fn milliseconds(name: &str, label: &str, cap: Option<f64>) -> Field {
437 + // `min` and `max` are fields rather than builders on this type, where
438 + // `step` is a builder. Set directly, the way the settings screen sets a
439 + // value.
440 + let mut field = Field::new(FieldKind::Number, name, label).step("10");
441 + field.min = Some("0".to_owned());
442 + field.max = cap.map(|cap| format!("{cap:.0}"));
443 + field
444 + }
445 +
446 + /// What happens to the edited sample, and what happened to the last one.
447 + fn result(body: Slot, sample: &Editing) -> Slot {
448 + let mut field =
449 + Field::radio(RESULT, "Result", result_modes()).changes(Action::post("/edit/result"));
450 + if let Some(chosen) = &sample.result {
451 + field = field.value(chosen.clone());
452 + }
453 +
454 + let mut body = body
455 + .with(Node::section("Result"))
456 + .with(Node::Field(Box::new(field)));
457 +
458 + if sample.result.as_deref() == Some(EditResultMode::Replace.as_value()) {
459 + body = body.with(Node::Notice {
460 + kind: Notice::Banner,
461 + tone: Tone::Warning,
462 + text: "Replace mode: the original is removed from this vault. Use Create sibling to keep both, or Undo below to revert.".to_owned(),
463 + });
464 + }
465 +
466 + // The standing undo. See the module header: this is an act rather than
467 + // `Response::undoable`, and the ten-second timeout the shipped panel keeps
468 + // (with an `egui::Id` round trip and a `request_repaint_after` to land it)
469 + // is renderer policy that no longer has anywhere to be written down.
470 + if let Some(last) = &sample.undoing {
471 + body = body
472 + .with(Node::text(format!("Last edit: {last}")))
473 + .with(Node::Act(Act::new("Undo", Action::post("/edit/undo"))));
474 + }
475 + body
476 + }
477 +
478 + /// The two answers to "Result".
479 + fn result_modes() -> Vec<Choice> {
480 + vec![
481 + Choice::new(EditResultMode::Replace.as_value(), "Replace original"),
482 + Choice::new(EditResultMode::Sibling.as_value(), "Create sibling"),
483 + ]
484 + }
485 +
486 + /// Everything chosen at once.
487 + fn batch(body: Slot, sample: &Editing) -> Slot {
488 + if sample.chosen < 2 {
489 + return body;
490 + }
491 + let chosen = sample.chosen;
492 +
493 + body.with(Node::section(format!("Batch: {chosen} samples")))
494 + .with(Node::text("Applies to every chosen sample at once."))
495 + .with(Node::Form {
496 + // Its own value rather than the single-sample slider's. See the
497 + // module header on the piggyback this deletes.
498 + fields: vec![
499 + Field::radio(
500 + MODE,
Lines truncated