Skip to main content

auracle_features/
audio.rs

1//! `φ_audio`: perceptual descriptors of the normalized standard-phrase render.
2//!
3//! Computed on Hann-windowed frames (2048 samples, 50% hop) of the mono
4//! render. Every field is finite by construction (renders are vetted first).
5//! Deliberately compact — 15 dims, and every one of them named in
6//! [`AudioFeatures::NAMES`] rather than counted here, so this line cannot go
7//! stale again. The taste model is a mixture of *linear* experts, and
8//! interpretable axes ("bright", "noisy", "slow attack", "long tail") are the
9//! point.
10//!
11//! ## Why these coordinates and not the obvious ones
12//!
13//! The model downstream is **linear in φ**, so the axis a feature lives on
14//! decides what preferences are *expressible at all*.
15//!
16//! - **Frequency features are logarithmic, not linear in Hz.** Brightness and
17//!   pitch perception are octave-based. On a linear-Hz axis normalized by
18//!   Nyquist, moving a patch from 200 Hz to 400 Hz — a full octave, an
19//!   enormous audible change — shifts the coordinate by 0.009, while
20//!   8 k → 16 k shifts it by 0.36. A linear model in that coordinate cannot
21//!   represent "I like my basses a shade brighter": the entire usable range is
22//!   swallowed by the bright tail of the pool. [`log_axis`] puts centroid,
23//!   rolloff and zero-crossing rate on a shared **octaves-above-20 Hz** scale,
24//!   normalized to `[0, 1]` at Nyquist so the vector stays sample-rate
25//!   agnostic.
26//! - **Heavy tails are logged.** `crest` spans 1 to 40+ and `tail_ratio`
27//!   spans three orders of magnitude; standardizing either raw hands the model
28//!   a coordinate whose z-score is a near-constant for most of the pool and
29//!   +4 for a handful of outliers.
30//! - **The attack crossing is interpolated, not floored.** Quantizing the
31//!   90 %-of-peak crossing to the analysis-window index makes `attack_s`
32//!   *exactly* zero for every patch whose first window is already at peak —
33//!   i.e. most percussive patches — turning a continuous axis into a
34//!   zero-inflated spike. A fine hop plus sub-window interpolation keeps it
35//!   continuous, and `ln(attack + 5 ms)` keeps the fast end resolved.
36
37use std::sync::Arc;
38
39use rustfft::num_complex::Complex;
40use rustfft::{Fft, FftPlanner};
41use serde::{Deserialize, Serialize};
42
43use crate::render::RenderedPhrase;
44
45const FRAME: usize = 2048;
46const HOP: usize = 1024;
47
48thread_local! {
49    /// The forward transform for [`FRAME`], planned once per thread.
50    /// See the note at its use site for why this is safe to cache.
51    static FFT_PLAN: Arc<dyn Fft<f64>> = FftPlanner::<f64>::new().plan_fft_forward(FRAME);
52}
53
54/// Anchor of the log-frequency axis: below this, frequency is inaudible as
55/// pitch and the ratio scale stops meaning anything.
56const F_ANCHOR: f64 = 20.0;
57
58/// Envelope window / hop for the attack measurement. The window is wide
59/// enough to be a stable RMS, the hop fine enough that interpolation between
60/// consecutive hops resolves attacks well under a millisecond.
61const ENV_WIN_S: f64 = 0.004;
62const ENV_HOP_S: f64 = 0.001;
63
64/// Map a frequency to **octaves above 20 Hz**, normalized so Nyquist is 1.0.
65///
66/// This is the coordinate every spectral feature lives on; see the module doc
67/// for why a linear-Hz axis makes ordinary timbral preference inexpressible.
68pub fn log_axis(hz: f64, nyquist: f64) -> f64 {
69    let span = (nyquist.max(F_ANCHOR * 2.0) / F_ANCHOR).log2();
70    (hz.max(F_ANCHOR) / F_ANCHOR).log2() / span
71}
72
73/// Named perceptual descriptors. `to_vec` order matches [`AudioFeatures::NAMES`].
74#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
75pub struct AudioFeatures {
76    /// Mean spectral centroid on the [`log_axis`] (octaves above 20 Hz,
77    /// 1.0 = Nyquist) — brightness.
78    pub centroid_mean: f64,
79    /// Std of the log-axis centroid over frames — timbral movement, in
80    /// octaves, so a wobble means the same thing at any register.
81    pub centroid_std: f64,
82    /// Mean 85% spectral rolloff on the [`log_axis`].
83    pub rolloff_mean: f64,
84    /// Mean spectral flatness (0 tonal … 1 noisy).
85    pub flatness_mean: f64,
86    /// Mean spectral flux — how fast the spectrum changes.
87    pub flux_mean: f64,
88    /// Zero-crossing rate as an equivalent frequency, on the [`log_axis`].
89    pub zcr_mean: f64,
90    /// Mean frame RMS of the normalized render.
91    pub rms_mean: f64,
92    /// Std of frame RMS — dynamics/movement.
93    pub rms_std: f64,
94    /// `ln` crest factor: `ln(peak / whole-phrase RMS)`. Logged because the
95    /// raw factor is heavy-tailed (1 … 40+).
96    pub crest: f64,
97    /// `ln(attack + 5 ms)` of the first note (onset → 90% of that note's peak
98    /// RMS, interpolated between envelope hops).
99    pub attack_s: f64,
100    /// `ln` tail level: RMS of the final 300 ms relative to whole-phrase RMS —
101    /// captures release length and delay/reverb tails. Logged: the raw ratio
102    /// spans three orders of magnitude.
103    pub tail_ratio: f64,
104    /// Low-band energy fraction (below ~250 Hz) — weight/sub character.
105    pub bass_fraction: f64,
106    /// Std of the log-axis centroid over frames of the **held note's** gate-on
107    /// span only. `centroid_std` over the whole phrase conflates note-to-note
108    /// register jumps with genuine timbral motion; this coordinate is
109    /// register-constant by construction, so it is the axis on which "a filter
110    /// sweeping at 0.4 Hz" and "a static patch" are different patches at all.
111    /// 0.0 when the phrase has no held span long enough to measure.
112    pub held_centroid_std: f64,
113    /// `ln` RMS of the **highest note's** gate-on span relative to the held
114    /// note's — does the patch speak in the upper register, or does its
115    /// filter choke it? 0.0 when the phrase has no note meaningfully above
116    /// its first.
117    pub high_ratio: f64,
118    /// Mean spectral flatness over the **chord note's** gate-on span minus
119    /// the held note's — intermodulation and mud when voices stack. 0.0 when
120    /// the phrase has no chord note.
121    pub chord_flatness_delta: f64,
122}
123
124impl AudioFeatures {
125    /// Feature names in `to_vec` order.
126    ///
127    /// ## The `:p2` stimulus tag
128    ///
129    /// Every audio feature is a measurement **of the standard phrase**, so a
130    /// stimulus change changes what each value means even when the formula is
131    /// untouched — a slow pad's `rms_mean` under a phrase that never lets it
132    /// open is a different quantity from the same field under one that does.
133    /// The observation log stores raw φ **by name**, and
134    /// [`FitSet::build`](../../auracle_taste/observe/struct.FitSet.html)
135    /// projects old logs onto the current names: same name ⇒ same coordinate.
136    /// Tagging the names with the stimulus generation is therefore the
137    /// migration mechanism itself — votes recorded under the v1 phrase keep
138    /// their (stimulus-independent) structural coordinates and have their
139    /// old-stimulus audio coordinates honestly imputed as "no evidence",
140    /// instead of being silently mixed into a standardizer they were never
141    /// commensurable with. Bump the tag whenever
142    /// [`PhraseSpec::default`](crate::phrase::PhraseSpec) changes audibly.
143    pub const NAMES: [&'static str; 15] = [
144        "centroid_mean:p2",
145        "centroid_std:p2",
146        "rolloff_mean:p2",
147        "flatness_mean:p2",
148        "flux_mean:p2",
149        "zcr_mean:p2",
150        "rms_mean:p2",
151        "rms_std:p2",
152        "crest:p2",
153        "attack_s:p2",
154        "tail_ratio:p2",
155        "bass_fraction:p2",
156        "held_centroid_std:p2",
157        "high_ratio:p2",
158        "chord_flatness_delta:p2",
159    ];
160
161    /// Flatten to a vector in [`Self::NAMES`] order.
162    pub fn to_vec(&self) -> Vec<f64> {
163        vec![
164            self.centroid_mean,
165            self.centroid_std,
166            self.rolloff_mean,
167            self.flatness_mean,
168            self.flux_mean,
169            self.zcr_mean,
170            self.rms_mean,
171            self.rms_std,
172            self.crest,
173            self.attack_s,
174            self.tail_ratio,
175            self.bass_fraction,
176            self.held_centroid_std,
177            self.high_ratio,
178            self.chord_flatness_delta,
179        ]
180    }
181}
182
183/// Extract [`AudioFeatures`] from a (normalized) render.
184pub fn audio_features(r: &RenderedPhrase) -> AudioFeatures {
185    let x = &r.samples;
186    let n = x.len();
187    let sr = r.sample_rate;
188
189    // --- time-domain ---
190    let global_rms = (x.iter().map(|s| s * s).sum::<f64>() / n.max(1) as f64).sqrt();
191    let peak = x.iter().fold(0.0f64, |p, s| p.max(s.abs()));
192    let crest = (peak / (global_rms + 1e-12)).max(1e-6).ln();
193
194    let tail_len = ((0.3 * sr) as usize).min(n);
195    let tail_rms =
196        (x[n - tail_len..].iter().map(|s| s * s).sum::<f64>() / tail_len.max(1) as f64).sqrt();
197    // 1e-3 floor: a pluck that has fully decayed by the last 300 ms would
198    // otherwise send the log to −∞, and "silent tail" and "very quiet tail"
199    // are the same judgment to a listener anyway.
200    let tail_ratio = (tail_rms / (global_rms + 1e-12) + 1e-3).ln();
201
202    // Attack: overlapping short-window RMS from the first onset to the start
203    // of the second note (or end), time to reach 90% of that segment's max —
204    // *interpolated* between hops, so a fast attack is a small number rather
205    // than exactly zero.
206    let attack_s = {
207        let start = r.note_onsets.first().copied().unwrap_or(0);
208        let end = r.note_onsets.get(1).copied().unwrap_or(n).min(n);
209        let win = ((ENV_WIN_S * sr) as usize).max(1);
210        let hop = ((ENV_HOP_S * sr) as usize).max(1);
211        let seg = &x[start..end];
212        let mut env = Vec::with_capacity(seg.len() / hop + 1);
213        let mut i = 0;
214        while i + win <= seg.len() {
215            let w = &seg[i..i + win];
216            env.push((w.iter().map(|s| s * s).sum::<f64>() / win as f64).sqrt());
217            i += hop;
218        }
219        let max = env.iter().cloned().fold(0.0f64, f64::max);
220        let target = 0.9 * max;
221        let idx = env.iter().position(|&e| e >= target).unwrap_or(0);
222        let hops = if idx == 0 {
223            0.0
224        } else {
225            // Linear crossing between the last sub-threshold hop and this one.
226            let (lo, hi) = (env[idx - 1], env[idx]);
227            let frac = if hi > lo {
228                (target - lo) / (hi - lo)
229            } else {
230                1.0
231            };
232            (idx - 1) as f64 + frac.clamp(0.0, 1.0)
233        };
234        (hops * hop as f64 / sr + 0.005).ln()
235    };
236
237    // **DC-removed before counting.** A zero-crossing counter measures crossings
238    // of zero, not of the signal's own centre, so a constant offset suppresses
239    // them — a patch riding +0.3 with a ±0.2 oscillation crosses zero never and
240    // reads as maximally dark. The vet gate admits |mean|/rms up to 0.6, so that
241    // is a reachable render rather than a hypothetical one, and `zcr_mean` feeds
242    // a linear model as if it were a brightness measurement.
243    //
244    // Subtracting the mean is the whole fix: the crossing count of `x − x̄` is
245    // what the coordinate has always been trying to be. For a render with no
246    // offset — which is nearly all of them, `makes_dc` puts a blocker in front
247    // of every tube-mode patch — the mean is ~1e-4 of full scale and the count
248    // is unchanged.
249    let dc = x.iter().sum::<f64>() / n.max(1) as f64;
250    let zcr_fraction = if n > 1 {
251        x.windows(2)
252            .filter(|w| ((w[0] - dc) >= 0.0) != ((w[1] - dc) >= 0.0))
253            .count() as f64
254            / (n - 1) as f64
255    } else {
256        0.0
257    };
258    // A zero-crossing rate *is* a frequency (two crossings per cycle); put it
259    // on the same perceptual axis as the other spectral features.
260    let zcr_mean = log_axis(zcr_fraction * sr / 2.0, sr / 2.0);
261
262    // --- spectral, framewise ---
263    // The planner is built once per thread, not once per render. Planning is
264    // where rustfft computes the twiddle factors for `FRAME`, and a fresh
265    // `FftPlanner` per call redoes that for every candidate the search
266    // featurizes — thousands per generation, for a table that depends only on a
267    // compile-time constant.
268    //
269    // Bit-identical by construction: a cached planner hands back the same
270    // algorithm for the same size, so the transform is the same arithmetic in
271    // the same order. This cannot move φ, which is why it does not owe a
272    // revalidation.
273    //
274    // `thread_local!` rather than a global: `FftPlanner` is not `Sync`, and the
275    // featurizer runs on whatever thread the harness or the farm puts it on.
276    let fft = FFT_PLAN.with(Arc::clone);
277    let hann: Vec<f64> = (0..FRAME)
278        .map(|i| 0.5 - 0.5 * (std::f64::consts::TAU * i as f64 / FRAME as f64).cos())
279        .collect();
280
281    let bins = FRAME / 2;
282    let nyquist = sr / 2.0;
283    let bass_bin = (250.0 / nyquist * bins as f64) as usize; // ≤ ~250 Hz
284    let bin_hz = sr / FRAME as f64;
285
286    let mut centroids = Vec::new();
287    let mut rolloffs = Vec::new();
288    let mut flatnesses = Vec::new();
289    // Start position of the frame behind each centroids/flatnesses entry —
290    // what lets the segment-local features select frames by note span.
291    let mut spec_frame_pos = Vec::new();
292    let mut fluxes = Vec::new();
293    let mut frame_rms = Vec::new();
294    let mut bass_energy = 0.0f64;
295    let mut total_energy = 0.0f64;
296    let mut prev_mag: Option<(Vec<f64>, f64)> = None;
297
298    let mut pos = 0;
299    while pos + FRAME <= n {
300        let mut buf: Vec<Complex<f64>> = x[pos..pos + FRAME]
301            .iter()
302            .zip(&hann)
303            .map(|(s, w)| Complex::new(s * w, 0.0))
304            .collect();
305        fft.process(&mut buf);
306        let mag: Vec<f64> = buf[..bins].iter().map(|c| c.norm()).collect();
307        let power: f64 = mag.iter().map(|m| m * m).sum();
308
309        frame_rms
310            .push((x[pos..pos + FRAME].iter().map(|s| s * s).sum::<f64>() / FRAME as f64).sqrt());
311
312        if power > 1e-12 {
313            spec_frame_pos.push(pos);
314            let msum: f64 = mag.iter().sum();
315            let centroid_hz = mag
316                .iter()
317                .enumerate()
318                .map(|(i, m)| i as f64 * bin_hz * m)
319                .sum::<f64>()
320                / msum;
321            centroids.push(log_axis(centroid_hz, nyquist));
322
323            let target = 0.85 * power;
324            let mut acc = 0.0;
325            let mut roll = bins - 1;
326            for (i, m) in mag.iter().enumerate() {
327                acc += m * m;
328                if acc >= target {
329                    roll = i;
330                    break;
331                }
332            }
333            rolloffs.push(log_axis(roll as f64 * bin_hz, nyquist));
334
335            // Flatness: geometric / arithmetic mean of the power spectrum.
336            let log_mean = mag.iter().map(|m| (m * m + 1e-20).ln()).sum::<f64>() / bins as f64;
337            let arith_mean = power / bins as f64;
338            flatnesses.push((log_mean.exp() / (arith_mean + 1e-20)).min(1.0));
339
340            bass_energy += mag[..bass_bin.min(bins)].iter().map(|m| m * m).sum::<f64>();
341            total_energy += power;
342
343            if let Some((prev, prev_msum)) = &prev_mag {
344                // Normalize by the *combined* frame energy so flux stays in
345                // ~[0, 1]: dividing by the current frame alone explodes when
346                // a loud frame decays into near-silence.
347                let flux: f64 = mag
348                    .iter()
349                    .zip(prev)
350                    .map(|(a, b)| {
351                        let d = a - b;
352                        d * d
353                    })
354                    .sum::<f64>()
355                    .sqrt()
356                    / (msum + prev_msum + 1e-12);
357                fluxes.push(flux);
358            }
359            prev_mag = Some((mag, msum));
360        } else {
361            // **A silent frame breaks the chain rather than being skipped
362            // over.** Flux is the change between *adjacent* frames; carrying
363            // `prev_mag` across a gap would compare two frames that are not
364            // neighbours and report the difference as if it happened in one
365            // hop. A phrase with a rest in it — and this one has four — would
366            // then score a spurious burst of movement at every re-entry, which
367            // is the opposite of what a rest is.
368            //
369            // Dropping the sample is the honest reading: across a gap there is
370            // no adjacent pair to measure, so there is nothing to say.
371            prev_mag = None;
372        }
373        pos += HOP;
374    }
375
376    let mean = |v: &[f64]| {
377        if v.is_empty() {
378            0.0
379        } else {
380            v.iter().sum::<f64>() / v.len() as f64
381        }
382    };
383    let std = |v: &[f64]| {
384        if v.len() < 2 {
385            0.0
386        } else {
387            let m = mean(v);
388            (v.iter().map(|x| (x - m) * (x - m)).sum::<f64>() / v.len() as f64).sqrt()
389        }
390    };
391
392    // --- segment-local roles (see phrase.rs for why each segment exists) ---
393    // Roles are found by *property*, not position: the held reference is the
394    // first note, the high note is the highest note at least half an octave
395    // above it, the chord note is the first with chord voices. A phrase
396    // missing a role yields the honest 0.0 ("no evidence") for its features.
397    let frames_in = |lo: usize, hi: usize| -> Vec<usize> {
398        spec_frame_pos
399            .iter()
400            .enumerate()
401            .filter(|(_, &p)| p >= lo && p + FRAME <= hi)
402            .map(|(i, _)| i)
403            .collect()
404    };
405    let span_rms = |lo: usize, hi: usize| -> f64 {
406        let seg = &x[lo.min(n)..hi.min(n)];
407        if seg.is_empty() {
408            0.0
409        } else {
410            (seg.iter().map(|s| s * s).sum::<f64>() / seg.len() as f64).sqrt()
411        }
412    };
413
414    let held = r.spans.first();
415    let held_frames = held.map_or(Vec::new(), |h| frames_in(h.on_start, h.on_end));
416
417    let held_centroid_std = if held_frames.len() >= 3 {
418        let vals: Vec<f64> = held_frames.iter().map(|&i| centroids[i]).collect();
419        std(&vals)
420    } else {
421        0.0
422    };
423
424    let high_ratio = held
425        .and_then(|h| {
426            r.spans
427                .iter()
428                .filter(|s| s.voct >= h.voct + 0.5)
429                .max_by(|a, b| a.voct.total_cmp(&b.voct))
430                .map(|s| {
431                    let hi = span_rms(s.on_start, s.on_end);
432                    let lo = span_rms(h.on_start, h.on_end);
433                    ((hi + 1e-4) / (lo + 1e-4)).ln()
434                })
435        })
436        .unwrap_or(0.0);
437
438    let chord_flatness_delta = r
439        .spans
440        .iter()
441        .find(|s| s.chord > 0)
442        .and_then(|chord| {
443            let held_flat: Vec<f64> = held_frames.iter().map(|&i| flatnesses[i]).collect();
444            let chord_flat: Vec<f64> = frames_in(chord.on_start, chord.on_end)
445                .iter()
446                .map(|&i| flatnesses[i])
447                .collect();
448            if held_flat.is_empty() || chord_flat.is_empty() {
449                None
450            } else {
451                Some(mean(&chord_flat) - mean(&held_flat))
452            }
453        })
454        .unwrap_or(0.0);
455
456    AudioFeatures {
457        centroid_mean: mean(&centroids),
458        centroid_std: std(&centroids),
459        rolloff_mean: mean(&rolloffs),
460        flatness_mean: mean(&flatnesses),
461        flux_mean: mean(&fluxes),
462        zcr_mean,
463        rms_mean: mean(&frame_rms),
464        rms_std: std(&frame_rms),
465        crest,
466        attack_s,
467        tail_ratio,
468        bass_fraction: if total_energy > 0.0 {
469            bass_energy / total_energy
470        } else {
471            0.0
472        },
473        held_centroid_std,
474        high_ratio,
475        chord_flatness_delta,
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482    use crate::render::RenderedPhrase;
483    use std::f64::consts::TAU;
484
485    const SR: f64 = 48_000.0;
486
487    /// A bare phrase around `samples` — no spans, one onset at the start.
488    /// The segment-local coordinates all return 0.0 without spans, which is
489    /// what these tests want: they are about two coordinates each.
490    fn phrase(samples: Vec<f64>) -> RenderedPhrase {
491        RenderedPhrase {
492            samples,
493            sample_rate: SR,
494            note_onsets: vec![0],
495            spans: vec![],
496        }
497    }
498
499    /// A sine with a phase offset, so no sample lands exactly on the centre.
500    /// A sample of exactly 0.0 has its sign decided by the last bit of the
501    /// computed mean, which is not a property either test is about.
502    fn sine(hz: f64, n: usize, amp: f64) -> Vec<f64> {
503        (0..n)
504            .map(|i| amp * (TAU * hz * i as f64 / SR + 0.3).sin())
505            .collect()
506    }
507
508    /// ZCR counts crossings of the signal's own centre, not of zero.
509    ///
510    /// The fixture is the case the coordinate got wrong: a tone riding a DC
511    /// offset large enough that it never reaches zero. The first assertion
512    /// pins that down as a property of the fixture rather than a claim in a
513    /// comment — this signal crosses zero *never* — and the second says the
514    /// coordinate must nonetheless read it as exactly as bright as the
515    /// centred tone it is a copy of, because it is the same oscillation.
516    #[test]
517    fn zcr_counts_crossings_of_the_signals_own_centre() {
518        let centred = sine(440.0, SR as usize, 0.5);
519        // Same oscillation, scaled and lifted clear of zero: ±0.2 about +0.3.
520        let riding: Vec<f64> = centred.iter().map(|s| s * 0.4 + 0.3).collect();
521
522        let raw_crossings = riding
523            .windows(2)
524            .filter(|w| (w[0] >= 0.0) != (w[1] >= 0.0))
525            .count();
526        assert_eq!(
527            raw_crossings, 0,
528            "fixture must never cross zero, or it does not exercise the bug"
529        );
530
531        let a = audio_features(&phrase(centred));
532        let b = audio_features(&phrase(riding));
533
534        // Before the DC removal this read `log_axis(0, nyquist)` — the floor of
535        // the axis, i.e. maximally dark — against a genuinely bright tone.
536        assert!(
537            (a.zcr_mean - b.zcr_mean).abs() < 1e-9,
538            "offset tone read {} against {} for the same oscillation",
539            b.zcr_mean,
540            a.zcr_mean
541        );
542        assert!(b.zcr_mean > log_axis(0.0, SR / 2.0) + 0.1);
543    }
544
545    /// Flux is the change between *adjacent* frames, so a rest breaks the
546    /// chain rather than being stepped over.
547    ///
548    /// Both fixtures are a burst, a rest, and a burst of the same tone; they
549    /// differ only in how loud the **second** burst is. That is the one edit
550    /// that isolates the bug, because flux is normalized by the combined frame
551    /// magnitude: scaling a burst scales every frame overlapping it by the
552    /// same factor, and a ratio of two scaled quantities is the ratio it was.
553    /// So every flux sample with both feet in one burst is invariant, and with
554    /// a rest longer than a frame no single frame straddles both bursts —
555    /// leaving exactly one comparison in the phrase that can see the amplitude
556    /// change. It is the comparison across the rest, and it is the one a rest
557    /// must not produce.
558    ///
559    /// Every span is a whole number of hops, which is what makes the fixture
560    /// bite. Frames advance by `HOP` from zero, so unaligned spans leave the
561    /// last frame before the rest holding a sliver of tone under the near-zero
562    /// edge of the Hann window; that frame's magnitude is negligible against
563    /// the one after the rest, the ratio goes to 1 whatever the amplitude, and
564    /// the bug hides. Aligned, the frames either side of the rest are each
565    /// half tone at full window weight, and the step between them is real.
566    ///
567    /// With the chain broken the two fixtures agree to the last bit. Carrying
568    /// `prev_mag` across instead scores the re-entry as a step from full scale
569    /// to a tenth of it, which is movement that did not happen in one hop.
570    #[test]
571    fn flux_does_not_step_across_a_rest() {
572        let burst = 8 * HOP;
573        // Four hops of rest: at least one frame is entirely silent, so the
574        // chain has somewhere to break, and no frame holds both bursts.
575        let rest = vec![0.0; 4 * HOP];
576        let build = |second_amp: f64| {
577            let mut s = sine(200.0, burst, 0.5);
578            s.extend_from_slice(&rest);
579            s.extend(sine(200.0, burst, second_amp));
580            audio_features(&phrase(s)).flux_mean
581        };
582
583        let level = build(0.5);
584        let quiet = build(0.05);
585
586        assert!(
587            (level - quiet).abs() < 1e-12,
588            "re-entering ten times quieter moved flux_mean {level} -> {quiet}, \
589             so a rest is still being stepped across"
590        );
591        // The fixture is only meaningful if there is flux to compare at all.
592        assert!(level > 0.0);
593    }
594}