Skip to main content

acorde_core/model/
playback.rs

1use super::duration::Duration;
2use super::notation::{Articulation, GuitarTechnique, TabPosition};
3use super::repeat::measure_sequence;
4use super::score::{NoteAddr, 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, PartialEq)]
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    /// Typed source address for notation hosts; `None` for metronome events.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub source: Option<NoteAddr>,
120    /// Original MusicXML voice number when the source used a non-default identifier.
121    /// `source` remains the stable four-slot canonical address used by editing APIs.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub source_voice_number: Option<u32>,
124    /// Absolute beat position from the start of the score.
125    pub time_beats: f64,
126    /// Absolute time in seconds from the start of the score.
127    pub time_secs: f64,
128    /// MIDI pitch number (0–127).
129    pub pitch_midi: u8,
130    /// Exact sounding pitch in hundredths of a MIDI semitone.
131    ///
132    /// `pitch_midi` is retained as a compatibility convenience for integer-MIDI hosts;
133    /// microtonal-capable hosts should use this field.
134    #[serde(default)]
135    pub pitch_midi_cents: i32,
136    /// MIDI velocity (1–127). Derived from [`Dynamic`](crate::Dynamic); defaults to 64.
137    /// Boosted by +20 for Accent / Marcato articulations (clamped to 127).
138    pub velocity: u8,
139    /// Sounding duration in beats. Halved for Staccato / Staccatissimo.
140    pub duration_beats: f64,
141    /// Sounding duration in seconds.
142    pub duration_secs: f64,
143    /// True when the note has `pedal_start` set (sustain pedal down).
144    pub pedal: bool,
145    /// Index of the part this event originates from (useful for per-channel MIDI routing).
146    pub part_index: usize,
147    /// MIDI channel of the originating part (`part.midi_channel`). For Tone.js channel routing.
148    pub channel: u8,
149    /// `true` for metronome click events injected via [`MetronomeConfig`].
150    #[serde(default)]
151    pub is_metronome: bool,
152}
153
154/// Version of the host-neutral playback timing comparison contract.
155pub const PLAYBACK_COMPARISON_CONTRACT_VERSION: u16 = 1;
156/// Maximum number of events accepted by one comparison operation.
157pub const MAX_PLAYBACK_COMPARISON_EVENTS: usize = 1_000_000;
158const MAX_PLAYBACK_MISMATCHES: usize = 256;
159/// Maximum number of projected tablature events retained in one report.
160pub const MAX_TAB_PERFORMANCE_EVENTS: usize = 1_000_000;
161const MAX_TAB_PERFORMANCE_DIAGNOSTICS: usize = 1_024;
162
163/// Timing tolerances for comparing a host/backend event trace with acorde's schedule.
164#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
165pub struct PlaybackTimingTolerance {
166    pub start_secs: f64,
167    pub duration_secs: f64,
168}
169
170impl Default for PlaybackTimingTolerance {
171    fn default() -> Self {
172        Self {
173            start_secs: 0.005,
174            duration_secs: 0.005,
175        }
176    }
177}
178
179/// A typed difference in a host playback trace. Audio rendering is deliberately not compared.
180#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
181pub enum PlaybackTimingMismatch {
182    EventCount { expected: usize, actual: usize },
183    EventIdentity { index: usize },
184    StartTime { index: usize, error_secs: f64 },
185    Duration { index: usize, error_secs: f64 },
186}
187
188/// Deterministic report for a bounded playback timing comparison.
189#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
190pub struct PlaybackTimingReport {
191    pub contract_version: u16,
192    pub expected_events: usize,
193    pub actual_events: usize,
194    pub matched_events: usize,
195    pub max_start_error_secs: f64,
196    pub max_duration_error_secs: f64,
197    pub within_tolerance: bool,
198    pub mismatches: Vec<PlaybackTimingMismatch>,
199}
200
201/// Compare a host-provided event trace with the deterministic score schedule.
202///
203/// This contract covers event identity and timing only. Web Audio scheduling,
204/// SoundFont decoding, device latency, and rendered PCM remain host/provider concerns.
205pub fn compare_playback_timing(
206    expected: &[PlaybackEvent],
207    actual: &[PlaybackEvent],
208    tolerance: &PlaybackTimingTolerance,
209) -> Result<PlaybackTimingReport, crate::Error> {
210    if !tolerance.start_secs.is_finite()
211        || tolerance.start_secs < 0.0
212        || !tolerance.duration_secs.is_finite()
213        || tolerance.duration_secs < 0.0
214    {
215        return Err(crate::Error::InvalidPlaybackComparison);
216    }
217    if expected.len() > MAX_PLAYBACK_COMPARISON_EVENTS {
218        return Err(crate::Error::PlaybackComparisonTooLarge(expected.len()));
219    }
220    if actual.len() > MAX_PLAYBACK_COMPARISON_EVENTS {
221        return Err(crate::Error::PlaybackComparisonTooLarge(actual.len()));
222    }
223    let mut mismatches = Vec::new();
224    if expected.len() != actual.len() {
225        mismatches.push(PlaybackTimingMismatch::EventCount {
226            expected: expected.len(),
227            actual: actual.len(),
228        });
229    }
230    let mut matched_events = 0;
231    let mut max_start_error_secs: f64 = 0.0;
232    let mut max_duration_error_secs: f64 = 0.0;
233    for (index, (expected_event, actual_event)) in expected.iter().zip(actual).enumerate() {
234        let source_identity_matches = match (&expected_event.source, &actual_event.source) {
235            (Some(expected), Some(actual)) => expected == actual,
236            _ => expected_event.address == actual_event.address,
237        };
238        let identity_matches = source_identity_matches
239            && expected_event.pitch_midi_cents == actual_event.pitch_midi_cents
240            && expected_event.velocity == actual_event.velocity
241            && expected_event.part_index == actual_event.part_index
242            && expected_event.channel == actual_event.channel
243            && expected_event.is_metronome == actual_event.is_metronome;
244        let start_error_secs = (expected_event.time_secs - actual_event.time_secs).abs();
245        let duration_error_secs = (expected_event.duration_secs - actual_event.duration_secs).abs();
246        if start_error_secs.is_finite() {
247            max_start_error_secs = max_start_error_secs.max(start_error_secs);
248        }
249        if duration_error_secs.is_finite() {
250            max_duration_error_secs = max_duration_error_secs.max(duration_error_secs);
251        }
252        let start_matches =
253            start_error_secs.is_finite() && start_error_secs <= tolerance.start_secs;
254        let duration_matches =
255            duration_error_secs.is_finite() && duration_error_secs <= tolerance.duration_secs;
256        if identity_matches && start_matches && duration_matches {
257            matched_events += 1;
258            continue;
259        }
260        if mismatches.len() < MAX_PLAYBACK_MISMATCHES {
261            if !identity_matches {
262                mismatches.push(PlaybackTimingMismatch::EventIdentity { index });
263            }
264            if !start_matches && mismatches.len() < MAX_PLAYBACK_MISMATCHES {
265                mismatches.push(PlaybackTimingMismatch::StartTime {
266                    index,
267                    error_secs: start_error_secs,
268                });
269            }
270            if !duration_matches && mismatches.len() < MAX_PLAYBACK_MISMATCHES {
271                mismatches.push(PlaybackTimingMismatch::Duration {
272                    index,
273                    error_secs: duration_error_secs,
274                });
275            }
276        }
277    }
278    Ok(PlaybackTimingReport {
279        contract_version: PLAYBACK_COMPARISON_CONTRACT_VERSION,
280        expected_events: expected.len(),
281        actual_events: actual.len(),
282        matched_events,
283        max_start_error_secs,
284        max_duration_error_secs,
285        within_tolerance: mismatches.is_empty(),
286        mismatches,
287    })
288}
289
290/// Version of the host-neutral tablature performance projection contract.
291pub const TAB_PERFORMANCE_CONTRACT_VERSION: u16 = 3;
292
293#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
294pub struct TablaturePerformanceEvent {
295    pub playback: PlaybackEvent,
296    pub string: u8,
297    pub fret: u8,
298    /// Authored guitar technique for the host playback adapter, when present.
299    #[serde(default)]
300    pub technique: Option<GuitarTechnique>,
301    /// Authored bend alteration in cents, when the technique is `Bend`.
302    #[serde(default)]
303    pub bend_alter_cents: Option<i16>,
304    pub expected_pitch_midi_cents: i32,
305    pub pitch_error_cents: i32,
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
309pub enum TablaturePerformanceDiagnostic {
310    NoTablatureStaff {
311        address: String,
312    },
313    MissingPosition {
314        address: String,
315        pitch_index: usize,
316    },
317    StringOutOfRange {
318        address: String,
319        string: u8,
320        lines: u8,
321    },
322    TuningUnavailable {
323        address: String,
324        string: u8,
325    },
326    PitchMismatch {
327        address: String,
328        pitch_index: usize,
329        error_cents: i32,
330    },
331}
332
333#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
334pub struct TablaturePerformanceReport {
335    pub contract_version: u16,
336    pub events: Vec<TablaturePerformanceEvent>,
337    pub diagnostics: Vec<TablaturePerformanceDiagnostic>,
338}
339
340/// Version of the score-model tablature round-trip diagnostic contract.
341pub const TAB_ROUND_TRIP_CONTRACT_VERSION: u16 = 1;
342
343#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
344pub struct TablatureRoundTripReport {
345    pub contract_version: u16,
346    pub checked_notes: usize,
347    pub positioned_notes: usize,
348    pub equivalent: bool,
349    pub diagnostics: Vec<TablatureRoundTripDiagnostic>,
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
353pub enum TablatureRoundTripDiagnostic {
354    StructureMismatch {
355        address: String,
356    },
357    TablatureConfigMismatch {
358        address: String,
359    },
360    PositionMismatch {
361        address: String,
362        pitch_index: usize,
363        expected: Option<TabPosition>,
364        actual: Option<TabPosition>,
365    },
366}
367
368/// Verify that authored tablature survives the canonical score JSON round-trip.
369///
370/// This checks score-model persistence only. It does not claim MusicXML/MSCX or external
371/// application equivalence; those format and host comparisons remain separate gates.
372pub fn tablature_round_trip_report(
373    score: &Score,
374) -> Result<TablatureRoundTripReport, crate::Error> {
375    let encoded = serde_json::to_string(score)
376        .map_err(|error| crate::Error::TabRoundTripSerialization(error.to_string()))?;
377    let restored: Score = serde_json::from_str(&encoded)
378        .map_err(|error| crate::Error::TabRoundTripSerialization(error.to_string()))?;
379    let mut checked_notes = 0;
380    let mut positioned_notes = 0;
381    let mut diagnostics = Vec::new();
382    for (part_index, part) in score.parts.iter().enumerate() {
383        let Some(restored_part) = restored.parts.get(part_index) else {
384            diagnostics.push(TablatureRoundTripDiagnostic::StructureMismatch {
385                address: format!("{part_index}"),
386            });
387            continue;
388        };
389        for (staff_index, staff) in part.staves.iter().enumerate() {
390            let address = format!("{part_index}:{staff_index}");
391            let Some(restored_staff) = restored_part.staves.get(staff_index) else {
392                diagnostics.push(TablatureRoundTripDiagnostic::StructureMismatch { address });
393                continue;
394            };
395            if staff.tablature != restored_staff.tablature {
396                diagnostics.push(TablatureRoundTripDiagnostic::TablatureConfigMismatch {
397                    address: address.clone(),
398                });
399            }
400            for (measure_index, measure) in staff.measures.iter().enumerate() {
401                let Some(restored_measure) = restored_staff.measures.get(measure_index) else {
402                    diagnostics.push(TablatureRoundTripDiagnostic::StructureMismatch {
403                        address: format!("{address}:{measure_index}"),
404                    });
405                    continue;
406                };
407                for (voice_index, voice) in measure.voices.iter().enumerate() {
408                    let Some(restored_voice) = restored_measure.voices.get(voice_index) else {
409                        diagnostics.push(TablatureRoundTripDiagnostic::StructureMismatch {
410                            address: format!("{address}:{measure_index}:{voice_index}"),
411                        });
412                        continue;
413                    };
414                    for (note_index, note) in voice.iter().enumerate() {
415                        checked_notes += 1;
416                        if !note.tab_positions.is_empty() || note.tab_position.is_some() {
417                            positioned_notes += 1;
418                        }
419                        let note_address =
420                            format!("{address}:{measure_index}:{voice_index}:{note_index}");
421                        let Some(restored_note) = restored_voice.get(note_index) else {
422                            diagnostics.push(TablatureRoundTripDiagnostic::StructureMismatch {
423                                address: note_address,
424                            });
425                            continue;
426                        };
427                        let pitch_count = note.pitches.len().max(note.tab_positions.len()).max(1);
428                        for pitch_index in 0..pitch_count {
429                            let expected = note
430                                .tab_positions
431                                .get(pitch_index)
432                                .cloned()
433                                .or_else(|| note.tab_position.clone());
434                            let actual = restored_note
435                                .tab_positions
436                                .get(pitch_index)
437                                .cloned()
438                                .or_else(|| restored_note.tab_position.clone());
439                            if expected != actual {
440                                diagnostics.push(TablatureRoundTripDiagnostic::PositionMismatch {
441                                    address: note_address.clone(),
442                                    pitch_index,
443                                    expected,
444                                    actual,
445                                });
446                            }
447                        }
448                    }
449                }
450            }
451        }
452    }
453    Ok(TablatureRoundTripReport {
454        contract_version: TAB_ROUND_TRIP_CONTRACT_VERSION,
455        checked_notes,
456        positioned_notes,
457        equivalent: diagnostics.is_empty(),
458        diagnostics,
459    })
460}
461
462/// Project score playback events onto authored tablature positions.
463///
464/// This operation never invents a string or fret. Callers may run
465/// [`assign_tablature_positions`](crate::assign_tablature_positions) before this
466/// projection when automatic positions are desired.
467pub fn project_tablature_performance(
468    score: &Score,
469    options: &PlaybackOptions,
470) -> Result<TablaturePerformanceReport, crate::Error> {
471    let playback = to_playback_events(score, options);
472    if playback.len() > MAX_TAB_PERFORMANCE_EVENTS {
473        return Err(crate::Error::TabPerformanceTooLarge(playback.len()));
474    }
475    let mut events = Vec::new();
476    let mut diagnostics = Vec::new();
477    for event in playback {
478        let Some(address) = event.address.as_deref() else {
479            continue;
480        };
481        let Some((part_index, staff_index, measure_index, voice_index, note_index)) =
482            parse_playback_address(address)
483        else {
484            continue;
485        };
486        let Some(staff) = score
487            .parts
488            .get(part_index)
489            .and_then(|part| part.staves.get(staff_index))
490        else {
491            continue;
492        };
493        let Some(note) = staff
494            .measures
495            .get(measure_index)
496            .and_then(|measure| measure.voices.get(voice_index))
497            .and_then(|voice| voice.get(note_index))
498        else {
499            continue;
500        };
501        let Some(tab) = staff.tablature.as_ref() else {
502            push_tab_diagnostic(
503                &mut diagnostics,
504                TablaturePerformanceDiagnostic::NoTablatureStaff {
505                    address: address.into(),
506                },
507            );
508            continue;
509        };
510        let transpose_cents = if event.channel == 9 {
511            0
512        } else {
513            i32::from(staff.transpose_semitones) * 100
514        };
515        let written_pitch_cents = event.pitch_midi_cents - transpose_cents;
516        let pitch_index = note
517            .pitches
518            .iter()
519            .position(|pitch| pitch.to_midi_cents() == written_pitch_cents)
520            .unwrap_or(0);
521        let position = note
522            .tab_positions
523            .get(pitch_index)
524            .or(note.tab_position.as_ref());
525        let Some(position) = position else {
526            push_tab_diagnostic(
527                &mut diagnostics,
528                TablaturePerformanceDiagnostic::MissingPosition {
529                    address: address.into(),
530                    pitch_index,
531                },
532            );
533            continue;
534        };
535        if position.string == 0 || position.string > tab.lines {
536            push_tab_diagnostic(
537                &mut diagnostics,
538                TablaturePerformanceDiagnostic::StringOutOfRange {
539                    address: address.into(),
540                    string: position.string,
541                    lines: tab.lines,
542                },
543            );
544            continue;
545        }
546        let Some(tuning) = tab.tuning_midi.get(usize::from(position.string - 1)) else {
547            push_tab_diagnostic(
548                &mut diagnostics,
549                TablaturePerformanceDiagnostic::TuningUnavailable {
550                    address: address.into(),
551                    string: position.string,
552                },
553            );
554            continue;
555        };
556        let expected_pitch_midi_cents =
557            tuning.saturating_add(i16::from(position.fret) + i16::from(tab.capo)) as i32 * 100;
558        let pitch_error_cents = event.pitch_midi_cents - expected_pitch_midi_cents;
559        if pitch_error_cents != 0 {
560            push_tab_diagnostic(
561                &mut diagnostics,
562                TablaturePerformanceDiagnostic::PitchMismatch {
563                    address: address.into(),
564                    pitch_index,
565                    error_cents: pitch_error_cents,
566                },
567            );
568        }
569        events.push(TablaturePerformanceEvent {
570            playback: event,
571            string: position.string,
572            fret: position.fret,
573            technique: note.guitar_technique.clone(),
574            bend_alter_cents: note.guitar_bend_alter_cents,
575            expected_pitch_midi_cents,
576            pitch_error_cents,
577        });
578    }
579    Ok(TablaturePerformanceReport {
580        contract_version: TAB_PERFORMANCE_CONTRACT_VERSION,
581        events,
582        diagnostics,
583    })
584}
585
586fn parse_playback_address(address: &str) -> Option<(usize, usize, usize, usize, usize)> {
587    let mut fields = address.split(':').map(|field| field.parse::<usize>().ok());
588    Some((
589        fields.next()??,
590        fields.next()??,
591        fields.next()??,
592        fields.next()??,
593        fields.next()??,
594    ))
595}
596
597fn push_tab_diagnostic(
598    diagnostics: &mut Vec<TablaturePerformanceDiagnostic>,
599    diagnostic: TablaturePerformanceDiagnostic,
600) {
601    if diagnostics.len() < MAX_TAB_PERFORMANCE_DIAGNOSTICS {
602        diagnostics.push(diagnostic);
603    }
604}
605
606/// Convert a [`Score`] into a flat, time-ordered list of [`PlaybackEvent`]s.
607///
608/// All parts, staves, and voices are included unless excluded via [`PlaybackOptions`].
609/// Repeat sections and volta brackets are expanded using [`measure_sequence`].
610/// Events are sorted by `time_beats`.
611pub fn to_playback_events(score: &Score, options: &PlaybackOptions) -> Vec<PlaybackEvent> {
612    let bpm = options
613        .bpm_override
614        .unwrap_or(score.settings.tempo_bpm)
615        .max(1) as f64;
616    let full_seq = measure_sequence(score);
617    let seq: Vec<usize> = if let Some((lo, hi)) = options.loop_region {
618        full_seq
619            .into_iter()
620            .filter(|&idx| idx >= lo && idx <= hi)
621            .collect()
622    } else {
623        full_seq
624    };
625    let mut events: Vec<PlaybackEvent> = Vec::new();
626
627    for (part_index, part) in score.parts.iter().enumerate() {
628        if options.muted_parts.contains(&part_index) {
629            continue;
630        }
631        for (staff_index, staff) in part.staves.iter().enumerate() {
632            for voice_idx in 0..4usize {
633                let mut time_beats = 0.0f64;
634                let mut time_secs_cursor = 0.0f64;
635                let mut current_bpm = bpm;
636                for &idx in &seq {
637                    let measure = match staff.measures.get(idx) {
638                        Some(m) => m,
639                        None => continue,
640                    };
641                    if let Some(b) = measure.tempo {
642                        current_bpm = b.max(1) as f64;
643                    }
644                    let measure_beats = measure
645                        .time_sig
646                        .as_ref()
647                        .unwrap_or(&score.settings.time_signature)
648                        .total_beats();
649                    let measure_start_beats = time_beats;
650                    let measure_start_secs = time_secs_cursor;
651                    let mut local_beats = 0.0f64;
652                    let mut local_secs = 0.0f64;
653                    let mut swing_first = true;
654                    for (note_index, note) in measure.voices[voice_idx].iter().enumerate() {
655                        if note.is_grace {
656                            continue;
657                        }
658                        let dur = match options.swing {
659                            Some(ratio)
660                                if note.tuplet.is_none()
661                                    && note.dot_count == 0
662                                    && note.duration == options.swing_unit =>
663                            {
664                                let pair = note.beats() * 2.0;
665                                let d = if swing_first {
666                                    ratio * pair
667                                } else {
668                                    (1.0 - ratio) * pair
669                                };
670                                swing_first = !swing_first;
671                                d
672                            }
673                            Some(_) => {
674                                swing_first = true;
675                                note.beats()
676                            }
677                            None => note.beats(),
678                        };
679                        if !note.is_rest {
680                            let mut velocity = note
681                                .dynamic
682                                .as_ref()
683                                .map(|d| d.to_velocity())
684                                .unwrap_or(64u8);
685                            let mut sounding_dur = dur;
686                            for art in &note.articulations {
687                                match art {
688                                    Articulation::Staccato | Articulation::Staccatissimo => {
689                                        sounding_dur *= 0.5;
690                                    }
691                                    Articulation::Accent | Articulation::Marcato => {
692                                        velocity = velocity.saturating_add(20).min(127);
693                                    }
694                                    Articulation::Fermata => {
695                                        sounding_dur *= options.fermata_multiplier;
696                                    }
697                                    _ => {}
698                                }
699                            }
700                            let pedal = note.pedal_start;
701                            let transpose = if part.midi_channel == 9 {
702                                0i8
703                            } else {
704                                staff.transpose_semitones
705                            };
706                            for pitch in &note.pitches {
707                                let midi = (pitch.to_midi() + transpose as i16).clamp(0, 127) as u8;
708                                events.push(PlaybackEvent {
709                                    address: Some(format!(
710                                        "{part_index}:{staff_index}:{idx}:{voice_idx}:{note_index}"
711                                    )),
712                                    source: Some(NoteAddr {
713                                        part: part_index,
714                                        staff: staff_index,
715                                        measure: idx,
716                                        voice: voice_idx,
717                                        note: note_index,
718                                    }),
719                                    source_voice_number: measure.source_voice_numbers[voice_idx],
720                                    time_beats: measure_start_beats + local_beats,
721                                    time_secs: measure_start_secs + local_secs,
722                                    pitch_midi: midi,
723                                    pitch_midi_cents: pitch.to_midi_cents()
724                                        + transpose as i32 * 100,
725                                    velocity,
726                                    duration_beats: sounding_dur,
727                                    duration_secs: sounding_dur / current_bpm * 60.0,
728                                    pedal,
729                                    part_index,
730                                    channel: part.midi_channel,
731                                    is_metronome: false,
732                                });
733                            }
734                        }
735                        local_beats += dur;
736                        local_secs += dur / current_bpm * 60.0;
737                    }
738                    // A voice may omit rests, but the next measure still starts
739                    // at the notated bar boundary. This also keeps sparse voices
740                    // aligned with compute_playback_position and other voices.
741                    time_beats = measure_start_beats + measure_beats;
742                    time_secs_cursor = measure_start_secs + measure_beats / current_bpm * 60.0;
743                }
744            }
745        }
746    }
747
748    if let Some(ref metro) = options.metronome {
749        let mut cursor_secs = 0.0f64;
750        let mut cursor_beats = 0.0f64;
751        let mut metro_bpm = bpm;
752        for &idx in &seq {
753            let first_staff = score.parts.first().and_then(|p| p.staves.first());
754            if let Some(t) = first_staff
755                .and_then(|s| s.measures.get(idx))
756                .and_then(|m| m.tempo)
757            {
758                metro_bpm = t.max(1) as f64;
759            }
760            let ts = first_staff
761                .and_then(|s| s.measures.get(idx))
762                .and_then(|m| m.time_sig.as_ref())
763                .unwrap_or(&score.settings.time_signature);
764            let beat_unit = ts.beat_unit_beats();
765            let num_beats = (ts.total_beats() / beat_unit).round() as u32;
766            for b in 0..num_beats {
767                let is_accent = b == 0;
768                let beat_offset_secs = b as f64 * beat_unit / metro_bpm * 60.0;
769                events.push(PlaybackEvent {
770                    address: None,
771                    source: None,
772                    source_voice_number: None,
773                    time_beats: cursor_beats + b as f64 * beat_unit,
774                    time_secs: cursor_secs + beat_offset_secs,
775                    pitch_midi: if is_accent {
776                        metro.accent_pitch
777                    } else {
778                        metro.beat_pitch
779                    },
780                    pitch_midi_cents: i32::from(if is_accent {
781                        metro.accent_pitch
782                    } else {
783                        metro.beat_pitch
784                    }) * 100,
785                    velocity: if is_accent {
786                        metro.accent_velocity
787                    } else {
788                        metro.beat_velocity
789                    },
790                    duration_beats: beat_unit * 0.1,
791                    duration_secs: beat_unit * 0.1 / metro_bpm * 60.0,
792                    pedal: false,
793                    part_index: usize::MAX,
794                    channel: metro.channel,
795                    is_metronome: true,
796                });
797            }
798            let measure_beats = ts.total_beats();
799            cursor_secs += measure_beats / metro_bpm * 60.0;
800            cursor_beats += measure_beats;
801        }
802    }
803
804    events = merge_tied_events(score, events);
805    events.sort_by(|a, b| {
806        a.time_beats
807            .partial_cmp(&b.time_beats)
808            .unwrap_or(std::cmp::Ordering::Equal)
809    });
810    events
811}
812
813/// Coalesce adjacent playback events that are connected by authored ties.
814///
815/// Ties are a notational continuation, not repeated attacks. The event keeps the first
816/// source address and accumulates the sounding duration of each contiguous segment. Malformed
817/// or non-contiguous tie endings remain as independent events so playback never silently drops
818/// a note.
819fn merge_tied_events(score: &Score, events: Vec<PlaybackEvent>) -> Vec<PlaybackEvent> {
820    use std::collections::HashMap;
821
822    let mut merged = Vec::with_capacity(events.len());
823    let mut pending: HashMap<(usize, usize, usize, i32), usize> = HashMap::new();
824    for event in events {
825        let Some(source) = event.source.as_ref() else {
826            merged.push(event);
827            continue;
828        };
829        let tied_note = score
830            .parts
831            .get(source.part)
832            .and_then(|part| part.staves.get(source.staff))
833            .and_then(|staff| staff.measures.get(source.measure))
834            .and_then(|measure| measure.voices.get(source.voice))
835            .and_then(|voice| voice.get(source.note));
836        let Some(note) = tied_note else {
837            merged.push(event);
838            continue;
839        };
840        let key = (
841            source.part,
842            source.staff,
843            source.voice,
844            event.pitch_midi_cents,
845        );
846        if note.tie_end
847            && pending.get(&key).is_some_and(|&index| {
848                merged.get(index).is_some_and(|previous| {
849                    (previous.time_beats + previous.duration_beats - event.time_beats).abs() < 1e-9
850                })
851            })
852        {
853            let index = pending[&key];
854            let previous = &mut merged[index];
855            previous.duration_beats += event.duration_beats;
856            previous.duration_secs += event.duration_secs;
857            if !note.tie_start {
858                pending.remove(&key);
859            }
860            continue;
861        }
862
863        let index = merged.len();
864        merged.push(event);
865        if note.tie_start {
866            pending.insert(key, index);
867        } else {
868            pending.remove(&key);
869        }
870    }
871    merged
872}
873
874/// Convert a score into playback events while enforcing the host-comparison event bound.
875pub fn to_playback_events_bounded(
876    score: &Score,
877    options: &PlaybackOptions,
878) -> Result<Vec<PlaybackEvent>, crate::Error> {
879    let events = to_playback_events(score, options);
880    if events.len() > MAX_PLAYBACK_COMPARISON_EVENTS {
881        return Err(crate::Error::PlaybackComparisonTooLarge(events.len()));
882    }
883    Ok(events)
884}
885
886// ── PlaybackPosition + compute_playback_position ──────────────────────────────
887
888/// Score position at a specific elapsed time.
889#[derive(Debug, Clone, Serialize, Deserialize)]
890pub struct PlaybackPosition {
891    /// Physical measure index (0-based), same coordinate space as [`PlaybackEvent`] fields.
892    pub measure_index: usize,
893    /// Beat offset within the measure (`0.0 … time_sig.total_beats()`).
894    pub beat: f64,
895}
896
897struct MeasureSegment {
898    measure_idx: usize,
899    start_secs: f64,
900    duration_secs: f64,
901    beats: f64,
902    bpm: f64,
903}
904
905fn build_measure_segments(score: &Score, options: &PlaybackOptions) -> Vec<MeasureSegment> {
906    let init_bpm = options
907        .bpm_override
908        .unwrap_or(score.settings.tempo_bpm)
909        .max(1) as f64;
910    let full_seq = measure_sequence(score);
911    let seq: Vec<usize> = if let Some((lo, hi)) = options.loop_region {
912        full_seq
913            .into_iter()
914            .filter(|&i| i >= lo && i <= hi)
915            .collect()
916    } else {
917        full_seq
918    };
919
920    let mut segments = Vec::with_capacity(seq.len());
921    let mut cursor_secs = 0.0f64;
922    let mut current_bpm = init_bpm;
923
924    for idx in seq {
925        let first_measure = score
926            .parts
927            .first()
928            .and_then(|p| p.staves.first())
929            .and_then(|s| s.measures.get(idx));
930        if let Some(t) = first_measure.and_then(|m| m.tempo) {
931            current_bpm = t.max(1) as f64;
932        }
933        let ts = first_measure
934            .and_then(|m| m.time_sig.as_ref())
935            .unwrap_or(&score.settings.time_signature);
936        let beats = ts.total_beats();
937        let duration_secs = beats / current_bpm * 60.0;
938
939        segments.push(MeasureSegment {
940            measure_idx: idx,
941            start_secs: cursor_secs,
942            duration_secs,
943            beats,
944            bpm: current_bpm,
945        });
946        cursor_secs += duration_secs;
947    }
948    segments
949}
950
951/// Map `elapsed_secs` to a position within the score.
952///
953/// Returns `None` if `elapsed_secs` is negative or past the end of the last measure.
954/// Pass the same [`PlaybackOptions`] used for [`to_playback_events`] so that `loop_region`
955/// and tempo overrides are applied consistently.
956pub fn compute_playback_position(
957    score: &Score,
958    options: &PlaybackOptions,
959    elapsed_secs: f64,
960) -> Option<PlaybackPosition> {
961    if elapsed_secs < 0.0 {
962        return None;
963    }
964    let segments = build_measure_segments(score, options);
965    for seg in &segments {
966        if elapsed_secs < seg.start_secs + seg.duration_secs + 1e-9 {
967            let beat = ((elapsed_secs - seg.start_secs) * seg.bpm / 60.0).clamp(0.0, seg.beats);
968            return Some(PlaybackPosition {
969                measure_index: seg.measure_idx,
970                beat,
971            });
972        }
973    }
974    None
975}
976
977#[cfg(test)]
978mod tests {
979    use super::*;
980    use crate::model::{
981        duration::Duration,
982        pitch::{Pitch, Step},
983        score::{Note, Score},
984    };
985
986    fn opts(bpm: Option<u16>) -> PlaybackOptions {
987        PlaybackOptions {
988            bpm_override: bpm,
989            ..Default::default()
990        }
991    }
992
993    #[test]
994    fn bounded_playback_events_match_unbounded_schedule_within_limit() {
995        let score = Score::new("bounded", 120, 4, 4, 0, 1);
996        let options = opts(Some(120));
997        let unbounded = to_playback_events(&score, &options);
998        let bounded = to_playback_events_bounded(&score, &options).expect("bounded schedule");
999        assert_eq!(bounded, unbounded);
1000    }
1001
1002    #[test]
1003    fn empty_score_no_events() {
1004        let score = Score::new("T", 120, 4, 4, 0, 1);
1005        assert!(to_playback_events(&score, &opts(None)).is_empty());
1006    }
1007
1008    #[test]
1009    fn single_note_at_beat_zero() {
1010        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1011        score.parts[0].staves[0].measures[0].voices[0] =
1012            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
1013        let events = to_playback_events(&score, &opts(None));
1014        assert_eq!(events.len(), 1);
1015        assert_eq!(events[0].address.as_deref(), Some("0:0:0:0:0"));
1016        assert_eq!(
1017            events[0].source,
1018            Some(NoteAddr {
1019                part: 0,
1020                staff: 0,
1021                measure: 0,
1022                voice: 0,
1023                note: 0,
1024            })
1025        );
1026        assert!((events[0].time_beats).abs() < 1e-9);
1027        assert_eq!(events[0].pitch_midi, 60);
1028        assert_eq!(events[0].velocity, 64);
1029        assert!((events[0].duration_beats - 1.0).abs() < 1e-9);
1030        assert_eq!(events[0].part_index, 0);
1031    }
1032
1033    #[test]
1034    fn tied_notes_are_one_continuous_playback_event() {
1035        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1036        let mut first = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1037        first.tie_start = true;
1038        let mut second = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1039        second.tie_end = true;
1040        score.parts[0].staves[0].measures[0].voices[0] = vec![first, second];
1041
1042        let events = to_playback_events(&score, &opts(None));
1043        assert_eq!(events.len(), 1);
1044        assert_eq!(
1045            events[0].source.as_ref().map(|address| address.note),
1046            Some(0)
1047        );
1048        assert!((events[0].time_beats).abs() < 1e-9);
1049        assert!((events[0].duration_beats - 2.0).abs() < 1e-9);
1050        assert!((events[0].duration_secs - 1.0).abs() < 1e-9);
1051    }
1052
1053    #[test]
1054    fn ties_cross_measure_boundaries_and_tempo_changes_without_retriggering() {
1055        let mut score = Score::new("T", 120, 4, 4, 0, 2);
1056        let first_measure = &mut score.parts[0].staves[0].measures[0];
1057        let mut first = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
1058        first.tie_start = true;
1059        first_measure.voices[0] = vec![first];
1060
1061        let second_measure = &mut score.parts[0].staves[0].measures[1];
1062        second_measure.tempo = Some(60);
1063        let mut second = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
1064        second.tie_end = true;
1065        second_measure.voices[0] = vec![second];
1066
1067        let events = to_playback_events(&score, &opts(None));
1068        assert_eq!(events.len(), 1);
1069        assert_eq!(
1070            events[0].source.as_ref().map(|address| address.measure),
1071            Some(0)
1072        );
1073        assert!((events[0].time_beats).abs() < 1e-9);
1074        assert!((events[0].duration_beats - 8.0).abs() < 1e-9);
1075        assert!((events[0].duration_secs - 6.0).abs() < 1e-9);
1076    }
1077
1078    #[test]
1079    fn malformed_tie_end_does_not_drop_playback_event() {
1080        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1081        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1082        note.tie_end = true;
1083        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
1084        let events = to_playback_events(&score, &opts(None));
1085        assert_eq!(events.len(), 1);
1086        assert!((events[0].duration_beats - 1.0).abs() < 1e-9);
1087    }
1088
1089    #[test]
1090    fn microtonal_playback_event_keeps_exact_midi_cents() {
1091        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1092        score.parts[0].staves[0].measures[0].voices[0] = vec![Note::new(
1093            Pitch::with_microtone(Step::C, 4, 0, 50),
1094            Duration::Quarter,
1095        )];
1096        let events = to_playback_events(&score, &opts(None));
1097        assert_eq!(events[0].pitch_midi, 61);
1098        assert_eq!(events[0].pitch_midi_cents, 6050);
1099    }
1100
1101    #[test]
1102    fn chord_expands_to_multiple_events() {
1103        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1104        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1105        note.pitches.push(Pitch::new(Step::E, 4));
1106        note.pitches.push(Pitch::new(Step::G, 4));
1107        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
1108        let events = to_playback_events(&score, &opts(None));
1109        assert_eq!(events.len(), 3);
1110        assert!(
1111            events
1112                .iter()
1113                .all(|event| event.address.as_deref() == Some("0:0:0:0:0"))
1114        );
1115        assert!(events.iter().all(|e| e.time_beats.abs() < 1e-9));
1116    }
1117
1118    #[test]
1119    fn grace_notes_excluded() {
1120        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1121        let mut grace = Note::new(Pitch::new(Step::D, 4), Duration::Eighth);
1122        grace.is_grace = true;
1123        let regular = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1124        score.parts[0].staves[0].measures[0].voices[0] = vec![grace, regular];
1125        let events = to_playback_events(&score, &opts(None));
1126        assert_eq!(events.len(), 1);
1127        assert_eq!(events[0].pitch_midi, 60);
1128    }
1129
1130    #[test]
1131    fn metronome_events_have_no_source_address() {
1132        let score = Score::new("T", 120, 4, 4, 0, 1);
1133        let options = PlaybackOptions {
1134            metronome: Some(MetronomeConfig::default()),
1135            ..Default::default()
1136        };
1137        let events = to_playback_events(&score, &options);
1138        assert!(!events.is_empty());
1139        assert!(events.iter().all(|event| event.address.is_none()));
1140    }
1141
1142    #[test]
1143    fn second_note_has_correct_time() {
1144        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1145        score.parts[0].staves[0].measures[0].voices[0] = vec![
1146            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
1147            Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
1148        ];
1149        let events = to_playback_events(&score, &opts(None));
1150        assert_eq!(events.len(), 2);
1151        assert!((events[0].time_beats).abs() < 1e-9);
1152        assert!((events[1].time_beats - 1.0).abs() < 1e-9);
1153    }
1154
1155    #[test]
1156    fn sparse_voice_keeps_measure_boundaries() {
1157        let mut score = Score::new("T", 120, 4, 4, 0, 2);
1158        let note = || Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1159        score.parts[0].staves[0].measures[0].voices[1] = vec![note()];
1160        score.parts[0].staves[0].measures[1].voices[1] = vec![note()];
1161
1162        let events = to_playback_events(&score, &opts(None));
1163        assert_eq!(events.len(), 2);
1164        assert!((events[0].time_beats).abs() < 1e-9);
1165        assert!((events[1].time_beats - 4.0).abs() < 1e-9);
1166        assert!((events[1].time_secs - 2.0).abs() < 1e-9);
1167    }
1168
1169    #[test]
1170    fn time_secs_120_bpm_quarter_note_is_half_second() {
1171        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1172        score.parts[0].staves[0].measures[0].voices[0] =
1173            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
1174        let events = to_playback_events(&score, &opts(None));
1175        assert!((events[0].time_secs).abs() < 1e-9);
1176        assert!((events[0].duration_secs - 0.5).abs() < 1e-9);
1177    }
1178
1179    #[test]
1180    fn bpm_override_changes_time_secs() {
1181        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1182        score.parts[0].staves[0].measures[0].voices[0] = vec![
1183            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
1184            Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
1185        ];
1186        let events = to_playback_events(&score, &opts(Some(60)));
1187        assert!((events[0].time_secs).abs() < 1e-9);
1188        assert!((events[1].time_secs - 1.0).abs() < 1e-9);
1189    }
1190
1191    #[test]
1192    fn transpose_semitones_shifts_midi_output() {
1193        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1194        score.parts[0].staves[0].transpose_semitones = -2;
1195        score.parts[0].staves[0].measures[0].voices[0] =
1196            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
1197        let events = to_playback_events(&score, &opts(None));
1198        assert_eq!(events.len(), 1);
1199        assert_eq!(events[0].pitch_midi, 58);
1200    }
1201
1202    #[test]
1203    fn percussion_channel_9_ignores_transpose_semitones() {
1204        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1205        score.parts[0].midi_channel = 9;
1206        score.parts[0].staves[0].transpose_semitones = -2;
1207        score.parts[0].staves[0].measures[0].voices[0] =
1208            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
1209        let events = to_playback_events(&score, &opts(None));
1210        assert_eq!(events.len(), 1);
1211        assert_eq!(events[0].pitch_midi, 60);
1212    }
1213
1214    #[test]
1215    fn staccato_halves_duration_beats() {
1216        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1217        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1218        note.articulations
1219            .push(crate::model::notation::Articulation::Staccato);
1220        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
1221        let events = to_playback_events(&score, &opts(None));
1222        assert_eq!(events.len(), 1);
1223        assert!((events[0].duration_beats - 0.5).abs() < 1e-9);
1224    }
1225
1226    #[test]
1227    fn staccatissimo_also_halves_duration() {
1228        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1229        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1230        note.articulations
1231            .push(crate::model::notation::Articulation::Staccatissimo);
1232        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
1233        let events = to_playback_events(&score, &opts(None));
1234        assert!((events[0].duration_beats - 0.5).abs() < 1e-9);
1235    }
1236
1237    #[test]
1238    fn staccato_does_not_shift_next_note_time() {
1239        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1240        let mut n1 = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1241        n1.articulations
1242            .push(crate::model::notation::Articulation::Staccato);
1243        let n2 = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
1244        score.parts[0].staves[0].measures[0].voices[0] = vec![n1, n2];
1245        let events = to_playback_events(&score, &opts(None));
1246        assert_eq!(events.len(), 2);
1247        assert!((events[1].time_beats - 1.0).abs() < 1e-9);
1248    }
1249
1250    #[test]
1251    fn accent_boosts_velocity_clamped() {
1252        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1253        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1254        note.articulations
1255            .push(crate::model::notation::Articulation::Accent);
1256        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
1257        let events = to_playback_events(&score, &opts(None));
1258        assert_eq!(events[0].velocity, 84);
1259    }
1260
1261    #[test]
1262    fn accent_clamped_at_127() {
1263        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1264        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1265        note.dynamic = Some(crate::model::notation::Dynamic::Ffff);
1266        note.articulations
1267            .push(crate::model::notation::Articulation::Accent);
1268        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
1269        let events = to_playback_events(&score, &opts(None));
1270        assert_eq!(events[0].velocity, 127);
1271    }
1272
1273    #[test]
1274    fn tenuto_keeps_full_duration() {
1275        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1276        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1277        note.articulations
1278            .push(crate::model::notation::Articulation::Tenuto);
1279        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
1280        let events = to_playback_events(&score, &opts(None));
1281        assert!((events[0].duration_beats - 1.0).abs() < 1e-9);
1282    }
1283
1284    #[test]
1285    fn pedal_start_sets_pedal_field() {
1286        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1287        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1288        note.pedal_start = true;
1289        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
1290        let events = to_playback_events(&score, &opts(None));
1291        assert!(events[0].pedal);
1292    }
1293
1294    #[test]
1295    fn no_pedal_start_pedal_is_false() {
1296        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1297        score.parts[0].staves[0].measures[0].voices[0] =
1298            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
1299        let events = to_playback_events(&score, &opts(None));
1300        assert!(!events[0].pedal);
1301    }
1302
1303    #[test]
1304    fn set_tempo_at_measure_changes_time_secs() {
1305        let mut score = Score::new("T", 120, 4, 4, 0, 2);
1306        score.parts[0].staves[0].measures[0].voices[0] =
1307            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1308        score.parts[0].staves[0].measures[1].tempo = Some(60);
1309        score.parts[0].staves[0].measures[1].voices[0] =
1310            vec![Note::new(Pitch::new(Step::D, 4), Duration::Whole)];
1311        let events = to_playback_events(&score, &opts(None));
1312        assert_eq!(events.len(), 2);
1313        assert!((events[0].time_secs).abs() < 1e-9);
1314        assert!((events[0].duration_secs - 2.0).abs() < 1e-9);
1315        assert!((events[1].time_secs - 2.0).abs() < 1e-9);
1316        assert!((events[1].duration_secs - 4.0).abs() < 1e-9);
1317    }
1318
1319    #[test]
1320    fn muted_part_produces_no_events() {
1321        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1322        score.parts[0].staves[0].measures[0].voices[0] =
1323            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
1324        let options = PlaybackOptions {
1325            muted_parts: vec![0],
1326            ..Default::default()
1327        };
1328        assert!(to_playback_events(&score, &options).is_empty());
1329    }
1330
1331    #[test]
1332    fn part_index_field_set_correctly() {
1333        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1334        score.parts[0].staves[0].measures[0].voices[0] =
1335            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
1336        let events = to_playback_events(&score, &opts(None));
1337        assert_eq!(events[0].part_index, 0);
1338    }
1339
1340    // ── loop_region ───────────────────────────────────────────────────────────
1341
1342    #[test]
1343    fn loop_region_filters_measures() {
1344        // 3 measures; notes in measures 0, 1, 2. Loop on [1,2] → only events from 1,2.
1345        let mut score = Score::new("T", 120, 4, 4, 0, 3);
1346        for mi in 0..3 {
1347            score.parts[0].staves[0].measures[mi].voices[0] =
1348                vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1349        }
1350        let options = PlaybackOptions {
1351            loop_region: Some((1, 2)),
1352            ..Default::default()
1353        };
1354        let events = to_playback_events(&score, &options);
1355        assert_eq!(events.len(), 2);
1356        // First event in the region should start at beat 0 (region-relative)
1357        assert!((events[0].time_beats).abs() < 1e-9);
1358    }
1359
1360    #[test]
1361    fn loop_region_none_plays_all_measures() {
1362        let mut score = Score::new("T", 120, 4, 4, 0, 3);
1363        for mi in 0..3 {
1364            score.parts[0].staves[0].measures[mi].voices[0] =
1365                vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1366        }
1367        let events = to_playback_events(&score, &opts(None));
1368        assert_eq!(events.len(), 3);
1369    }
1370
1371    // ── Fermata ───────────────────────────────────────────────────────────────
1372
1373    #[test]
1374    fn fermata_multiplier_extends_duration() {
1375        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1376        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1377        note.articulations
1378            .push(crate::model::notation::Articulation::Fermata);
1379        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
1380        let options = PlaybackOptions {
1381            fermata_multiplier: 2.0,
1382            ..Default::default()
1383        };
1384        let events = to_playback_events(&score, &options);
1385        assert_eq!(events.len(), 1);
1386        assert!((events[0].duration_beats - 2.0).abs() < 1e-9);
1387    }
1388
1389    #[test]
1390    fn fermata_default_multiplier_is_1_5() {
1391        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1392        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1393        note.articulations
1394            .push(crate::model::notation::Articulation::Fermata);
1395        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
1396        let events = to_playback_events(&score, &PlaybackOptions::default());
1397        assert_eq!(events.len(), 1);
1398        assert!((events[0].duration_beats - 1.5).abs() < 1e-9);
1399    }
1400
1401    #[test]
1402    fn non_fermata_note_unaffected_by_fermata_multiplier() {
1403        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1404        score.parts[0].staves[0].measures[0].voices[0] =
1405            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
1406        let options = PlaybackOptions {
1407            fermata_multiplier: 3.0,
1408            ..Default::default()
1409        };
1410        let events = to_playback_events(&score, &options);
1411        assert!((events[0].duration_beats - 1.0).abs() < 1e-9);
1412    }
1413
1414    // ── swing ─────────────────────────────────────────────────────────────────
1415
1416    #[test]
1417    fn swing_triplet_first_eighth_is_long() {
1418        // Two eighth notes in one measure; swing=0.67 → first=0.67, second=0.33
1419        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1420        score.parts[0].staves[0].measures[0].voices[0] = vec![
1421            Note::new(Pitch::new(Step::C, 4), Duration::Eighth),
1422            Note::new(Pitch::new(Step::D, 4), Duration::Eighth),
1423        ];
1424        let options = PlaybackOptions {
1425            swing: Some(0.67),
1426            ..Default::default()
1427        };
1428        let events = to_playback_events(&score, &options);
1429        // events are sorted by time_beats; C comes first (time_beats=0), D second
1430        let e_c = events.iter().find(|e| e.pitch_midi == 60).unwrap();
1431        let e_d = events.iter().find(|e| e.pitch_midi == 62).unwrap();
1432        assert!(
1433            (e_c.duration_beats - 0.67).abs() < 1e-9,
1434            "first eighth should be 0.67"
1435        );
1436        assert!(
1437            (e_d.duration_beats - 0.33).abs() < 1e-9,
1438            "second eighth should be 0.33"
1439        );
1440        // D starts at 0.67, not 0.5
1441        assert!(
1442            (e_d.time_beats - 0.67).abs() < 1e-9,
1443            "second note start should be at 0.67"
1444        );
1445    }
1446
1447    #[test]
1448    fn swing_non_eighth_not_affected() {
1449        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1450        score.parts[0].staves[0].measures[0].voices[0] =
1451            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
1452        let options = PlaybackOptions {
1453            swing: Some(0.67),
1454            ..Default::default()
1455        };
1456        let events = to_playback_events(&score, &options);
1457        assert!(
1458            (events[0].duration_beats - 1.0).abs() < 1e-9,
1459            "quarter note unaffected"
1460        );
1461    }
1462
1463    #[test]
1464    fn swing_none_is_straight() {
1465        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1466        score.parts[0].staves[0].measures[0].voices[0] =
1467            vec![Note::new(Pitch::new(Step::C, 4), Duration::Eighth)];
1468        let options = PlaybackOptions {
1469            swing: None,
1470            ..Default::default()
1471        };
1472        let events = to_playback_events(&score, &options);
1473        assert!(
1474            (events[0].duration_beats - 0.5).abs() < 1e-9,
1475            "no swing = straight eighth"
1476        );
1477    }
1478
1479    #[test]
1480    fn channel_matches_part_midi_channel() {
1481        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1482        score.parts[0].midi_channel = 3;
1483        score.parts[0].staves[0].measures[0].voices[0] =
1484            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
1485        let events = to_playback_events(&score, &opts(None));
1486        assert_eq!(events[0].channel, 3);
1487    }
1488
1489    #[test]
1490    fn swing_unit_default_is_eighth() {
1491        assert_eq!(PlaybackOptions::default().swing_unit, Duration::Eighth);
1492    }
1493
1494    #[test]
1495    fn swing_unit_sixteenth() {
1496        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1497        score.parts[0].staves[0].measures[0].voices[0] = vec![
1498            Note::new(Pitch::new(Step::C, 4), Duration::Sixteenth),
1499            Note::new(Pitch::new(Step::D, 4), Duration::Sixteenth),
1500        ];
1501        let options = PlaybackOptions {
1502            swing: Some(0.67),
1503            swing_unit: Duration::Sixteenth,
1504            ..Default::default()
1505        };
1506        let events = to_playback_events(&score, &options);
1507        let e_c = events.iter().find(|e| e.pitch_midi == 60).unwrap();
1508        let e_d = events.iter().find(|e| e.pitch_midi == 62).unwrap();
1509        assert!(
1510            (e_c.duration_beats - 0.335).abs() < 1e-9,
1511            "first 16th should be 0.335"
1512        );
1513        assert!(
1514            (e_d.duration_beats - 0.165).abs() < 1e-9,
1515            "second 16th should be 0.165"
1516        );
1517    }
1518
1519    #[test]
1520    fn swing_resets_per_measure() {
1521        // 2 measures each with 2 eighth notes; each measure's first eighth should be "long"
1522        let mut score = Score::new("T", 120, 4, 4, 0, 2);
1523        let pair = || {
1524            vec![
1525                Note::new(Pitch::new(Step::C, 4), Duration::Eighth),
1526                Note::new(Pitch::new(Step::D, 4), Duration::Eighth),
1527            ]
1528        };
1529        score.parts[0].staves[0].measures[0].voices[0] = pair();
1530        score.parts[0].staves[0].measures[1].voices[0] = pair();
1531        let options = PlaybackOptions {
1532            swing: Some(0.67),
1533            ..Default::default()
1534        };
1535        let events = to_playback_events(&score, &options);
1536        // Four events sorted by time: m0-C, m0-D, m1-C, m1-D
1537        let durations: Vec<f64> = events.iter().map(|e| e.duration_beats).collect();
1538        // m0 first (long)
1539        assert!((durations[0] - 0.67).abs() < 1e-9, "m0 first note long");
1540        // m0 second (short)
1541        assert!((durations[1] - 0.33).abs() < 1e-9, "m0 second note short");
1542        // m1 first (long again — reset)
1543        assert!(
1544            (durations[2] - 0.67).abs() < 1e-9,
1545            "m1 first note long (reset)"
1546        );
1547        // m1 second (short)
1548        assert!((durations[3] - 0.33).abs() < 1e-9, "m1 second note short");
1549    }
1550
1551    #[test]
1552    fn multi_voice_events_preserve_source_voice_addresses() {
1553        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1554        score.parts[0].staves[0].measures[0].voices[0] =
1555            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
1556        score.parts[0].staves[0].measures[0].voices[1] =
1557            vec![Note::new(Pitch::new(Step::E, 4), Duration::Quarter)];
1558
1559        let events = to_playback_events(&score, &PlaybackOptions::default());
1560        let addresses: Vec<&str> = events
1561            .iter()
1562            .filter_map(|event| event.address.as_deref())
1563            .collect();
1564        assert!(addresses.contains(&"0:0:0:0:0"));
1565        assert!(addresses.contains(&"0:0:0:1:0"));
1566    }
1567
1568    #[test]
1569    fn playback_exposes_original_musicxml_voice_number_alongside_slot_address() {
1570        let mut score = Score::new("T", 120, 4, 4, 0, 1);
1571        let measure = &mut score.parts[0].staves[0].measures[0];
1572        measure.voices[1] = vec![Note::new(Pitch::new(Step::E, 4), Duration::Quarter)];
1573        measure.source_voice_numbers[1] = Some(5);
1574
1575        let events = to_playback_events(&score, &PlaybackOptions::default());
1576        let event = events
1577            .iter()
1578            .find(|event| event.address.as_deref() == Some("0:0:0:1:0"))
1579            .expect("event from slot 1");
1580        assert_eq!(event.source.as_ref().map(|source| source.voice), Some(1));
1581        assert_eq!(event.source_voice_number, Some(5));
1582    }
1583
1584    // ── compute_playback_position ─────────────────────────────────────────────
1585
1586    #[test]
1587    fn playback_position_at_zero_is_measure_0_beat_0() {
1588        let score = Score::new("T", 120, 4, 4, 0, 4);
1589        let pos = compute_playback_position(&score, &PlaybackOptions::default(), 0.0).unwrap();
1590        assert_eq!(pos.measure_index, 0);
1591        assert!(pos.beat.abs() < 1e-9);
1592    }
1593
1594    #[test]
1595    fn playback_position_at_half_measure_is_beat_2() {
1596        // 4/4, 120 BPM → 1 measure = 2.0 s; 0.5 s = beat 1.0
1597        let score = Score::new("T", 120, 4, 4, 0, 4);
1598        let pos = compute_playback_position(&score, &PlaybackOptions::default(), 0.5).unwrap();
1599        assert_eq!(pos.measure_index, 0);
1600        assert!(
1601            (pos.beat - 1.0).abs() < 1e-9,
1602            "expected beat 1.0, got {}",
1603            pos.beat
1604        );
1605    }
1606
1607    #[test]
1608    fn playback_position_beyond_score_is_none() {
1609        // 4/4, 120 BPM, 1 measure = 2.0 s; 10.0 s is beyond
1610        let score = Score::new("T", 120, 4, 4, 0, 1);
1611        assert!(compute_playback_position(&score, &PlaybackOptions::default(), 10.0).is_none());
1612    }
1613
1614    #[test]
1615    fn playback_position_tempo_change_takes_effect() {
1616        // measure 0: 120 BPM (2.0 s), measure 1: 60 BPM (4.0 s)
1617        // At elapsed=2.5 s → inside measure 1, 0.5 s into it → beat 0.5
1618        let mut score = Score::new("T", 120, 4, 4, 0, 2);
1619        score.parts[0].staves[0].measures[1].tempo = Some(60);
1620        let pos = compute_playback_position(&score, &PlaybackOptions::default(), 2.5).unwrap();
1621        assert_eq!(pos.measure_index, 1);
1622        assert!(
1623            (pos.beat - 0.5).abs() < 1e-9,
1624            "expected beat 0.5, got {}",
1625            pos.beat
1626        );
1627    }
1628
1629    #[test]
1630    fn playback_position_loop_region_starts_at_zero() {
1631        // loop_region=[1,2] → elapsed=0 should map to measure 1, beat 0
1632        let score = Score::new("T", 120, 4, 4, 0, 4);
1633        let options = PlaybackOptions {
1634            loop_region: Some((1, 2)),
1635            ..Default::default()
1636        };
1637        let pos = compute_playback_position(&score, &options, 0.0).unwrap();
1638        assert_eq!(pos.measure_index, 1);
1639        assert!(pos.beat.abs() < 1e-9);
1640    }
1641
1642    // ── MetronomeConfig ───────────────────────────────────────────────────────
1643
1644    #[test]
1645    fn metronome_injects_beat_events() {
1646        // 4/4, 1 measure → should inject 4 metronome events
1647        let score = Score::new("T", 120, 4, 4, 0, 1);
1648        let options = PlaybackOptions {
1649            metronome: Some(MetronomeConfig::default()),
1650            ..Default::default()
1651        };
1652        let events = to_playback_events(&score, &options);
1653        let metro_events: Vec<_> = events.iter().filter(|e| e.is_metronome).collect();
1654        assert_eq!(metro_events.len(), 4, "expected 4 metronome clicks in 4/4");
1655    }
1656
1657    #[test]
1658    fn metronome_accent_is_first_beat() {
1659        let score = Score::new("T", 120, 4, 4, 0, 1);
1660        let metro = MetronomeConfig::default();
1661        let options = PlaybackOptions {
1662            metronome: Some(metro.clone()),
1663            ..Default::default()
1664        };
1665        let events = to_playback_events(&score, &options);
1666        let mut metro_events: Vec<_> = events.iter().filter(|e| e.is_metronome).collect();
1667        metro_events.sort_by(|a, b| a.time_beats.partial_cmp(&b.time_beats).unwrap());
1668        assert_eq!(metro_events[0].pitch_midi, metro.accent_pitch);
1669        assert_eq!(metro_events[0].velocity, metro.accent_velocity);
1670    }
1671
1672    #[test]
1673    fn metronome_regular_beat_pitch() {
1674        let score = Score::new("T", 120, 4, 4, 0, 1);
1675        let metro = MetronomeConfig::default();
1676        let options = PlaybackOptions {
1677            metronome: Some(metro.clone()),
1678            ..Default::default()
1679        };
1680        let events = to_playback_events(&score, &options);
1681        let mut metro_events: Vec<_> = events.iter().filter(|e| e.is_metronome).collect();
1682        metro_events.sort_by(|a, b| a.time_beats.partial_cmp(&b.time_beats).unwrap());
1683        for ev in &metro_events[1..] {
1684            assert_eq!(ev.pitch_midi, metro.beat_pitch);
1685            assert_eq!(ev.velocity, metro.beat_velocity);
1686        }
1687    }
1688
1689    #[test]
1690    fn metronome_events_are_marked() {
1691        let score = Score::new("T", 120, 4, 4, 0, 1);
1692        let options = PlaybackOptions {
1693            metronome: Some(MetronomeConfig::default()),
1694            ..Default::default()
1695        };
1696        let events = to_playback_events(&score, &options);
1697        assert!(events.iter().any(|e| e.is_metronome));
1698    }
1699
1700    #[test]
1701    fn metronome_none_produces_no_extra_events() {
1702        let score = Score::new("T", 120, 4, 4, 0, 1);
1703        let events = to_playback_events(&score, &PlaybackOptions::default());
1704        assert!(events.iter().all(|e| !e.is_metronome));
1705    }
1706
1707    fn comparison_event(time_secs: f64, duration_secs: f64) -> PlaybackEvent {
1708        PlaybackEvent {
1709            address: Some("0:0:0:0:0".into()),
1710            source: Some(NoteAddr {
1711                part: 0,
1712                staff: 0,
1713                measure: 0,
1714                voice: 0,
1715                note: 0,
1716            }),
1717            source_voice_number: None,
1718            time_beats: 0.0,
1719            time_secs,
1720            pitch_midi: 60,
1721            pitch_midi_cents: 6000,
1722            velocity: 64,
1723            duration_beats: 1.0,
1724            duration_secs,
1725            pedal: false,
1726            part_index: 0,
1727            channel: 0,
1728            is_metronome: false,
1729        }
1730    }
1731
1732    #[test]
1733    fn playback_timing_comparison_reports_tolerance_and_identity() {
1734        let expected = [comparison_event(1.0, 0.5)];
1735        let actual = [comparison_event(1.004, 0.503)];
1736        let report = compare_playback_timing(
1737            &expected,
1738            &actual,
1739            &PlaybackTimingTolerance {
1740                start_secs: 0.005,
1741                duration_secs: 0.005,
1742            },
1743        )
1744        .expect("comparison");
1745        assert!(report.within_tolerance);
1746        assert_eq!(report.matched_events, 1);
1747        assert_eq!(
1748            report.contract_version,
1749            PLAYBACK_COMPARISON_CONTRACT_VERSION
1750        );
1751
1752        let mut typed_only = comparison_event(1.004, 0.503);
1753        typed_only.address = None;
1754        let report = compare_playback_timing(
1755            &expected,
1756            &[typed_only],
1757            &PlaybackTimingTolerance::default(),
1758        )
1759        .expect("typed source comparison");
1760        assert_eq!(report.matched_events, 1);
1761
1762        let actual = [comparison_event(1.02, 0.6)];
1763        let report =
1764            compare_playback_timing(&expected, &actual, &PlaybackTimingTolerance::default())
1765                .expect("comparison");
1766        assert!(!report.within_tolerance);
1767        assert!(
1768            report
1769                .mismatches
1770                .iter()
1771                .any(|m| matches!(m, PlaybackTimingMismatch::StartTime { index: 0, .. }))
1772        );
1773        assert!(
1774            report
1775                .mismatches
1776                .iter()
1777                .any(|m| matches!(m, PlaybackTimingMismatch::Duration { index: 0, .. }))
1778        );
1779    }
1780
1781    #[test]
1782    fn playback_timing_comparison_rejects_invalid_tolerance() {
1783        let error = compare_playback_timing(
1784            &[],
1785            &[],
1786            &PlaybackTimingTolerance {
1787                start_secs: -0.1,
1788                duration_secs: 0.0,
1789            },
1790        );
1791        assert!(matches!(
1792            error,
1793            Err(crate::Error::InvalidPlaybackComparison)
1794        ));
1795    }
1796
1797    #[test]
1798    fn tablature_performance_projection_keeps_position_and_reports_pitch_error() {
1799        let mut score = Score::new("Tab", 120, 4, 4, 0, 1);
1800        score.parts[0].staves[0].tablature = Some(super::super::notation::TablatureConfig {
1801            lines: 6,
1802            tuning_midi: vec![40, 45, 50, 55, 59, 64],
1803            capo: 0,
1804        });
1805        let notes = &mut score.parts[0].staves[0].measures[0].voices[0];
1806        *notes = vec![crate::Note::new(
1807            crate::Pitch::new(crate::Step::C, 4),
1808            crate::Duration::Quarter,
1809        )];
1810        notes[0].tab_position = Some(super::super::notation::TabPosition {
1811            string: 1,
1812            fret: 20,
1813        });
1814        notes[0].guitar_technique = Some(super::super::notation::GuitarTechnique::Bend);
1815        notes[0].guitar_bend_alter_cents = Some(150);
1816        let report = project_tablature_performance(&score, &PlaybackOptions::default())
1817            .expect("tablature projection");
1818        assert_eq!(report.events.len(), 1);
1819        assert_eq!(report.events[0].string, 1);
1820        assert_eq!(report.events[0].fret, 20);
1821        assert_eq!(
1822            report.events[0].technique,
1823            Some(super::super::notation::GuitarTechnique::Bend)
1824        );
1825        assert_eq!(report.events[0].bend_alter_cents, Some(150));
1826        assert!(report.diagnostics.is_empty());
1827
1828        score.parts[0].staves[0].measures[0].voices[0][0].tab_position =
1829            Some(super::super::notation::TabPosition {
1830                string: 1,
1831                fret: 19,
1832            });
1833        let report = project_tablature_performance(&score, &PlaybackOptions::default())
1834            .expect("tablature projection");
1835        assert_eq!(report.events[0].pitch_error_cents, 100);
1836        assert!(matches!(
1837            report.diagnostics[0],
1838            TablaturePerformanceDiagnostic::PitchMismatch {
1839                error_cents: 100,
1840                ..
1841            }
1842        ));
1843    }
1844
1845    #[test]
1846    fn tablature_performance_projection_does_not_invent_missing_positions() {
1847        let mut score = Score::new("Tab", 120, 4, 4, 0, 1);
1848        score.parts[0].staves[0].tablature = Some(super::super::notation::TablatureConfig {
1849            lines: 6,
1850            tuning_midi: vec![40, 45, 50, 55, 59, 64],
1851            capo: 0,
1852        });
1853        score.parts[0].staves[0].measures[0].voices[0].push(crate::Note::new(
1854            crate::Pitch::new(crate::Step::C, 4),
1855            crate::Duration::Quarter,
1856        ));
1857        let report = project_tablature_performance(&score, &PlaybackOptions::default())
1858            .expect("tablature projection");
1859        assert!(report.events.is_empty());
1860        assert!(matches!(
1861            report.diagnostics[0],
1862            TablaturePerformanceDiagnostic::MissingPosition { .. }
1863        ));
1864    }
1865
1866    #[test]
1867    fn tablature_performance_event_accepts_legacy_json_without_technique() {
1868        let mut score = Score::new("Tab", 120, 4, 4, 0, 1);
1869        score.parts[0].staves[0].tablature = Some(super::super::notation::TablatureConfig {
1870            lines: 6,
1871            tuning_midi: vec![40, 45, 50, 55, 59, 64],
1872            capo: 0,
1873        });
1874        let mut note = crate::Note::new(
1875            crate::Pitch::new(crate::Step::C, 4),
1876            crate::Duration::Quarter,
1877        );
1878        note.tab_position = Some(super::super::notation::TabPosition {
1879            string: 1,
1880            fret: 20,
1881        });
1882        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
1883        let report = project_tablature_performance(&score, &PlaybackOptions::default())
1884            .expect("tablature projection");
1885        let mut legacy = serde_json::to_value(&report.events[0]).expect("event JSON");
1886        legacy
1887            .as_object_mut()
1888            .expect("event object")
1889            .remove("technique");
1890        let restored: TablaturePerformanceEvent =
1891            serde_json::from_value(legacy).expect("legacy event JSON");
1892        assert_eq!(restored.technique, None);
1893    }
1894
1895    #[test]
1896    fn tablature_round_trip_report_preserves_authored_positions() {
1897        let mut score = Score::new("Tab", 120, 4, 4, 0, 1);
1898        score.parts[0].staves[0].tablature = Some(super::super::notation::TablatureConfig {
1899            lines: 6,
1900            tuning_midi: vec![40, 45, 50, 55, 59, 64],
1901            capo: 2,
1902        });
1903        let mut note = crate::Note::new(
1904            crate::Pitch::new(crate::Step::C, 4),
1905            crate::Duration::Quarter,
1906        );
1907        note.tab_positions = vec![crate::TabPosition { string: 5, fret: 1 }];
1908        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
1909        let report = tablature_round_trip_report(&score).expect("tab round-trip report");
1910        assert!(report.equivalent);
1911        assert_eq!(report.checked_notes, 1);
1912        assert_eq!(report.positioned_notes, 1);
1913        assert!(report.diagnostics.is_empty());
1914    }
1915}