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}