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