Skip to main content

max / audiofiles

Describe the forge's chop and batch questions Sixth pass of the makeover-immediate forms adoption, and the first to sweep the `ui.add(...)` hole the 2026-08-16 frequency table named and left open. The chop section was invisible to all five earlier passes because its three mode controls are `ui.add_enabled(_, egui::RadioButton::new(..))` rather than `ui.radio(`, which is the same blind spot in a new shape. Converted: - The chop method, a described `Radio` over Transient / Divisions / BPM grid. `ChopMode` gains the `as_value`/`from_value` pairing `InstrumentMode` and `FadeCurve` already carry, with a round-trip test, so the value the control submits and the variant the draw matches on cannot drift. The section's own `strong` "Chop" heading is gone: the label belongs to the question. - Transient sensitivity, a described `Range` stepped at 0.01. The two ends are what the question means -- 0 is every dip in the waveform, 1 is only the loudest onsets -- and without the step egui's continuous drag re-runs the detector against a value nobody chose. - The equal-divisions count and the BPM grid's per-beat resolution, both described `Radio`s over their fixed sets. - The batch trim threshold, a described `Range` over -96..-20 dBFS stepped at a whole decibel. Not converted, with reasons rather than silence: - The tempo stays a `DragValue`. It is a validated number, not a range: 20 and 300 are guard rails on an answer people arrive with. A described `Number` is a text well here and needs a buffer that survives frames, which `bpm: f32` is not, so a half-typed "2" on the way to "200" would re-grid at 2 BPM. - The ADSR envelope's four sliders are blocked on two filed gaps: three of them are logarithmic (makeover-layout 45fc1a38, whose brief is corrected in the same pass -- it read as one money-shaped site and is now four) and all of them carry a unit, which a described field has nowhere to put. 1358 tests pass, clippy clean, fmt clean.
Author: Max Johnson <me@maxj.phd> · 2026-08-21 16:40 UTC
Signed with PGP, not checked
Commit: 32e295072f30edfa774a042cf3d02d1ee60a604c
Parent: 5d966c1
2 files changed, +205 insertions, -87 deletions
@@ -844,6 +844,60 @@
844 844 Bpm,
845 845 }
846 846
847 + impl ChopMode {
848 + /// What a described field submits for this method.
849 + ///
850 + /// `InstrumentMode` and `FadeCurve` carry the same pairing and it is here,
851 + /// in the UI crate, rather than beside them in `audiofiles-core`: a chop
852 + /// method never leaves the forge window, so there is no pipeline variant
853 + /// for a copy of it to drift from. What the pairing still buys is the
854 + /// other half — the value a described option carries and the value the
855 + /// draw matches on are one fact, and a disagreement is a control that
856 + /// answers with a method nothing recognises rather than a compile error.
857 + #[must_use]
858 + pub const fn as_value(self) -> &'static str {
859 + match self {
860 + Self::Transient => "transient",
861 + Self::Equal => "equal",
862 + Self::Bpm => "bpm",
863 + }
864 + }
865 +
866 + /// The method a described field submitted, if it named one.
867 + ///
868 + /// `None` for anything else rather than a fall back to `Transient`: a
869 + /// value no option carries means the control and this pairing have
870 + /// drifted, and the caller leaves the mode alone on it.
871 + #[must_use]
872 + pub fn from_value(value: &str) -> Option<Self> {
873 + match value {
874 + "transient" => Some(Self::Transient),
875 + "equal" => Some(Self::Equal),
876 + "bpm" => Some(Self::Bpm),
877 + _ => None,
878 + }
879 + }
880 + }
881 +
882 + #[cfg(test)]
883 + mod chop_mode_tests {
884 + use super::ChopMode;
885 +
886 + #[test]
887 + fn every_method_round_trips_through_the_value_a_described_field_submits() {
888 + for mode in [ChopMode::Transient, ChopMode::Equal, ChopMode::Bpm] {
889 + assert_eq!(ChopMode::from_value(mode.as_value()), Some(mode));
890 + }
891 + }
892 +
893 + #[test]
894 + fn a_value_no_option_carries_leaves_the_method_alone() {
895 + assert_eq!(ChopMode::from_value(""), None);
896 + assert_eq!(ChopMode::from_value("Transient"), None);
897 + assert_eq!(ChopMode::from_value("bpm grid"), None);
898 + }
899 + }
900 +
847 901 /// GUI-side state for the Sample Forge window (chop / conform / batch).
848 902 pub struct ForgeUiState {
849 903 pub show_window: bool,
@@ -75,77 +75,118 @@
75 75 });
76 76 }
77 77
78 + /// How the equal-divisions count is offered, and what each option submits.
79 + ///
80 + /// A fixed set with every member on screen, which is what makes it a described
81 + /// [`makeover_layout::FieldKind::Radio`] rather than a select: five powers of
82 + /// two are the whole question, and hiding four of them behind a closed control
83 + /// would cost more than it saves.
84 + const DIVISION_CHOICES: &[(&str, usize)] = &[("2", 2), ("4", 4), ("8", 8), ("16", 16), ("32", 32)];
85 +
86 + /// Grid resolution per beat: what the option submits, and what it reads as.
87 + ///
88 + /// The submitted value is the multiplier the grid is computed from and the
89 + /// label is the note it means, which is why these are two strings rather than
90 + /// one formatted at the draw: `4` and `1/16` are the same fact in the two
91 + /// vocabularies this control sits between.
92 + const SUBDIVISION_CHOICES: &[(&str, &str, u32)] =
93 + &[("1", "1/4", 1), ("2", "1/8", 2), ("4", "1/16", 4)];
94 +
95 + /// The granularity transient sensitivity moves in.
96 + ///
97 + /// Two decimals, the classifier thresholds' granularity for their reason: with
98 + /// no step egui's slider is continuous, and a detector re-run against
99 + /// 0.4300000000000001 is a re-run against a value nobody chose.
100 + const SENSITIVITY_STEP: &str = "0.01";
101 +
78 102 /// Chop controls: method + parameters, preview, and chop.
79 103 fn draw_chop_section(ui: &mut egui::Ui, state: &mut BrowserState) {
80 104 let disabled = state.forge.busy;
81 - ui.label(egui::RichText::new("Chop").strong());
105 + let state_when_busy = disabled.then_some(makeover_layout::State::Disabled);
82 106
83 - ui.horizontal(|ui| {
84 - if ui
85 - .add_enabled(
86 - !disabled,
87 - egui::RadioButton::new(state.forge.chop_mode == ChopMode::Transient, "Transient"),
88 - )
89 - .clicked()
90 - {
91 - state.forge.chop_mode = ChopMode::Transient;
92 - state.forge.slice_marks.clear();
93 - }
94 - if ui
95 - .add_enabled(
96 - !disabled,
97 - egui::RadioButton::new(state.forge.chop_mode == ChopMode::Equal, "Divisions"),
98 - )
99 - .clicked()
100 - {
101 - state.forge.chop_mode = ChopMode::Equal;
102 - state.forge.slice_marks.clear();
103 - }
104 - if ui
105 - .add_enabled(
106 - !disabled,
107 - egui::RadioButton::new(state.forge.chop_mode == ChopMode::Bpm, "BPM grid"),
108 - )
109 - .clicked()
110 - {
111 - state.forge.chop_mode = ChopMode::Bpm;
112 - state.forge.slice_marks.clear();
113 - }
114 - });
107 + // The method question, described. It was three `RadioButton`s built by
108 + // hand, which is what kept this section invisible to five sweeps: every one
109 + // of them matched `ui.radio(`, and these are `ui.add_enabled` with a widget
110 + // argument, the one hole the 2026-08-16 frequency table named and did not
111 + // close.
112 + //
113 + // The section's own `strong` "Chop" line is gone, the conform picker's
114 + // precedent: the label belongs to the question, and a heading repeating it
115 + // above the control is the same words twice.
116 + let mode_options = [
117 + makeover_layout::Choice::new(ChopMode::Transient.as_value(), "Transient"),
118 + makeover_layout::Choice::new(ChopMode::Equal.as_value(), "Divisions"),
119 + makeover_layout::Choice::new(ChopMode::Bpm.as_value(), "BPM grid"),
120 + ];
121 + let described = makeover_layout::Field::radio("chop_mode", "Chop", &mode_options);
122 + let was = state.forge.chop_mode;
123 + let mut value = was.as_value().to_owned();
124 + widgets::field(
125 + ui,
126 + &described,
127 + makeover_immediate::Filling::Text(&mut value),
128 + state_when_busy,
129 + );
130 + // `from_value` returning `None` is drift between the options and this
131 + // pairing, and leaves the mode alone rather than guessing at one.
132 + if let Some(mode) = ChopMode::from_value(&value)
133 + && mode != was
134 + {
135 + state.forge.chop_mode = mode;
136 + state.forge.slice_marks.clear();
137 + }
115 138
116 139 match state.forge.chop_mode {
117 140 ChopMode::Transient => {
118 - ui.horizontal(|ui| {
119 - ui.label("Sensitivity:");
120 - if ui
121 - .add_enabled(
122 - !disabled,
123 - egui::Slider::new(&mut state.forge.sensitivity, 0.0..=1.0),
124 - )
125 - .changed()
126 - {
127 - state.forge.slice_marks.clear();
128 - }
129 - });
141 + // A described `Range`: 0 is every dip in the waveform and 1 is only
142 + // the loudest onsets, so the two ends are the question and a bare
143 + // 0.43 in a well would not be a quieter version of this control.
144 + let described = makeover_layout::Field {
145 + step: Some(SENSITIVITY_STEP),
146 + ..makeover_layout::Field::range("sensitivity", "Sensitivity", "0", "1")
147 + };
148 + let mut text = format!("{:.2}", state.forge.sensitivity);
149 + widgets::field(
150 + ui,
151 + &described,
152 + makeover_immediate::Filling::Text(&mut text),
153 + state_when_busy,
154 + );
155 + if let Ok(parsed) = text.parse::<f32>()
156 + && (parsed - state.forge.sensitivity).abs() > f32::EPSILON
157 + {
158 + state.forge.sensitivity = parsed;
159 + state.forge.slice_marks.clear();
160 + }
130 161 }
131 162 ChopMode::Equal => {
132 - ui.horizontal(|ui| {
133 - ui.label("Slices:");
134 - for n in [2usize, 4, 8, 16, 32] {
135 - if ui
136 - .add_enabled(
137 - !disabled,
138 - egui::Button::selectable(state.forge.divisions == n, n.to_string()),
139 - )
140 - .clicked()
141 - {
142 - state.forge.divisions = n;
143 - state.forge.slice_marks.clear();
144 - }
145 - }
146 - });
163 + let options: Vec<makeover_layout::Choice<'_>> = DIVISION_CHOICES
164 + .iter()
165 + .map(|(value, _)| makeover_layout::Choice::new(value, value))
166 + .collect();
167 + let described = makeover_layout::Field::radio("divisions", "Slices", &options);
168 + let mut value = state.forge.divisions.to_string();
169 + widgets::field(
170 + ui,
171 + &described,
172 + makeover_immediate::Filling::Text(&mut value),
173 + state_when_busy,
174 + );
175 + if let Some((_, chosen)) = DIVISION_CHOICES.iter().find(|(v, _)| *v == value)
176 + && *chosen != state.forge.divisions
177 + {
178 + state.forge.divisions = *chosen;
179 + state.forge.slice_marks.clear();
180 + }
147 181 }
148 182 ChopMode::Bpm => {
183 + // The tempo stays a `DragValue` and is the one control in this
184 + // section that is not described. It is a validated number rather
185 + // than a range -- 20 and 300 are guard rails on an answer people
186 + // arrive with, not the two ends the question means -- and a
187 + // described `Number` is a text well here, which needs a buffer that
188 + // survives frames. `state.forge.bpm` is an `f32`, so a half-typed
189 + // "2" on the way to "200" would re-grid the sample at 2 BPM.
149 190 ui.horizontal(|ui| {
150 191 ui.label("BPM:");
151 192 if ui
@@ -159,25 +200,26 @@
159 200 {
160 201 state.forge.slice_marks.clear();
161 202 }
162 - ui.label("Per beat:");
163 - for n in [1u32, 2, 4] {
164 - let label = match n {
165 - 1 => "1/4",
166 - 2 => "1/8",
167 - _ => "1/16",
168 - };
169 - if ui
170 - .add_enabled(
171 - !disabled,
172 - egui::Button::selectable(state.forge.subdivisions == n, label),
173 - )
174 - .clicked()
175 - {
176 - state.forge.subdivisions = n;
177 - state.forge.slice_marks.clear();
178 - }
179 - }
180 203 });
204 +
205 + let options: Vec<makeover_layout::Choice<'_>> = SUBDIVISION_CHOICES
206 + .iter()
207 + .map(|(value, label, _)| makeover_layout::Choice::new(value, label))
208 + .collect();
209 + let described = makeover_layout::Field::radio("subdivisions", "Per beat", &options);
210 + let mut value = state.forge.subdivisions.to_string();
211 + widgets::field(
212 + ui,
213 + &described,
214 + makeover_immediate::Filling::Text(&mut value),
215 + state_when_busy,
216 + );
217 + if let Some((_, _, chosen)) = SUBDIVISION_CHOICES.iter().find(|(v, _, _)| *v == value)
218 + && *chosen != state.forge.subdivisions
219 + {
220 + state.forge.subdivisions = *chosen;
221 + state.forge.slice_marks.clear();
222 + }
181 223 }
182 224 }
183 225
@@ -302,6 +344,14 @@
302 344 );
303 345 }
304 346
347 + /// The granularity the trim threshold moves in.
348 + ///
349 + /// A whole decibel, which is what the `DragValue`'s `speed(1.0)` already meant
350 + /// and what the threshold is stored and compared at. Without it egui's own
351 + /// granularity is continuous, so a drag writes back -63.417 and the batch runs
352 + /// against a floor nobody chose.
353 + const TRIM_THRESHOLD_STEP: &str = "1";
354 +
305 355 /// Batch section: trim silence across the current multi-selection. Batch
306 356 /// normalize/gain live in the Sample Editor's batch section.
307 357 fn draw_batch_section(ui: &mut egui::Ui, state: &mut BrowserState) {
@@ -316,15 +366,29 @@
316 366 return;
317 367 }
318 368
319 - ui.horizontal(|ui| {
320 - ui.label("Threshold:");
321 - ui.add(
322 - egui::DragValue::new(&mut state.forge.trim_threshold_db)
323 - .speed(1.0)
324 - .range(-96.0..=-20.0)
325 - .suffix(" dBFS"),
326 - );
327 - });
369 + // A described `Range`, on the classifier thresholds' reasoning: the two
370 + // ends are what the question means. -96 dBFS is the noise floor of a
371 + // 16-bit file and -20 is loud enough to eat a quiet tail, so a bare -63
372 + // says nothing without both of them beside it, and there is no answer
373 + // outside the pair to be told about afterwards.
374 + //
375 + // The unit is in the label because a described field has nowhere else to
376 + // put one: there is no `suffix`, and a dBFS reading with no unit is a
377 + // number in the wrong scale for anyone who reads it as a percentage.
378 + let described = makeover_layout::Field {
379 + step: Some(TRIM_THRESHOLD_STEP),
380 + ..makeover_layout::Field::range("trim_threshold", "Threshold (dBFS)", "-96", "-20")
381 + };
382 + let mut text = format!("{:.0}", state.forge.trim_threshold_db);
383 + widgets::field(
384 + ui,
385 + &described,
386 + makeover_immediate::Filling::Text(&mut text),
387 + None,
388 + );
389 + if let Ok(parsed) = text.parse::<f64>() {
390 + state.forge.trim_threshold_db = parsed;
391 + }
328 392 let threshold = state.forge.trim_threshold_db;
329 393 if ui
330 394 .button(format!("Trim silence on {count} samples"))