Skip to main content

agent_rubato/
lib.rs

1//! # agent-rubato
2//!
3//! Tempo flexibility for adaptive agent scheduling.
4//!
5//! In music, *rubato* means "stolen time" — the performer borrows time from
6//! slow passages to spend on fast ones, keeping the overall pulse alive while
7//! allowing expressive flexibility. This crate brings that same idea to agent
8//! scheduling: agents can stretch and compress their work timing adaptively,
9//! borrowing cycles from idle phases and spending them during bursts.
10
11use std::collections::HashMap;
12use std::time::{Duration, Instant};
13
14// ---------------------------------------------------------------------------
15// TempoMarking
16// ---------------------------------------------------------------------------
17
18/// A tempo marking with BPM and a "human feel" factor.
19///
20/// Rather than rigid metronomic timing, `TempoMarking` introduces slight
21/// variance around the target BPM, simulating how human musicians (and
22/// well-tuned agents) naturally ebb and flow around a target pace.
23#[derive(Debug, Clone, PartialEq)]
24pub struct TempoMarking {
25    /// Base beats per minute.
26    bpm: f64,
27    /// Human-feel variance factor (0.0 = robotic, 1.0 = very expressive).
28    feel: f64,
29    /// Optional label (e.g. "Allegro", "urgent", "idle").
30    label: Option<String>,
31}
32
33impl TempoMarking {
34    /// Create a new tempo marking.
35    pub fn new(bpm: f64, feel: f64) -> Self {
36        Self {
37            bpm: bpm.clamp(1.0, 600.0),
38            feel: feel.clamp(0.0, 1.0),
39            label: None,
40        }
41    }
42
43    /// Create a tempo marking with a human-readable label.
44    pub fn labeled(bpm: f64, feel: f64, label: impl Into<String>) -> Self {
45        Self {
46            bpm: bpm.clamp(1.0, 600.0),
47            feel: feel.clamp(0.0, 1.0),
48            label: Some(label.into()),
49        }
50    }
51
52    /// The base BPM.
53    pub fn bpm(&self) -> f64 {
54        self.bpm
55    }
56
57    /// The human-feel factor.
58    pub fn feel(&self) -> f64 {
59        self.feel
60    }
61
62    /// Duration of one beat at the base BPM.
63    pub fn beat_duration(&self) -> Duration {
64        Duration::from_secs_f64(60.0 / self.bpm)
65    }
66
67    /// Duration of one beat with feel applied (± variance).
68    /// The `variation` parameter should be in [-1.0, 1.0] and is scaled by feel.
69    pub fn beat_with_feel(&self, variation: f64) -> Duration {
70        let base_secs = 60.0 / self.bpm;
71        let adjusted = base_secs * (1.0 + variation * self.feel * 0.1);
72        Duration::from_secs_f64(adjusted.max(0.001))
73    }
74
75    /// The label, if any.
76    pub fn label(&self) -> Option<&str> {
77        self.label.as_deref()
78    }
79
80    /// Well-known tempo: Largo (slow, broad).
81    pub fn largo() -> Self {
82        Self::labeled(50.0, 0.3, "Largo")
83    }
84
85    /// Well-known tempo: Andante (walking pace).
86    pub fn andante() -> Self {
87        Self::labeled(80.0, 0.2, "Andante")
88    }
89
90    /// Well-known tempo: Allegro (fast, lively).
91    pub fn allegro() -> Self {
92        Self::labeled(140.0, 0.15, "Allegro")
93    }
94
95    /// Well-known tempo: Presto (very fast).
96    pub fn presto() -> Self {
97        Self::labeled(180.0, 0.1, "Presto")
98    }
99}
100
101// ---------------------------------------------------------------------------
102// RubatoProfile
103// ---------------------------------------------------------------------------
104
105/// Describes how much tempo can stretch for an agent.
106///
107/// A `RubatoProfile` sets the boundaries of permissible timing flexibility.
108/// An agent with high stretch can borrow a lot of time; one with low stretch
109/// must stay close to the written tempo.
110#[derive(Debug, Clone, PartialEq)]
111pub struct RubatoProfile {
112    /// Maximum percentage the tempo can increase (compressed, faster).
113    max_compress: f64,
114    /// Maximum percentage the tempo can decrease (stretched, slower).
115    max_stretch: f64,
116    /// How quickly the tempo can change (rate per second).
117    transition_rate: f64,
118    /// Name of the profile.
119    name: String,
120}
121
122impl RubatoProfile {
123    /// Create a new rubato profile.
124    pub fn new(name: impl Into<String>, max_compress: f64, max_stretch: f64, transition_rate: f64) -> Self {
125        Self {
126            name: name.into(),
127            max_compress: max_compress.clamp(0.0, 1.0),
128            max_stretch: max_stretch.clamp(0.0, 1.0),
129            transition_rate: transition_rate.clamp(0.0, 10.0),
130        }
131    }
132
133    /// Strict profile: minimal tempo flexibility.
134    pub fn strict() -> Self {
135        Self::new("strict", 0.05, 0.05, 0.1)
136    }
137
138    /// Moderate profile: balanced flexibility.
139    pub fn moderate() -> Self {
140        Self::new("moderate", 0.2, 0.25, 0.5)
141    }
142
143    /// Expressive profile: wide tempo flexibility.
144    pub fn expressive() -> Self {
145        Self::new("expressive", 0.4, 0.5, 1.0)
146    }
147
148    /// Apply rubato to a base BPM, given a stretch factor.
149    ///
150    /// `factor` ranges from -1.0 (max stretch/slow) to +1.0 (max compress/fast).
151    pub fn apply(&self, base_bpm: f64, factor: f64) -> f64 {
152        let clamped = factor.clamp(-1.0, 1.0);
153        if clamped >= 0.0 {
154            base_bpm * (1.0 + clamped * self.max_compress)
155        } else {
156            base_bpm * (1.0 - clamped.abs() * self.max_stretch)
157        }
158    }
159
160    /// Compute the minimum BPM allowed by this profile.
161    pub fn min_bpm(&self, base_bpm: f64) -> f64 {
162        base_bpm * (1.0 - self.max_stretch)
163    }
164
165    /// Compute the maximum BPM allowed by this profile.
166    pub fn max_bpm(&self, base_bpm: f64) -> f64 {
167        base_bpm * (1.0 + self.max_compress)
168    }
169
170    /// Whether a given BPM is within the rubato range for a base BPM.
171    pub fn is_within_range(&self, base_bpm: f64, actual_bpm: f64) -> bool {
172        actual_bpm >= self.min_bpm(base_bpm) && actual_bpm <= self.max_bpm(base_bpm)
173    }
174
175    pub fn name(&self) -> &str {
176        &self.name
177    }
178    pub fn max_compress(&self) -> f64 {
179        self.max_compress
180    }
181    pub fn max_stretch(&self) -> f64 {
182        self.max_stretch
183    }
184    pub fn transition_rate(&self) -> f64 {
185        self.transition_rate
186    }
187}
188
189// ---------------------------------------------------------------------------
190// TempoCurve
191// ---------------------------------------------------------------------------
192
193/// Shape of a tempo change over time.
194#[derive(Debug, Clone, Copy, PartialEq)]
195pub enum CurveShape {
196    /// Gradually speeding up.
197    Accelerando,
198    /// Gradually slowing down.
199    Ritardando,
200    /// Free rubato (expressive fluctuation).
201    Rubato,
202    /// Constant tempo (no change).
203    Flat,
204}
205
206/// A tempo curve: a programmed change in tempo over a duration.
207///
208/// Like a musician's accelerando or ritardando, a `TempoCurve` smoothly
209/// transitions from one tempo to another over a given time span.
210#[derive(Debug, Clone)]
211pub struct TempoCurve {
212    /// The starting tempo (BPM).
213    start_bpm: f64,
214    /// The ending tempo (BPM).
215    end_bpm: f64,
216    /// Duration of the curve in beats.
217    duration_beats: u32,
218    /// Shape of the curve.
219    shape: CurveShape,
220    /// Optional label.
221    label: Option<String>,
222}
223
224impl TempoCurve {
225    /// Create a new tempo curve.
226    pub fn new(start_bpm: f64, end_bpm: f64, duration_beats: u32) -> Self {
227        let shape = if (start_bpm - end_bpm).abs() < 0.01 {
228            CurveShape::Flat
229        } else if end_bpm > start_bpm {
230            CurveShape::Accelerando
231        } else {
232            CurveShape::Ritardando
233        };
234        Self {
235            start_bpm,
236            end_bpm,
237            duration_beats: duration_beats.max(1),
238            shape,
239            label: None,
240        }
241    }
242
243    /// Create a rubato-shaped curve (free fluctuation).
244    pub fn rubato(base_bpm: f64, fluctuation: f64, duration_beats: u32) -> Self {
245        Self {
246            start_bpm: base_bpm,
247            end_bpm: base_bpm + fluctuation,
248            duration_beats: duration_beats.max(1),
249            shape: CurveShape::Rubato,
250            label: None,
251        }
252    }
253
254    /// Label the curve.
255    pub fn with_label(mut self, label: impl Into<String>) -> Self {
256        self.label = Some(label.into());
257        self
258    }
259
260    /// Get the BPM at a given beat position (linear interpolation).
261    pub fn bpm_at_beat(&self, beat: f64) -> f64 {
262        let t = (beat / self.duration_beats as f64).clamp(0.0, 1.0);
263        self.start_bpm + (self.end_bpm - self.start_bpm) * t
264    }
265
266    /// Get the BPM at a given normalized position (0.0 = start, 1.0 = end).
267    /// Uses ease-in-ease-out for smoother transitions.
268    pub fn bpm_at_position(&self, pos: f64) -> f64 {
269        let t = pos.clamp(0.0, 1.0);
270        // Ease-in-ease-out: 3t² - 2t³
271        let eased = if self.shape == CurveShape::Flat {
272            t
273        } else {
274            3.0 * t * t - 2.0 * t * t * t
275        };
276        self.start_bpm + (self.end_bpm - self.start_bpm) * eased
277    }
278
279    /// The shape of the curve.
280    pub fn shape(&self) -> CurveShape {
281        self.shape
282    }
283
284    /// Start BPM.
285    pub fn start_bpm(&self) -> f64 {
286        self.start_bpm
287    }
288
289    /// End BPM.
290    pub fn end_bpm(&self) -> f64 {
291        self.end_bpm
292    }
293
294    /// Duration in beats.
295    pub fn duration_beats(&self) -> u32 {
296        self.duration_beats
297    }
298
299    /// Label.
300    pub fn label(&self) -> Option<&str> {
301        self.label.as_deref()
302    }
303
304    /// Total change in BPM.
305    pub fn delta(&self) -> f64 {
306        self.end_bpm - self.start_bpm
307    }
308
309    /// Is the curve speeding up?
310    pub fn is_accelerando(&self) -> bool {
311        self.shape == CurveShape::Accelerando
312    }
313
314    /// Is the curve slowing down?
315    pub fn is_ritardando(&self) -> bool {
316        self.shape == CurveShape::Ritardando
317    }
318}
319
320// ---------------------------------------------------------------------------
321// TempoFollower
322// ---------------------------------------------------------------------------
323
324/// Track the tempo of other agents and follow along.
325///
326/// A `TempoFollower` observes timestamps of another agent's actions and
327/// infers their current tempo, allowing this agent to synchronize.
328#[derive(Debug, Clone)]
329pub struct TempoFollower {
330    /// The agent being followed.
331    leader_id: String,
332    /// Recent beat timestamps.
333    observed_beats: Vec<Instant>,
334    /// Maximum beats to track.
335    window_size: usize,
336    /// Smoothing factor for BPM estimation (0.0–1.0).
337    smoothing: f64,
338    /// Current estimated BPM of the leader.
339    estimated_bpm: Option<f64>,
340}
341
342impl TempoFollower {
343    /// Create a new follower for the given leader.
344    pub fn new(leader_id: impl Into<String>) -> Self {
345        Self {
346            leader_id: leader_id.into(),
347            observed_beats: Vec::new(),
348            window_size: 16,
349            smoothing: 0.3,
350            estimated_bpm: None,
351        }
352    }
353
354    /// Set the observation window size.
355    pub fn with_window_size(mut self, size: usize) -> Self {
356        self.window_size = size.max(2);
357        self
358    }
359
360    /// Set the smoothing factor.
361    pub fn with_smoothing(mut self, smoothing: f64) -> Self {
362        self.smoothing = smoothing.clamp(0.0, 1.0);
363        self
364    }
365
366    /// Observe a beat from the leader.
367    pub fn observe_beat(&mut self) {
368        let now = Instant::now();
369        self.observed_beats.push(now);
370        if self.observed_beats.len() > self.window_size {
371            self.observed_beats.remove(0);
372        }
373        self.recalculate();
374    }
375
376    /// Observe a beat at a specific time (for testing).
377    pub fn observe_beat_at(&mut self, time: Instant) {
378        self.observed_beats.push(time);
379        if self.observed_beats.len() > self.window_size {
380            self.observed_beats.remove(0);
381        }
382        self.recalculate();
383    }
384
385    fn recalculate(&mut self) {
386        if self.observed_beats.len() < 2 {
387            return;
388        }
389        let first = self.observed_beats[0];
390        let last = *self.observed_beats.last().unwrap();
391        let elapsed = last.duration_since(first).as_secs_f64();
392        if elapsed <= 0.0 {
393            return;
394        }
395        let intervals = self.observed_beats.len() - 1;
396        let raw_bpm = (intervals as f64 / elapsed) * 60.0;
397        self.estimated_bpm = Some(match self.estimated_bpm {
398            Some(prev) => prev + self.smoothing * (raw_bpm - prev),
399            None => raw_bpm,
400        });
401    }
402
403    /// Get the estimated BPM of the leader.
404    pub fn estimated_bpm(&self) -> Option<f64> {
405        self.estimated_bpm
406    }
407
408    /// The leader being followed.
409    pub fn leader_id(&self) -> &str {
410        &self.leader_id
411    }
412
413    /// Number of observed beats.
414    pub fn observed_count(&self) -> usize {
415        self.observed_beats.len()
416    }
417
418    /// Compute the sync offset: how far off our tempo is from the leader's.
419    pub fn sync_offset(&self, our_bpm: f64) -> Option<f64> {
420        self.estimated_bpm.map(|leader| leader - our_bpm)
421    }
422
423    /// Reset all observations.
424    pub fn reset(&mut self) {
425        self.observed_beats.clear();
426        self.estimated_bpm = None;
427    }
428}
429
430// ---------------------------------------------------------------------------
431// TempoLeader
432// ---------------------------------------------------------------------------
433
434/// Set tempo for a group of agents.
435///
436/// A `TempoLeader` broadcasts tempo information so that followers can
437/// synchronize their work cycles.
438#[derive(Debug, Clone)]
439pub struct TempoLeader {
440    /// The leader's identifier.
441    leader_id: String,
442    /// Current tempo.
443    current: TempoMarking,
444    /// Registered followers.
445    followers: HashMap<String, f64>,
446    /// Whether rubato is enabled for the group.
447    rubato_enabled: bool,
448    /// Active tempo curve, if any.
449    active_curve: Option<TempoCurve>,
450    /// Current position in the curve (0.0–1.0).
451    curve_position: f64,
452}
453
454impl TempoLeader {
455    /// Create a new tempo leader.
456    pub fn new(leader_id: impl Into<String>, initial_tempo: TempoMarking) -> Self {
457        Self {
458            leader_id: leader_id.into(),
459            current: initial_tempo,
460            followers: HashMap::new(),
461            rubato_enabled: false,
462            active_curve: None,
463            curve_position: 0.0,
464        }
465    }
466
467    /// Enable or disable rubato for the group.
468    pub fn set_rubato(&mut self, enabled: bool) {
469        self.rubato_enabled = enabled;
470    }
471
472    /// Register a follower with an optional tempo offset.
473    pub fn add_follower(&mut self, follower_id: impl Into<String>, offset_bpm: f64) {
474        self.followers.insert(follower_id.into(), offset_bpm);
475    }
476
477    /// Remove a follower.
478    pub fn remove_follower(&mut self, follower_id: &str) -> bool {
479        self.followers.remove(follower_id).is_some()
480    }
481
482    /// Change the tempo.
483    pub fn set_tempo(&mut self, tempo: TempoMarking) {
484        self.current = tempo;
485    }
486
487    /// Start a tempo curve (accelerando/ritardando).
488    pub fn start_curve(&mut self, curve: TempoCurve) {
489        self.active_curve = Some(curve);
490        self.curve_position = 0.0;
491    }
492
493    /// Advance the curve by a step (0.0–1.0 increment).
494    /// Returns the current BPM from the curve.
495    pub fn advance_curve(&mut self, step: f64) -> Option<f64> {
496        if let Some(ref curve) = self.active_curve {
497            self.curve_position = (self.curve_position + step).min(1.0);
498            let bpm = curve.bpm_at_position(self.curve_position);
499            if self.curve_position >= 1.0 {
500                self.current = TempoMarking::new(bpm, self.current.feel);
501                self.active_curve = None;
502            }
503            Some(bpm)
504        } else {
505            None
506        }
507    }
508
509    /// Get the effective BPM, accounting for any active curve.
510    pub fn effective_bpm(&self) -> f64 {
511        if let Some(ref curve) = self.active_curve {
512            curve.bpm_at_position(self.curve_position)
513        } else {
514            self.current.bpm()
515        }
516    }
517
518    /// Get the tempo for a specific follower (base ± offset).
519    pub fn tempo_for_follower(&self, follower_id: &str) -> f64 {
520        let base = self.effective_bpm();
521        let offset = self.followers.get(follower_id).copied().unwrap_or(0.0);
522        base + offset
523    }
524
525    /// Leader ID.
526    pub fn leader_id(&self) -> &str {
527        &self.leader_id
528    }
529
530    /// Number of followers.
531    pub fn follower_count(&self) -> usize {
532        self.followers.len()
533    }
534
535    /// Current tempo marking.
536    pub fn current_tempo(&self) -> &TempoMarking {
537        &self.current
538    }
539
540    /// Whether rubato is enabled.
541    pub fn rubato_enabled(&self) -> bool {
542        self.rubato_enabled
543    }
544
545    /// Whether a curve is active.
546    pub fn curve_active(&self) -> bool {
547        self.active_curve.is_some()
548    }
549}
550
551// ---------------------------------------------------------------------------
552// TempoMap
553// ---------------------------------------------------------------------------
554
555/// A map of tempo changes over a session.
556///
557/// A `TempoMap` stores planned tempo curves and markings across a timeline,
558/// letting agents look up what tempo they should be at during any point
559/// in a session.
560#[derive(Debug, Clone)]
561pub struct TempoMapEntry {
562    /// Start beat of this entry.
563    start_beat: f64,
564    /// The tempo or curve at this point.
565    bpm: f64,
566    /// Optional label.
567    label: Option<String>,
568}
569
570/// Tempo map: tempo changes across a session.
571#[derive(Debug, Clone)]
572pub struct TempoMap {
573    entries: Vec<TempoMapEntry>,
574    total_beats: f64,
575    default_bpm: f64,
576}
577
578impl TempoMap {
579    /// Create a new tempo map with a default BPM.
580    pub fn new(default_bpm: f64) -> Self {
581        Self {
582            entries: Vec::new(),
583            total_beats: 0.0,
584            default_bpm,
585        }
586    }
587
588    /// Add a tempo marking at a given beat.
589    pub fn add_marking(&mut self, beat: f64, bpm: f64, label: Option<String>) {
590        let entry = TempoMapEntry {
591            start_beat: beat,
592            bpm,
593            label,
594        };
595        self.entries.push(entry);
596        self.entries.sort_by(|a, b| a.start_beat.partial_cmp(&b.start_beat).unwrap());
597        if beat > self.total_beats {
598            self.total_beats = beat;
599        }
600    }
601
602    /// Look up the BPM at a given beat position.
603    pub fn bpm_at_beat(&self, beat: f64) -> f64 {
604        if self.entries.is_empty() {
605            return self.default_bpm;
606        }
607        // Find the last entry at or before this beat.
608        let mut result = self.default_bpm;
609        for entry in &self.entries {
610            if entry.start_beat <= beat {
611                result = entry.bpm;
612            } else {
613                break;
614            }
615        }
616        result
617    }
618
619    /// Total beats in the map.
620    pub fn total_beats(&self) -> f64 {
621        self.total_beats
622    }
623
624    /// Number of tempo entries.
625    pub fn entry_count(&self) -> usize {
626        self.entries.len()
627    }
628
629    /// Whether the map is empty.
630    pub fn is_empty(&self) -> bool {
631        self.entries.is_empty()
632    }
633
634    /// Get all entries.
635    pub fn entries(&self) -> &[TempoMapEntry] {
636        &self.entries
637    }
638
639    /// Compute the total duration of the map at the given tempos.
640    pub fn total_duration(&self) -> Duration {
641        if self.entries.is_empty() {
642            return Duration::from_secs(0);
643        }
644        let mut total = 0.0_f64;
645        for i in 0..self.entries.len() {
646            let start = self.entries[i].start_beat;
647            let end = if i + 1 < self.entries.len() {
648                self.entries[i + 1].start_beat
649            } else {
650                self.total_beats
651            };
652            let beats = end - start;
653            let secs_per_beat = 60.0 / self.entries[i].bpm;
654            total += beats * secs_per_beat;
655        }
656        Duration::from_secs_f64(total)
657    }
658}
659
660// ===========================================================================
661// Tests
662// ===========================================================================
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667
668    // --- TempoMarking tests ---
669
670    #[test]
671    fn test_tempo_marking_creation() {
672        let tm = TempoMarking::new(120.0, 0.5);
673        assert_eq!(tm.bpm(), 120.0);
674        assert_eq!(tm.feel(), 0.5);
675        assert!(tm.label().is_none());
676    }
677
678    #[test]
679    fn test_tempo_marking_labeled() {
680        let tm = TempoMarking::labeled(100.0, 0.2, "Moderato");
681        assert_eq!(tm.label(), Some("Moderato"));
682    }
683
684    #[test]
685    fn test_tempo_marking_clamping() {
686        let tm = TempoMarking::new(0.0, 5.0);
687        assert_eq!(tm.bpm(), 1.0);
688        assert_eq!(tm.feel(), 1.0);
689    }
690
691    #[test]
692    fn test_beat_duration() {
693        let tm = TempoMarking::new(60.0, 0.0);
694        assert_eq!(tm.beat_duration(), Duration::from_secs(1));
695        let tm2 = TempoMarking::new(120.0, 0.0);
696        assert_eq!(tm2.beat_duration(), Duration::from_millis(500));
697    }
698
699    #[test]
700    fn test_beat_with_feel() {
701        let robotic = TempoMarking::new(100.0, 0.0);
702        let base = robotic.beat_duration();
703        let with_zero_feel = robotic.beat_with_feel(1.0);
704        assert_eq!(base, with_zero_feel); // no feel = no variation
705
706        let human = TempoMarking::new(100.0, 1.0);
707        let with_feel = human.beat_with_feel(1.0);
708        assert_ne!(base, with_feel);
709    }
710
711    #[test]
712    fn test_well_known_tempos() {
713        assert_eq!(TempoMarking::largo().bpm(), 50.0);
714        assert_eq!(TempoMarking::andante().bpm(), 80.0);
715        assert_eq!(TempoMarking::allegro().bpm(), 140.0);
716        assert_eq!(TempoMarking::presto().bpm(), 180.0);
717    }
718
719    // --- RubatoProfile tests ---
720
721    #[test]
722    fn test_rubato_profile_creation() {
723        let p = RubatoProfile::new("test", 0.1, 0.2, 0.5);
724        assert_eq!(p.name(), "test");
725        assert_eq!(p.max_compress(), 0.1);
726        assert_eq!(p.max_stretch(), 0.2);
727        assert_eq!(p.transition_rate(), 0.5);
728    }
729
730    #[test]
731    fn test_rubato_apply_compress() {
732        let p = RubatoProfile::moderate();
733        let result = p.apply(100.0, 1.0); // max compress
734        assert!(result > 100.0);
735        assert_eq!(result, 120.0); // 100 * (1 + 0.2)
736    }
737
738    #[test]
739    fn test_rubato_apply_stretch() {
740        let p = RubatoProfile::moderate();
741        let result = p.apply(100.0, -1.0); // max stretch
742        assert_eq!(result, 75.0); // 100 * (1 - 0.25)
743    }
744
745    #[test]
746    fn test_rubato_apply_neutral() {
747        let p = RubatoProfile::moderate();
748        let result = p.apply(100.0, 0.0);
749        assert_eq!(result, 100.0);
750    }
751
752    #[test]
753    fn test_rubato_range() {
754        let p = RubatoProfile::expressive();
755        assert_eq!(p.min_bpm(100.0), 50.0);  // 100 * 0.5
756        assert_eq!(p.max_bpm(100.0), 140.0);  // 100 * 1.4
757        assert!(p.is_within_range(100.0, 80.0));
758        assert!(p.is_within_range(100.0, 120.0));
759        assert!(!p.is_within_range(100.0, 30.0));
760    }
761
762    #[test]
763    fn test_preset_profiles() {
764        let s = RubatoProfile::strict();
765        assert!(s.max_compress() < 0.1);
766        let m = RubatoProfile::moderate();
767        assert!(m.max_compress() > s.max_compress());
768        let e = RubatoProfile::expressive();
769        assert!(e.max_compress() > m.max_compress());
770    }
771
772    // --- TempoCurve tests ---
773
774    #[test]
775    fn test_accelerando_curve() {
776        let c = TempoCurve::new(80.0, 140.0, 16);
777        assert_eq!(c.shape(), CurveShape::Accelerando);
778        assert!(c.is_accelerando());
779        assert!(!c.is_ritardando());
780    }
781
782    #[test]
783    fn test_ritardando_curve() {
784        let c = TempoCurve::new(140.0, 80.0, 16);
785        assert_eq!(c.shape(), CurveShape::Ritardando);
786        assert!(c.is_ritardando());
787    }
788
789    #[test]
790    fn test_flat_curve() {
791        let c = TempoCurve::new(120.0, 120.0, 8);
792        assert_eq!(c.shape(), CurveShape::Flat);
793    }
794
795    #[test]
796    fn test_curve_bpm_at_beat() {
797        let c = TempoCurve::new(100.0, 200.0, 10);
798        assert_eq!(c.bpm_at_beat(0.0), 100.0);
799        assert_eq!(c.bpm_at_beat(5.0), 150.0);
800        assert_eq!(c.bpm_at_beat(10.0), 200.0);
801    }
802
803    #[test]
804    fn test_curve_bpm_at_position() {
805        let c = TempoCurve::new(100.0, 200.0, 10);
806        assert_eq!(c.bpm_at_position(0.0), 100.0);
807        assert_eq!(c.bpm_at_position(1.0), 200.0);
808        // Ease-in-ease-out: midpoint should be exactly 150.0 for linear-like curve
809        let mid = c.bpm_at_position(0.5);
810        assert_eq!(mid, 150.0); // 3*(0.25) - 2*(0.125) = 0.5 => 100 + 100*0.5 = 150
811    }
812
813    #[test]
814    fn test_curve_with_label() {
815        let c = TempoCurve::new(60.0, 120.0, 8).with_label("crescendo section");
816        assert_eq!(c.label(), Some("crescendo section"));
817    }
818
819    #[test]
820    fn test_curve_delta() {
821        let c = TempoCurve::new(80.0, 140.0, 8);
822        assert_eq!(c.delta(), 60.0);
823    }
824
825    #[test]
826    fn test_rubato_curve() {
827        let c = TempoCurve::rubato(120.0, 10.0, 32);
828        assert_eq!(c.shape(), CurveShape::Rubato);
829        assert_eq!(c.start_bpm(), 120.0);
830        assert_eq!(c.end_bpm(), 130.0);
831    }
832
833    // --- TempoFollower tests ---
834
835    #[test]
836    fn test_follower_creation() {
837        let f = TempoFollower::new("leader-1");
838        assert_eq!(f.leader_id(), "leader-1");
839        assert!(f.estimated_bpm().is_none());
840        assert_eq!(f.observed_count(), 0);
841    }
842
843    #[test]
844    fn test_follower_estimates_bpm() {
845        let mut f = TempoFollower::new("leader");
846        let now = Instant::now();
847        // Simulate 4 beats at 120 BPM (0.5s apart)
848        for i in 0..5 {
849            f.observe_beat_at(now + Duration::from_millis(500 * i as u64));
850        }
851        let bpm = f.estimated_bpm().unwrap();
852        // Should be approximately 120 BPM
853        assert!(bpm > 110.0 && bpm < 130.0, "Expected ~120 BPM, got {}", bpm);
854    }
855
856    #[test]
857    fn test_follower_sync_offset() {
858        let mut f = TempoFollower::new("leader");
859        let now = Instant::now();
860        for i in 0..5 {
861            f.observe_beat_at(now + Duration::from_millis(500 * i as u64));
862        }
863        let offset = f.sync_offset(100.0).unwrap();
864        assert!(offset > 0.0); // Leader is faster than us
865    }
866
867    #[test]
868    fn test_follower_reset() {
869        let mut f = TempoFollower::new("leader");
870        f.observe_beat();
871        f.observe_beat();
872        f.reset();
873        assert_eq!(f.observed_count(), 0);
874        assert!(f.estimated_bpm().is_none());
875    }
876
877    #[test]
878    fn test_follower_window_size() {
879        let mut f = TempoFollower::new("leader").with_window_size(4);
880        for _ in 0..10 {
881            f.observe_beat();
882        }
883        assert_eq!(f.observed_count(), 4);
884    }
885
886    // --- TempoLeader tests ---
887
888    #[test]
889    fn test_leader_creation() {
890        let l = TempoLeader::new("conductor", TempoMarking::allegro());
891        assert_eq!(l.leader_id(), "conductor");
892        assert_eq!(l.follower_count(), 0);
893        assert!(!l.rubato_enabled());
894        assert!(!l.curve_active());
895    }
896
897    #[test]
898    fn test_leader_followers() {
899        let mut l = TempoLeader::new("conductor", TempoMarking::allegro());
900        l.add_follower("agent-a", 0.0);
901        l.add_follower("agent-b", 10.0);
902        assert_eq!(l.follower_count(), 2);
903        assert_eq!(l.tempo_for_follower("agent-a"), 140.0);
904        assert_eq!(l.tempo_for_follower("agent-b"), 150.0);
905        assert_eq!(l.tempo_for_follower("unknown"), 140.0); // default offset = 0
906    }
907
908    #[test]
909    fn test_leader_remove_follower() {
910        let mut l = TempoLeader::new("conductor", TempoMarking::andante());
911        l.add_follower("agent-a", 0.0);
912        assert!(l.remove_follower("agent-a"));
913        assert!(!l.remove_follower("agent-a"));
914        assert_eq!(l.follower_count(), 0);
915    }
916
917    #[test]
918    fn test_leader_set_tempo() {
919        let mut l = TempoLeader::new("conductor", TempoMarking::andante());
920        l.set_tempo(TempoMarking::presto());
921        assert_eq!(l.effective_bpm(), 180.0);
922    }
923
924    #[test]
925    fn test_leader_rubato() {
926        let mut l = TempoLeader::new("conductor", TempoMarking::allegro());
927        l.set_rubato(true);
928        assert!(l.rubato_enabled());
929    }
930
931    #[test]
932    fn test_leader_curve() {
933        let mut l = TempoLeader::new("conductor", TempoMarking::new(80.0, 0.0));
934        let curve = TempoCurve::new(80.0, 140.0, 16);
935        l.start_curve(curve);
936        assert!(l.curve_active());
937
938        // Advance through the curve
939        let bpm_25 = l.advance_curve(0.25);
940        assert!(bpm_25.is_some());
941        assert!(l.curve_active());
942
943        let bpm_50 = l.advance_curve(0.25);
944        assert!(bpm_50.is_some());
945
946        let bpm_100 = l.advance_curve(0.50);
947        assert!(bpm_100.is_some());
948        assert!(!l.curve_active()); // curve complete
949        assert_eq!(l.effective_bpm(), bpm_100.unwrap());
950    }
951
952    // --- TempoMap tests ---
953
954    #[test]
955    fn test_empty_tempo_map() {
956        let map = TempoMap::new(120.0);
957        assert!(map.is_empty());
958        assert_eq!(map.bpm_at_beat(0.0), 120.0);
959        assert_eq!(map.bpm_at_beat(100.0), 120.0);
960    }
961
962    #[test]
963    fn test_tempo_map_entries() {
964        let mut map = TempoMap::new(120.0);
965        map.add_marking(0.0, 80.0, Some("Intro".into()));
966        map.add_marking(16.0, 120.0, Some("Theme".into()));
967        map.add_marking(48.0, 100.0, Some("Bridge".into()));
968        map.add_marking(64.0, 140.0, Some("Finale".into()));
969
970        assert_eq!(map.entry_count(), 4);
971        assert_eq!(map.bpm_at_beat(0.0), 80.0);
972        assert_eq!(map.bpm_at_beat(15.0), 80.0);
973        assert_eq!(map.bpm_at_beat(16.0), 120.0);
974        assert_eq!(map.bpm_at_beat(32.0), 120.0);
975        assert_eq!(map.bpm_at_beat(48.0), 100.0);
976        assert_eq!(map.bpm_at_beat(60.0), 100.0);
977        assert_eq!(map.bpm_at_beat(64.0), 140.0);
978    }
979
980    #[test]
981    fn test_tempo_map_out_of_range() {
982        let mut map = TempoMap::new(120.0);
983        map.add_marking(0.0, 100.0, None);
984        map.add_marking(8.0, 120.0, None);
985        // Beyond the last entry, use the last entry's BPM
986        assert_eq!(map.bpm_at_beat(100.0), 120.0);
987    }
988
989    #[test]
990    fn test_tempo_map_default() {
991        let map = TempoMap::new(90.0);
992        assert_eq!(map.bpm_at_beat(0.0), 90.0);
993    }
994
995    #[test]
996    fn test_tempo_map_unordered_insert() {
997        let mut map = TempoMap::new(120.0);
998        map.add_marking(32.0, 140.0, None);
999        map.add_marking(0.0, 80.0, None);
1000        map.add_marking(16.0, 120.0, None);
1001        // Entries should be sorted by beat
1002        assert_eq!(map.bpm_at_beat(0.0), 80.0);
1003        assert_eq!(map.bpm_at_beat(16.0), 120.0);
1004        assert_eq!(map.bpm_at_beat(32.0), 140.0);
1005    }
1006
1007    #[test]
1008    fn test_tempo_map_total_beats() {
1009        let mut map = TempoMap::new(120.0);
1010        map.add_marking(0.0, 100.0, None);
1011        map.add_marking(64.0, 120.0, None);
1012        assert_eq!(map.total_beats(), 64.0);
1013    }
1014
1015    #[test]
1016    fn test_tempo_map_duration() {
1017        let mut map = TempoMap::new(120.0);
1018        // 8 beats at 120 BPM = 4 seconds
1019        map.add_marking(0.0, 120.0, None);
1020        map.total_beats = 8.0;
1021        let dur = map.total_duration();
1022        assert_eq!(dur, Duration::from_secs(4));
1023    }
1024
1025    #[test]
1026    fn test_tempo_map_entries_access() {
1027        let mut map = TempoMap::new(120.0);
1028        map.add_marking(0.0, 80.0, Some("A".into()));
1029        map.add_marking(16.0, 120.0, Some("B".into()));
1030        let entries = map.entries();
1031        assert_eq!(entries.len(), 2);
1032        assert_eq!(entries[0].label.as_deref(), Some("A"));
1033        assert_eq!(entries[1].label.as_deref(), Some("B"));
1034    }
1035}