Skip to main content

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}