max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
7 files changed,
+1357 insertions,
-21 deletions
| @@ -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