rlx_core/preset/schema/easing.rs
1//! [`Easing`]: the attack/release pair every binding is smoothed with
2//! (ADR-0019, widened to a pair by ADR-0035).
3//!
4//! Render-time arithmetic rather than schema, and it lives beside the schema
5//! because a preset's `[smoothing]` table is what produces it -- `raw::RawSmoothing`
6//! is the on-disk form.
7
8/// A binding's easing time constants in **seconds** (ADR-0019, widened to a pair
9/// by ADR-0035).
10///
11/// `attack` applies while the incoming value is **above** the held one and
12/// `release` while it is at or below — so a percussive parameter can reach its
13/// target in a frame or two and then glide back over most of a second, which no
14/// single constant expresses at any value.
15///
16/// The scalar `[smoothing]` form builds [`Easing::symmetric`], which is the
17/// low-pass ADR-0019 shipped: with both constants equal the direction test picks
18/// the same number either way, so the arithmetic is bit-for-bit unchanged.
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub struct Easing {
21 /// Constant used while the raw value is **above** the held value (rising).
22 pub attack: f32,
23 /// Constant used while the raw value is at or **below** the held value.
24 pub release: f32,
25}
26
27impl Easing {
28 /// No smoothing on either side: the value is applied instantly. The default
29 /// for a parameter absent from `[smoothing]`.
30 pub const INSTANT: Self = Self {
31 attack: 0.0,
32 release: 0.0,
33 };
34
35 /// One constant in both directions — the scalar `[smoothing]` form.
36 pub const fn symmetric(tau: f32) -> Self {
37 Self {
38 attack: tau,
39 release: tau,
40 }
41 }
42
43 /// One frame of the one-pole envelope: ease `held` toward `raw` over `dt`
44 /// real seconds, using whichever constant the direction of travel selects.
45 ///
46 /// **The single implementation of this vocabulary.** The render layer's
47 /// per-binding smoother and the spectrum scene's per-element smoother both
48 /// call it, so "smoothing in seconds, frame-rate independent, asymmetric by
49 /// direction" means exactly one thing everywhere (ADR-0019 / ADR-0035, Plan
50 /// 0034 Phase 3).
51 ///
52 /// The direction test is against the **held** value, not the raw signal's own
53 /// derivative: a value already above its new target releases toward it even
54 /// while the input is still rising. That is the envelope-follower convention,
55 /// and it is what keeps the behavior stable under a noisy input.
56 ///
57 /// **`dt` is finite and positive**, as `Scene::advance` states it: the
58 /// renderer's `sanitize_frame_dt` is the only answer to a degenerate frame
59 /// delta (ADR-0191), and nothing here second-guesses it. Outside that
60 /// precondition the arithmetic answers: `dt = 0` gives `alpha = 0`, which
61 /// holds `held`, and a non-finite `dt` poisons one frame that the
62 /// non-finite-`held` guard below turns back into `raw` on the next.
63 ///
64 /// A selected constant of `<= 0` (the default) or non-finite passes `raw`
65 /// through unchanged. Total and allocation-free — it runs per element per
66 /// frame.
67 ///
68 /// **A non-finite `held` or `raw` also passes `raw` through** — a snap,
69 /// which is what a smoother with no valid state should do (Plan 0038
70 /// Phase 9). This is not a theoretical edge: `log(0)` is `-inf` and silence
71 /// produces it every time the music stops, so a `[smoothing]`-listed binding
72 /// reaches this on ordinary material. Without the guard the arithmetic below
73 /// is `-inf + alpha * (-inf - -inf)` = `NaN`, and `NaN` is **absorbing**
74 /// here — `raw > held` is false for every `raw`, so the release branch is
75 /// taken and the state stays `NaN` forever. The binding would be dead for
76 /// the rest of the preset's run, recovering only on a switch.
77 ///
78 /// Both operands are checked because guarding `raw` alone does not fix it:
79 /// a stored `-inf` against a *finite* `raw` selects `attack` and computes
80 /// `-inf + inf`, which is `NaN` on the very next frame.
81 ///
82 /// **The ease ends by snapping to `raw` at its fixed point.** In f32 the
83 /// one-pole never arrives on its own: a frame moves `held` only while
84 /// `alpha * gap` is at least half the float spacing `u` at `held`, so it
85 /// stalls at a gap of about `u / (2 * alpha)` and stays there. A consumer
86 /// that truncates the value (`as usize`, `floor`) would then draw one step
87 /// low forever. So a frame that makes **no progress** returns `raw`.
88 ///
89 /// The test is "no progress", not "within some distance", because the stall
90 /// gap scales with `1 / alpha`: toward `2.0` it is ~3 spacings at tau 0.1 s
91 /// and 60 Hz but ~144 at tau 2 s and 144 Hz, so any fixed threshold is
92 /// outside some slow ease's stall and never fires. The fixed point always
93 /// comes: `alpha < 1` puts the exact sum strictly between `held` and `raw`,
94 /// round-to-nearest keeps it in `[held, raw]`, so the value moves
95 /// monotonically and must reach `raw` or a stall in finitely many frames.
96 /// The jump is at most the stall gap, about `2^-24 / alpha` of the value.
97 ///
98 /// **`alpha > 0` guards the snap.** For `dt / tau` below roughly `3e-8`,
99 /// `1 - exp(-dt/tau)` rounds to exactly zero and every frame makes no
100 /// progress; without the guard the slowest possible ease would become an
101 /// instant one. There the value holds.
102 pub fn step(self, held: f32, raw: f32, dt: f32) -> f32 {
103 if !held.is_finite() || !raw.is_finite() {
104 return raw;
105 }
106 let tau = if raw > held {
107 self.attack
108 } else {
109 self.release
110 };
111 if tau <= 0.0 || !tau.is_finite() {
112 return raw;
113 }
114 // alpha = 1 - exp(-dt/tau): the fraction of the gap closed this frame,
115 // frame-rate-independent because `dt` is real elapsed time (ADR-0019).
116 let alpha = 1.0 - (-dt / tau).exp();
117 let next = held + alpha * (raw - held);
118 if next == held && alpha > 0.0 {
119 return raw;
120 }
121 next
122 }
123}
124
125impl Default for Easing {
126 fn default() -> Self {
127 Self::INSTANT
128 }
129}