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    /// A selected constant of `<= 0` (the default) or non-finite, or a
58    /// non-positive `dt`, passes `raw` through unchanged. Total and
59    /// allocation-free — it runs per element per frame.
60    ///
61    /// **A non-finite `held` or `raw` also passes `raw` through** — a snap,
62    /// which is what a smoother with no valid state should do (Plan 0038
63    /// Phase 9). This is not a theoretical edge: `log(0)` is `-inf` and silence
64    /// produces it every time the music stops, so a `[smoothing]`-listed binding
65    /// reaches this on ordinary material. Without the guard the arithmetic below
66    /// is `-inf + alpha * (-inf - -inf)` = `NaN`, and `NaN` is **absorbing**
67    /// here — `raw > held` is false for every `raw`, so the release branch is
68    /// taken and the state stays `NaN` forever. The binding would be dead for
69    /// the rest of the preset's run, recovering only on a switch.
70    ///
71    /// Both operands are checked because guarding `raw` alone does not fix it:
72    /// a stored `-inf` against a *finite* `raw` selects `attack` and computes
73    /// `-inf + inf`, which is `NaN` on the very next frame.
74    pub fn step(self, held: f32, raw: f32, dt: f32) -> f32 {
75        if !held.is_finite() || !raw.is_finite() {
76            return raw;
77        }
78        let tau = if raw > held {
79            self.attack
80        } else {
81            self.release
82        };
83        if tau <= 0.0 || !tau.is_finite() || dt <= 0.0 {
84            return raw;
85        }
86        // alpha = 1 - exp(-dt/tau): the fraction of the gap closed this frame,
87        // frame-rate-independent because `dt` is real elapsed time (ADR-0019).
88        let alpha = 1.0 - (-dt / tau).exp();
89        held + alpha * (raw - held)
90    }
91}
92
93impl Default for Easing {
94    fn default() -> Self {
95        Self::INSTANT
96    }
97}