Skip to main content

acorde_core/model/
playback.rs

1use super::duration::Duration;
2use super::notation::Articulation;
3use super::repeat::measure_sequence;
4use super::score::Score;
5use serde::{Deserialize, Serialize};
6
7fn default_fermata_multiplier() -> f64 {
8    1.5
9}
10fn default_swing_unit() -> Duration {
11    Duration::Eighth
12}
13fn default_metronome_channel() -> u8 {
14    9
15}
16fn default_accent_pitch() -> u8 {
17    76
18}
19fn default_beat_pitch() -> u8 {
20    77
21}
22fn default_accent_velocity() -> u8 {
23    100
24}
25fn default_beat_velocity() -> u8 {
26    70
27}
28
29/// Click-track injected into [`to_playback_events`] output.
30///
31/// Metronome events are tagged with `PlaybackEvent.is_metronome = true` and can be
32/// routed separately by checking `channel` (default 9 = GM drums).
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct MetronomeConfig {
35    /// MIDI channel for the click track. Default 9 (GM drum channel).
36    #[serde(default = "default_metronome_channel")]
37    pub channel: u8,
38    /// MIDI note for the accented first beat. Default 76 (High Wood Block).
39    #[serde(default = "default_accent_pitch")]
40    pub accent_pitch: u8,
41    /// MIDI note for regular beats. Default 77 (Low Wood Block).
42    #[serde(default = "default_beat_pitch")]
43    pub beat_pitch: u8,
44    /// Velocity for the accented first beat. Default 100.
45    #[serde(default = "default_accent_velocity")]
46    pub accent_velocity: u8,
47    /// Velocity for regular beats. Default 70.
48    #[serde(default = "default_beat_velocity")]
49    pub beat_velocity: u8,
50}
51
52impl Default for MetronomeConfig {
53    fn default() -> Self {
54        Self {
55            channel: 9,
56            accent_pitch: 76,
57            beat_pitch: 77,
58            accent_velocity: 100,
59            beat_velocity: 70,
60        }
61    }
62}
63
64/// Options for [`to_playback_events`].
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct PlaybackOptions {
67    /// Replaces the score's tempo when `Some`; `None` uses `score.settings.tempo_bpm`.
68    pub bpm_override: Option<u16>,
69    /// Part indices to silence. Events from these parts are omitted entirely.
70    pub muted_parts: Vec<usize>,
71    /// Restrict playback to measures in the inclusive range `[start, end]`.
72    /// Physical measure indices (0-based). `None` plays the full sequence.
73    /// Events within the region start at `time_beats = 0`.
74    #[serde(default)]
75    pub loop_region: Option<(usize, usize)>,
76    /// Duration multiplier for notes with `Fermata` articulation. Default 1.5.
77    #[serde(default = "default_fermata_multiplier")]
78    pub fermata_multiplier: f64,
79    /// Swing ratio for pairs of plain notes of [`swing_unit`] duration. `None` = straight.
80    /// `0.67` ≈ triplet swing (2:1). Valid range: (0.5, 1.0).
81    /// Applied only to notes matching `swing_unit`, no tuplet, no dot.
82    #[serde(default)]
83    pub swing: Option<f64>,
84    /// Note duration that swing is applied to. Defaults to `Duration::Eighth`.
85    /// Set to `Duration::Sixteenth` for Latin/funk 16th-note swing.
86    #[serde(default = "default_swing_unit")]
87    pub swing_unit: Duration,
88    /// When `Some`, injects metronome click events into the event list.
89    /// Clicks are tagged with `PlaybackEvent.is_metronome = true`.
90    #[serde(default)]
91    pub metronome: Option<MetronomeConfig>,
92}
93
94impl Default for PlaybackOptions {
95    fn default() -> Self {
96        Self {
97            bpm_override: None,
98            muted_parts: Vec::new(),
99            loop_region: None,
100            fermata_multiplier: 1.5,
101            swing: None,
102            swing_unit: Duration::Eighth,
103            metronome: None,
104        }
105    }
106}
107
108/// A single sounding event suitable for audio playback engines (e.g. Web Audio, Tone.js).
109///
110/// Grace notes and rests are excluded. Chords are expanded to one event per pitch.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct PlaybackEvent {
113    /// Absolute beat position from the start of the score.
114    pub time_beats: f64,
115    /// Absolute time in seconds from the start of the score.
116    pub time_secs: f64,
117    /// MIDI pitch number (0–127).
118    pub pitch_midi: u8,
119    /// MIDI velocity (1–127). Derived from [`Dynamic`](crate::Dynamic); defaults to 64.
120    /// Boosted by +20 for Accent / Marcato articulations (clamped to 127).
121    pub velocity: u8,
122    /// Sounding duration in beats. Halved for Staccato / Staccatissimo.
123    pub duration_beats: f64,
124    /// Sounding duration in seconds.
125    pub duration_secs: f64,
126    /// True when the note has `pedal_start` set (sustain pedal down).
127    pub pedal: bool,
128    /// Index of the part this event originates from (useful for per-channel MIDI routing).
129    pub part_index: usize,
130    /// MIDI channel of the originating part (`part.midi_channel`). For Tone.js channel routing.
131    pub channel: u8,
132    /// `true` for metronome click events injected via [`MetronomeConfig`].
133    #[serde(default)]
134    pub is_metronome: bool,
135}
136
137/// Convert a [`Score`] into a flat, time-ordered list of [`PlaybackEvent`]s.
138///
139/// All parts, staves, and voices are included unless excluded via [`PlaybackOptions`].
140/// Repeat sections and volta brackets are expanded using [`measure_sequence`].
141/// Events are sorted by `time_beats`.
142pub fn to_playback_events(score: &Score, options: &PlaybackOptions) -> Vec<PlaybackEvent> {
143    let bpm = options
144        .bpm_override
145        .unwrap_or(score.settings.tempo_bpm)
146        .max(1) as f64;
147    let full_seq = measure_sequence(score);
148    let seq: Vec<usize> = if let Some((lo, hi)) = options.loop_region {
149        full_seq
150            .into_iter()
151            .filter(|&idx| idx >= lo && idx <= hi)
152            .collect()
153    } else {
154        full_seq
155    };
156    let mut events: Vec<PlaybackEvent> = Vec::new();
157
158    for (part_index, part) in score.parts.iter().enumerate() {
159        if options.muted_parts.contains(&part_index) {
160            continue;
161        }
162        for staff in &part.staves {
163            for voice_idx in 0..4usize {
164                let mut time_beats = 0.0f64;
165                let mut time_secs_cursor = 0.0f64;
166                let mut current_bpm = bpm;
167                for &idx in &seq {
168                    let measure = match staff.measures.get(idx) {
169                        Some(m) => m,
170                        None => continue,
171                    };
172                    if let Some(b) = measure.tempo {
173                        current_bpm = b.max(1) as f64;
174                    }
175                    let mut swing_first = true;
176                    for note in &measure.voices[voice_idx] {
177                        if note.is_grace {
178                            continue;
179                        }
180                        let dur = match options.swing {
181                            Some(ratio)
182                                if note.tuplet.is_none()
183                                    && note.dot_count == 0
184                                    && note.duration == options.swing_unit =>
185                            {
186                                let pair = note.beats() * 2.0;
187                                let d = if swing_first {
188                                    ratio * pair
189                                } else {
190                                    (1.0 - ratio) * pair
191                                };
192                                swing_first = !swing_first;
193                                d
194                            }
195                            Some(_) => {
196                                swing_first = true;
197                                note.beats()
198                            }
199                            None => note.beats(),
200                        };
201                        if !note.is_rest {
202                            let mut velocity = note
203                                .dynamic
204                                .as_ref()
205                                .map(|d| d.to_velocity())
206                                .unwrap_or(64u8);
207                            let mut sounding_dur = dur;
208                            for art in &note.articulations {
209                                match art {
210                                    Articulation::Staccato | Articulation::Staccatissimo => {
211                                        sounding_dur *= 0.5;
212                                    }
213                                    Articulation::Accent | Articulation::Marcato => {
214                                        velocity = velocity.saturating_add(20).min(127);
215                                    }
216                                    Articulation::Fermata => {
217                                        sounding_dur *= options.fermata_multiplier;
218                                    }
219                                    _ => {}
220                                }
221                            }
222                            let pedal = note.pedal_start;
223                            let transpose = if part.midi_channel == 9 {
224                                0i8
225                            } else {
226                                staff.transpose_semitones
227                            };
228                            for pitch in &note.pitches {
229                                let midi = (pitch.to_midi() + transpose as i16).clamp(0, 127) as u8;
230                                events.push(PlaybackEvent {
231                                    time_beats,
232                                    time_secs: time_secs_cursor,
233                                    pitch_midi: midi,
234                                    velocity,
235                                    duration_beats: sounding_dur,
236                                    duration_secs: sounding_dur / current_bpm * 60.0,
237                                    pedal,
238                                    part_index,
239                                    channel: part.midi_channel,
240                                    is_metronome: false,
241                                });
242                            }
243                        }
244                        time_beats += dur;
245                        time_secs_cursor += dur / current_bpm * 60.0;
246                    }
247                }
248            }
249        }
250    }
251
252    if let Some(ref metro) = options.metronome {
253        let mut cursor_secs = 0.0f64;
254        let mut cursor_beats = 0.0f64;
255        let mut metro_bpm = bpm;
256        for &idx in &seq {
257            let first_staff = score.parts.first().and_then(|p| p.staves.first());
258            if let Some(t) = first_staff
259                .and_then(|s| s.measures.get(idx))
260                .and_then(|m| m.tempo)
261            {
262                metro_bpm = t.max(1) as f64;
263            }
264            let ts = first_staff
265                .and_then(|s| s.measures.get(idx))
266                .and_then(|m| m.time_sig.as_ref())
267                .unwrap_or(&score.settings.time_signature);
268            let beat_unit = ts.beat_unit_beats();
269            let num_beats = (ts.total_beats() / beat_unit).round() as u32;
270            for b in 0..num_beats {
271                let is_accent = b == 0;
272                let beat_offset_secs = b as f64 * beat_unit / metro_bpm * 60.0;
273                events.push(PlaybackEvent {
274                    time_beats: cursor_beats + b as f64 * beat_unit,
275                    time_secs: cursor_secs + beat_offset_secs,
276                    pitch_midi: if is_accent {
277                        metro.accent_pitch
278                    } else {
279                        metro.beat_pitch
280                    },
281                    velocity: if is_accent {
282                        metro.accent_velocity
283                    } else {
284                        metro.beat_velocity
285                    },
286                    duration_beats: beat_unit * 0.1,
287                    duration_secs: beat_unit * 0.1 / metro_bpm * 60.0,
288                    pedal: false,
289                    part_index: usize::MAX,
290                    channel: metro.channel,
291                    is_metronome: true,
292                });
293            }
294            let measure_beats = ts.total_beats();
295            cursor_secs += measure_beats / metro_bpm * 60.0;
296            cursor_beats += measure_beats;
297        }
298    }
299
300    events.sort_by(|a, b| {
301        a.time_beats
302            .partial_cmp(&b.time_beats)
303            .unwrap_or(std::cmp::Ordering::Equal)
304    });
305    events
306}
307
308// ── PlaybackPosition + compute_playback_position ──────────────────────────────
309
310/// Score position at a specific elapsed time.
311#[derive(Debug, Clone, Serialize, Deserialize)]
312pub struct PlaybackPosition {
313    /// Physical measure index (0-based), same coordinate space as [`PlaybackEvent`] fields.
314    pub measure_index: usize,
315    /// Beat offset within the measure (`0.0 … time_sig.total_beats()`).
316    pub beat: f64,
317}
318
319struct MeasureSegment {
320    measure_idx: usize,
321    start_secs: f64,
322    duration_secs: f64,
323    beats: f64,
324    bpm: f64,
325}
326
327fn build_measure_segments(score: &Score, options: &PlaybackOptions) -> Vec<MeasureSegment> {
328    let init_bpm = options
329        .bpm_override
330        .unwrap_or(score.settings.tempo_bpm)
331        .max(1) as f64;
332    let full_seq = measure_sequence(score);
333    let seq: Vec<usize> = if let Some((lo, hi)) = options.loop_region {
334        full_seq
335            .into_iter()
336            .filter(|&i| i >= lo && i <= hi)
337            .collect()
338    } else {
339        full_seq
340    };
341
342    let mut segments = Vec::with_capacity(seq.len());
343    let mut cursor_secs = 0.0f64;
344    let mut current_bpm = init_bpm;
345
346    for idx in seq {
347        let first_measure = score
348            .parts
349            .first()
350            .and_then(|p| p.staves.first())
351            .and_then(|s| s.measures.get(idx));
352        if let Some(t) = first_measure.and_then(|m| m.tempo) {
353            current_bpm = t.max(1) as f64;
354        }
355        let ts = first_measure
356            .and_then(|m| m.time_sig.as_ref())
357            .unwrap_or(&score.settings.time_signature);
358        let beats = ts.total_beats();
359        let duration_secs = beats / current_bpm * 60.0;
360
361        segments.push(MeasureSegment {
362            measure_idx: idx,
363            start_secs: cursor_secs,
364            duration_secs,
365            beats,
366            bpm: current_bpm,
367        });
368        cursor_secs += duration_secs;
369    }
370    segments
371}
372
373/// Map `elapsed_secs` to a position within the score.
374///
375/// Returns `None` if `elapsed_secs` is negative or past the end of the last measure.
376/// Pass the same [`PlaybackOptions`] used for [`to_playback_events`] so that `loop_region`
377/// and tempo overrides are applied consistently.
378pub fn compute_playback_position(
379    score: &Score,
380    options: &PlaybackOptions,
381    elapsed_secs: f64,
382) -> Option<PlaybackPosition> {
383    if elapsed_secs < 0.0 {
384        return None;
385    }
386    let segments = build_measure_segments(score, options);
387    for seg in &segments {
388        if elapsed_secs < seg.start_secs + seg.duration_secs + 1e-9 {
389            let beat = ((elapsed_secs - seg.start_secs) * seg.bpm / 60.0).clamp(0.0, seg.beats);
390            return Some(PlaybackPosition {
391                measure_index: seg.measure_idx,
392                beat,
393            });
394        }
395    }
396    None
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402    use crate::model::{
403        duration::Duration,
404        pitch::{Pitch, Step},
405        score::{Note, Score},
406    };
407
408    fn opts(bpm: Option<u16>) -> PlaybackOptions {
409        PlaybackOptions {
410            bpm_override: bpm,
411            ..Default::default()
412        }
413    }
414
415    #[test]
416    fn empty_score_no_events() {
417        let score = Score::new("T", 120, 4, 4, 0, 1);
418        assert!(to_playback_events(&score, &opts(None)).is_empty());
419    }
420
421    #[test]
422    fn single_note_at_beat_zero() {
423        let mut score = Score::new("T", 120, 4, 4, 0, 1);
424        score.parts[0].staves[0].measures[0].voices[0] =
425            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
426        let events = to_playback_events(&score, &opts(None));
427        assert_eq!(events.len(), 1);
428        assert!((events[0].time_beats).abs() < 1e-9);
429        assert_eq!(events[0].pitch_midi, 60);
430        assert_eq!(events[0].velocity, 64);
431        assert!((events[0].duration_beats - 1.0).abs() < 1e-9);
432        assert_eq!(events[0].part_index, 0);
433    }
434
435    #[test]
436    fn chord_expands_to_multiple_events() {
437        let mut score = Score::new("T", 120, 4, 4, 0, 1);
438        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
439        note.pitches.push(Pitch::new(Step::E, 4));
440        note.pitches.push(Pitch::new(Step::G, 4));
441        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
442        let events = to_playback_events(&score, &opts(None));
443        assert_eq!(events.len(), 3);
444        assert!(events.iter().all(|e| e.time_beats.abs() < 1e-9));
445    }
446
447    #[test]
448    fn grace_notes_excluded() {
449        let mut score = Score::new("T", 120, 4, 4, 0, 1);
450        let mut grace = Note::new(Pitch::new(Step::D, 4), Duration::Eighth);
451        grace.is_grace = true;
452        let regular = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
453        score.parts[0].staves[0].measures[0].voices[0] = vec![grace, regular];
454        let events = to_playback_events(&score, &opts(None));
455        assert_eq!(events.len(), 1);
456        assert_eq!(events[0].pitch_midi, 60);
457    }
458
459    #[test]
460    fn second_note_has_correct_time() {
461        let mut score = Score::new("T", 120, 4, 4, 0, 1);
462        score.parts[0].staves[0].measures[0].voices[0] = vec![
463            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
464            Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
465        ];
466        let events = to_playback_events(&score, &opts(None));
467        assert_eq!(events.len(), 2);
468        assert!((events[0].time_beats).abs() < 1e-9);
469        assert!((events[1].time_beats - 1.0).abs() < 1e-9);
470    }
471
472    #[test]
473    fn time_secs_120_bpm_quarter_note_is_half_second() {
474        let mut score = Score::new("T", 120, 4, 4, 0, 1);
475        score.parts[0].staves[0].measures[0].voices[0] =
476            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
477        let events = to_playback_events(&score, &opts(None));
478        assert!((events[0].time_secs).abs() < 1e-9);
479        assert!((events[0].duration_secs - 0.5).abs() < 1e-9);
480    }
481
482    #[test]
483    fn bpm_override_changes_time_secs() {
484        let mut score = Score::new("T", 120, 4, 4, 0, 1);
485        score.parts[0].staves[0].measures[0].voices[0] = vec![
486            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
487            Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
488        ];
489        let events = to_playback_events(&score, &opts(Some(60)));
490        assert!((events[0].time_secs).abs() < 1e-9);
491        assert!((events[1].time_secs - 1.0).abs() < 1e-9);
492    }
493
494    #[test]
495    fn transpose_semitones_shifts_midi_output() {
496        let mut score = Score::new("T", 120, 4, 4, 0, 1);
497        score.parts[0].staves[0].transpose_semitones = -2;
498        score.parts[0].staves[0].measures[0].voices[0] =
499            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
500        let events = to_playback_events(&score, &opts(None));
501        assert_eq!(events.len(), 1);
502        assert_eq!(events[0].pitch_midi, 58);
503    }
504
505    #[test]
506    fn percussion_channel_9_ignores_transpose_semitones() {
507        let mut score = Score::new("T", 120, 4, 4, 0, 1);
508        score.parts[0].midi_channel = 9;
509        score.parts[0].staves[0].transpose_semitones = -2;
510        score.parts[0].staves[0].measures[0].voices[0] =
511            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
512        let events = to_playback_events(&score, &opts(None));
513        assert_eq!(events.len(), 1);
514        assert_eq!(events[0].pitch_midi, 60);
515    }
516
517    #[test]
518    fn staccato_halves_duration_beats() {
519        let mut score = Score::new("T", 120, 4, 4, 0, 1);
520        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
521        note.articulations
522            .push(crate::model::notation::Articulation::Staccato);
523        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
524        let events = to_playback_events(&score, &opts(None));
525        assert_eq!(events.len(), 1);
526        assert!((events[0].duration_beats - 0.5).abs() < 1e-9);
527    }
528
529    #[test]
530    fn staccatissimo_also_halves_duration() {
531        let mut score = Score::new("T", 120, 4, 4, 0, 1);
532        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
533        note.articulations
534            .push(crate::model::notation::Articulation::Staccatissimo);
535        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
536        let events = to_playback_events(&score, &opts(None));
537        assert!((events[0].duration_beats - 0.5).abs() < 1e-9);
538    }
539
540    #[test]
541    fn staccato_does_not_shift_next_note_time() {
542        let mut score = Score::new("T", 120, 4, 4, 0, 1);
543        let mut n1 = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
544        n1.articulations
545            .push(crate::model::notation::Articulation::Staccato);
546        let n2 = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
547        score.parts[0].staves[0].measures[0].voices[0] = vec![n1, n2];
548        let events = to_playback_events(&score, &opts(None));
549        assert_eq!(events.len(), 2);
550        assert!((events[1].time_beats - 1.0).abs() < 1e-9);
551    }
552
553    #[test]
554    fn accent_boosts_velocity_clamped() {
555        let mut score = Score::new("T", 120, 4, 4, 0, 1);
556        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
557        note.articulations
558            .push(crate::model::notation::Articulation::Accent);
559        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
560        let events = to_playback_events(&score, &opts(None));
561        assert_eq!(events[0].velocity, 84);
562    }
563
564    #[test]
565    fn accent_clamped_at_127() {
566        let mut score = Score::new("T", 120, 4, 4, 0, 1);
567        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
568        note.dynamic = Some(crate::model::notation::Dynamic::Ffff);
569        note.articulations
570            .push(crate::model::notation::Articulation::Accent);
571        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
572        let events = to_playback_events(&score, &opts(None));
573        assert_eq!(events[0].velocity, 127);
574    }
575
576    #[test]
577    fn tenuto_keeps_full_duration() {
578        let mut score = Score::new("T", 120, 4, 4, 0, 1);
579        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
580        note.articulations
581            .push(crate::model::notation::Articulation::Tenuto);
582        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
583        let events = to_playback_events(&score, &opts(None));
584        assert!((events[0].duration_beats - 1.0).abs() < 1e-9);
585    }
586
587    #[test]
588    fn pedal_start_sets_pedal_field() {
589        let mut score = Score::new("T", 120, 4, 4, 0, 1);
590        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
591        note.pedal_start = true;
592        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
593        let events = to_playback_events(&score, &opts(None));
594        assert!(events[0].pedal);
595    }
596
597    #[test]
598    fn no_pedal_start_pedal_is_false() {
599        let mut score = Score::new("T", 120, 4, 4, 0, 1);
600        score.parts[0].staves[0].measures[0].voices[0] =
601            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
602        let events = to_playback_events(&score, &opts(None));
603        assert!(!events[0].pedal);
604    }
605
606    #[test]
607    fn set_tempo_at_measure_changes_time_secs() {
608        let mut score = Score::new("T", 120, 4, 4, 0, 2);
609        score.parts[0].staves[0].measures[0].voices[0] =
610            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
611        score.parts[0].staves[0].measures[1].tempo = Some(60);
612        score.parts[0].staves[0].measures[1].voices[0] =
613            vec![Note::new(Pitch::new(Step::D, 4), Duration::Whole)];
614        let events = to_playback_events(&score, &opts(None));
615        assert_eq!(events.len(), 2);
616        assert!((events[0].time_secs).abs() < 1e-9);
617        assert!((events[0].duration_secs - 2.0).abs() < 1e-9);
618        assert!((events[1].time_secs - 2.0).abs() < 1e-9);
619        assert!((events[1].duration_secs - 4.0).abs() < 1e-9);
620    }
621
622    #[test]
623    fn muted_part_produces_no_events() {
624        let mut score = Score::new("T", 120, 4, 4, 0, 1);
625        score.parts[0].staves[0].measures[0].voices[0] =
626            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
627        let options = PlaybackOptions {
628            muted_parts: vec![0],
629            ..Default::default()
630        };
631        assert!(to_playback_events(&score, &options).is_empty());
632    }
633
634    #[test]
635    fn part_index_field_set_correctly() {
636        let mut score = Score::new("T", 120, 4, 4, 0, 1);
637        score.parts[0].staves[0].measures[0].voices[0] =
638            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
639        let events = to_playback_events(&score, &opts(None));
640        assert_eq!(events[0].part_index, 0);
641    }
642
643    // ── loop_region ───────────────────────────────────────────────────────────
644
645    #[test]
646    fn loop_region_filters_measures() {
647        // 3 measures; notes in measures 0, 1, 2. Loop on [1,2] → only events from 1,2.
648        let mut score = Score::new("T", 120, 4, 4, 0, 3);
649        for mi in 0..3 {
650            score.parts[0].staves[0].measures[mi].voices[0] =
651                vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
652        }
653        let options = PlaybackOptions {
654            loop_region: Some((1, 2)),
655            ..Default::default()
656        };
657        let events = to_playback_events(&score, &options);
658        assert_eq!(events.len(), 2);
659        // First event in the region should start at beat 0 (region-relative)
660        assert!((events[0].time_beats).abs() < 1e-9);
661    }
662
663    #[test]
664    fn loop_region_none_plays_all_measures() {
665        let mut score = Score::new("T", 120, 4, 4, 0, 3);
666        for mi in 0..3 {
667            score.parts[0].staves[0].measures[mi].voices[0] =
668                vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
669        }
670        let events = to_playback_events(&score, &opts(None));
671        assert_eq!(events.len(), 3);
672    }
673
674    // ── Fermata ───────────────────────────────────────────────────────────────
675
676    #[test]
677    fn fermata_multiplier_extends_duration() {
678        let mut score = Score::new("T", 120, 4, 4, 0, 1);
679        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
680        note.articulations
681            .push(crate::model::notation::Articulation::Fermata);
682        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
683        let options = PlaybackOptions {
684            fermata_multiplier: 2.0,
685            ..Default::default()
686        };
687        let events = to_playback_events(&score, &options);
688        assert_eq!(events.len(), 1);
689        assert!((events[0].duration_beats - 2.0).abs() < 1e-9);
690    }
691
692    #[test]
693    fn fermata_default_multiplier_is_1_5() {
694        let mut score = Score::new("T", 120, 4, 4, 0, 1);
695        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
696        note.articulations
697            .push(crate::model::notation::Articulation::Fermata);
698        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
699        let events = to_playback_events(&score, &PlaybackOptions::default());
700        assert_eq!(events.len(), 1);
701        assert!((events[0].duration_beats - 1.5).abs() < 1e-9);
702    }
703
704    #[test]
705    fn non_fermata_note_unaffected_by_fermata_multiplier() {
706        let mut score = Score::new("T", 120, 4, 4, 0, 1);
707        score.parts[0].staves[0].measures[0].voices[0] =
708            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
709        let options = PlaybackOptions {
710            fermata_multiplier: 3.0,
711            ..Default::default()
712        };
713        let events = to_playback_events(&score, &options);
714        assert!((events[0].duration_beats - 1.0).abs() < 1e-9);
715    }
716
717    // ── swing ─────────────────────────────────────────────────────────────────
718
719    #[test]
720    fn swing_triplet_first_eighth_is_long() {
721        // Two eighth notes in one measure; swing=0.67 → first=0.67, second=0.33
722        let mut score = Score::new("T", 120, 4, 4, 0, 1);
723        score.parts[0].staves[0].measures[0].voices[0] = vec![
724            Note::new(Pitch::new(Step::C, 4), Duration::Eighth),
725            Note::new(Pitch::new(Step::D, 4), Duration::Eighth),
726        ];
727        let options = PlaybackOptions {
728            swing: Some(0.67),
729            ..Default::default()
730        };
731        let events = to_playback_events(&score, &options);
732        // events are sorted by time_beats; C comes first (time_beats=0), D second
733        let e_c = events.iter().find(|e| e.pitch_midi == 60).unwrap();
734        let e_d = events.iter().find(|e| e.pitch_midi == 62).unwrap();
735        assert!(
736            (e_c.duration_beats - 0.67).abs() < 1e-9,
737            "first eighth should be 0.67"
738        );
739        assert!(
740            (e_d.duration_beats - 0.33).abs() < 1e-9,
741            "second eighth should be 0.33"
742        );
743        // D starts at 0.67, not 0.5
744        assert!(
745            (e_d.time_beats - 0.67).abs() < 1e-9,
746            "second note start should be at 0.67"
747        );
748    }
749
750    #[test]
751    fn swing_non_eighth_not_affected() {
752        let mut score = Score::new("T", 120, 4, 4, 0, 1);
753        score.parts[0].staves[0].measures[0].voices[0] =
754            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
755        let options = PlaybackOptions {
756            swing: Some(0.67),
757            ..Default::default()
758        };
759        let events = to_playback_events(&score, &options);
760        assert!(
761            (events[0].duration_beats - 1.0).abs() < 1e-9,
762            "quarter note unaffected"
763        );
764    }
765
766    #[test]
767    fn swing_none_is_straight() {
768        let mut score = Score::new("T", 120, 4, 4, 0, 1);
769        score.parts[0].staves[0].measures[0].voices[0] =
770            vec![Note::new(Pitch::new(Step::C, 4), Duration::Eighth)];
771        let options = PlaybackOptions {
772            swing: None,
773            ..Default::default()
774        };
775        let events = to_playback_events(&score, &options);
776        assert!(
777            (events[0].duration_beats - 0.5).abs() < 1e-9,
778            "no swing = straight eighth"
779        );
780    }
781
782    #[test]
783    fn channel_matches_part_midi_channel() {
784        let mut score = Score::new("T", 120, 4, 4, 0, 1);
785        score.parts[0].midi_channel = 3;
786        score.parts[0].staves[0].measures[0].voices[0] =
787            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
788        let events = to_playback_events(&score, &opts(None));
789        assert_eq!(events[0].channel, 3);
790    }
791
792    #[test]
793    fn swing_unit_default_is_eighth() {
794        assert_eq!(PlaybackOptions::default().swing_unit, Duration::Eighth);
795    }
796
797    #[test]
798    fn swing_unit_sixteenth() {
799        let mut score = Score::new("T", 120, 4, 4, 0, 1);
800        score.parts[0].staves[0].measures[0].voices[0] = vec![
801            Note::new(Pitch::new(Step::C, 4), Duration::Sixteenth),
802            Note::new(Pitch::new(Step::D, 4), Duration::Sixteenth),
803        ];
804        let options = PlaybackOptions {
805            swing: Some(0.67),
806            swing_unit: Duration::Sixteenth,
807            ..Default::default()
808        };
809        let events = to_playback_events(&score, &options);
810        let e_c = events.iter().find(|e| e.pitch_midi == 60).unwrap();
811        let e_d = events.iter().find(|e| e.pitch_midi == 62).unwrap();
812        assert!(
813            (e_c.duration_beats - 0.335).abs() < 1e-9,
814            "first 16th should be 0.335"
815        );
816        assert!(
817            (e_d.duration_beats - 0.165).abs() < 1e-9,
818            "second 16th should be 0.165"
819        );
820    }
821
822    #[test]
823    fn swing_resets_per_measure() {
824        // 2 measures each with 2 eighth notes; each measure's first eighth should be "long"
825        let mut score = Score::new("T", 120, 4, 4, 0, 2);
826        let pair = || {
827            vec![
828                Note::new(Pitch::new(Step::C, 4), Duration::Eighth),
829                Note::new(Pitch::new(Step::D, 4), Duration::Eighth),
830            ]
831        };
832        score.parts[0].staves[0].measures[0].voices[0] = pair();
833        score.parts[0].staves[0].measures[1].voices[0] = pair();
834        let options = PlaybackOptions {
835            swing: Some(0.67),
836            ..Default::default()
837        };
838        let events = to_playback_events(&score, &options);
839        // Four events sorted by time: m0-C, m0-D, m1-C, m1-D
840        let durations: Vec<f64> = events.iter().map(|e| e.duration_beats).collect();
841        // m0 first (long)
842        assert!((durations[0] - 0.67).abs() < 1e-9, "m0 first note long");
843        // m0 second (short)
844        assert!((durations[1] - 0.33).abs() < 1e-9, "m0 second note short");
845        // m1 first (long again — reset)
846        assert!(
847            (durations[2] - 0.67).abs() < 1e-9,
848            "m1 first note long (reset)"
849        );
850        // m1 second (short)
851        assert!((durations[3] - 0.33).abs() < 1e-9, "m1 second note short");
852    }
853
854    // ── compute_playback_position ─────────────────────────────────────────────
855
856    #[test]
857    fn playback_position_at_zero_is_measure_0_beat_0() {
858        let score = Score::new("T", 120, 4, 4, 0, 4);
859        let pos = compute_playback_position(&score, &PlaybackOptions::default(), 0.0).unwrap();
860        assert_eq!(pos.measure_index, 0);
861        assert!(pos.beat.abs() < 1e-9);
862    }
863
864    #[test]
865    fn playback_position_at_half_measure_is_beat_2() {
866        // 4/4, 120 BPM → 1 measure = 2.0 s; 0.5 s = beat 1.0
867        let score = Score::new("T", 120, 4, 4, 0, 4);
868        let pos = compute_playback_position(&score, &PlaybackOptions::default(), 0.5).unwrap();
869        assert_eq!(pos.measure_index, 0);
870        assert!(
871            (pos.beat - 1.0).abs() < 1e-9,
872            "expected beat 1.0, got {}",
873            pos.beat
874        );
875    }
876
877    #[test]
878    fn playback_position_beyond_score_is_none() {
879        // 4/4, 120 BPM, 1 measure = 2.0 s; 10.0 s is beyond
880        let score = Score::new("T", 120, 4, 4, 0, 1);
881        assert!(compute_playback_position(&score, &PlaybackOptions::default(), 10.0).is_none());
882    }
883
884    #[test]
885    fn playback_position_tempo_change_takes_effect() {
886        // measure 0: 120 BPM (2.0 s), measure 1: 60 BPM (4.0 s)
887        // At elapsed=2.5 s → inside measure 1, 0.5 s into it → beat 0.5
888        let mut score = Score::new("T", 120, 4, 4, 0, 2);
889        score.parts[0].staves[0].measures[1].tempo = Some(60);
890        let pos = compute_playback_position(&score, &PlaybackOptions::default(), 2.5).unwrap();
891        assert_eq!(pos.measure_index, 1);
892        assert!(
893            (pos.beat - 0.5).abs() < 1e-9,
894            "expected beat 0.5, got {}",
895            pos.beat
896        );
897    }
898
899    #[test]
900    fn playback_position_loop_region_starts_at_zero() {
901        // loop_region=[1,2] → elapsed=0 should map to measure 1, beat 0
902        let score = Score::new("T", 120, 4, 4, 0, 4);
903        let options = PlaybackOptions {
904            loop_region: Some((1, 2)),
905            ..Default::default()
906        };
907        let pos = compute_playback_position(&score, &options, 0.0).unwrap();
908        assert_eq!(pos.measure_index, 1);
909        assert!(pos.beat.abs() < 1e-9);
910    }
911
912    // ── MetronomeConfig ───────────────────────────────────────────────────────
913
914    #[test]
915    fn metronome_injects_beat_events() {
916        // 4/4, 1 measure → should inject 4 metronome events
917        let score = Score::new("T", 120, 4, 4, 0, 1);
918        let options = PlaybackOptions {
919            metronome: Some(MetronomeConfig::default()),
920            ..Default::default()
921        };
922        let events = to_playback_events(&score, &options);
923        let metro_events: Vec<_> = events.iter().filter(|e| e.is_metronome).collect();
924        assert_eq!(metro_events.len(), 4, "expected 4 metronome clicks in 4/4");
925    }
926
927    #[test]
928    fn metronome_accent_is_first_beat() {
929        let score = Score::new("T", 120, 4, 4, 0, 1);
930        let metro = MetronomeConfig::default();
931        let options = PlaybackOptions {
932            metronome: Some(metro.clone()),
933            ..Default::default()
934        };
935        let events = to_playback_events(&score, &options);
936        let mut metro_events: Vec<_> = events.iter().filter(|e| e.is_metronome).collect();
937        metro_events.sort_by(|a, b| a.time_beats.partial_cmp(&b.time_beats).unwrap());
938        assert_eq!(metro_events[0].pitch_midi, metro.accent_pitch);
939        assert_eq!(metro_events[0].velocity, metro.accent_velocity);
940    }
941
942    #[test]
943    fn metronome_regular_beat_pitch() {
944        let score = Score::new("T", 120, 4, 4, 0, 1);
945        let metro = MetronomeConfig::default();
946        let options = PlaybackOptions {
947            metronome: Some(metro.clone()),
948            ..Default::default()
949        };
950        let events = to_playback_events(&score, &options);
951        let mut metro_events: Vec<_> = events.iter().filter(|e| e.is_metronome).collect();
952        metro_events.sort_by(|a, b| a.time_beats.partial_cmp(&b.time_beats).unwrap());
953        for ev in &metro_events[1..] {
954            assert_eq!(ev.pitch_midi, metro.beat_pitch);
955            assert_eq!(ev.velocity, metro.beat_velocity);
956        }
957    }
958
959    #[test]
960    fn metronome_events_are_marked() {
961        let score = Score::new("T", 120, 4, 4, 0, 1);
962        let options = PlaybackOptions {
963            metronome: Some(MetronomeConfig::default()),
964            ..Default::default()
965        };
966        let events = to_playback_events(&score, &options);
967        assert!(events.iter().any(|e| e.is_metronome));
968    }
969
970    #[test]
971    fn metronome_none_produces_no_extra_events() {
972        let score = Score::new("T", 120, 4, 4, 0, 1);
973        let events = to_playback_events(&score, &PlaybackOptions::default());
974        assert!(events.iter().all(|e| !e.is_metronome));
975    }
976}