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(¢roids),
458 centroid_std: std(¢roids),
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}