Skip to main content

max / audiofiles

Flip the Sample Forge to the described screen The forge serves from `quasi::forge` and `ui/forge_panel.rs` is gone. The window loses its "(described)" suffix, since it is no longer beside anything. Parity held with one named difference, and it is the field gap again: the shipped device combo carries no label, so it is announced by its own placeholder rather than by what is being asked. makeover-immediate 0.34.0 fixes that and is not published, so the fidelity test carries both halves of it. One harness correction fell out of the flip. A described `Node::Select`'s options were being read as choices, and every renderer draws a select as a strip of pressable segments -- egui reports a selectable label as `Role::Button` with nothing to tell it from an ordinary one. The forge's eight slice options came back as buttons from the renderer and choices from the reader, which is the reader being wrong. `Choice` stays for `FieldKind::Select` and `Radio`: a box you open and a set of radios are genuinely different things to operate.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 18:03 UTC
Signed with PGP, not checked
Commit: 4de115cbe1744f0144d6459836aebf2c5fed6d21
Parent: 44f64ab
5 files changed, +41 insertions, -448 deletions
@@ -4,8 +4,8 @@
4 4
5 5 use crate::state::{BrowserState, ImportMode};
6 6 use crate::ui::{
7 - detail, edit_panel, export_screens, file_list, filter_panel, footer, forge_panel,
8 - import_screens, instrument_panel, layout_strip, overlays, sidebar, theme, toolbar,
7 + detail, edit_panel, export_screens, file_list, filter_panel, footer, import_screens,
8 + instrument_panel, layout_strip, overlays, sidebar, theme, toolbar,
9 9 };
10 10 use audiofiles_core::vfs::NodeType;
11 11
@@ -283,14 +283,6 @@
283 283 }
284 284
285 285 // Floating sample forge window
286 - if state.forge.show_window {
287 - forge_panel::draw_forge_window(ctx, state);
288 - }
289 -
290 - // The described forge, beside the shipped one and on the same condition:
291 - // it is a window the app opens for a sample rather than one with a toggle,
292 - // so there is no second flag to keep in step.
293 - #[cfg(feature = "quasi")]
294 286 if state.forge.show_window {
295 287 crate::quasi::panel::draw_forge(ctx, state);
296 288 }
@@ -318,14 +318,7 @@
318 318 themes: themes(),
319 319 intents: &intents,
320 320 };
321 - let closed = window(
322 - ctx,
323 - "Sample Forge (described)",
324 - &mut runtime,
325 - &host,
326 - "/forge",
327 - true,
328 - );
321 + let closed = window(ctx, "Sample Forge", &mut runtime, &host, "/forge", true);
329 322 state.described.forge = runtime;
330 323 apply(ctx, state, None, intents.into_inner());
331 324 if closed {
@@ -251,8 +251,18 @@
251 251 // A segmented control is one choice with several labels on the
252 252 // shipped side too, so each option is an offer rather than the
253 253 // strip being one.
254 + //
255 + // A `Button` and not a `Choice`, which reads backwards until you
256 + // ask what a renderer draws: every host draws a `Node::Select` as a
257 + // strip of pressable segments, and egui reports a selectable label
258 + // as `Role::Button` with nothing to distinguish it from an ordinary
259 + // one. `Choice` stays for `FieldKind::Select` and `Radio`, which are
260 + // a box you open and a set of radios -- genuinely different things
261 + // to operate. Corrected 2026-08-22, when the forge's eight slice
262 + // options came back as buttons from the renderer and choices from
263 + // here.
254 264 for (choice, own) in options {
255 - out.push(Role::Choice, choice.label.clone(), false);
265 + out.push(Role::Button, choice.label.clone(), false);
256 266 if let Some(action) = own.as_ref().or(action.as_ref()) {
257 267 out.addresses.push(action.destination.as_str().to_owned());
258 268 }
@@ -924,3 +934,30 @@
924 934 // to discount.
925 935 Parity::strict().assert(&described, &drawn);
926 936 }
937 +
938 + /// A sample open in the forge, which is what that screen is about.
939 + fn with_the_forge_open(state: &mut crate::state::BrowserState) {
940 + state.nav.selection.set_single(0);
941 + state.open_forge_window("aaa111");
942 + }
943 +
944 + #[test]
945 + fn the_forge_serves_what_it_describes() {
946 + let (mut state, _dir) = fixture();
947 + with_the_forge_open(&mut state);
948 +
949 + let described = described(&super::panel::described_screen(&state, "/forge"));
950 + let drawn = shipped(|ui| {
951 + super::panel::draw_forge(ui.ctx(), &mut state);
952 + });
953 +
954 + described.addresses_resolve();
955 + Parity::strict()
956 + .in_a_window("Sample Forge")
957 + // The device picker, unnamed by the renderer and therefore announced by
958 + // its own placeholder. See `Parity::unnamed_field`; both lines come out
959 + // when makeover-immediate 0.34.0 is published.
960 + .unnamed_field("Conform for device")
961 + .dropping("Select device...")
962 + .assert(&described, &drawn);
963 + }
@@ -10,7 +10,6 @@
10 10 pub mod file_list_menus;
11 11 pub mod filter_panel;
12 12 pub mod footer;
13 - pub mod forge_panel;
14 13 pub mod import_screens;
15 14 pub mod instrument_panel;
16 15 pub mod layout_strip;
@@ -1,428 +1,0 @@
1 - //! Floating Sample Forge window: chop, conform, and batch operations, the
2 - //! "maker" surface that turns a managed sample into hardware-ready material.
3 -
4 - use egui;
5 -
6 - use crate::state::{BrowserState, ChopMode};
7 - use crate::waveform;
8 -
9 - use super::theme;
10 - use super::widgets;
11 -
12 - /// Draw the forge window. Call from the overlay layer.
13 - pub fn draw_forge_window(ctx: &egui::Context, state: &mut BrowserState) {
14 - let mut open = state.forge.show_window;
15 - widgets::tool_window(ctx, "Sample Forge", &mut open, 420.0, 340.0, |ui| {
16 - if state.forge.hash.is_none() {
17 - ui.label("Select a sample and open the forge to chop, conform, or batch-process it.");
18 - return;
19 - }
20 -
21 - if state.forge.busy {
22 - ui.horizontal(|ui| {
23 - ui.spinner();
24 - ui.label("Working...");
25 - });
26 - ui.separator();
27 - }
28 -
29 - draw_waveform_with_marks(ui, state);
30 - draw_info_line(ui, state);
31 -
32 - ui.separator();
33 - draw_chop_section(ui, state);
34 -
35 - ui.separator();
36 - draw_conform_section(ui, state);
37 -
38 - ui.separator();
39 - draw_batch_section(ui, state);
40 -
41 - ui.separator();
42 - draw_foreshadow_section(ui);
43 - });
44 - state.forge.show_window = open;
45 - }
46 -
47 - /// Waveform with slice-boundary markers overlaid (from Preview). Uses the
48 - /// forge's own captured waveform so it stays bound to the sample being forged
49 - /// even if the file-list selection changes.
50 - fn draw_waveform_with_marks(ui: &mut egui::Ui, state: &BrowserState) {
51 - if let Some(ref waveform_data) = state.forge.waveform {
52 - let resp = waveform::draw_waveform(ui, waveform_data, None, 120.0);
53 - let rect = resp.rect;
54 - let painter = ui.painter_at(rect);
55 - for &frac in &state.forge.slice_marks {
56 - let x = rect.left() + rect.width() * frac.clamp(0.0, 1.0);
57 - painter.line_segment(
58 - [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())],
59 - egui::Stroke::new(1.0, theme::warning()),
60 - );
61 - }
62 - }
63 - }
64 -
65 - fn draw_info_line(ui: &mut egui::Ui, state: &BrowserState) {
66 - let name = &state.forge.name;
67 - let rate = state.forge.source_rate;
68 - ui.horizontal_wrapped(|ui| {
69 - ui.label(egui::RichText::new(name).strong().size(12.0));
70 - ui.label(
71 - egui::RichText::new(format!("{rate} Hz"))
72 - .color(theme::content_muted())
73 - .size(11.0),
74 - );
75 - });
76 - }
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 -
102 - /// Chop controls: method + parameters, preview, and chop.
103 - fn draw_chop_section(ui: &mut egui::Ui, state: &mut BrowserState) {
104 - let disabled = state.forge.busy;
105 - let state_when_busy = disabled.then_some(makeover_layout::State::Disabled);
106 -
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 - }
138 -
139 - match state.forge.chop_mode {
140 - ChopMode::Transient => {
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 - curve: makeover_layout::Curve::Linear {
146 - step: Some(SENSITIVITY_STEP),
147 - },
148 - ..makeover_layout::Field::range("sensitivity", "Sensitivity", "0", "1")
149 - };
150 - let mut text = format!("{:.2}", state.forge.sensitivity);
151 - let response = widgets::field(
152 - ui,
153 - &described,
154 - makeover_immediate::Filling::Text(&mut text),
155 - state_when_busy,
156 - );
157 - // The value follows the drag, because the control has to show what
158 - // the hand is doing. The marks are dropped when it settles, which
159 - // is `widgets::settled`'s rule: a drag across the bar is one answer
160 - // and not one per frame.
161 - if let Ok(parsed) = text.parse::<f32>() {
162 - state.forge.sensitivity = parsed;
163 - }
164 - if widgets::settled(response) {
165 - state.forge.slice_marks.clear();
166 - }
167 - }
168 - ChopMode::Equal => {
169 - let options: Vec<makeover_layout::Choice<'_>> = DIVISION_CHOICES
170 - .iter()
171 - .map(|(value, _)| makeover_layout::Choice::new(value, value))
172 - .collect();
173 - let described = makeover_layout::Field::radio("divisions", "Slices", &options);
174 - let mut value = state.forge.divisions.to_string();
175 - widgets::field(
176 - ui,
177 - &described,
178 - makeover_immediate::Filling::Text(&mut value),
179 - state_when_busy,
180 - );
181 - if let Some((_, chosen)) = DIVISION_CHOICES.iter().find(|(v, _)| *v == value)
182 - && *chosen != state.forge.divisions
183 - {
184 - state.forge.divisions = *chosen;
185 - state.forge.slice_marks.clear();
186 - }
187 - }
188 - ChopMode::Bpm => {
189 - // A described `Number`, not a `Range`: 20 and 300 are guard rails
190 - // on an answer people arrive with rather than the two ends the
191 - // question means. Nobody drags to find a tempo, they know it.
192 - //
193 - // It answers on submit. The half-typed "2" on the way to "200" is
194 - // what kept this control undescribed in the 2026-08-21 pass, and
195 - // `widgets::number_field` is the answer to it: the buffer is the
196 - // panel's, and 2 never reaches `state.forge.bpm` at all.
197 - //
198 - // What the description buys over the `DragValue` it replaces is the
199 - // refusal. `DragValue::range` clamps in silence, so a user who
200 - // meant 400 got 300 and was never told; an out-of-range entry now
201 - // stays on screen as typed, with the bounds as the field's error.
202 - let spec = widgets::NumberFieldSpec {
203 - name: "bpm",
204 - label: "BPM",
205 - min: 20.0,
206 - max: 300.0,
207 - decimals: 0,
208 - hint: None,
209 - };
210 - if widgets::number_field(
211 - ui,
212 - &spec,
213 - &mut state.forge.bpm_input,
214 - &mut state.forge.bpm,
215 - state_when_busy,
216 - ) {
217 - state.forge.slice_marks.clear();
218 - }
219 -
220 - let options: Vec<makeover_layout::Choice<'_>> = SUBDIVISION_CHOICES
221 - .iter()
222 - .map(|(value, label, _)| makeover_layout::Choice::new(value, label))
223 - .collect();
224 - let described = makeover_layout::Field::radio("subdivisions", "Per beat", &options);
225 - let mut value = state.forge.subdivisions.to_string();
226 - widgets::field(
227 - ui,
228 - &described,
229 - makeover_immediate::Filling::Text(&mut value),
230 - state_when_busy,
231 - );
232 - if let Some((_, _, chosen)) = SUBDIVISION_CHOICES.iter().find(|(v, _, _)| *v == value)
233 - && *chosen != state.forge.subdivisions
234 - {
235 - state.forge.subdivisions = *chosen;
236 - state.forge.slice_marks.clear();
237 - }
238 - }
239 - }
240 -
241 - ui.horizontal(|ui| {
242 - if ui
243 - .add_enabled(!disabled, egui::Button::new("Preview slices"))
244 - .clicked()
245 - {
246 - state.forge_preview_slices();
247 - }
248 - // AF-9: require a current preview before chopping so the user always
249 - // commits to a known slice count instead of an unknown number. Changing
250 - // any chop parameter clears the marks, which re-gates this button.
251 - let slice_count = state.forge.slice_marks.len().saturating_sub(1);
252 - let has_preview = slice_count > 0;
253 - let chop_label = if has_preview {
254 - let noun = if slice_count == 1 { "slice" } else { "slices" };
255 - format!("Chop into {slice_count} {noun}")
256 - } else {
257 - "Chop".to_string()
258 - };
259 - let chop = ui.add_enabled(!disabled && has_preview, egui::Button::new(chop_label));
260 - if chop.clicked() {
261 - state.forge_apply_chop();
262 - }
263 - if !has_preview {
264 - chop.on_hover_text("Preview the slices first to see how many will be created.");
265 - }
266 - });
267 - ui.label(
268 - egui::RichText::new("Slices are written into a new folder beside this sample.")
269 - .small()
270 - .color(theme::content_muted()),
271 - );
272 - }
273 -
274 - /// Conform controls: pick a device, conform to its accepted format.
275 - fn draw_conform_section(ui: &mut egui::Ui, state: &mut BrowserState) {
276 - let disabled = state.forge.busy;
277 -
278 - if state.forge.devices.is_empty() {
279 - ui.label(egui::RichText::new("Conform for device").strong());
280 - ui.label(
281 - egui::RichText::new("No device profiles available.")
282 - .small()
283 - .color(theme::content_muted()),
284 - );
285 - return;
286 - }
287 -
288 - // The option labels have to outlive the described field, which holds them
289 - // as `&str`. Built once here rather than inside the draw, since a summary
290 - // is part of what the option reads as.
291 - let labels: Vec<String> = state
292 - .forge
293 - .devices
294 - .iter()
295 - .map(|(name, summary)| {
296 - if summary.is_empty() {
297 - name.clone()
298 - } else {
299 - format!("{name} ({summary})")
300 - }
301 - })
302 - .collect();
303 - let options: Vec<makeover_layout::Choice<'_>> = state
304 - .forge
305 - .devices
306 - .iter()
307 - .zip(&labels)
308 - .map(|((name, _), label)| makeover_layout::Choice::new(name, label))
309 - .collect();
310 -
311 - // The instruction is the field's ghost text rather than a disabled button's
312 - // job. It was `selected_text` before makeover-layout 0.28.0, which is what
313 - // stopped this picker being described in the 2026-08-16 pass: a described
314 - // select with nothing chosen read as an empty box, so the only thing left
315 - // saying what to do would have been the greyed Conform beside it.
316 - let described = makeover_layout::Field {
317 - placeholder: Some("Select device..."),
318 - ..makeover_layout::Field::select("conform_device", "Conform for device", &options)
319 - };
320 -
321 - // The label is the field's now, so the section's own `strong` line is gone.
322 - // What is left here is the button beside it, which is not part of the
323 - // question.
324 - //
325 - // Both the click and the answer come back out of this block rather than
326 - // being acted on inside it: the described options borrow `state.forge`, and
327 - // conforming writes to `state`.
328 - let mut chosen = state.forge.conform_device.clone().unwrap_or_default();
329 - let conform = {
330 - let mut conform = false;
331 - ui.horizontal(|ui| {
332 - widgets::field(
333 - ui,
334 - &described,
335 - makeover_immediate::Filling::Text(&mut chosen),
336 - disabled.then_some(makeover_layout::State::Disabled),
337 - );
338 -
339 - let can_conform = !disabled && !chosen.is_empty();
340 - conform = ui
341 - .add_enabled(can_conform, egui::Button::new("Conform"))
342 - .clicked();
343 - });
344 - conform
345 - };
346 - // Written back after the draw: the field borrows the buffer, and an empty
347 - // one is "nothing chosen" rather than a device named the empty string.
348 - state.forge.conform_device = (!chosen.is_empty()).then_some(chosen);
349 - if conform && let Some(device) = state.forge.conform_device.clone() {
350 - state.forge_conform_device(&device);
351 - }
352 -
353 - ui.label(
354 - egui::RichText::new(
355 - "Resamples and converts bit depth to match the device, as a new sample.",
356 - )
357 - .small()
358 - .color(theme::content_muted()),
359 - );
360 - }
361 -
362 - /// The granularity the trim threshold moves in.
363 - ///
364 - /// A whole decibel, which is what the `DragValue`'s `speed(1.0)` already meant
365 - /// and what the threshold is stored and compared at. Without it egui's own
366 - /// granularity is continuous, so a drag writes back -63.417 and the batch runs
367 - /// against a floor nobody chose.
368 - const TRIM_THRESHOLD_STEP: &str = "1";
369 -
370 - /// Batch section: trim silence across the current multi-selection. Batch
371 - /// normalize/gain live in the Sample Editor's batch section.
372 - fn draw_batch_section(ui: &mut egui::Ui, state: &mut BrowserState) {
373 - let count = state.selected_sample_hashes().len();
374 - ui.label(egui::RichText::new("Batch").strong());
375 - if count < 2 {
376 - ui.label(
377 - egui::RichText::new("Select 2+ samples to batch trim silence.")
378 - .small()
379 - .color(theme::content_muted()),
380 - );
381 - return;
382 - }
383 -
384 - // A described `Range`, on the classifier thresholds' reasoning: the two
385 - // ends are what the question means. -96 dBFS is the noise floor of a
386 - // 16-bit file and -20 is loud enough to eat a quiet tail, so a bare -63
387 - // says nothing without both of them beside it, and there is no answer
388 - // outside the pair to be told about afterwards.
389 - //
390 - // The unit is in the label because a described field has nowhere else to
391 - // put one: there is no `suffix`, and a dBFS reading with no unit is a
392 - // number in the wrong scale for anyone who reads it as a percentage.
393 - let described = makeover_layout::Field {
394 - curve: makeover_layout::Curve::Linear {
395 - step: Some(TRIM_THRESHOLD_STEP),
396 - },
397 - unit: Some("dBFS"),
398 - ..makeover_layout::Field::range("trim_threshold", "Threshold", "-96", "-20")
399 - };
400 - let mut text = format!("{:.0}", state.forge.trim_threshold_db);
401 - widgets::field(
402 - ui,
403 - &described,
404 - makeover_immediate::Filling::Text(&mut text),
405 - None,
406 - );
407 - if let Ok(parsed) = text.parse::<f64>() {
408 - state.forge.trim_threshold_db = parsed;
409 - }
410 - let threshold = state.forge.trim_threshold_db;
411 - if ui
412 - .button(format!("Trim silence on {count} samples"))
413 - .clicked()
414 - {
415 - state.batch_trim_silence(threshold);
416 - }
417 - }
418 -
419 - /// Foreshadow the CLAP/VST plugin host, the planned follow-on headline. Copy
420 - /// only; not built for this launch.
421 - fn draw_foreshadow_section(ui: &mut egui::Ui) {
422 - ui.label(
423 - egui::RichText::new("Plugin processing (CLAP/VST): coming soon")
424 - .small()
425 - .italics()
426 - .color(theme::content_muted()),
427 - );
428 - }