Skip to main content

tono_core/adaptive/
mod.rs

1//! adaptive — reactive music for games.
2//!
3//! Game music that responds to play: stack **stems** that fade in as the action
4//! heats up, drive them with a single `intensity` knob, and fire one-shot
5//! **stingers** on events. [`AdaptiveMusic`] is an [`AudioSource`], so it drops
6//! onto the same real-time path as everything else and stays deterministic.
7//!
8//! ```
9//! use tono_core::adaptive::AdaptiveMusic;
10//! use tono_core::dsl::SoundDoc;
11//!
12//! # fn demo(drums: &SoundDoc, lead: &SoundDoc) {
13//! let mut music = AdaptiveMusic::new(48_000);
14//! music.add_layer_doc(drums, 0.0); // always playing (rendered at 48 kHz)
15//! music.add_layer_doc(lead, 0.6);  // joins when it gets intense
16//! music.set_intensity(0.8);        // combat! the lead swells in
17//! # }
18//! ```
19
20mod layers;
21mod schedule;
22mod sections;
23
24use crate::dsl::SoundDoc;
25use crate::render;
26use crate::runtime::AudioSource;
27pub use schedule::Quantize;
28
29/// A stereo buffer played on a seamless loop — a music stem.
30pub struct LoopBuffer {
31    left: Vec<f32>,
32    right: Vec<f32>,
33    pos: usize,
34}
35
36impl LoopBuffer {
37    /// Render `doc` once **at the doc's own `sample_rate`** and loop it. If
38    /// the buffer will play through an [`AdaptiveMusic`]/engine running at a
39    /// different rate, use [`from_doc_at`](Self::from_doc_at) — a rate
40    /// mismatch plays back silently detuned. (For a click-free loop, author
41    /// the doc with a `loop` playback so its tail meets its head, or trim it
42    /// with [`from_stereo`](Self::from_stereo).)
43    pub fn from_doc(doc: &SoundDoc) -> Self {
44        let (left, right) = render_stereo_pair(doc);
45        LoopBuffer::from_stereo(left, right)
46    }
47
48    /// [`from_doc`](Self::from_doc) rendered at an explicit `sample_rate` —
49    /// the safe constructor when the playback rate is the engine's, not the
50    /// doc's.
51    pub fn from_doc_at(doc: &SoundDoc, sample_rate: u32) -> Self {
52        LoopBuffer::from_doc(&doc_at(doc, sample_rate))
53    }
54
55    /// Loop pre-rendered stereo samples — trim them to a musical bar length for a
56    /// seamless loop. Channels of unequal length are truncated to the shorter
57    /// one: indexing past the short channel would panic on the audio thread.
58    pub fn from_stereo(mut left: Vec<f32>, mut right: Vec<f32>) -> Self {
59        let n = left.len().min(right.len());
60        left.truncate(n);
61        right.truncate(n);
62        LoopBuffer {
63            left,
64            right,
65            pos: 0,
66        }
67    }
68
69    /// Render `doc` and loop it at **exactly** `frames` samples — truncated or
70    /// zero-padded to that length. Use it to force a set of stems onto one shared
71    /// loop grid so their layered cross-fades stay sample-aligned (never drift
72    /// phase). See [`AdaptiveMusic::add_stem_set`].
73    pub fn from_doc_len(doc: &SoundDoc, frames: usize) -> Self {
74        let (mut left, mut right) = render_stereo_pair(doc);
75        left.resize(frames, 0.0);
76        right.resize(frames, 0.0);
77        LoopBuffer {
78            left,
79            right,
80            pos: 0,
81        }
82    }
83
84    /// [`from_doc_len`](Self::from_doc_len) rendered at an explicit
85    /// `sample_rate` (see [`from_doc_at`](Self::from_doc_at)).
86    pub fn from_doc_len_at(doc: &SoundDoc, frames: usize, sample_rate: u32) -> Self {
87        LoopBuffer::from_doc_len(&doc_at(doc, sample_rate), frames)
88    }
89}
90
91/// The doc re-stamped to render at `sample_rate` — how every doc-taking
92/// [`AdaptiveMusic`] entry point pins rendering to the engine's rate (the same
93/// override `Engine::new_player` applies), so a doc left at the default
94/// 44 100 Hz can't play detuned through a 48 kHz engine.
95fn doc_at(doc: &SoundDoc, sample_rate: u32) -> SoundDoc {
96    let mut doc = doc.clone();
97    doc.sample_rate = sample_rate;
98    doc
99}
100
101impl AudioSource for LoopBuffer {
102    fn fill(&mut self, out: &mut [f32]) -> usize {
103        let frames = out.len() / 2;
104        let n = self.left.len();
105        if n == 0 {
106            out.fill(0.0);
107            return frames;
108        }
109        for f in 0..frames {
110            out[f * 2] = self.left[self.pos];
111            out[f * 2 + 1] = self.right[self.pos];
112            // Wrap eagerly so `pos` stays in `0..n` — no modulo on the hot
113            // loop, and no usize overflow on very long sessions.
114            self.pos += 1;
115            if self.pos == n {
116                self.pos = 0;
117            }
118        }
119        frames
120    }
121
122    fn reset(&mut self) {
123        self.pos = 0;
124    }
125}
126
127struct Layer {
128    source: Box<dyn AudioSource + Send>,
129    /// Intensity at/above which this stem plays at full volume.
130    fade_in_at: f32,
131    gain: f32,
132    target: f32,
133}
134
135struct Stinger {
136    left: Vec<f32>,
137    right: Vec<f32>,
138    pos: usize,
139}
140
141impl Stinger {
142    /// Channels of unequal length are truncated to the shorter one, so `fill`
143    /// can index both directly and the spent-stinger cull is exact.
144    fn new(mut left: Vec<f32>, mut right: Vec<f32>) -> Self {
145        let n = left.len().min(right.len());
146        left.truncate(n);
147        right.truncate(n);
148        Stinger {
149            left,
150            right,
151            pos: 0,
152        }
153    }
154}
155
156/// Duck attack time — a couple of ms is fast enough to feel instant without
157/// the click a gain step would make.
158const DUCK_ATTACK_SECS: f32 = 0.002;
159/// Snap threshold (~-80 dB from target) so the exponential ramps land exactly
160/// instead of asymptoting forever.
161const DUCK_SNAP_EPSILON: f32 = 1e-4;
162
163/// Render a doc once and return its stereo pair — mono is **duplicated**, the
164/// doc's Haas/Wide `stereo` treatment is NOT applied (unlike
165/// `player::render_stereo`, which stereoizes; unifying the two would change
166/// adaptive playback bytes for treated docs, so the divergence is deliberate).
167/// Public so hosts can render off the audio thread and hand the buffers to
168/// [`AdaptiveMusic::stinger_stereo`].
169pub fn render_stereo_pair(doc: &SoundDoc) -> (Vec<f32>, Vec<f32>) {
170    let p = render::render_product(doc);
171    p.stereo.unwrap_or_else(|| (p.mono.clone(), p.mono))
172}
173
174/// A deferred change fired when the clock reaches `fire_at` frames.
175struct Scheduled {
176    fire_at: u64,
177    action: Action,
178}
179
180enum Action {
181    SetIntensity(f32),
182    Stinger(Stinger),
183    Transition { to: usize },
184}
185
186/// A horizontal section: one looping bed swapped in on a beat boundary.
187struct Section {
188    name: String,
189    buffer: LoopBuffer,
190}
191
192/// A short declick cross-fade from the current section to `to`.
193struct SectionFade {
194    to: usize,
195    /// The gain the fade started from (0.0 entering; the mid-fade value when
196    /// a reversal flips the step).
197    from_gain: f32,
198    /// Per-frame gain increment; negative when a cancelled transition ramps
199    /// the fade back down.
200    step: f32,
201    /// Frames faded so far. The gain is computed from this absolute count
202    /// (`from_gain + frames_done * step`), never accumulated per span, so the
203    /// ramp is bit-exact for any host block size.
204    frames_done: usize,
205}
206
207/// A layered, intensity-driven music bed with one-shot stingers.
208///
209/// Horizontal re-sequencing — sections that swap on the bar, like a film
210/// score reacting to play:
211///
212/// ```
213/// use tono_core::adaptive::{AdaptiveMusic, Quantize};
214/// use tono_core::dsl::{Node, SoundDoc};
215///
216/// let explore = SoundDoc::new("explore", Node::Sine { freq: 220.0.into() });
217/// let battle = SoundDoc::new("battle", Node::Sine { freq: 261.6.into() });
218///
219/// let mut music = AdaptiveMusic::new(48_000);
220/// music.set_tempo(120.0, 4);                    // beats drive the boundaries
221/// music.add_section("explore", &explore);       // starts playing
222/// let battle = music.add_section("battle", &battle);
223///
224/// music.transition_to(battle, Quantize::Bar);   // combat! swaps on the next bar
225/// music.set_intensity_at(0.9, Quantize::Beat);  // stems swell on the beat
226/// ```
227pub struct AdaptiveMusic {
228    layers: Vec<Layer>,
229    stingers: Vec<Stinger>,
230    intensity: f32,
231    /// Per-sample one-pole coefficient for the layer cross-fades.
232    fade_coeff: f32,
233    scratch: Vec<f32>,
234    sample_rate: u32,
235    /// Frozen: `fill` outputs silence and holds the position clock + layers.
236    paused: bool,
237    /// Frames rendered while playing since construction or the last `reset` —
238    /// the musical clock a beat-locked game derives its beat position from.
239    position: u64,
240    /// Current master duck multiplier (1.0 = no duck), moving per sample.
241    duck_gain: f32,
242    /// The floor the current duck is attacking toward; snaps back to 1.0 once
243    /// reached so the release phase takes over.
244    duck_target: f32,
245    /// Per-sample one-pole coefficient for the (fast) duck attack.
246    duck_attack: f32,
247    /// Per-sample one-pole coefficient for the duck recovery.
248    duck_coeff: f32,
249    /// Tempo for beat/bar quantization (`0` = unset → everything is immediate).
250    bpm: f32,
251    /// Beats per bar (time-signature numerator) for bar quantization.
252    beats_per_bar: u32,
253    /// Deferred changes fired when the clock reaches their frame.
254    pending: Vec<Scheduled>,
255    /// Horizontal sections; the current one plays as the base bed.
256    sections: Vec<Section>,
257    /// Index of the section currently sounding (`None` until one is added).
258    current_section: Option<usize>,
259    /// An in-flight section cross-fade.
260    section_fade: Option<SectionFade>,
261    /// Per-sample increment for a section cross-fade (~60 ms declick swap).
262    section_step: f32,
263}
264
265impl AdaptiveMusic {
266    /// A new, empty bed. Layer cross-fades take ~1.5 s.
267    pub fn new(sample_rate: u32) -> Self {
268        let fade_coeff = 1.0 - (-1.0 / (1.5 * sample_rate as f32)).exp();
269        AdaptiveMusic {
270            layers: Vec::new(),
271            stingers: Vec::new(),
272            intensity: 0.0,
273            fade_coeff,
274            scratch: Vec::new(),
275            sample_rate,
276            paused: false,
277            position: 0,
278            duck_gain: 1.0,
279            duck_target: 1.0,
280            duck_attack: 0.0,
281            duck_coeff: 0.0,
282            bpm: 0.0,
283            beats_per_bar: 4,
284            pending: Vec::new(),
285            sections: Vec::new(),
286            current_section: None,
287            section_fade: None,
288            // A ~60 ms declick cross-fade — the swap is already beat-aligned, so
289            // this only smooths the seam, it doesn't blur the downbeat.
290            section_step: 1.0 / (0.06 * sample_rate as f32),
291        }
292    }
293    // ---- Transport ----
294
295    /// Freeze the bed: [`fill`](AudioSource::fill) outputs silence and both the
296    /// position clock and every layer hold their place, so [`resume`](Self::resume)
297    /// continues seamlessly.
298    pub fn pause(&mut self) {
299        self.paused = true;
300    }
301
302    /// Resume from a [`pause`](Self::pause).
303    pub fn resume(&mut self) {
304        self.paused = false;
305    }
306
307    /// Whether the bed is currently paused.
308    pub fn is_paused(&self) -> bool {
309        self.paused
310    }
311
312    /// Restart the bed from the top: the position clock returns to 0, every
313    /// layer rewinds to its loop head, ringing stingers are cleared, any pending
314    /// quantized schedules are dropped, and an in-flight duck resets to unity.
315    /// The intensity and layer target gains are left as they are — call this to
316    /// line the music up with a beat clock at sample 0.
317    pub fn reset(&mut self) {
318        self.position = 0;
319        self.stingers.clear();
320        self.pending.clear();
321        self.section_fade = None;
322        // Any in-flight duck is dropped — a restart starts at unity.
323        self.duck_gain = 1.0;
324        self.duck_target = 1.0;
325        for l in &mut self.layers {
326            l.source.reset();
327        }
328        for s in &mut self.sections {
329            s.buffer.reset();
330        }
331    }
332}
333
334impl AudioSource for AdaptiveMusic {
335    fn fill(&mut self, out: &mut [f32]) -> usize {
336        let frames = out.len() / 2;
337        out.fill(0.0);
338        // Paused: silence, and hold the clock + every layer where they are.
339        if self.paused {
340            return frames;
341        }
342        // Apply each scheduled action at its exact frame — render sub-spans
343        // around boundaries, so the output is identical for any host block
344        // size (an action must not fire early just because the block is big).
345        let mut done = 0usize;
346        while done < frames {
347            self.fire_due();
348            let next = self
349                .pending
350                .iter()
351                .map(|s| s.fire_at)
352                .filter(|&t| t > self.position)
353                .min();
354            let span = match next {
355                Some(t) => ((t - self.position) as usize).min(frames - done),
356                None => frames - done,
357            };
358            self.render_span(&mut out[done * 2..(done + span) * 2], span);
359            done += span;
360        }
361        frames
362    }
363
364    /// Restart the bed through the trait as well — previously only the
365    /// inherent [`reset`](AdaptiveMusic::reset) ran, so via
366    /// `&mut dyn AudioSource` (a host transport, a mixer) reset was a silent
367    /// no-op that left the clock, stingers, and schedules running.
368    fn reset(&mut self) {
369        AdaptiveMusic::reset(self);
370    }
371}
372
373impl AdaptiveMusic {
374    /// Render one uninterrupted span of `frames` frames into `out` (sections,
375    /// layers, stingers, duck) and advance the clock. [`AudioSource::fill`]
376    /// splits the block at schedule boundaries and calls this per sub-span.
377    fn render_span(&mut self, out: &mut [f32], frames: usize) {
378        if self.scratch.len() < frames * 2 {
379            self.scratch.resize(frames * 2, 0.0);
380        }
381        let coeff = self.fade_coeff;
382        let scratch = &mut self.scratch[..frames * 2];
383
384        // Horizontal section (the base bed), optionally cross-fading to a new one.
385        // The fade's step can be negative: a cancelled transition ramps the same
386        // fade back down to 0 (a click-free reversal) instead of completing.
387        let fade = self
388            .section_fade
389            .as_ref()
390            .map(|f| (f.to, f.from_gain, f.step, f.frames_done));
391        if let Some((to, start, step, done)) = fade {
392            let g = |f: usize| (start + (done + f) as f32 * step).clamp(0.0, 1.0);
393            if let Some(cur) = self.current_section {
394                self.sections[cur].buffer.fill(scratch);
395                for f in 0..frames {
396                    let g = g(f);
397                    out[f * 2] += scratch[f * 2] * (1.0 - g);
398                    out[f * 2 + 1] += scratch[f * 2 + 1] * (1.0 - g);
399                }
400            }
401            self.sections[to].buffer.fill(scratch);
402            for f in 0..frames {
403                let g = g(f);
404                out[f * 2] += scratch[f * 2] * g;
405                out[f * 2 + 1] += scratch[f * 2 + 1] * g;
406            }
407            let end = g(frames);
408            if end >= 1.0 {
409                self.current_section = Some(to);
410                self.section_fade = None;
411            } else if end <= 0.0 {
412                // A reversed fade settled back on the current section.
413                self.section_fade = None;
414            } else if let Some(fd) = self.section_fade.as_mut() {
415                fd.frames_done = done + frames;
416            }
417        } else if let Some(cur) = self.current_section {
418            self.sections[cur].buffer.fill(scratch);
419            for f in 0..frames {
420                out[f * 2] += scratch[f * 2];
421                out[f * 2 + 1] += scratch[f * 2 + 1];
422            }
423        }
424
425        for layer in &mut self.layers {
426            layer.source.fill(scratch);
427            for f in 0..frames {
428                layer.gain += (layer.target - layer.gain) * coeff;
429                // Snap at the fade's end (like the duck below) so a faded-out
430                // layer reaches exactly 0 — the one-pole otherwise asymptotes
431                // forever, rendering inaudible content at full CPU cost.
432                if (layer.target - layer.gain).abs() < DUCK_SNAP_EPSILON {
433                    layer.gain = layer.target;
434                }
435                out[f * 2] += scratch[f * 2] * layer.gain;
436                out[f * 2 + 1] += scratch[f * 2 + 1] * layer.gain;
437            }
438        }
439        for st in &mut self.stingers {
440            // Channels are equal length by construction (`Stinger::new`), so
441            // direct indexing is safe and the cull below is exact.
442            let n = (st.left.len() - st.pos).min(frames);
443            for f in 0..n {
444                out[f * 2] += st.left[st.pos];
445                out[f * 2 + 1] += st.right[st.pos];
446                st.pos += 1;
447            }
448        }
449        self.stingers.retain(|s| s.pos < s.left.len());
450        // Master duck: attack toward the floor, then release to unity. Skip
451        // entirely when idle at unity so the common case pays nothing.
452        if self.duck_gain < 1.0 || self.duck_target < 1.0 {
453            for f in 0..frames {
454                if self.duck_gain > self.duck_target {
455                    // Attack: ramp down to the floor over a few ms (no click).
456                    self.duck_gain += (self.duck_target - self.duck_gain) * self.duck_attack;
457                    if self.duck_gain <= self.duck_target + DUCK_SNAP_EPSILON {
458                        self.duck_gain = self.duck_target;
459                        // Floor reached — release from here on.
460                        self.duck_target = 1.0;
461                    }
462                } else {
463                    // Release: recover toward unity, snapping so it doesn't
464                    // asymptote below 1.0 forever (a permanent tiny attenuation).
465                    self.duck_gain += (1.0 - self.duck_gain) * self.duck_coeff;
466                    if self.duck_gain >= 1.0 - DUCK_SNAP_EPSILON {
467                        self.duck_gain = 1.0;
468                    }
469                }
470                out[f * 2] *= self.duck_gain;
471                out[f * 2 + 1] *= self.duck_gain;
472            }
473        }
474        self.position += frames as u64;
475    }
476}
477#[cfg(test)]
478mod tests {
479    use super::*;
480
481    fn doc(json: &str) -> SoundDoc {
482        serde_json::from_str(json).unwrap()
483    }
484    fn peak(s: &[f32]) -> f32 {
485        s.iter().fold(0.0f32, |m, &x| m.max(x.abs()))
486    }
487
488    #[test]
489    fn layers_fade_with_intensity() {
490        let base = doc(r#"{ "name":"b", "duration":0.2, "root": { "type":"sine", "freq":220 } }"#);
491        let hi = doc(r#"{ "name":"h", "duration":0.2, "root": { "type":"sine", "freq":880 } }"#);
492        let mut music = AdaptiveMusic::new(48_000);
493        music.add_layer(LoopBuffer::from_doc(&base), 0.0); // always on
494        music.add_layer(LoopBuffer::from_doc(&hi), 0.5); // fades in at 0.5
495
496        let mut out = vec![0.0f32; 512 * 2];
497        music.fill(&mut out);
498        assert!(peak(&out) > 0.0, "base layer sounds");
499        assert_eq!(
500            music.layer_gain(1),
501            Some(0.0),
502            "hi layer silent at intensity 0"
503        );
504
505        music.set_intensity(1.0);
506        for _ in 0..400 {
507            music.fill(&mut vec![0.0f32; 512 * 2]); // let the cross-fade run
508        }
509        assert!(
510            music.layer_gain(1).unwrap() > 0.9,
511            "hi layer swelled in with intensity"
512        );
513    }
514
515    #[test]
516    fn stingers_fire_and_finish() {
517        let base = doc(r#"{ "name":"b", "duration":0.2, "root": { "type":"sine", "freq":220 } }"#);
518        let sting =
519            doc(r#"{ "name":"s", "duration":0.05, "root": { "type":"sine", "freq":1320 } }"#);
520        let mut music = AdaptiveMusic::new(48_000);
521        music.add_layer(LoopBuffer::from_doc(&base), 0.0);
522        music.stinger(&sting);
523        assert_eq!(music.active_stingers(), 1);
524        // 0.05 s ≈ 2400 frames; serve well past it.
525        for _ in 0..20 {
526            music.fill(&mut vec![0.0f32; 512 * 2]);
527        }
528        assert_eq!(
529            music.active_stingers(),
530            0,
531            "stinger finished and was culled"
532        );
533    }
534
535    #[test]
536    fn loop_buffer_reset_rewinds_to_head() {
537        let base = doc(r#"{ "name":"b", "duration":0.2, "root": { "type":"sine", "freq":220 } }"#);
538        let mut buf = LoopBuffer::from_doc(&base);
539
540        let mut first = vec![0.0f32; 64 * 2];
541        buf.fill(&mut first);
542        buf.fill(&mut vec![0.0f32; 512 * 2]); // advance somewhere else in the loop
543        buf.reset();
544        let mut again = vec![0.0f32; 64 * 2];
545        buf.fill(&mut again);
546
547        assert_eq!(
548            first, again,
549            "reset replays from the loop head, sample-identical"
550        );
551    }
552
553    #[test]
554    fn position_advances_while_playing_and_holds_when_paused() {
555        let base = doc(r#"{ "name":"b", "duration":0.2, "root": { "type":"sine", "freq":220 } }"#);
556        let mut music = AdaptiveMusic::new(48_000);
557        music.add_layer(LoopBuffer::from_doc(&base), 0.0);
558
559        assert_eq!(music.position_frames(), 0);
560        music.fill(&mut vec![0.0f32; 512 * 2]);
561        assert_eq!(music.position_frames(), 512, "the clock advances by frames");
562
563        music.pause();
564        assert!(music.is_paused());
565        let mut out = vec![0.1f32; 512 * 2];
566        music.fill(&mut out);
567        assert_eq!(peak(&out), 0.0, "paused output is silent");
568        assert_eq!(music.position_frames(), 512, "the clock holds while paused");
569
570        music.resume();
571        music.fill(&mut vec![0.0f32; 512 * 2]);
572        assert_eq!(music.position_frames(), 1024, "resumes advancing");
573    }
574
575    #[test]
576    fn reset_zeroes_the_clock_and_restarts_layers() {
577        let base = doc(r#"{ "name":"b", "duration":0.2, "root": { "type":"sine", "freq":220 } }"#);
578        let mut music = AdaptiveMusic::new(48_000);
579        music.add_layer(LoopBuffer::from_doc(&base), 0.0);
580        music.set_intensity(1.0);
581
582        music.fill(&mut vec![0.0f32; 512 * 2]);
583        assert!(music.position_frames() > 0);
584        music.reset();
585        assert_eq!(music.position_frames(), 0, "the clock is back at sample 0");
586
587        let mut out = vec![0.0f32; 512 * 2];
588        music.fill(&mut out);
589        assert!(peak(&out) > 0.0, "the bed plays again from the top");
590    }
591
592    #[test]
593    fn duck_attenuates_then_recovers() {
594        use std::time::Duration;
595        let base = doc(r#"{ "name":"b", "duration":0.2, "root": { "type":"sine", "freq":220 } }"#);
596
597        // Undicked reference peak over one block.
598        let mut plain = AdaptiveMusic::new(48_000);
599        plain.add_layer(LoopBuffer::from_doc(&base), 0.0);
600        let mut plain_out = vec![0.0f32; 512 * 2];
601        plain.fill(&mut plain_out);
602        let reference = peak(&plain_out);
603
604        // A fresh, identical bed, ducked hard just before the same block.
605        let mut music = AdaptiveMusic::new(48_000);
606        music.add_layer(LoopBuffer::from_doc(&base), 0.0);
607        music.duck(0.9, Duration::from_millis(180));
608        let mut ducked = vec![0.0f32; 512 * 2];
609        music.fill(&mut ducked);
610        assert!(
611            peak(&ducked) < reference,
612            "ducked block is quieter than the undicked reference"
613        );
614
615        // Recover well past the release, then the gain is back near unity.
616        for _ in 0..64 {
617            music.fill(&mut vec![0.0f32; 512 * 2]);
618        }
619        let mut recovered = vec![0.0f32; 512 * 2];
620        music.fill(&mut recovered);
621        assert!(
622            peak(&recovered) > 0.9 * reference,
623            "the duck recovered toward unity"
624        );
625    }
626
627    // ---- interactive-music v2 ----
628
629    fn tone(freq: f32) -> SoundDoc {
630        doc(&format!(
631            r#"{{ "name":"t", "duration":0.25, "root": {{ "type":"sine", "freq":{freq} }} }}"#
632        ))
633    }
634
635    /// Advance the clock by exactly `frames` in one block.
636    fn advance(m: &mut AdaptiveMusic, frames: usize) {
637        m.fill(&mut vec![0.0f32; frames * 2]);
638    }
639
640    #[test]
641    fn clock_counts_beats_and_bars() {
642        let mut m = AdaptiveMusic::new(48_000);
643        m.add_layer(LoopBuffer::from_doc(&tone(220.0)), 0.0);
644        m.set_tempo(120.0, 4); // 120 bpm → 24 000 frames/beat at 48 kHz
645        advance(&mut m, 24_000);
646        assert!(
647            (m.beats() - 1.0).abs() < 1e-6,
648            "one beat elapsed: {}",
649            m.beats()
650        );
651        advance(&mut m, 24_000 * 7); // to 8 beats = 2 bars total
652        assert!(
653            (m.bars() - 2.0).abs() < 1e-6,
654            "two bars elapsed: {}",
655            m.bars()
656        );
657    }
658
659    #[test]
660    fn intensity_change_waits_for_the_bar() {
661        let mut m = AdaptiveMusic::new(48_000);
662        m.add_layer(LoopBuffer::from_doc(&tone(220.0)), 0.0);
663        m.add_layer(LoopBuffer::from_doc(&tone(880.0)), 0.5); // joins at intensity ≥ 0.5
664        m.set_tempo(120.0, 4); // 96 000 frames/bar
665        m.set_intensity_at(1.0, Quantize::Bar);
666        // A block short of the bar: the target has not been raised yet.
667        advance(&mut m, 48_000);
668        assert_eq!(m.intensity(), 0.0, "intensity holds before the bar");
669        // Cross the bar line: the scheduled change fires.
670        advance(&mut m, 48_100);
671        assert_eq!(m.intensity(), 1.0, "intensity applied on the bar");
672    }
673
674    #[test]
675    fn transition_swaps_sections_on_the_bar() {
676        let mut m = AdaptiveMusic::new(48_000);
677        let explore = m.add_section("explore", &tone(330.0));
678        let battle = m.add_section("battle", &tone(660.0));
679        assert_eq!(m.current_section(), Some(explore));
680        assert_eq!(m.section_named("battle"), Some(battle));
681        m.set_tempo(120.0, 4); // 96 000 frames/bar
682        m.transition_to(battle, Quantize::Bar);
683        // Before the bar: still on explore, and audible.
684        let mut out = vec![0.0f32; 512 * 2];
685        m.fill(&mut out);
686        assert!(peak(&out) > 0.0, "section audio plays");
687        assert_eq!(m.current_section(), Some(explore), "holds until the bar");
688        // Past the bar + the short cross-fade: now on battle.
689        for _ in 0..200 {
690            advance(&mut m, 512);
691        }
692        assert_eq!(m.current_section(), Some(battle), "swapped to battle");
693    }
694
695    #[test]
696    fn buffer_and_doc_section_apis_agree() {
697        // add_section_buffer (off-lock render) must match add_section (renders
698        // internally) sample-for-sample. add_section renders at the ENGINE's
699        // rate, so the off-lock caller uses from_doc_at with the same rate.
700        let d = tone(220.0);
701        let via_doc = {
702            let mut m = AdaptiveMusic::new(48_000);
703            m.add_section("a", &d);
704            let mut o = vec![0.0f32; 256 * 2];
705            m.fill(&mut o);
706            o
707        };
708        let via_buf = {
709            let mut m = AdaptiveMusic::new(48_000);
710            m.add_section_buffer("a", LoopBuffer::from_doc_at(&d, 48_000));
711            let mut o = vec![0.0f32; 256 * 2];
712            m.fill(&mut o);
713            o
714        };
715        assert_eq!(
716            via_doc, via_buf,
717            "add_section_buffer must match add_section"
718        );
719    }
720
721    #[test]
722    fn doc_apis_render_at_the_engine_rate() {
723        // A doc left at the default 44_100 must render at the ENGINE's rate
724        // through every doc-taking entry point — the old behavior played it
725        // detuned ~9% through a 48 kHz engine.
726        let d = tone(220.0); // tone() leaves doc.sample_rate at the default
727        assert_ne!(d.sample_rate, 48_000, "test needs a mismatched doc");
728
729        let mut at_engine_rate = AdaptiveMusic::new(48_000);
730        at_engine_rate.add_layer_doc(&d, 0.0);
731        let mut a = vec![0.0f32; 512];
732        at_engine_rate.fill(&mut a);
733
734        let mut explicit = AdaptiveMusic::new(48_000);
735        explicit.add_layer(LoopBuffer::from_doc_at(&d, 48_000), 0.0);
736        let mut b = vec![0.0f32; 512];
737        explicit.fill(&mut b);
738        assert_eq!(a, b, "add_layer_doc == from_doc_at at the engine rate");
739
740        let mut wrong = AdaptiveMusic::new(48_000);
741        wrong.add_layer(LoopBuffer::from_doc(&d), 0.0);
742        let mut c = vec![0.0f32; 512];
743        wrong.fill(&mut c);
744        assert_ne!(a, c, "the doc-rate render is a different (detuned) signal");
745    }
746
747    #[test]
748    fn transition_to_the_current_section_is_a_pure_noop() {
749        // Requesting the section already playing must not start a fade: doing so
750        // filled the same buffer twice per block and advanced its play head
751        // twice (an audible speed-up). Output must equal a plain render.
752        let plain = {
753            let mut m = AdaptiveMusic::new(48_000);
754            m.add_section("a", &tone(220.0));
755            m.add_section("b", &tone(440.0));
756            let mut o = vec![0.0f32; 256 * 2];
757            m.fill(&mut o);
758            o
759        };
760        let mut m = AdaptiveMusic::new(48_000);
761        let a = m.add_section("a", &tone(220.0));
762        m.add_section("b", &tone(440.0));
763        m.transition_to(a, Quantize::Immediate); // `a` is already current
764        let mut o = vec![0.0f32; 256 * 2];
765        m.fill(&mut o);
766        assert_eq!(
767            o, plain,
768            "a transition to the current section changed the mix"
769        );
770    }
771
772    #[test]
773    fn stinger_fires_on_the_beat() {
774        let mut m = AdaptiveMusic::new(48_000);
775        m.add_layer(LoopBuffer::from_doc(&tone(220.0)), 0.0);
776        m.set_tempo(120.0, 4); // beat at 24 000 frames
777        m.stinger_at(&tone(1320.0), Quantize::Beat);
778        assert_eq!(m.active_stingers(), 0, "not yet — waiting for the beat");
779        // Serve realistic blocks up to just before the beat.
780        for _ in 0..46 {
781            advance(&mut m, 512); // 23 552 frames < 24 000
782        }
783        assert_eq!(m.active_stingers(), 0, "still before the beat");
784        advance(&mut m, 512); // crosses 24 000 — the stinger fires (and is long)
785        assert_eq!(m.active_stingers(), 1, "stinger fired on the beat");
786    }
787
788    #[test]
789    fn quantized_schedule_is_deterministic() {
790        let run = || {
791            let mut m = AdaptiveMusic::new(48_000);
792            m.add_section("a", &tone(330.0));
793            let b = m.add_section("b", &tone(660.0));
794            m.add_layer(LoopBuffer::from_doc(&tone(220.0)), 0.0);
795            m.set_tempo(140.0, 4);
796            m.set_intensity_at(0.8, Quantize::Beat);
797            m.transition_to(b, Quantize::Bar);
798            m.stinger_at(&tone(990.0), Quantize::Bars(2));
799            let mut acc = Vec::new();
800            let mut out = vec![0.0f32; 333 * 2]; // odd block size stresses boundaries
801            for _ in 0..300 {
802                m.fill(&mut out);
803                acc.extend_from_slice(&out);
804            }
805            acc
806        };
807        assert_eq!(
808            run(),
809            run(),
810            "a fixed tempo + block schedule replays identically"
811        );
812    }
813
814    #[test]
815    fn immediate_api_unchanged_without_tempo() {
816        // No tempo set → quantized calls act immediately (back-compat).
817        let mut m = AdaptiveMusic::new(48_000);
818        m.add_layer(LoopBuffer::from_doc(&tone(220.0)), 0.5);
819        m.set_intensity_at(1.0, Quantize::Bar); // no tempo → immediate
820        assert_eq!(m.intensity(), 1.0, "no tempo → applies immediately");
821    }
822
823    #[test]
824    fn from_doc_len_forces_the_grid_and_loops_at_it() {
825        // A 0.5 s tone forced to 1000 frames: it loops exactly at the grid, so
826        // block N is sample-identical to block N+1.
827        let grid = 1000;
828        let mut buf = LoopBuffer::from_doc_len(&tone(220.0), grid);
829        let mut first = vec![0.0f32; grid * 2];
830        let mut second = vec![0.0f32; grid * 2];
831        buf.fill(&mut first);
832        buf.fill(&mut second);
833        assert_eq!(first, second, "the buffer loops exactly at the grid length");
834    }
835
836    #[test]
837    fn stem_set_shares_one_beat_grid() {
838        let mut music = AdaptiveMusic::new(48_000);
839        music.set_tempo(120.0, 4); // 48000*60/120 = 24000 frames/beat
840
841        let base = tone(220.0);
842        let hi = tone(880.0);
843        let grid = music.add_stem_set(&[(&base, 0.0), (&hi, 0.5)], 4.0);
844
845        // 4 beats × 24000 = 96000 frames — the provable shared length.
846        assert_eq!(grid, 96_000, "grid = duration_beats × frames_per_beat");
847        assert_eq!(music.layer_gain(0), Some(1.0), "base always on");
848        assert_eq!(music.layer_gain(1), Some(0.0), "hi silent until intensity");
849        // Both stems are forced to `grid` frames, so their loops are phase-locked
850        // by construction (see from_doc_len's loop test).
851    }
852
853    #[test]
854    fn stem_set_without_tempo_falls_back_to_the_first_stem() {
855        let mut music = AdaptiveMusic::new(48_000);
856        let base = tone(220.0);
857        let grid = music.add_stem_set(&[(&base, 0.0)], 4.0);
858        assert!(
859            grid > 0,
860            "no tempo → grid falls back to the first stem's natural length"
861        );
862    }
863
864    #[test]
865    fn scheduled_events_are_block_size_invariant() {
866        // The determinism guarantee: quantized actions fire at their exact
867        // frame, so the same schedule renders identical audio no matter what
868        // block size the host serves (a 128-frame AudioWorklet vs a 512-frame
869        // cpal callback used to render different audio).
870        let run = |block: usize| {
871            let mut m = AdaptiveMusic::new(48_000);
872            m.add_section("a", &tone(330.0));
873            let b = m.add_section("b", &tone(660.0));
874            m.add_layer(LoopBuffer::from_doc(&tone(220.0)), 0.0);
875            m.add_layer(LoopBuffer::from_doc(&tone(880.0)), 0.5);
876            m.set_tempo(120.0, 4);
877            m.set_intensity_at(1.0, Quantize::Beat);
878            m.stinger_at(&tone(990.0), Quantize::Beat);
879            m.transition_to(b, Quantize::Bar);
880            let mut acc = Vec::new();
881            let mut out = vec![0.0f32; block * 2];
882            for _ in 0..(200_000 / block) {
883                m.fill(&mut out);
884                acc.extend_from_slice(&out);
885            }
886            acc
887        };
888        let a = run(128);
889        let b = run(512);
890        let n = a.len().min(b.len());
891        assert_eq!(
892            a[..n],
893            b[..n],
894            "output must not depend on the host block size"
895        );
896    }
897
898    #[test]
899    fn transition_reversal_mid_fade_cancels_back() {
900        // During a fade A→B the effective section is B — asking for A again
901        // cancels the fade (running it in reverse), not an "already current"
902        // no-op that lets B take over anyway.
903        let mut m = AdaptiveMusic::new(48_000);
904        let a = m.add_section("a", &tone(330.0));
905        let b = m.add_section("b", &tone(660.0));
906        m.transition_to(b, Quantize::Immediate);
907        advance(&mut m, 512); // partway into the fade
908        m.transition_to(a, Quantize::Immediate);
909        for _ in 0..40 {
910            advance(&mut m, 512);
911        }
912        assert_eq!(m.current_section(), Some(a), "cancelled back to a");
913    }
914
915    #[test]
916    fn re_transition_to_the_fade_target_is_a_noop() {
917        // transition_to(B) mid-fade must not restart B's fade (rewinding its
918        // buffer and audibly restarting): the original fade just completes.
919        let mut m = AdaptiveMusic::new(48_000);
920        m.add_section("a", &tone(330.0));
921        let b = m.add_section("b", &tone(660.0));
922        m.transition_to(b, Quantize::Immediate);
923        advance(&mut m, 512); // 512 frames of fade progress
924        m.transition_to(b, Quantize::Immediate);
925        advance(&mut m, 2500); // +512 > the 2880-frame fade — done if unrestarted
926        assert_eq!(
927            m.current_section(),
928            Some(b),
929            "the fade completed on its original clock"
930        );
931    }
932
933    #[test]
934    fn audio_source_reset_restarts_the_bed() {
935        // Through `&mut dyn AudioSource` reset used to be a silent no-op.
936        let mut m = AdaptiveMusic::new(48_000);
937        m.add_layer(LoopBuffer::from_doc(&tone(220.0)), 0.0);
938        m.stinger(&tone(990.0));
939        advance(&mut m, 512);
940        assert!(m.position_frames() > 0);
941        let src: &mut dyn AudioSource = &mut m;
942        src.reset();
943        assert_eq!(
944            m.position_frames(),
945            0,
946            "reset through the trait restarts the clock"
947        );
948        assert_eq!(m.active_stingers(), 0, "and clears stingers");
949    }
950
951    #[test]
952    fn third_section_switch_completes_the_fade_first() {
953        // Mid-fade A→B, transition_to(C): the in-flight fade completes (B's
954        // partial contribution is not hard-cut), then C fades in.
955        let mut m = AdaptiveMusic::new(48_000);
956        m.add_section("a", &tone(330.0));
957        let b = m.add_section("b", &tone(660.0));
958        let c = m.add_section("c", &tone(990.0));
959        m.transition_to(b, Quantize::Immediate);
960        advance(&mut m, 512); // 512 into the 2880-frame fade
961        m.transition_to(c, Quantize::Immediate);
962        advance(&mut m, 2880); // past the original fade's end (at 2880)
963        assert_eq!(
964            m.current_section(),
965            Some(b),
966            "the in-flight fade completed before the onward transition"
967        );
968        for _ in 0..20 {
969            advance(&mut m, 512);
970        }
971        assert_eq!(m.current_section(), Some(c), "then c faded in");
972    }
973}