Skip to main content

max / audiofiles

Describe the sample editor Sixteen shapes: the editor, its two bodies, the seven sections, the four questions two forms each share, the clipping fragment and the batch reverse. Seven helpers took the body region and handed it back and one took an Act, which is every section in the file. `disable_while` was `Act`'s version of the same shape and is `disabled when` now. `editor` answered a Result, which is a read that had not been hoisted. `showing` does the read and the four call sites lose their `?` on the describing half. The shared questions are what earned `include` in a form (quasi-declare 0.1.4): `span` twice in one form, `milliseconds` four times across two.
Author: Max Johnson <me@maxj.phd> · 2026-09-04 23:08 UTC
Signed with PGP, not checked
Commit: d97285ea3f4bbe17f394daec46866cfa0c41e4b2
Parent: 8308689
2 files changed, +188 insertions, -315 deletions
M Cargo.lock +17 -17
@@ -4257,7 +4257,7 @@
4257 4257
4258 4258 [[package]]
4259 4259 name = "quasi-declare"
4260 - version = "0.1.3"
4260 + version = "0.1.4"
4261 4261 dependencies = [
4262 4262 "proc-macro2",
4263 4263 "quote",
@@ -7566,6 +7566,22 @@
7566 7566 "winnow 1.0.4",
7567 7567 ]
7568 7568
7569 + [[patch.unused]]
7570 + name = "kberg"
7571 + version = "0.1.0"
7572 +
7573 + [[patch.unused]]
7574 + name = "ops-status"
7575 + version = "0.1.0"
7576 +
7577 + [[patch.unused]]
7578 + name = "painhours"
7579 + version = "0.1.0"
7580 +
7581 + [[patch.unused]]
7582 + name = "quasi-type"
7583 + version = "0.1.3"
7584 +
7569 7585 [[patch.unused]]
7570 7586 name = "quasi-axum"
7571 7587 version = "0.101.1"
@@ -7593,19 +7609,3 @@
7593 7609 [[patch.unused]]
7594 7610 name = "quasi-webview"
7595 7611 version = "0.101.1"
7596 -
7597 - [[patch.unused]]
7598 - name = "quasi-type"
7599 - version = "0.1.3"
7600 -
7601 - [[patch.unused]]
7602 - name = "kberg"
7603 - version = "0.1.0"
7604 -
7605 - [[patch.unused]]
7606 - name = "ops-status"
7607 - version = "0.1.0"
7608 -
7609 - [[patch.unused]]
7610 - name = "painhours"
7611 - version = "0.1.0"
@@ -96,11 +96,9 @@
96 96 //! Described as `Act::disabled` on what an edit would start, which says the
97 97 //! same thing without naming a colour.
98 98
99 - use quasi_router::layout::{FieldKind, Tone};
100 - use quasi_router::{
101 - Act, Action, Choice, Consult, Field, Figure, Node, RegionKind, Request, Response, RouteError,
102 - Router, Screen, Slot,
103 - };
99 + use quasi_declare::declare;
100 + use quasi_router::layout::Tone;
101 + use quasi_router::{Action, Choice, Consult, Request, Response, RouteError, Router};
104 102
105 103 use audiofiles_core::edit::FadeCurve;
106 104
@@ -173,18 +171,7 @@
173 171
174 172 /// `GET /edit`
175 173 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))
174 + showing(state)
188 175 }
189 176
190 177 /// What is being edited, or a refusal.
@@ -199,274 +186,183 @@
199 186 .ok_or_else(|| RouteError::not_found("nothing is being edited"))
200 187 }
201 188
189 + /// The editor, read and then described.
190 + pub(super) fn showing(state: &Panels<'_>) -> Result<Response, RouteError> {
191 + Ok(Response::from(editor(&read(state)?)))
192 + }
193 +
194 + /// What the editor draws, read off the app.
195 + struct Edit {
196 + /// The question a finished edit is waiting on, while one is waiting.
197 + asking: Option<Asking>,
198 + /// The editor proper, while nothing is waiting.
199 + working_on: Option<Sample>,
200 + }
201 +
202 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 - ))
203 + struct Asking {
204 + /// The two answers to "Result".
205 + modes: Vec<Choice>,
206 + /// The standing answer, or the one this screen defaults to.
207 + chosen: String,
208 + /// Whether a standing answer exists, which is what the checkbox reads.
209 + ///
210 + /// The shipped checkbox is ticked when a standing answer exists, which is
211 + /// the same fact this reads.
212 + remembered: &'static str,
235 213 }
236 214
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::banner(Tone::Info, "Applying edit..."))
259 - .with(Node::Act(Act::new("Cancel", Action::post("/edit/cancel"))));
260 - }
261 -
262 - body = transport(body, sample);
263 - body = trim_section(body);
264 - body = levels(body, sample);
265 - body = transform(body, sample);
266 - body = silence(body, sample);
267 - body = result(body, sample);
268 - batch(body, sample)
269 - }
270 -
271 - /// Play, pause and stop, independent of the main list's selection.
272 - fn transport(body: Slot, sample: &Editing) -> Slot {
273 - body.with(Node::Act(
274 - Act::new(
275 - if sample.playing { "Pause" } else { "Play" },
276 - Action::post("/edit/play"),
277 - )
278 - .key("space"),
279 - ))
280 - .with(Node::Act(Act::new("Stop", Action::post("/edit/stop"))))
281 - }
282 -
283 - /// The span to keep.
284 - fn trim_section(body: Slot) -> Slot {
285 - body.with(Node::section("Trim")).with(Node::Form {
286 - // Two ranges over a fraction of the sample, which is what the shipped
287 - // sliders are. The seconds the shipped panel prints beside each is the
288 - // same number in the sample's units, and a description that carried both
289 - // would be describing a label. See the module header on the interval
290 - // gap: these two constrain each other and cannot say so.
291 - fields: vec![span(START, "Start"), span(END, "End").value("1")],
292 - submit: "Trim".to_owned(),
293 - action: Action::post("/edit/trim"),
294 - })
295 - }
296 -
297 - /// One end of the trim, as a fraction of the whole.
298 - fn span(name: &str, label: &str) -> Field {
299 - Field::range(name, label, "0", "1").step("0.001").value("0")
300 - }
301 -
302 - /// Gain and normalise.
303 - fn levels(body: Slot, sample: &Editing) -> Slot {
304 - let mut body = body.with(Node::section("Levels"));
305 -
306 - body = body
307 - .with(Node::Form {
308 - fields: vec![
309 - Field::range(GAIN, "Gain", "-24", "24")
310 - .step("0.1")
311 - .value("0")
312 - // The clipping consequence, which the field cannot carry
313 - // itself. See the module header, `5672cad4`. A question
314 - // rather than a write: nothing is applied until the form
315 - // is submitted. The wait is what keeps a drag from asking
316 - // once a frame.
317 - .consulting(Consult::new(Action::post("/edit/gain/preview"))),
318 - ],
319 - submit: "Apply gain".to_owned(),
320 - action: Action::post("/edit/gain"),
321 - })
322 - .with(Node::Region(
323 - Slot::new(CLIPPING, RegionKind::Group).with(clips(sample.peak_db, 0.0)),
324 - ));
325 -
326 - // Peak and LUFS have different ranges (-24..0 dBFS against -24..-6 LUFS) and
327 - // different defaults, and the shipped panel resets the target when the mode
328 - // changes because "the carried-over value is meaningless across modes". Both
329 - // facts are about the pair rather than about either control, and neither is
330 - // sayable: the widest range is described and the route refuses what falls
331 - // outside the chosen mode's half.
332 - body.with(Node::Form {
333 - fields: vec![
334 - Field::radio(
335 - MODE,
336 - "Normalize by",
337 - vec![
338 - Choice::new(PEAK, "Peak"),
339 - Choice::new(LUFS, "Loudness (LUFS)"),
340 - ],
341 - )
342 - .value(PEAK),
343 - Field::range(TARGET, "Target", "-24", "0")
344 - .step("0.1")
345 - .value("-1"),
346 - ],
347 - submit: "Normalize".to_owned(),
348 - action: Action::post("/edit/normalize"),
349 - })
350 - }
351 -
352 - /// What the gain about to be applied would do to the peak.
353 - ///
354 - /// Its own node so `Field::consults` can answer it as a fragment while the
355 - /// control is being moved, which is the rename preview's arrangement.
356 - fn clips(peak: Option<f64>, gain: f64) -> Node {
357 - let Some(peak) = peak else {
358 - return Node::empty("Peak unknown until this sample is analysed.");
359 - };
360 - let predicted = peak + gain;
361 - if predicted <= 0.0 {
362 - return Node::text(format!("Peak: {peak:.1} dB -> {predicted:.1} dB"));
363 - }
364 - Node::banner(
365 - Tone::Danger,
366 - format!("Peak: {peak:.1} dB -> {predicted:.1} dB (clips!)"),
367 - )
368 - }
369 -
370 - /// Reverse and fade.
371 - fn transform(body: Slot, sample: &Editing) -> Slot {
372 - body.with(Node::section("Transform"))
373 - .with(Node::Act(disable_while(
374 - Act::new("Reverse", Action::post("/edit/reverse")),
375 - sample,
376 - )))
377 - .with(Node::Form {
378 - // The fade row was left out of the forms conversion as "a slider, a
379 - // chooser and an Apply composing one operation", which was the right
380 - // call about a *field* and is what a `Form` is for: several answers
381 - // and one submit that uses them together.
382 - fields: vec![
383 - Field::radio(
384 - FADE_IN,
385 - "Fade",
386 - vec![Choice::new("in", "In"), Choice::new("out", "Out")],
387 - )
388 - .value("in"),
389 - Field::range(LENGTH, "Length", "10", "10000")
390 - .step("10")
391 - .value("100"),
392 - Field::select(
393 - CURVE,
394 - "Curve",
395 - FadeCurve::all()
396 - .into_iter()
397 - .map(|curve| Choice::new(curve.as_value(), curve.label()))
398 - .collect(),
399 - )
400 - .value(FadeCurve::Linear.as_value()),
401 - ],
402 - submit: "Apply fade".to_owned(),
403 - action: Action::post("/edit/fade"),
404 - })
405 - }
406 -
407 - /// Insert and remove.
408 - fn silence(body: Slot, sample: &Editing) -> Slot {
409 - // The shipped drag values clamp to the sample's length where analysis has
410 - // said what it is. A described `max` says the same thing, and where the
411 - // length is unknown there is nothing to say rather than a made-up ceiling.
412 - let cap = sample.duration.map(|seconds| seconds * 1000.0);
413 -
414 - body.with(Node::section("Silence"))
415 - .with(Node::Form {
416 - fields: vec![
417 - milliseconds(AT, "Insert at", cap).value("0"),
418 - milliseconds(LENGTH, "Duration", Some(60_000.0)).value("100"),
419 - ],
420 - submit: "Insert".to_owned(),
421 - action: Action::post("/edit/silence/insert"),
422 - })
423 - .with(Node::Form {
424 - fields: vec![
425 - milliseconds(FROM, "Remove from", cap).value("0"),
426 - milliseconds(TO, "to", cap).value("0"),
427 - ],
428 - submit: "Remove".to_owned(),
429 - action: Action::post("/edit/silence/remove"),
430 - })
431 - }
432 -
433 - /// A number of milliseconds, bounded where the app knows the bound.
434 - fn milliseconds(name: &str, label: &str, cap: Option<f64>) -> Field {
435 - // `min` and `max` are fields rather than builders on this type, where
436 - // `step` is a builder. Set directly, the way the settings screen sets a
437 - // value.
438 - let mut field = Field::new(FieldKind::Number, name, label).step("10");
439 - field.min = Some("0".to_owned());
440 - field.max = cap.map(|cap| format!("{cap:.0}"));
441 - field
215 + /// The sample being edited.
216 + struct Sample {
217 + /// What it is called.
218 + name: String,
219 + /// What the shipped info line says, as facts rather than one muted string.
220 + rate: String,
221 + /// How long it runs, where analysis has said.
222 + duration: Option<String>,
223 + /// Its peak, where analysis has said.
224 + peak: Option<String>,
225 + /// Whether this sample is the preview that is playing.
226 + playing: bool,
227 + /// Whether an edit is being applied right now.
228 + busy: bool,
229 + /// What the clipping region says about the gain as it stands.
230 + clipping: Clipping,
231 + /// The longest silence position the app can vouch for, where it knows one.
232 + ///
233 + /// The shipped drag values clamp to the sample's length where analysis has
234 + /// said what it is. A described `max` says the same thing, and where the
235 + /// length is unknown there is nothing to say rather than a made-up ceiling.
236 + cap: Option<String>,
237 + /// What happens to the edited sample.
238 + result: Handling,
239 + /// Everything chosen at once, where enough is chosen for that to mean
240 + /// anything.
241 + batch: Option<Batch>,
442 242 }
443 243
444 244 /// What happens to the edited sample, and what happened to the last one.
445 - fn result(body: Slot, sample: &Editing) -> Slot {
446 - let mut field =
447 - Field::radio(RESULT, "Result", result_modes()).writes(Action::post("/edit/result"));
448 - if let Some(chosen) = &sample.result {
449 - field = field.value(chosen.clone());
450 - }
245 + struct Handling {
246 + /// The two answers.
247 + modes: Vec<Choice>,
248 + /// The standing answer, where there is one.
249 + chosen: Option<String>,
250 + /// Whether that answer is the one that removes the original.
251 + replacing: bool,
252 + /// The last edit, while it is still reversible, by the name it goes under.
253 + undoing: Option<String>,
254 + }
451 255
452 - let mut body = body
453 - .with(Node::section("Result"))
454 - .with(Node::Field(Box::new(field)));
256 + /// Everything chosen at once.
257 + struct Batch {
258 + /// The heading, which counts them.
259 + heading: String,
260 + /// What normalising them all reads.
261 + normalize: String,
262 + /// What applying gain to them all reads.
263 + gain: String,
264 + /// What reversing them all reads.
265 + reverse: String,
266 + /// What reversing them asks first, where there is enough to regret.
267 + ///
268 + /// The shipped threshold, kept with its reasoning: single-sample Reverse is
269 + /// its own undo (click it again), and on a large selection that trick
270 + /// requires remembering it ran at all.
271 + confirm: Option<String>,
272 + }
455 273
456 - if sample.result.as_deref() == Some(EditResultMode::Replace.as_value()) {
457 - body = body.with(Node::banner(Tone::Warning, "Replace mode: the original is removed from this vault. Use Create sibling to keep both, or Undo below to revert."));
458 - }
274 + /// What the gain about to be applied would do to the peak.
275 + struct Clipping {
276 + /// Which of the three things there is to say.
277 + says: Says,
278 + /// The sentence, where the peak is known.
279 + said: String,
280 + }
459 281
460 - // The standing undo. See the module header: this is an act rather than
461 - // `Response::undoable`, and the ten-second timeout the shipped panel keeps
462 - // (with an `egui::Id` round trip and a `request_repaint_after` to land it)
463 - // is renderer policy that no longer has anywhere to be written down.
464 - if let Some(last) = &sample.undoing {
465 - body = body
466 - .with(Node::text(format!("Last edit: {last}")))
467 - .with(Node::Act(Act::new("Undo", Action::post("/edit/undo"))));
282 + /// What there is to say about a predicted peak.
283 + enum Says {
284 + /// The sample has not been analysed, so there is no peak to predict from.
285 + Unknown,
286 + /// It stays under zero.
287 + Under,
288 + /// It does not.
289 + Clips,
290 + }
291 +
292 + /// What the editor draws, read off the app.
293 + fn read(state: &Panels<'_>) -> Result<Edit, RouteError> {
294 + let sample = subject(state)?;
295 + Ok(if sample.asking {
296 + Edit {
297 + asking: Some(Asking {
298 + modes: result_modes(),
299 + chosen: sample
300 + .result
301 + .clone()
302 + .unwrap_or_else(|| EditResultMode::Sibling.as_value().to_owned()),
303 + remembered: if sample.result.is_some() { "on" } else { "" },
304 + }),
305 + working_on: None,
306 + }
307 + } else {
308 + Edit {
309 + asking: None,
310 + working_on: Some(sample_read(&sample)),
311 + }
312 + })
313 + }
314 +
315 + /// The editor proper, read off the sample.
316 + fn sample_read(sample: &Editing) -> Sample {
317 + Sample {
318 + name: sample.name.clone(),
319 + rate: sample.sample_rate.to_string(),
320 + duration: sample.duration.map(|seconds| format!("{seconds:.3}")),
321 + peak: sample.peak_db.map(|peak| format!("{peak:.1}")),
322 + playing: sample.playing,
323 + busy: sample.working,
324 + clipping: clipping_read(sample.peak_db, 0.0),
325 + cap: sample
326 + .duration
327 + .map(|seconds| format!("{:.0}", seconds * 1000.0)),
328 + result: Handling {
329 + modes: result_modes(),
330 + chosen: sample.result.clone(),
331 + replacing: sample.result.as_deref() == Some(EditResultMode::Replace.as_value()),
332 + undoing: sample.undoing.clone(),
333 + },
334 + batch: (sample.chosen >= 2).then(|| Batch {
335 + heading: format!("Batch: {} samples", sample.chosen),
336 + normalize: format!("Normalize {} samples", sample.chosen),
337 + gain: format!("Apply gain to {} samples", sample.chosen),
338 + reverse: format!("Reverse {} samples", sample.chosen),
339 + confirm: (sample.chosen > REGRET)
340 + .then(|| format!("Reverse {} samples?", sample.chosen)),
341 + }),
342 + }
343 + }
344 +
345 + /// What a gain would do to the peak, read off what analysis found.
346 + fn clipping_read(peak: Option<f64>, gain: f64) -> Clipping {
347 + let Some(peak) = peak else {
348 + return Clipping {
349 + says: Says::Unknown,
350 + said: String::new(),
351 + };
352 + };
353 + let predicted = peak + gain;
354 + Clipping {
355 + says: if predicted <= 0.0 {
356 + Says::Under
357 + } else {
358 + Says::Clips
359 + },
360 + said: if predicted <= 0.0 {
361 + format!("Peak: {peak:.1} dB -> {predicted:.1} dB")
362 + } else {
363 + format!("Peak: {peak:.1} dB -> {predicted:.1} dB (clips!)")
364 + },
468 365 }
469 - body
470 366 }
471 367
472 368 /// The two answers to "Result".
@@ -477,76 +373,343 @@
477 373 ]
478 374 }
479 375
480 - /// Everything chosen at once.
481 - fn batch(body: Slot, sample: &Editing) -> Slot {
482 - if sample.chosen < 2 {
483 - return body;
484 - }
485 - let chosen = sample.chosen;
486 -
487 - body.with(Node::section(format!("Batch: {chosen} samples")))
488 - .with(Node::text("Applies to every chosen sample at once."))
489 - .with(Node::Form {
490 - // Its own value rather than the single-sample slider's. See the
491 - // module header on the piggyback this deletes.
492 - fields: vec![
493 - Field::radio(
494 - MODE,
495 - "Normalize by",
496 - vec![
497 - Choice::new(PEAK, "Peak"),
498 - Choice::new(LUFS, "Loudness (LUFS)"),
499 - ],
500 - )
501 - .value(PEAK),
502 - Field::range(TARGET, "Target", "-24", "0")
Lines truncated