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