nooise 1.9.0

Ambient music generator for the terminal
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
//! The Kick voice: a pitch-glide FM body shared by all four kick
//! characters, plus their click, drive, and amp envelope.

use super::*;

pub(crate) struct KickEngine {
    pub(crate) sample_rate: f32,
    pub(crate) trigger: GridTrigger,
    pub(crate) voices: Vec<KickVoice>,
    pub(crate) rng: StdRng,
    pub(crate) telemetry: Arc<FluidTelemetry>,
}

impl KickEngine {
    pub(crate) fn new(sample_rate: f32, telemetry: Arc<FluidTelemetry>) -> Self {
        Self {
            sample_rate,
            trigger: GridTrigger::new(),
            voices: Vec::with_capacity(4),
            rng: StdRng::from_entropy(),
            telemetry,
        }
    }

    pub(crate) fn next(&mut self, c: &KickControls, timing: TimingContext) -> (f32, f32) {
        if self
            .trigger
            .pop_swung(timing, c.interval_beats, c.offset_beats, c.swing)
        {
            self.voices.push(KickVoice::new(
                kick_type_index(c.voice_type),
                c,
                self.sample_rate,
                &mut self.rng,
            ));
            self.telemetry.kick_pulse.fetch_add(1, Ordering::Relaxed);
        }

        let rng = &mut self.rng;
        mix_and_retain(&mut self.voices, |v| v.next(rng), KickVoice::is_done)
    }
}

/// Shared click transient + amplitude envelope + soft-attack + pan
/// machinery behind every `kick.type` voice: a single exponential amplitude
/// decay (also gates voice life via `is_done`), an optional short noise click
/// layered in at onset, an optional linear fade-in that rounds off the onset
/// transient, and a fixed per-voice
/// stereo pan drawn once at construction. Each variant supplies its own
/// pitch/oscillator body and filter around this; `shape` only covers the
/// parts identical across all four types. `shape` updates `amp` for the
/// *next* call after using today's value to build `s`, so moving it ahead of
/// a caller-applied filter stage never changes the sample actually returned
/// (the filter never reads `amp`).
///
/// Both softening parameters are per-variant and inert at their Sub values:
/// `attack_samples` 0 leaves the fade-in branch untaken, and `click_scale`
/// 1.0 is an exact f32 identity on `c.click`. Sub therefore stays
/// byte-identical to its pre-`kick.type` render (enforced by
/// `kick_type_zero_matches_legacy_sub_voice_exactly`).
pub(crate) struct KickVoiceCore {
    pub(crate) amp: f32,
    pub(crate) amp_decay: f32,
    pub(crate) click_remaining: u64,
    pub(crate) click_level: f32,
    pub(crate) attack_remaining: u64,
    pub(crate) attack_gain: f32,
    pub(crate) attack_inc: f32,
    pub(crate) pan_gains: (f32, f32),
}

impl KickVoiceCore {
    pub(crate) fn new(
        c: &KickControls,
        sample_rate: f32,
        rng: &mut StdRng,
        attack_ms: f32,
        click_scale: f32,
    ) -> Self {
        let amp_tau = (c.amp_decay_ms * 0.001 * sample_rate / 3.0).max(1.0);
        let attack_samples = (attack_ms * 0.001 * sample_rate).round().max(0.0) as u64;
        Self {
            amp: c.level,
            amp_decay: (-1.0 / amp_tau).exp(),
            click_remaining: (c.amp_decay_ms * 0.001 * sample_rate * 0.04).round() as u64,
            click_level: c.click * click_scale,
            attack_remaining: attack_samples,
            attack_gain: 0.0,
            attack_inc: if attack_samples == 0 {
                0.0
            } else {
                1.0 / attack_samples as f32
            },
            pan_gains: StereoPanner::gains(rng.gen_range(-0.15f32..0.15)),
        }
    }

    #[inline]
    pub(crate) fn shape<R: Rng>(&mut self, body: f32, rng: &mut R) -> f32 {
        let mut s = body * self.amp;
        if self.click_remaining > 0 {
            s += rng.gen_range(-1.0f32..1.0) * self.click_level * self.amp;
            self.click_remaining -= 1;
        }
        // Applied to the output sample, never to `amp`, so the decay envelope
        // math (and `is_done`) is identical with or without an attack ramp.
        if self.attack_remaining > 0 {
            s *= self.attack_gain;
            self.attack_gain = (self.attack_gain + self.attack_inc).min(1.0);
            self.attack_remaining -= 1;
        }
        self.amp *= self.amp_decay;
        s
    }

    pub(crate) fn is_done(&self) -> bool {
        self.amp < 0.0001
    }
}

/// `kick.type` selects the voice character used for every new kick hit.
/// Index 0 (`Sub`) is the legacy voice, unchanged and the default; switching
/// type never touches the shared trigger/scheduling path in
/// `KickEngine::next` above. Types 1-3 are all authored soft and textural for
/// ambient use: each takes a short onset fade-in and a scaled-down click via
/// `KickVoiceCore` so none of them reads as a drum-machine transient.
pub(crate) enum KickVoice {
    A(SubKickVoice),
    B(WarmKickVoice),
    C(WoodKickVoice),
    D(FeltKickVoice),
}

impl KickVoice {
    pub(crate) fn new(
        voice_type: usize,
        c: &KickControls,
        sample_rate: f32,
        rng: &mut StdRng,
    ) -> Self {
        match voice_type {
            0 => Self::A(SubKickVoice::new(c, sample_rate, rng)),
            1 => Self::B(WarmKickVoice::new(c, sample_rate, rng)),
            2 => Self::C(WoodKickVoice::new(c, sample_rate, rng)),
            _ => Self::D(FeltKickVoice::new(c, sample_rate, rng)),
        }
    }

    pub(crate) fn next<R: Rng>(&mut self, rng: &mut R) -> (f32, f32) {
        match self {
            Self::A(voice) => voice.next(rng),
            Self::B(voice) => voice.next(rng),
            Self::C(voice) => voice.next(rng),
            Self::D(voice) => voice.next(rng),
        }
    }

    pub(crate) fn is_done(&self) -> bool {
        match self {
            Self::A(voice) => voice.is_done(),
            Self::B(voice) => voice.is_done(),
            Self::C(voice) => voice.is_done(),
            Self::D(voice) => voice.is_done(),
        }
    }
}

/// Shared FM body behind every `kick.type` voice: an exponential pitch glide
/// from `start_freq` toward a per-type drop ratio, plus a sine modulator at a
/// per-type carrier ratio whose depth decays ~3x faster than the pitch, which
/// is what makes the onset read as a tight thud. Each variant supplies its own
/// constants and shapes the returned carrier phase into a waveform itself, so
/// carrier shape and any post-body filter stay per-type.
pub(crate) struct KickFmBody {
    pub(crate) phase: f32,
    pub(crate) mod_phase: f32,
    pub(crate) freq: f32,
    pub(crate) target_freq: f32,
    pub(crate) freq_glide: f32,
    pub(crate) mod_ratio: f32,
    pub(crate) fm_depth: f32,
    pub(crate) fm_depth_decay: f32,
    pub(crate) sample_rate: f32,
}

impl KickFmBody {
    pub(crate) fn new(
        c: &KickControls,
        sample_rate: f32,
        pitch_drop_ratio: f32,
        mod_ratio: f32,
        fm_depth: f32,
    ) -> Self {
        let tau = (c.pitch_decay_ms * 0.001 * sample_rate / 3.0).max(1.0);
        let fm_tau = (c.pitch_decay_ms * 0.001 * sample_rate / 9.0).max(1.0);
        Self {
            phase: 0.0,
            mod_phase: 0.0,
            freq: c.start_freq,
            target_freq: c.start_freq * pitch_drop_ratio,
            freq_glide: 1.0 / tau,
            mod_ratio,
            fm_depth,
            fm_depth_decay: (-1.0 / fm_tau).exp(),
            sample_rate,
        }
    }

    /// Advances the glide, the modulator, and the carrier by one sample and
    /// returns the carrier phase. FM can push that phase outside `[0, TAU)`, so
    /// a caller's waveform shaper must be well-defined for any value.
    #[inline]
    pub(crate) fn next_phase(&mut self) -> f32 {
        self.freq += (self.target_freq - self.freq) * self.freq_glide;

        let mod_freq = self.freq * self.mod_ratio;
        self.mod_phase += TAU * mod_freq / self.sample_rate;
        if self.mod_phase >= TAU {
            self.mod_phase -= TAU;
        }
        let fm = self.mod_phase.sin() * self.fm_depth * self.freq;
        self.fm_depth *= self.fm_depth_decay;

        self.phase += TAU * (self.freq + fm) / self.sample_rate;
        if self.phase >= TAU {
            self.phase -= TAU;
        }
        self.phase
    }
}

/// One-pole lowpass mapped exponentially from `kick.filter`, shared by every
/// type that ends in a lowpass (Sub, Warm, Felt). `bias` shifts the same
/// mapping darker or brighter per type.
pub(crate) struct KickLowPass {
    pub(crate) state: f32,
    pub(crate) coeff: f32,
}

impl KickLowPass {
    pub(crate) fn new(filter: f32, bias: f32) -> Self {
        Self {
            state: 0.0,
            coeff: 10_f32.powf(filter * 3.0 + bias).clamp(0.01, 0.99),
        }
    }

    #[inline]
    pub(crate) fn process(&mut self, s: f32) -> f32 {
        self.state += self.coeff * (s - self.state);
        self.state
    }
}

/// Pitch drop: the body settles at 0.28x its starting frequency.
const KICK_SUB_PITCH_DROP_RATIO: f32 = 0.28;
/// Modulator-to-carrier ratio: 2x puts sidebands on the harmonic series, which
/// is what keeps this voice reading as one fused low body.
const KICK_SUB_FM_MOD_RATIO: f32 = 2.0;
/// FM depth, the deepest of the four types: this is the hard transient edge the
/// three ambient types deliberately back away from.
const KICK_SUB_FM_DEPTH: f32 = 3.5;
/// Lowpass mapping bias; the reference every other type's bias is stated
/// relative to.
const KICK_SUB_FILTER_BIAS: f32 = -2.5;

/// Type 0 (default): the original kick voice, byte-for-byte unchanged. A
/// sine carrier phase-modulated by a 2x-ratio sine modulator with decaying
/// depth (a tight FM thud), an exponential pitch glide from `start_freq` down
/// to `start_freq * 0.28`, an onset noise click, and a one-pole lowpass mapped
/// from `kick.filter`. Drive runs later in the shared layer module chain.
pub(crate) struct SubKickVoice {
    pub(crate) core: KickVoiceCore,
    pub(crate) body: KickFmBody,
    pub(crate) lowpass: KickLowPass,
}

impl SubKickVoice {
    pub(crate) fn new(c: &KickControls, sample_rate: f32, rng: &mut StdRng) -> Self {
        Self {
            core: KickVoiceCore::new(c, sample_rate, rng, 0.0, 1.0),
            body: KickFmBody::new(
                c,
                sample_rate,
                KICK_SUB_PITCH_DROP_RATIO,
                KICK_SUB_FM_MOD_RATIO,
                KICK_SUB_FM_DEPTH,
            ),
            lowpass: KickLowPass::new(c.filter, KICK_SUB_FILTER_BIAS),
        }
    }

    pub(crate) fn next<R: Rng>(&mut self, rng: &mut R) -> (f32, f32) {
        if self.core.is_done() {
            return (0.0, 0.0);
        }

        let body = self.body.next_phase().sin();
        let s = self.lowpass.process(self.core.shape(body, rng));

        (s * self.core.pan_gains.0, s * self.core.pan_gains.1)
    }

    pub(crate) fn is_done(&self) -> bool {
        self.core.is_done()
    }
}

/// Shallower pitch-drop ratio than Sub's fixed 0.28x, so the Warm voice's
/// body settles a little above Sub without reading as a second sub layer.
const KICK_WARM_PITCH_DROP_RATIO: f32 = 0.42;
/// Modulator-to-carrier ratio. Below Sub's fixed 2x: a 1.5 ratio places
/// sidebands at non-harmonic-series intervals that read hollow and woody
/// rather than bright. Ratios at or above 3x produce the metallic clang this
/// voice deliberately avoids.
const KICK_WARM_FM_MOD_RATIO: f32 = 1.5;
/// FM depth, well below Sub's fixed 3.5, so the modulator rounds the body out
/// instead of adding a hard transient edge.
const KICK_WARM_FM_DEPTH: f32 = 1.2;
/// Lowpass mapping bias: nudged up from Sub's `-2.5` so this voice's slightly
/// higher body isn't over-attenuated, but kept most of the way back toward
/// Sub's so it stays dark rather than bright.
const KICK_WARM_FILTER_BIAS: f32 = -2.35;
/// Linear onset fade-in. Rounds off the transient snap so the hit reads as a
/// swell into a body rather than a drum-machine attack.
const KICK_WARM_ATTACK_MS: f32 = 6.0;
/// The broadband onset noise burst is the single most aggressive-sounding
/// element of a kick; scaled well down from the user's `kick.click`.
const KICK_WARM_CLICK_SCALE: f32 = 0.45;
/// Output trim: matches Sub's perceived loudness at the same `kick.level`.
const KICK_WARM_OUTPUT_GAIN: f32 = 0.9;

/// Type 1: a warm, round FM body. Same FM-thud/pitch-glide approach as Sub,
/// but with a shallow FM depth at a hollow, woody modulator ratio, a slightly
/// shallower pitch drop, a soft attack ramp, and a scaled-down click. Shares
/// Sub's click/one-pole-lowpass/pan machinery via `KickVoiceCore`.
pub(crate) struct WarmKickVoice {
    pub(crate) core: KickVoiceCore,
    pub(crate) body: KickFmBody,
    pub(crate) lowpass: KickLowPass,
}

impl WarmKickVoice {
    pub(crate) fn new(c: &KickControls, sample_rate: f32, rng: &mut StdRng) -> Self {
        Self {
            core: KickVoiceCore::new(
                c,
                sample_rate,
                rng,
                KICK_WARM_ATTACK_MS,
                KICK_WARM_CLICK_SCALE,
            ),
            body: KickFmBody::new(
                c,
                sample_rate,
                KICK_WARM_PITCH_DROP_RATIO,
                KICK_WARM_FM_MOD_RATIO,
                KICK_WARM_FM_DEPTH,
            ),
            lowpass: KickLowPass::new(c.filter, KICK_WARM_FILTER_BIAS),
        }
    }

    pub(crate) fn next<R: Rng>(&mut self, rng: &mut R) -> (f32, f32) {
        if self.core.is_done() {
            return (0.0, 0.0);
        }

        let body = self.body.next_phase().sin();
        let s = self
            .lowpass
            .process(self.core.shape(body, rng) * KICK_WARM_OUTPUT_GAIN);

        (s * self.core.pan_gains.0, s * self.core.pan_gains.1)
    }

    pub(crate) fn is_done(&self) -> bool {
        self.core.is_done()
    }
}

/// `kick.filter` maps to the Wood bandpass's center frequency across this
/// range instead of a lowpass cutoff — an exponential (perceptually even)
/// sweep across the low-mid range where a struck wooden body resonates.
/// Deliberately narrow and low: higher centers read as a thin, hollow tom.
const KICK_WOOD_CENTER_MIN_HZ: f32 = 110.0;
const KICK_WOOD_CENTER_MAX_HZ: f32 = 400.0;
/// SVF damping factor (Chamberlin topology): lower = more resonant. Set high
/// enough that the band colors the body without the long "boingy" ring a
/// lightly-damped SVF produces.
const KICK_WOOD_DAMP: f32 = 0.9;
/// Pitch-drop ratio, slightly shallower than Sub's 0.28x so the resonant body
/// has a clearer starting pitch to color.
const KICK_WOOD_PITCH_DROP_RATIO: f32 = 0.35;
/// Bandpass/dry blend. The bandpass alone throws away the carrier's low end
/// and reads thin; blending the unfiltered dry signal back in keeps the
/// weight underneath the wooden coloration.
const KICK_WOOD_BANDPASS_MIX: f32 = 0.6;
/// Linear onset fade-in, rounding off the transient snap.
const KICK_WOOD_ATTACK_MS: f32 = 5.0;
/// Scales the user's `kick.click` down; the broadband burst fights the soft
/// wooden body.
const KICK_WOOD_CLICK_SCALE: f32 = 0.35;
/// Output trim: the heavily-damped bandpass has no resonant peak to make up
/// for and sheds most of the carrier's energy, so even blended against the
/// dry signal this needs a substantial boost to sit at Sub's perceived
/// loudness.
const KICK_WOOD_OUTPUT_GAIN: f32 = 1.7;

/// Type 2: a soft wooden body. Runs the dry voice signal through a
/// hand-rolled, heavily-damped 2-pole bandpass (Chamberlin state-variable
/// filter) centered in the 110-400Hz range via `kick.filter`, then blends
/// that band back against the dry signal so the struck-wood coloration sits
/// on top of the kick's own low end rather than replacing it. Keeps the same
/// trigger/pitch-envelope/click/pan structure as Sub.
pub(crate) struct WoodKickVoice {
    pub(crate) core: KickVoiceCore,
    pub(crate) body: KickFmBody,
    pub(crate) svf_low: f32,
    pub(crate) svf_band: f32,
    pub(crate) svf_f: f32,
}

impl WoodKickVoice {
    pub(crate) fn new(c: &KickControls, sample_rate: f32, rng: &mut StdRng) -> Self {
        let filter = c.filter.clamp(0.0, 1.0);
        let center_hz = KICK_WOOD_CENTER_MIN_HZ
            * (KICK_WOOD_CENTER_MAX_HZ / KICK_WOOD_CENTER_MIN_HZ).powf(filter);
        // Chamberlin SVF frequency coefficient; clamped well below the
        // stability limit (2.0) since `center_hz` can reach 400Hz even at low
        // sample rates used in tests.
        let svf_f = (2.0 * (std::f32::consts::PI * center_hz / sample_rate).sin()).clamp(0.0, 1.9);
        Self {
            core: KickVoiceCore::new(
                c,
                sample_rate,
                rng,
                KICK_WOOD_ATTACK_MS,
                KICK_WOOD_CLICK_SCALE,
            ),
            body: KickFmBody::new(
                c,
                sample_rate,
                KICK_WOOD_PITCH_DROP_RATIO,
                KICK_SUB_FM_MOD_RATIO,
                KICK_SUB_FM_DEPTH,
            ),
            svf_low: 0.0,
            svf_band: 0.0,
            svf_f,
        }
    }

    pub(crate) fn next<R: Rng>(&mut self, rng: &mut R) -> (f32, f32) {
        if self.core.is_done() {
            return (0.0, 0.0);
        }

        let body = self.body.next_phase().sin();
        let dry = self.core.shape(body, rng);

        // Chamberlin state-variable filter, bandpass output: two running
        // integrators (`svf_low`, `svf_band`) plus a feedback resonance term
        // gated by `KICK_WOOD_DAMP`.
        let high = dry - self.svf_low - KICK_WOOD_DAMP * self.svf_band;
        self.svf_band += self.svf_f * high;
        self.svf_low += self.svf_f * self.svf_band;
        let s = (self.svf_band * KICK_WOOD_BANDPASS_MIX + dry * (1.0 - KICK_WOOD_BANDPASS_MIX))
            * KICK_WOOD_OUTPUT_GAIN;

        (s * self.core.pan_gains.0, s * self.core.pan_gains.1)
    }

    pub(crate) fn is_done(&self) -> bool {
        self.core.is_done()
    }
}

/// Pitch-drop ratio, matching Sub's default so only the carrier waveform and
/// filter darkness change for this type.
const KICK_FELT_PITCH_DROP_RATIO: f32 = 0.28;
/// FM depth, well below Sub's fixed 3.5: the triangle carrier already brings
/// its own odd harmonics, so Sub's depth would push this into buzz.
const KICK_FELT_FM_DEPTH: f32 = 1.8;
/// Lowpass mapping bias: below Sub's `-2.5`, so the same `kick.filter` range
/// lands darker and duller — the felt-beater muffling.
const KICK_FELT_FILTER_BIAS: f32 = -2.9;
/// Linear onset fade-in, the longest of the three soft types: a felt mallet
/// compresses on contact rather than striking instantly.
const KICK_FELT_ATTACK_MS: f32 = 8.0;
/// Scales the user's `kick.click` furthest down; a felt beater has almost no
/// broadband contact noise.
const KICK_FELT_CLICK_SCALE: f32 = 0.25;
/// Output trim: matches Sub's perceived loudness at the same `kick.level`.
const KICK_FELT_OUTPUT_GAIN: f32 = 0.95;

/// Type 3: a soft mallet/felt character. Swaps Sub's sine carrier for a naive
/// (non-band-limited, consistent with this codebase's additive-approximation
/// approach elsewhere — see `bass.rs`'s Saw voice) triangle: odd harmonics
/// only, falling off as 1/n², so it thickens the body without adding edge.
/// Uses a darker lowpass mapping than Sub and keeps the same
/// pitch-envelope/click/pan structure. Optional Drive follows all kick types
/// through the shared layer chain.
pub(crate) struct FeltKickVoice {
    pub(crate) core: KickVoiceCore,
    pub(crate) body: KickFmBody,
    pub(crate) lowpass: KickLowPass,
}

impl FeltKickVoice {
    pub(crate) fn new(c: &KickControls, sample_rate: f32, rng: &mut StdRng) -> Self {
        Self {
            core: KickVoiceCore::new(
                c,
                sample_rate,
                rng,
                KICK_FELT_ATTACK_MS,
                KICK_FELT_CLICK_SCALE,
            ),
            body: KickFmBody::new(
                c,
                sample_rate,
                KICK_FELT_PITCH_DROP_RATIO,
                KICK_SUB_FM_MOD_RATIO,
                KICK_FELT_FM_DEPTH,
            ),
            lowpass: KickLowPass::new(c.filter, KICK_FELT_FILTER_BIAS),
        }
    }

    pub(crate) fn next<R: Rng>(&mut self, rng: &mut R) -> (f32, f32) {
        if self.core.is_done() {
            return (0.0, 0.0);
        }

        // Naive bipolar triangle from the (FM-modulated) phase. Written to be
        // well-defined for any phase value, since FM can push the carrier phase
        // outside the wrapped [0, TAU) range.
        let t = self.body.next_phase() / TAU;
        let body = 4.0 * (t - (t + 0.5).floor()).abs() - 1.0;
        let s = self
            .lowpass
            .process(self.core.shape(body, rng) * KICK_FELT_OUTPUT_GAIN);

        (s * self.core.pan_gains.0, s * self.core.pan_gains.1)
    }

    pub(crate) fn is_done(&self) -> bool {
        self.core.is_done()
    }
}

#[cfg(test)]
mod tests {
    use std::sync::Arc;

    use super::*;

    #[test]
    fn swing_delays_the_kicks_odd_subdivision() {
        let telemetry = Arc::new(FluidTelemetry::default());
        let mut kick = KickEngine::new(48_000.0, Arc::clone(&telemetry));
        let controls = KickControls {
            level: 1.0,
            interval_beats: 0.5,
            swing: 1.0,
            ..KickControls::default()
        };

        kick.next(&controls, TimingContext::new(48_000.0, 120.0, 0.0));
        kick.next(&controls, TimingContext::new(48_000.0, 120.0, 0.5));
        assert_eq!(telemetry.kick_pulse.load(Ordering::Relaxed), 1);

        kick.next(&controls, TimingContext::new(48_000.0, 120.0, 0.75));
        assert_eq!(telemetry.kick_pulse.load(Ordering::Relaxed), 2);
    }
}