Skip to main content

acorde_core/model/
playback.rs

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