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 measure_beats = measure
176                        .time_sig
177                        .as_ref()
178                        .unwrap_or(&score.settings.time_signature)
179                        .total_beats();
180                    let measure_start_beats = time_beats;
181                    let measure_start_secs = time_secs_cursor;
182                    let mut local_beats = 0.0f64;
183                    let mut local_secs = 0.0f64;
184                    let mut swing_first = true;
185                    for note in &measure.voices[voice_idx] {
186                        if note.is_grace {
187                            continue;
188                        }
189                        let dur = match options.swing {
190                            Some(ratio)
191                                if note.tuplet.is_none()
192                                    && note.dot_count == 0
193                                    && note.duration == options.swing_unit =>
194                            {
195                                let pair = note.beats() * 2.0;
196                                let d = if swing_first {
197                                    ratio * pair
198                                } else {
199                                    (1.0 - ratio) * pair
200                                };
201                                swing_first = !swing_first;
202                                d
203                            }
204                            Some(_) => {
205                                swing_first = true;
206                                note.beats()
207                            }
208                            None => note.beats(),
209                        };
210                        if !note.is_rest {
211                            let mut velocity = note
212                                .dynamic
213                                .as_ref()
214                                .map(|d| d.to_velocity())
215                                .unwrap_or(64u8);
216                            let mut sounding_dur = dur;
217                            for art in &note.articulations {
218                                match art {
219                                    Articulation::Staccato | Articulation::Staccatissimo => {
220                                        sounding_dur *= 0.5;
221                                    }
222                                    Articulation::Accent | Articulation::Marcato => {
223                                        velocity = velocity.saturating_add(20).min(127);
224                                    }
225                                    Articulation::Fermata => {
226                                        sounding_dur *= options.fermata_multiplier;
227                                    }
228                                    _ => {}
229                                }
230                            }
231                            let pedal = note.pedal_start;
232                            let transpose = if part.midi_channel == 9 {
233                                0i8
234                            } else {
235                                staff.transpose_semitones
236                            };
237                            for pitch in &note.pitches {
238                                let midi = (pitch.to_midi() + transpose as i16).clamp(0, 127) as u8;
239                                events.push(PlaybackEvent {
240                                    time_beats: measure_start_beats + local_beats,
241                                    time_secs: measure_start_secs + local_secs,
242                                    pitch_midi: midi,
243                                    velocity,
244                                    duration_beats: sounding_dur,
245                                    duration_secs: sounding_dur / current_bpm * 60.0,
246                                    pedal,
247                                    part_index,
248                                    channel: part.midi_channel,
249                                    is_metronome: false,
250                                });
251                            }
252                        }
253                        local_beats += dur;
254                        local_secs += dur / current_bpm * 60.0;
255                    }
256                    // A voice may omit rests, but the next measure still starts
257                    // at the notated bar boundary. This also keeps sparse voices
258                    // aligned with compute_playback_position and other voices.
259                    time_beats = measure_start_beats + measure_beats;
260                    time_secs_cursor = measure_start_secs + measure_beats / current_bpm * 60.0;
261                }
262            }
263        }
264    }
265
266    if let Some(ref metro) = options.metronome {
267        let mut cursor_secs = 0.0f64;
268        let mut cursor_beats = 0.0f64;
269        let mut metro_bpm = bpm;
270        for &idx in &seq {
271            let first_staff = score.parts.first().and_then(|p| p.staves.first());
272            if let Some(t) = first_staff
273                .and_then(|s| s.measures.get(idx))
274                .and_then(|m| m.tempo)
275            {
276                metro_bpm = t.max(1) as f64;
277            }
278            let ts = first_staff
279                .and_then(|s| s.measures.get(idx))
280                .and_then(|m| m.time_sig.as_ref())
281                .unwrap_or(&score.settings.time_signature);
282            let beat_unit = ts.beat_unit_beats();
283            let num_beats = (ts.total_beats() / beat_unit).round() as u32;
284            for b in 0..num_beats {
285                let is_accent = b == 0;
286                let beat_offset_secs = b as f64 * beat_unit / metro_bpm * 60.0;
287                events.push(PlaybackEvent {
288                    time_beats: cursor_beats + b as f64 * beat_unit,
289                    time_secs: cursor_secs + beat_offset_secs,
290                    pitch_midi: if is_accent {
291                        metro.accent_pitch
292                    } else {
293                        metro.beat_pitch
294                    },
295                    velocity: if is_accent {
296                        metro.accent_velocity
297                    } else {
298                        metro.beat_velocity
299                    },
300                    duration_beats: beat_unit * 0.1,
301                    duration_secs: beat_unit * 0.1 / metro_bpm * 60.0,
302                    pedal: false,
303                    part_index: usize::MAX,
304                    channel: metro.channel,
305                    is_metronome: true,
306                });
307            }
308            let measure_beats = ts.total_beats();
309            cursor_secs += measure_beats / metro_bpm * 60.0;
310            cursor_beats += measure_beats;
311        }
312    }
313
314    events.sort_by(|a, b| {
315        a.time_beats
316            .partial_cmp(&b.time_beats)
317            .unwrap_or(std::cmp::Ordering::Equal)
318    });
319    events
320}
321
322// ── PlaybackPosition + compute_playback_position ──────────────────────────────
323
324/// Score position at a specific elapsed time.
325#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct PlaybackPosition {
327    /// Physical measure index (0-based), same coordinate space as [`PlaybackEvent`] fields.
328    pub measure_index: usize,
329    /// Beat offset within the measure (`0.0 … time_sig.total_beats()`).
330    pub beat: f64,
331}
332
333struct MeasureSegment {
334    measure_idx: usize,
335    start_secs: f64,
336    duration_secs: f64,
337    beats: f64,
338    bpm: f64,
339}
340
341fn build_measure_segments(score: &Score, options: &PlaybackOptions) -> Vec<MeasureSegment> {
342    let init_bpm = options
343        .bpm_override
344        .unwrap_or(score.settings.tempo_bpm)
345        .max(1) as f64;
346    let full_seq = measure_sequence(score);
347    let seq: Vec<usize> = if let Some((lo, hi)) = options.loop_region {
348        full_seq
349            .into_iter()
350            .filter(|&i| i >= lo && i <= hi)
351            .collect()
352    } else {
353        full_seq
354    };
355
356    let mut segments = Vec::with_capacity(seq.len());
357    let mut cursor_secs = 0.0f64;
358    let mut current_bpm = init_bpm;
359
360    for idx in seq {
361        let first_measure = score
362            .parts
363            .first()
364            .and_then(|p| p.staves.first())
365            .and_then(|s| s.measures.get(idx));
366        if let Some(t) = first_measure.and_then(|m| m.tempo) {
367            current_bpm = t.max(1) as f64;
368        }
369        let ts = first_measure
370            .and_then(|m| m.time_sig.as_ref())
371            .unwrap_or(&score.settings.time_signature);
372        let beats = ts.total_beats();
373        let duration_secs = beats / current_bpm * 60.0;
374
375        segments.push(MeasureSegment {
376            measure_idx: idx,
377            start_secs: cursor_secs,
378            duration_secs,
379            beats,
380            bpm: current_bpm,
381        });
382        cursor_secs += duration_secs;
383    }
384    segments
385}
386
387/// Map `elapsed_secs` to a position within the score.
388///
389/// Returns `None` if `elapsed_secs` is negative or past the end of the last measure.
390/// Pass the same [`PlaybackOptions`] used for [`to_playback_events`] so that `loop_region`
391/// and tempo overrides are applied consistently.
392pub fn compute_playback_position(
393    score: &Score,
394    options: &PlaybackOptions,
395    elapsed_secs: f64,
396) -> Option<PlaybackPosition> {
397    if elapsed_secs < 0.0 {
398        return None;
399    }
400    let segments = build_measure_segments(score, options);
401    for seg in &segments {
402        if elapsed_secs < seg.start_secs + seg.duration_secs + 1e-9 {
403            let beat = ((elapsed_secs - seg.start_secs) * seg.bpm / 60.0).clamp(0.0, seg.beats);
404            return Some(PlaybackPosition {
405                measure_index: seg.measure_idx,
406                beat,
407            });
408        }
409    }
410    None
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use crate::model::{
417        duration::Duration,
418        pitch::{Pitch, Step},
419        score::{Note, Score},
420    };
421
422    fn opts(bpm: Option<u16>) -> PlaybackOptions {
423        PlaybackOptions {
424            bpm_override: bpm,
425            ..Default::default()
426        }
427    }
428
429    #[test]
430    fn empty_score_no_events() {
431        let score = Score::new("T", 120, 4, 4, 0, 1);
432        assert!(to_playback_events(&score, &opts(None)).is_empty());
433    }
434
435    #[test]
436    fn single_note_at_beat_zero() {
437        let mut score = Score::new("T", 120, 4, 4, 0, 1);
438        score.parts[0].staves[0].measures[0].voices[0] =
439            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
440        let events = to_playback_events(&score, &opts(None));
441        assert_eq!(events.len(), 1);
442        assert!((events[0].time_beats).abs() < 1e-9);
443        assert_eq!(events[0].pitch_midi, 60);
444        assert_eq!(events[0].velocity, 64);
445        assert!((events[0].duration_beats - 1.0).abs() < 1e-9);
446        assert_eq!(events[0].part_index, 0);
447    }
448
449    #[test]
450    fn chord_expands_to_multiple_events() {
451        let mut score = Score::new("T", 120, 4, 4, 0, 1);
452        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
453        note.pitches.push(Pitch::new(Step::E, 4));
454        note.pitches.push(Pitch::new(Step::G, 4));
455        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
456        let events = to_playback_events(&score, &opts(None));
457        assert_eq!(events.len(), 3);
458        assert!(events.iter().all(|e| e.time_beats.abs() < 1e-9));
459    }
460
461    #[test]
462    fn grace_notes_excluded() {
463        let mut score = Score::new("T", 120, 4, 4, 0, 1);
464        let mut grace = Note::new(Pitch::new(Step::D, 4), Duration::Eighth);
465        grace.is_grace = true;
466        let regular = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
467        score.parts[0].staves[0].measures[0].voices[0] = vec![grace, regular];
468        let events = to_playback_events(&score, &opts(None));
469        assert_eq!(events.len(), 1);
470        assert_eq!(events[0].pitch_midi, 60);
471    }
472
473    #[test]
474    fn second_note_has_correct_time() {
475        let mut score = Score::new("T", 120, 4, 4, 0, 1);
476        score.parts[0].staves[0].measures[0].voices[0] = vec![
477            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
478            Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
479        ];
480        let events = to_playback_events(&score, &opts(None));
481        assert_eq!(events.len(), 2);
482        assert!((events[0].time_beats).abs() < 1e-9);
483        assert!((events[1].time_beats - 1.0).abs() < 1e-9);
484    }
485
486    #[test]
487    fn sparse_voice_keeps_measure_boundaries() {
488        let mut score = Score::new("T", 120, 4, 4, 0, 2);
489        let note = || Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
490        score.parts[0].staves[0].measures[0].voices[1] = vec![note()];
491        score.parts[0].staves[0].measures[1].voices[1] = vec![note()];
492
493        let events = to_playback_events(&score, &opts(None));
494        assert_eq!(events.len(), 2);
495        assert!((events[0].time_beats).abs() < 1e-9);
496        assert!((events[1].time_beats - 4.0).abs() < 1e-9);
497        assert!((events[1].time_secs - 2.0).abs() < 1e-9);
498    }
499
500    #[test]
501    fn time_secs_120_bpm_quarter_note_is_half_second() {
502        let mut score = Score::new("T", 120, 4, 4, 0, 1);
503        score.parts[0].staves[0].measures[0].voices[0] =
504            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
505        let events = to_playback_events(&score, &opts(None));
506        assert!((events[0].time_secs).abs() < 1e-9);
507        assert!((events[0].duration_secs - 0.5).abs() < 1e-9);
508    }
509
510    #[test]
511    fn bpm_override_changes_time_secs() {
512        let mut score = Score::new("T", 120, 4, 4, 0, 1);
513        score.parts[0].staves[0].measures[0].voices[0] = vec![
514            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
515            Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
516        ];
517        let events = to_playback_events(&score, &opts(Some(60)));
518        assert!((events[0].time_secs).abs() < 1e-9);
519        assert!((events[1].time_secs - 1.0).abs() < 1e-9);
520    }
521
522    #[test]
523    fn transpose_semitones_shifts_midi_output() {
524        let mut score = Score::new("T", 120, 4, 4, 0, 1);
525        score.parts[0].staves[0].transpose_semitones = -2;
526        score.parts[0].staves[0].measures[0].voices[0] =
527            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
528        let events = to_playback_events(&score, &opts(None));
529        assert_eq!(events.len(), 1);
530        assert_eq!(events[0].pitch_midi, 58);
531    }
532
533    #[test]
534    fn percussion_channel_9_ignores_transpose_semitones() {
535        let mut score = Score::new("T", 120, 4, 4, 0, 1);
536        score.parts[0].midi_channel = 9;
537        score.parts[0].staves[0].transpose_semitones = -2;
538        score.parts[0].staves[0].measures[0].voices[0] =
539            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
540        let events = to_playback_events(&score, &opts(None));
541        assert_eq!(events.len(), 1);
542        assert_eq!(events[0].pitch_midi, 60);
543    }
544
545    #[test]
546    fn staccato_halves_duration_beats() {
547        let mut score = Score::new("T", 120, 4, 4, 0, 1);
548        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
549        note.articulations
550            .push(crate::model::notation::Articulation::Staccato);
551        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
552        let events = to_playback_events(&score, &opts(None));
553        assert_eq!(events.len(), 1);
554        assert!((events[0].duration_beats - 0.5).abs() < 1e-9);
555    }
556
557    #[test]
558    fn staccatissimo_also_halves_duration() {
559        let mut score = Score::new("T", 120, 4, 4, 0, 1);
560        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
561        note.articulations
562            .push(crate::model::notation::Articulation::Staccatissimo);
563        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
564        let events = to_playback_events(&score, &opts(None));
565        assert!((events[0].duration_beats - 0.5).abs() < 1e-9);
566    }
567
568    #[test]
569    fn staccato_does_not_shift_next_note_time() {
570        let mut score = Score::new("T", 120, 4, 4, 0, 1);
571        let mut n1 = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
572        n1.articulations
573            .push(crate::model::notation::Articulation::Staccato);
574        let n2 = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
575        score.parts[0].staves[0].measures[0].voices[0] = vec![n1, n2];
576        let events = to_playback_events(&score, &opts(None));
577        assert_eq!(events.len(), 2);
578        assert!((events[1].time_beats - 1.0).abs() < 1e-9);
579    }
580
581    #[test]
582    fn accent_boosts_velocity_clamped() {
583        let mut score = Score::new("T", 120, 4, 4, 0, 1);
584        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
585        note.articulations
586            .push(crate::model::notation::Articulation::Accent);
587        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
588        let events = to_playback_events(&score, &opts(None));
589        assert_eq!(events[0].velocity, 84);
590    }
591
592    #[test]
593    fn accent_clamped_at_127() {
594        let mut score = Score::new("T", 120, 4, 4, 0, 1);
595        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
596        note.dynamic = Some(crate::model::notation::Dynamic::Ffff);
597        note.articulations
598            .push(crate::model::notation::Articulation::Accent);
599        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
600        let events = to_playback_events(&score, &opts(None));
601        assert_eq!(events[0].velocity, 127);
602    }
603
604    #[test]
605    fn tenuto_keeps_full_duration() {
606        let mut score = Score::new("T", 120, 4, 4, 0, 1);
607        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
608        note.articulations
609            .push(crate::model::notation::Articulation::Tenuto);
610        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
611        let events = to_playback_events(&score, &opts(None));
612        assert!((events[0].duration_beats - 1.0).abs() < 1e-9);
613    }
614
615    #[test]
616    fn pedal_start_sets_pedal_field() {
617        let mut score = Score::new("T", 120, 4, 4, 0, 1);
618        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
619        note.pedal_start = true;
620        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
621        let events = to_playback_events(&score, &opts(None));
622        assert!(events[0].pedal);
623    }
624
625    #[test]
626    fn no_pedal_start_pedal_is_false() {
627        let mut score = Score::new("T", 120, 4, 4, 0, 1);
628        score.parts[0].staves[0].measures[0].voices[0] =
629            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
630        let events = to_playback_events(&score, &opts(None));
631        assert!(!events[0].pedal);
632    }
633
634    #[test]
635    fn set_tempo_at_measure_changes_time_secs() {
636        let mut score = Score::new("T", 120, 4, 4, 0, 2);
637        score.parts[0].staves[0].measures[0].voices[0] =
638            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
639        score.parts[0].staves[0].measures[1].tempo = Some(60);
640        score.parts[0].staves[0].measures[1].voices[0] =
641            vec![Note::new(Pitch::new(Step::D, 4), Duration::Whole)];
642        let events = to_playback_events(&score, &opts(None));
643        assert_eq!(events.len(), 2);
644        assert!((events[0].time_secs).abs() < 1e-9);
645        assert!((events[0].duration_secs - 2.0).abs() < 1e-9);
646        assert!((events[1].time_secs - 2.0).abs() < 1e-9);
647        assert!((events[1].duration_secs - 4.0).abs() < 1e-9);
648    }
649
650    #[test]
651    fn muted_part_produces_no_events() {
652        let mut score = Score::new("T", 120, 4, 4, 0, 1);
653        score.parts[0].staves[0].measures[0].voices[0] =
654            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
655        let options = PlaybackOptions {
656            muted_parts: vec![0],
657            ..Default::default()
658        };
659        assert!(to_playback_events(&score, &options).is_empty());
660    }
661
662    #[test]
663    fn part_index_field_set_correctly() {
664        let mut score = Score::new("T", 120, 4, 4, 0, 1);
665        score.parts[0].staves[0].measures[0].voices[0] =
666            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
667        let events = to_playback_events(&score, &opts(None));
668        assert_eq!(events[0].part_index, 0);
669    }
670
671    // ── loop_region ───────────────────────────────────────────────────────────
672
673    #[test]
674    fn loop_region_filters_measures() {
675        // 3 measures; notes in measures 0, 1, 2. Loop on [1,2] → only events from 1,2.
676        let mut score = Score::new("T", 120, 4, 4, 0, 3);
677        for mi in 0..3 {
678            score.parts[0].staves[0].measures[mi].voices[0] =
679                vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
680        }
681        let options = PlaybackOptions {
682            loop_region: Some((1, 2)),
683            ..Default::default()
684        };
685        let events = to_playback_events(&score, &options);
686        assert_eq!(events.len(), 2);
687        // First event in the region should start at beat 0 (region-relative)
688        assert!((events[0].time_beats).abs() < 1e-9);
689    }
690
691    #[test]
692    fn loop_region_none_plays_all_measures() {
693        let mut score = Score::new("T", 120, 4, 4, 0, 3);
694        for mi in 0..3 {
695            score.parts[0].staves[0].measures[mi].voices[0] =
696                vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
697        }
698        let events = to_playback_events(&score, &opts(None));
699        assert_eq!(events.len(), 3);
700    }
701
702    // ── Fermata ───────────────────────────────────────────────────────────────
703
704    #[test]
705    fn fermata_multiplier_extends_duration() {
706        let mut score = Score::new("T", 120, 4, 4, 0, 1);
707        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
708        note.articulations
709            .push(crate::model::notation::Articulation::Fermata);
710        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
711        let options = PlaybackOptions {
712            fermata_multiplier: 2.0,
713            ..Default::default()
714        };
715        let events = to_playback_events(&score, &options);
716        assert_eq!(events.len(), 1);
717        assert!((events[0].duration_beats - 2.0).abs() < 1e-9);
718    }
719
720    #[test]
721    fn fermata_default_multiplier_is_1_5() {
722        let mut score = Score::new("T", 120, 4, 4, 0, 1);
723        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
724        note.articulations
725            .push(crate::model::notation::Articulation::Fermata);
726        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
727        let events = to_playback_events(&score, &PlaybackOptions::default());
728        assert_eq!(events.len(), 1);
729        assert!((events[0].duration_beats - 1.5).abs() < 1e-9);
730    }
731
732    #[test]
733    fn non_fermata_note_unaffected_by_fermata_multiplier() {
734        let mut score = Score::new("T", 120, 4, 4, 0, 1);
735        score.parts[0].staves[0].measures[0].voices[0] =
736            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
737        let options = PlaybackOptions {
738            fermata_multiplier: 3.0,
739            ..Default::default()
740        };
741        let events = to_playback_events(&score, &options);
742        assert!((events[0].duration_beats - 1.0).abs() < 1e-9);
743    }
744
745    // ── swing ─────────────────────────────────────────────────────────────────
746
747    #[test]
748    fn swing_triplet_first_eighth_is_long() {
749        // Two eighth notes in one measure; swing=0.67 → first=0.67, second=0.33
750        let mut score = Score::new("T", 120, 4, 4, 0, 1);
751        score.parts[0].staves[0].measures[0].voices[0] = vec![
752            Note::new(Pitch::new(Step::C, 4), Duration::Eighth),
753            Note::new(Pitch::new(Step::D, 4), Duration::Eighth),
754        ];
755        let options = PlaybackOptions {
756            swing: Some(0.67),
757            ..Default::default()
758        };
759        let events = to_playback_events(&score, &options);
760        // events are sorted by time_beats; C comes first (time_beats=0), D second
761        let e_c = events.iter().find(|e| e.pitch_midi == 60).unwrap();
762        let e_d = events.iter().find(|e| e.pitch_midi == 62).unwrap();
763        assert!(
764            (e_c.duration_beats - 0.67).abs() < 1e-9,
765            "first eighth should be 0.67"
766        );
767        assert!(
768            (e_d.duration_beats - 0.33).abs() < 1e-9,
769            "second eighth should be 0.33"
770        );
771        // D starts at 0.67, not 0.5
772        assert!(
773            (e_d.time_beats - 0.67).abs() < 1e-9,
774            "second note start should be at 0.67"
775        );
776    }
777
778    #[test]
779    fn swing_non_eighth_not_affected() {
780        let mut score = Score::new("T", 120, 4, 4, 0, 1);
781        score.parts[0].staves[0].measures[0].voices[0] =
782            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
783        let options = PlaybackOptions {
784            swing: Some(0.67),
785            ..Default::default()
786        };
787        let events = to_playback_events(&score, &options);
788        assert!(
789            (events[0].duration_beats - 1.0).abs() < 1e-9,
790            "quarter note unaffected"
791        );
792    }
793
794    #[test]
795    fn swing_none_is_straight() {
796        let mut score = Score::new("T", 120, 4, 4, 0, 1);
797        score.parts[0].staves[0].measures[0].voices[0] =
798            vec![Note::new(Pitch::new(Step::C, 4), Duration::Eighth)];
799        let options = PlaybackOptions {
800            swing: None,
801            ..Default::default()
802        };
803        let events = to_playback_events(&score, &options);
804        assert!(
805            (events[0].duration_beats - 0.5).abs() < 1e-9,
806            "no swing = straight eighth"
807        );
808    }
809
810    #[test]
811    fn channel_matches_part_midi_channel() {
812        let mut score = Score::new("T", 120, 4, 4, 0, 1);
813        score.parts[0].midi_channel = 3;
814        score.parts[0].staves[0].measures[0].voices[0] =
815            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
816        let events = to_playback_events(&score, &opts(None));
817        assert_eq!(events[0].channel, 3);
818    }
819
820    #[test]
821    fn swing_unit_default_is_eighth() {
822        assert_eq!(PlaybackOptions::default().swing_unit, Duration::Eighth);
823    }
824
825    #[test]
826    fn swing_unit_sixteenth() {
827        let mut score = Score::new("T", 120, 4, 4, 0, 1);
828        score.parts[0].staves[0].measures[0].voices[0] = vec![
829            Note::new(Pitch::new(Step::C, 4), Duration::Sixteenth),
830            Note::new(Pitch::new(Step::D, 4), Duration::Sixteenth),
831        ];
832        let options = PlaybackOptions {
833            swing: Some(0.67),
834            swing_unit: Duration::Sixteenth,
835            ..Default::default()
836        };
837        let events = to_playback_events(&score, &options);
838        let e_c = events.iter().find(|e| e.pitch_midi == 60).unwrap();
839        let e_d = events.iter().find(|e| e.pitch_midi == 62).unwrap();
840        assert!(
841            (e_c.duration_beats - 0.335).abs() < 1e-9,
842            "first 16th should be 0.335"
843        );
844        assert!(
845            (e_d.duration_beats - 0.165).abs() < 1e-9,
846            "second 16th should be 0.165"
847        );
848    }
849
850    #[test]
851    fn swing_resets_per_measure() {
852        // 2 measures each with 2 eighth notes; each measure's first eighth should be "long"
853        let mut score = Score::new("T", 120, 4, 4, 0, 2);
854        let pair = || {
855            vec![
856                Note::new(Pitch::new(Step::C, 4), Duration::Eighth),
857                Note::new(Pitch::new(Step::D, 4), Duration::Eighth),
858            ]
859        };
860        score.parts[0].staves[0].measures[0].voices[0] = pair();
861        score.parts[0].staves[0].measures[1].voices[0] = pair();
862        let options = PlaybackOptions {
863            swing: Some(0.67),
864            ..Default::default()
865        };
866        let events = to_playback_events(&score, &options);
867        // Four events sorted by time: m0-C, m0-D, m1-C, m1-D
868        let durations: Vec<f64> = events.iter().map(|e| e.duration_beats).collect();
869        // m0 first (long)
870        assert!((durations[0] - 0.67).abs() < 1e-9, "m0 first note long");
871        // m0 second (short)
872        assert!((durations[1] - 0.33).abs() < 1e-9, "m0 second note short");
873        // m1 first (long again — reset)
874        assert!(
875            (durations[2] - 0.67).abs() < 1e-9,
876            "m1 first note long (reset)"
877        );
878        // m1 second (short)
879        assert!((durations[3] - 0.33).abs() < 1e-9, "m1 second note short");
880    }
881
882    // ── compute_playback_position ─────────────────────────────────────────────
883
884    #[test]
885    fn playback_position_at_zero_is_measure_0_beat_0() {
886        let score = Score::new("T", 120, 4, 4, 0, 4);
887        let pos = compute_playback_position(&score, &PlaybackOptions::default(), 0.0).unwrap();
888        assert_eq!(pos.measure_index, 0);
889        assert!(pos.beat.abs() < 1e-9);
890    }
891
892    #[test]
893    fn playback_position_at_half_measure_is_beat_2() {
894        // 4/4, 120 BPM → 1 measure = 2.0 s; 0.5 s = beat 1.0
895        let score = Score::new("T", 120, 4, 4, 0, 4);
896        let pos = compute_playback_position(&score, &PlaybackOptions::default(), 0.5).unwrap();
897        assert_eq!(pos.measure_index, 0);
898        assert!(
899            (pos.beat - 1.0).abs() < 1e-9,
900            "expected beat 1.0, got {}",
901            pos.beat
902        );
903    }
904
905    #[test]
906    fn playback_position_beyond_score_is_none() {
907        // 4/4, 120 BPM, 1 measure = 2.0 s; 10.0 s is beyond
908        let score = Score::new("T", 120, 4, 4, 0, 1);
909        assert!(compute_playback_position(&score, &PlaybackOptions::default(), 10.0).is_none());
910    }
911
912    #[test]
913    fn playback_position_tempo_change_takes_effect() {
914        // measure 0: 120 BPM (2.0 s), measure 1: 60 BPM (4.0 s)
915        // At elapsed=2.5 s → inside measure 1, 0.5 s into it → beat 0.5
916        let mut score = Score::new("T", 120, 4, 4, 0, 2);
917        score.parts[0].staves[0].measures[1].tempo = Some(60);
918        let pos = compute_playback_position(&score, &PlaybackOptions::default(), 2.5).unwrap();
919        assert_eq!(pos.measure_index, 1);
920        assert!(
921            (pos.beat - 0.5).abs() < 1e-9,
922            "expected beat 0.5, got {}",
923            pos.beat
924        );
925    }
926
927    #[test]
928    fn playback_position_loop_region_starts_at_zero() {
929        // loop_region=[1,2] → elapsed=0 should map to measure 1, beat 0
930        let score = Score::new("T", 120, 4, 4, 0, 4);
931        let options = PlaybackOptions {
932            loop_region: Some((1, 2)),
933            ..Default::default()
934        };
935        let pos = compute_playback_position(&score, &options, 0.0).unwrap();
936        assert_eq!(pos.measure_index, 1);
937        assert!(pos.beat.abs() < 1e-9);
938    }
939
940    // ── MetronomeConfig ───────────────────────────────────────────────────────
941
942    #[test]
943    fn metronome_injects_beat_events() {
944        // 4/4, 1 measure → should inject 4 metronome events
945        let score = Score::new("T", 120, 4, 4, 0, 1);
946        let options = PlaybackOptions {
947            metronome: Some(MetronomeConfig::default()),
948            ..Default::default()
949        };
950        let events = to_playback_events(&score, &options);
951        let metro_events: Vec<_> = events.iter().filter(|e| e.is_metronome).collect();
952        assert_eq!(metro_events.len(), 4, "expected 4 metronome clicks in 4/4");
953    }
954
955    #[test]
956    fn metronome_accent_is_first_beat() {
957        let score = Score::new("T", 120, 4, 4, 0, 1);
958        let metro = MetronomeConfig::default();
959        let options = PlaybackOptions {
960            metronome: Some(metro.clone()),
961            ..Default::default()
962        };
963        let events = to_playback_events(&score, &options);
964        let mut metro_events: Vec<_> = events.iter().filter(|e| e.is_metronome).collect();
965        metro_events.sort_by(|a, b| a.time_beats.partial_cmp(&b.time_beats).unwrap());
966        assert_eq!(metro_events[0].pitch_midi, metro.accent_pitch);
967        assert_eq!(metro_events[0].velocity, metro.accent_velocity);
968    }
969
970    #[test]
971    fn metronome_regular_beat_pitch() {
972        let score = Score::new("T", 120, 4, 4, 0, 1);
973        let metro = MetronomeConfig::default();
974        let options = PlaybackOptions {
975            metronome: Some(metro.clone()),
976            ..Default::default()
977        };
978        let events = to_playback_events(&score, &options);
979        let mut metro_events: Vec<_> = events.iter().filter(|e| e.is_metronome).collect();
980        metro_events.sort_by(|a, b| a.time_beats.partial_cmp(&b.time_beats).unwrap());
981        for ev in &metro_events[1..] {
982            assert_eq!(ev.pitch_midi, metro.beat_pitch);
983            assert_eq!(ev.velocity, metro.beat_velocity);
984        }
985    }
986
987    #[test]
988    fn metronome_events_are_marked() {
989        let score = Score::new("T", 120, 4, 4, 0, 1);
990        let options = PlaybackOptions {
991            metronome: Some(MetronomeConfig::default()),
992            ..Default::default()
993        };
994        let events = to_playback_events(&score, &options);
995        assert!(events.iter().any(|e| e.is_metronome));
996    }
997
998    #[test]
999    fn metronome_none_produces_no_extra_events() {
1000        let score = Score::new("T", 120, 4, 4, 0, 1);
1001        let events = to_playback_events(&score, &PlaybackOptions::default());
1002        assert!(events.iter().all(|e| !e.is_metronome));
1003    }
1004}