Skip to main content

max / audiofiles

Describe the ADSR envelope, the last hand-rolled sliders in either crate They were blocked on two gaps at once, and the first is now closed: makeover-layout 0.32.0's `Curve`, from Max's reframe that a slider's data is a fraction and a function taking numbers to numbers. Attack, decay and release are `Curve::Logarithmic` because an envelope time spans four orders of magnitude and the half that matters is the short one -- a 5 ms attack and a 50 ms attack are audibly different instruments, and a linear track puts both inside its first one percent. Sustain is the linear member, and it is why the ratio curve falls back rather than refusing: a sustain is a level between silence and full, so its low end is zero and a constant ratio is undefined there. The second gap is open (makeover-layout 32215e21), so the unit is in the label -- the convention three fields in this app already arrived at independently, and what that gap exists to ratify or replace. The four hovers became hints, which is what every earlier pass did with a message no keyboard reaches. The four existing `step: Some(..)` sites move onto their curve in the same pass. They compiled unchanged and would have silently lost their granularity, which is the one sharp edge in the 0.32.0 narrowing. 1365 tests pass, clippy clean, fmt clean.
Author: Max Johnson <me@maxj.phd> · 2026-08-21 17:39 UTC
Signed with PGP, not checked
Commit: 08e80f6804fb562303331b31f0c137338f800036
Parent: a3e59e3
4 files changed, +134 insertions, -50 deletions
M Cargo.toml +4 -4
@@ -17,12 +17,12 @@
17 17 # to compile against an API added in a later one. The two move together --
18 18 # makeover-immediate re-exports nothing, so the `Column` the app describes and
19 19 # the `Column` the renderer matches on have to be the same type.
20 - makeover-layout = "0.31.0"
21 - makeover-immediate = "0.30.0"
20 + makeover-layout = "0.32.0"
21 + makeover-immediate = "0.31.0"
22 22 # The described screens, behind audiofiles-browser's `quasi` feature. By git URL
23 23 # with a version requirement, per the tree's rule for cross-repo deps.
24 - quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.45" }
25 - quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.45" }
24 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.46" }
25 + quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.46" }
26 26 egui = { version = "0.35", default-features = false, features = ["default_fonts"] }
27 27 egui_extras = { version = "0.35", default-features = false }
28 28 eframe = { version = "0.35", default-features = false, features = ["default_fonts", "glow"] }
@@ -33,7 +33,9 @@
33 33 value: &mut f32,
34 34 ) -> Option<egui::Response> {
35 35 let described = makeover_layout::Field {
36 - step: Some(THRESHOLD_STEP),
36 + curve: makeover_layout::Curve::Linear {
37 + step: Some(THRESHOLD_STEP),
38 + },
37 39 ..makeover_layout::Field::range(name, label, "0", "1")
38 40 };
39 41 let mut text = format!("{value:.2}");
@@ -849,7 +851,7 @@
849 851 // the fewest groups worth having and 24 is where the grouping
850 852 // stops telling you anything.
851 853 let described = makeover_layout::Field {
852 - step: Some("1"),
854 + curve: makeover_layout::Curve::Linear { step: Some("1") },
853 855 ..makeover_layout::Field::range("cluster_k", "Groups", "2", "24")
854 856 };
855 857 let mut text = k.to_string();
@@ -142,7 +142,9 @@
142 142 // the loudest onsets, so the two ends are the question and a bare
143 143 // 0.43 in a well would not be a quieter version of this control.
144 144 let described = makeover_layout::Field {
145 - step: Some(SENSITIVITY_STEP),
145 + curve: makeover_layout::Curve::Linear {
146 + step: Some(SENSITIVITY_STEP),
147 + },
146 148 ..makeover_layout::Field::range("sensitivity", "Sensitivity", "0", "1")
147 149 };
148 150 let mut text = format!("{:.2}", state.forge.sensitivity);
@@ -389,7 +391,9 @@
389 391 // put one: there is no `suffix`, and a dBFS reading with no unit is a
390 392 // number in the wrong scale for anyone who reads it as a percentage.
391 393 let described = makeover_layout::Field {
392 - step: Some(TRIM_THRESHOLD_STEP),
394 + curve: makeover_layout::Curve::Linear {
395 + step: Some(TRIM_THRESHOLD_STEP),
396 + },
393 397 ..makeover_layout::Field::range("trim_threshold", "Threshold (dBFS)", "-96", "-20")
394 398 };
395 399 let mut text = format!("{:.0}", state.forge.trim_threshold_db);
@@ -604,6 +604,57 @@
604 604 .collect()
605 605 }
606 606
607 + /// The granularity an envelope time moves in.
608 + ///
609 + /// A millisecond, which is what the sliders' `max_decimals(3)` already showed
610 + /// and what the shortest attack worth having is measured in. The step is in the
611 + /// value's own units under either curve (makeover-layout 0.32.0), so it reads as
612 + /// three decimals here exactly as it did before the curve existed.
613 + const TIME_STEP: &str = "0.001";
614 +
615 + /// The granularity the sustain level moves in: two decimals, as it was drawn.
616 + const LEVEL_STEP: &str = "0.01";
617 +
618 + /// One envelope time, as a described field on a constant-ratio track.
619 + ///
620 + /// The three times differ only in their name, their help and their upper bound,
621 + /// so they are one function rather than three copies of six lines. The value
622 + /// round-trips through a string for `threshold_field`'s reason: that is what
623 + /// the description carries and what every renderer of it takes, so a number
624 + /// would be this renderer's convenience imposed on the other two.
625 + #[expect(
626 + clippy::too_many_arguments,
627 + reason = "each argument is one fact about the question, and bundling them into a struct for \
628 + three call sites in one file would be further to read, not less"
629 + )]
630 + fn envelope_field(
631 + ui: &mut egui::Ui,
632 + name: &str,
633 + label: &str,
634 + hint: &str,
635 + step: &'static str,
636 + min: f32,
637 + max: f32,
638 + value: &mut f32,
639 + ) {
640 + let (min_text, max_text) = (format!("{min}"), format!("{max}"));
641 + let described = makeover_layout::Field {
642 + hint: Some(hint),
643 + curve: makeover_layout::Curve::Logarithmic { step: Some(step) },
644 + ..makeover_layout::Field::range(name, label, &min_text, &max_text)
645 + };
646 + let mut text = format!("{value:.3}");
647 + widgets::field(
648 + ui,
649 + &described,
650 + makeover_immediate::Filling::Text(&mut text),
651 + None,
652 + );
653 + if let Ok(parsed) = text.parse::<f32>() {
654 + *value = parsed;
655 + }
656 + }
657 +
607 658 /// Draw ADSR envelope sliders, with a live envelope-shape preview above so the
608 659 /// effect of each parameter is visible before the user releases the slider.
609 660 fn draw_adsr_controls(ui: &mut egui::Ui, state: &mut BrowserState) {
@@ -649,53 +700,80 @@
649 700 envelope.release,
650 701 );
651 702
703 + // The four described, at last. They were the last hand-rolled sliders in
704 + // either crate and they were blocked on two things at once: a described
705 + // range had no way to say its track was not linear, and it still has no way
706 + // to say what its numbers are measured in.
707 + //
708 + // The first is closed -- makeover-layout 0.32.0's `Curve`, from Max's
709 + // reframe that a slider's data is a fraction and a function taking numbers
710 + // to numbers. Three of these are `Curve::Logarithmic` because an envelope
711 + // time spans four orders of magnitude and the half that matters is the
712 + // short one: a 5 ms attack and a 50 ms attack are audibly different
713 + // instruments, and a linear track puts both inside its first one percent.
714 + //
715 + // The second is open (makeover-layout `32215e21`), so the unit is in the
716 + // label. That is the convention three fields in this app already arrived at
717 + // independently, and it is what the gap exists to ratify or replace.
718 + //
719 + // Two per row, each keeping its own label above its own bar: the
720 + // classifier's paired thresholds, which is the shape this app uses whenever
721 + // two fields are read across.
652 722 ui.horizontal(|ui| {
653 - ui.label(
654 - egui::RichText::new("A")
655 - .small()
656 - .color(theme::content_secondary()),
657 - )
658 - .on_hover_text("Attack: time to reach full volume after key press");
659 - let slider = egui::Slider::new(&mut envelope.attack, 0.001..=5.0)
660 - .logarithmic(true)
661 - .max_decimals(3)
662 - .suffix("s");
663 - ui.add(slider);
664 -
665 - ui.label(
666 - egui::RichText::new("D")
667 - .small()
668 - .color(theme::content_secondary()),
669 - )
670 - .on_hover_text("Decay: time to fall from peak to sustain level");
671 - let slider = egui::Slider::new(&mut envelope.decay, 0.001..=5.0)
672 - .logarithmic(true)
673 - .max_decimals(3)
674 - .suffix("s");
675 - ui.add(slider);
723 + envelope_field(
724 + ui,
725 + "attack",
726 + "Attack (s)",
727 + "Time to reach full volume after key press.",
728 + TIME_STEP,
729 + 0.001,
730 + 5.0,
731 + &mut envelope.attack,
732 + );
733 + envelope_field(
734 + ui,
735 + "decay",
736 + "Decay (s)",
737 + "Time to fall from peak to the sustain level.",
738 + TIME_STEP,
739 + 0.001,
740 + 5.0,
741 + &mut envelope.decay,
742 + );
676 743 });
677 744
678 745 ui.horizontal(|ui| {
679 - ui.label(
680 - egui::RichText::new("S")
681 - .small()
682 - .color(theme::content_secondary()),
683 - )
684 - .on_hover_text("Sustain: held volume level while the key is down (0 to 1)");
685 - let slider = egui::Slider::new(&mut envelope.sustain, 0.0..=1.0).max_decimals(2);
686 - ui.add(slider);
746 + // The one linear member, and the reason `Curve::Logarithmic` falls back
747 + // rather than refusing: a sustain is a level between silence and full,
748 + // so its low end is zero and a constant ratio is undefined there.
749 + let described = makeover_layout::Field {
750 + hint: Some("Held volume while the key is down."),
751 + curve: makeover_layout::Curve::Linear {
752 + step: Some(LEVEL_STEP),
753 + },
754 + ..makeover_layout::Field::range("sustain", "Sustain", "0", "1")
755 + };
756 + let mut text = format!("{:.2}", envelope.sustain);
757 + widgets::field(
758 + ui,
759 + &described,
760 + makeover_immediate::Filling::Text(&mut text),
761 + None,
762 + );
763 + if let Ok(parsed) = text.parse::<f32>() {
764 + envelope.sustain = parsed;
765 + }
687 766
688 - ui.label(
689 - egui::RichText::new("R")
690 - .small()
691 - .color(theme::content_secondary()),
692 - )
693 - .on_hover_text("Release: time to fade to silence after key release");
694 - let slider = egui::Slider::new(&mut envelope.release, 0.001..=10.0)
695 - .logarithmic(true)
696 - .max_decimals(3)
697 - .suffix("s");
698 - ui.add(slider);
767 + envelope_field(
768 + ui,
769 + "release",
770 + "Release (s)",
771 + "Time to fade to silence after key release.",
772 + TIME_STEP,
773 + 0.001,
774 + 10.0,
775 + &mut envelope.release,
776 + );
699 777 });
700 778
701 779 state.shared.instrument.lock().config.envelope = envelope;