Skip to main content

acorde_core/model/
playback.rs

1use super::duration::Duration;
2use super::notation::{Articulation, ChordSymbol, GuitarTechnique, TabPosition};
3use super::repeat::measure_sequence;
4use super::score::{GuitarBendPoint, Note, NoteAddr, Score};
5use serde::{Deserialize, Serialize};
6use std::collections::{BTreeMap, BTreeSet};
7
8fn default_fermata_multiplier() -> f64 {
9    1.5
10}
11fn default_fermata_hold_beats() -> f64 {
12    0.0
13}
14fn default_swing_unit() -> Duration {
15    Duration::Eighth
16}
17fn default_tempo_multiplier() -> f64 {
18    1.0
19}
20fn default_metronome_channel() -> u8 {
21    9
22}
23fn default_accent_pitch() -> u8 {
24    76
25}
26fn default_beat_pitch() -> u8 {
27    77
28}
29fn default_accent_velocity() -> u8 {
30    100
31}
32fn default_beat_velocity() -> u8 {
33    70
34}
35
36/// Explicit opt-in event realization policy.
37///
38/// [`Authored`](Self::Authored) is the default: ornaments and arpeggiation remain notation
39/// semantics on one event. The versioned realization profile is deliberately opt-in so hosts
40/// never receive invented attacks merely by upgrading acorde.
41#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
42#[serde(rename_all = "kebab-case")]
43pub enum PlaybackRealizationProfile {
44    /// Preserve authored event semantics without generating auxiliary attacks.
45    #[default]
46    Authored,
47    /// Deterministic chromatic ornament attacks and chord arpeggiation for preview providers.
48    ///
49    /// Trills and shakes alternate the written pitch with one chromatic semitone above; mordents
50    /// and turns use a fixed four-attack figure. Chord tones marked `arpeggiate` are staggered
51    /// low-to-high (or high-to-low) by at most 0.18 beats. This is a reproducible preview policy,
52    /// not a claim about historical performance practice.
53    OrnamentArpeggioV1,
54}
55
56/// Click-track injected into [`to_playback_events`] output.
57///
58/// Metronome events are tagged with `PlaybackEvent.is_metronome = true` and can be
59/// routed separately by checking `channel` (default 9 = GM drums).
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
61pub struct MetronomeConfig {
62    /// MIDI channel for the click track. Default 9 (GM drum channel).
63    #[serde(default = "default_metronome_channel")]
64    pub channel: u8,
65    /// MIDI note for the accented first beat. Default 76 (High Wood Block).
66    #[serde(default = "default_accent_pitch")]
67    pub accent_pitch: u8,
68    /// MIDI note for regular beats. Default 77 (Low Wood Block).
69    #[serde(default = "default_beat_pitch")]
70    pub beat_pitch: u8,
71    /// Velocity for the accented first beat. Default 100.
72    #[serde(default = "default_accent_velocity")]
73    pub accent_velocity: u8,
74    /// Velocity for regular beats. Default 70.
75    #[serde(default = "default_beat_velocity")]
76    pub beat_velocity: u8,
77}
78
79impl Default for MetronomeConfig {
80    fn default() -> Self {
81        Self {
82            channel: 9,
83            accent_pitch: 76,
84            beat_pitch: 77,
85            accent_velocity: 100,
86            beat_velocity: 70,
87        }
88    }
89}
90
91/// Options for [`to_playback_events`].
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
93#[serde(default)]
94pub struct PlaybackOptions {
95    /// Replaces every written tempo when `Some` and suppresses written tempo ramps.
96    ///
97    /// Use [`tempo_multiplier`](Self::tempo_multiplier) when a host needs to speed up or slow
98    /// down a score while retaining the written tempo map.
99    pub bpm_override: Option<u16>,
100    /// Multiplies every effective tempo, including written tempo changes and ramps.
101    ///
102    /// The default `1.0` preserves the score. Non-finite or non-positive values are normalized
103    /// to `1.0` so an untrusted host option cannot produce an invalid schedule.
104    #[serde(default = "default_tempo_multiplier")]
105    pub tempo_multiplier: f64,
106    /// Part indices to silence. Events from these parts are omitted entirely.
107    pub muted_parts: Vec<usize>,
108    /// Restrict playback to measures in the inclusive range `[start, end]`.
109    /// Physical measure indices (0-based). `None` plays the full sequence.
110    /// Events within the region start at `time_beats = 0`.
111    #[serde(default)]
112    pub loop_region: Option<(usize, usize)>,
113    /// Duration multiplier for notes with `Fermata` articulation. Default 1.5.
114    #[serde(default = "default_fermata_multiplier")]
115    pub fermata_multiplier: f64,
116    /// Additional beat-domain hold requested after a fermata note. Default 0 keeps legacy
117    /// multiplier-only behavior; hosts decide how to realize the resulting silence.
118    #[serde(default = "default_fermata_hold_beats")]
119    pub fermata_hold_beats: f64,
120    /// Swing ratio for aligned pairs of plain notes or rests of [`swing_unit`] duration.
121    /// `None` = straight.
122    /// `0.67` ≈ triplet swing (2:1). Valid range: (0.5, 1.0).
123    /// Applied only when both adjacent rhythmic units match `swing_unit`, with no tuplet or dot.
124    #[serde(default)]
125    pub swing: Option<f64>,
126    /// Note duration that swing is applied to. Defaults to `Duration::Eighth`.
127    /// Set to `Duration::Sixteenth` for Latin/funk 16th-note swing.
128    #[serde(default = "default_swing_unit")]
129    pub swing_unit: Duration,
130    /// When `Some`, injects metronome click events into the event list.
131    /// Clicks are tagged with `PlaybackEvent.is_metronome = true`.
132    #[serde(default)]
133    pub metronome: Option<MetronomeConfig>,
134    /// Optional realization policy. The default retains authored ornament and arpeggio marks
135    /// without synthesizing extra events.
136    #[serde(default)]
137    pub realization_profile: PlaybackRealizationProfile,
138}
139
140impl Default for PlaybackOptions {
141    fn default() -> Self {
142        Self {
143            bpm_override: None,
144            tempo_multiplier: 1.0,
145            muted_parts: Vec::new(),
146            loop_region: None,
147            fermata_multiplier: 1.5,
148            fermata_hold_beats: 0.0,
149            swing: None,
150            swing_unit: Duration::Eighth,
151            metronome: None,
152            realization_profile: PlaybackRealizationProfile::Authored,
153        }
154    }
155}
156
157fn normalized_tempo_multiplier(options: &PlaybackOptions) -> f64 {
158    let multiplier = options.tempo_multiplier;
159    if multiplier.is_finite() && multiplier > 0.0 {
160        multiplier
161    } else {
162        1.0
163    }
164}
165
166fn swingable_rhythm_slot(note: &Note, unit: &Duration) -> bool {
167    !note.is_grace
168        && !note.is_cue
169        && note.tuplet.is_none()
170        && note.dot_count == 0
171        && note.duration == *unit
172}
173
174fn starts_swing_pair(
175    notes: &[Note],
176    note_index: usize,
177    written_beats: f64,
178    grid_offset_beats: f64,
179    measure_beats: f64,
180    unit: &Duration,
181) -> bool {
182    let Some(first) = notes.get(note_index) else {
183        return false;
184    };
185    let Some(second) = notes.get(note_index + 1) else {
186        return false;
187    };
188    if !swingable_rhythm_slot(first, unit) || !swingable_rhythm_slot(second, unit) {
189        return false;
190    }
191    let pair_beats = first.beats() * 2.0;
192    if written_beats + pair_beats > measure_beats + 1e-9 {
193        return false;
194    }
195    let slot = (written_beats + grid_offset_beats) / pair_beats;
196    (slot - slot.round()).abs() < 1e-9
197}
198
199fn initial_tempo_bpm(score: &Score, options: &PlaybackOptions) -> f64 {
200    f64::from(
201        options
202            .bpm_override
203            .unwrap_or(score.settings.tempo_bpm)
204            .max(1),
205    ) * normalized_tempo_multiplier(options)
206}
207
208fn written_tempo_bpm(options: &PlaybackOptions, tempo: u16) -> Option<f64> {
209    options
210        .bpm_override
211        .is_none()
212        .then(|| f64::from(tempo.max(1)) * normalized_tempo_multiplier(options))
213}
214
215fn written_tempo_ramp_bpm(options: &PlaybackOptions, tempo: Option<u16>) -> Option<f64> {
216    options.bpm_override.is_none().then_some(())?;
217    tempo.map(|tempo| f64::from(tempo.max(1)) * normalized_tempo_multiplier(options))
218}
219
220#[derive(Clone, Copy, Default)]
221struct ScoreTempoMark {
222    bpm: Option<u16>,
223    ramp_to_bpm: Option<u16>,
224}
225
226/// Tempo directions are score-wide even when an interchange format places them on one part.
227/// Resolve each field from the first staff that writes it, in score order.
228fn score_tempo_marks(score: &Score) -> Vec<ScoreTempoMark> {
229    let count = score
230        .parts
231        .iter()
232        .flat_map(|part| &part.staves)
233        .map(|staff| staff.measures.len())
234        .max()
235        .unwrap_or(0);
236    let mut marks = vec![ScoreTempoMark::default(); count];
237    for (index, mark) in marks.iter_mut().enumerate() {
238        for part in &score.parts {
239            for staff in &part.staves {
240                if let Some(measure) = staff.measures.get(index) {
241                    mark.bpm = mark.bpm.or(measure.tempo);
242                    mark.ramp_to_bpm = mark.ramp_to_bpm.or(measure.tempo_ramp_to);
243                }
244            }
245        }
246    }
247    marks
248}
249
250fn semantic_tempo_bpm(bpm: f64) -> u16 {
251    bpm.round().clamp(1.0, f64::from(u16::MAX)) as u16
252}
253
254/// A single sounding event suitable for audio playback engines (e.g. Web Audio, Tone.js).
255///
256/// Grace notes and rests are excluded. Chords are expanded to one event per pitch.
257#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
258pub struct PlaybackEvent {
259    /// Stable source note address (`part:staff:measure:voice:note`), or `None` for metronome events.
260    /// Chord pitches share the address of their source note.
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub address: Option<String>,
263    /// Typed source address for notation hosts; `None` for metronome events.
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub source: Option<NoteAddr>,
266    /// Original MusicXML voice number when the source used a non-default identifier.
267    /// `source` remains the stable four-slot canonical address used by editing APIs.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub source_voice_number: Option<u32>,
270    /// Absolute beat position from the start of the score.
271    pub time_beats: f64,
272    /// Absolute time in seconds from the start of the score.
273    pub time_secs: f64,
274    /// MIDI pitch number (0–127).
275    pub pitch_midi: u8,
276    /// Exact sounding pitch in hundredths of a MIDI semitone.
277    ///
278    /// `pitch_midi` is retained as a compatibility convenience for integer-MIDI hosts;
279    /// microtonal-capable hosts should use this field.
280    #[serde(default)]
281    pub pitch_midi_cents: i32,
282    /// Authored normalized pitch-bend curve for a bend-capable host. Each point is relative to
283    /// `pitch_midi_cents` and positioned within this event's sounding duration. Empty means the
284    /// event has no authored continuous bend.
285    #[serde(default)]
286    pub pitch_bend_curve: Vec<GuitarBendPoint>,
287    /// Authored pause requested after this note by a breath mark or caesura. The host decides
288    /// how to realize the silence while preserving this deterministic beat-domain contract.
289    #[serde(default)]
290    pub post_note_pause_beats: f64,
291    /// Authored articulation and ornament marks for a provider to interpret. The core schedule
292    /// deliberately does not invent auxiliary pitches for trill, mordent, or turn.
293    #[serde(default)]
294    pub articulations: Vec<Articulation>,
295    /// Authored chord symbol at this note position. It is a semantic cue for accompaniment or
296    /// harmonic playback providers; acorde does not invent accompaniment notes.
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub chord_symbol: Option<ChordSymbol>,
299    /// Authored guitar playing technique, when present. Providers may map this stable semantic
300    /// value to keyswitches or synthesis behavior without score re-parsing.
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub guitar_technique: Option<GuitarTechnique>,
303    /// MIDI velocity (1–127). Derived from the staff's [`Dynamic`](crate::Dynamic) in force
304    /// (see [`DynamicTimeline`]); 64 before the first marking.
305    /// Boosted by +20 for Accent / Marcato articulations (clamped to 127).
306    pub velocity: u8,
307    /// Sounding duration in beats. Halved for Staccato / Staccatissimo.
308    pub duration_beats: f64,
309    /// Sounding duration in seconds.
310    pub duration_secs: f64,
311    /// True when the note has `pedal_start` set (sustain pedal down).
312    pub pedal: bool,
313    /// Index of the part this event originates from (useful for per-channel MIDI routing).
314    pub part_index: usize,
315    /// MIDI channel of the originating part (`part.midi_channel`). For Tone.js channel routing.
316    pub channel: u8,
317    /// Effective General MIDI program after applying any measure-local instrument change.
318    #[serde(default)]
319    pub program: u8,
320    /// Stable effective instrument ID when the part or measure declares one.
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub instrument_id: Option<String>,
323    /// `true` for metronome click events injected via [`MetronomeConfig`].
324    #[serde(default)]
325    pub is_metronome: bool,
326}
327
328/// Version of the host-neutral playback timing comparison contract.
329pub const PLAYBACK_COMPARISON_CONTRACT_VERSION: u16 = 2;
330/// Maximum number of events accepted by one comparison operation.
331pub const MAX_PLAYBACK_COMPARISON_EVENTS: usize = 1_000_000;
332const MAX_PLAYBACK_MISMATCHES: usize = 256;
333/// Maximum number of projected tablature events retained in one report.
334pub const MAX_TAB_PERFORMANCE_EVENTS: usize = 1_000_000;
335const MAX_TAB_PERFORMANCE_DIAGNOSTICS: usize = 1_024;
336
337/// Version of the host-neutral offline rendering manifest contract.
338pub const OFFLINE_RENDER_CONTRACT_VERSION: u16 = 3;
339
340/// Score material selected for an offline render.  Addresses remain those of the
341/// source score even when a view or a range is selected.
342#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
343#[serde(rename_all = "kebab-case")]
344pub enum OfflineRenderScope {
345    #[default]
346    FullScore,
347    MeasureRange {
348        start: usize,
349        end: usize,
350    },
351    Selection {
352        addresses: Vec<NoteAddr>,
353    },
354}
355
356/// Navigation realization selected by an offline-render request.
357#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
358#[serde(rename_all = "kebab-case")]
359pub enum OfflineRenderNavigationPolicy {
360    /// Follow the score's authored repeats, jumps and endings.
361    #[default]
362    Authored,
363}
364
365/// One event with integer sample-frame boundaries for an offline renderer.
366#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
367pub struct OfflineRenderFrameEvent {
368    pub event: PlaybackEvent,
369    pub start_frame: u64,
370    pub duration_frames: u64,
371    pub end_frame: u64,
372}
373
374/// A non-note semantic action expressed on the same sample-frame clock as
375/// [`OfflineRenderFrameEvent`].  Audio providers can consume these without
376/// reverse-engineering notation from a note schedule.
377#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
378#[serde(rename_all = "kebab-case")]
379pub enum OfflineRenderSemanticEventKind {
380    /// A measure-local instrument routing change. Providers apply this before
381    /// attacks at the same frame rather than inferring it from note events.
382    InstrumentChange {
383        instrument_id: String,
384        channel: u8,
385        program: u8,
386        transpose_semitones: i8,
387    },
388    Controller {
389        channel: u8,
390        controller: u8,
391        value: u8,
392    },
393    /// Signed MIDI 14-bit pitch bend retained from the interchange timeline.
394    PitchBend {
395        channel: u8,
396        value: i16,
397    },
398    /// MIDI program selection at a canonical score tick.
399    ProgramChange {
400        channel: u8,
401        program: u8,
402    },
403    /// Channel pressure or key pressure at a canonical score tick.
404    Aftertouch {
405        channel: u8,
406        #[serde(default, skip_serializing_if = "Option::is_none")]
407        key: Option<u8>,
408        value: u8,
409    },
410    Tempo {
411        bpm: u16,
412        #[serde(default, skip_serializing_if = "Option::is_none")]
413        ramp_to_bpm: Option<u16>,
414    },
415    Navigation {
416        marker: String,
417    },
418    Pedal {
419        down: bool,
420    },
421    Articulation {
422        articulation: Articulation,
423    },
424    GuitarBend {
425        points: Vec<GuitarBendPoint>,
426    },
427    /// A semantic release boundary.  It intentionally has no synthesis
428    /// parameters: providers decide how a release tail is rendered.
429    ReleaseTail,
430}
431
432/// A source-addressable non-note action in an offline render manifest.
433#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
434pub struct OfflineRenderSemanticFrameEvent {
435    pub kind: OfflineRenderSemanticEventKind,
436    pub frame: u64,
437    #[serde(default, skip_serializing_if = "Option::is_none")]
438    pub source: Option<NoteAddr>,
439    #[serde(default, skip_serializing_if = "Option::is_none")]
440    pub measure: Option<OfflineRenderMeasureAddress>,
441}
442
443/// Stable source location for a measure-scoped offline action.
444#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
445pub struct OfflineRenderMeasureAddress {
446    pub part: usize,
447    pub staff: usize,
448    pub measure: usize,
449}
450
451/// Machine-readable condition observed while deriving an offline schedule.
452#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
453#[serde(rename_all = "kebab-case")]
454pub enum OfflineRenderDiagnosticKind {
455    /// A selected canonical note address did not yield a sounding event.
456    UnresolvedSelectionAddress,
457    /// A valid selection contained no sounding events after scheduling.
458    EmptySelection,
459}
460
461/// Source-located diagnostic for a provider-neutral offline manifest.
462#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
463pub struct OfflineRenderDiagnostic {
464    pub kind: OfflineRenderDiagnosticKind,
465    #[serde(default, skip_serializing_if = "Option::is_none")]
466    pub source: Option<NoteAddr>,
467}
468
469/// Encoded audio format requested from a host-side offline renderer.
470///
471/// The core does not encode audio; this keeps the requested output explicit in the deterministic
472/// schedule handed to the native SF2 renderer, Composer, or another host.
473#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
474pub enum OfflineRenderFormat {
475    #[default]
476    Wav,
477    Flac,
478    Mp3,
479}
480
481/// Host-neutral parameters for an offline render operation.
482#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
483pub struct OfflineRenderRequest {
484    /// Requested encoded format. The host reports unsupported formats explicitly.
485    #[serde(default)]
486    pub format: OfflineRenderFormat,
487    /// PCM sample rate used to convert event seconds to exact sample frames.
488    #[serde(default = "default_offline_render_sample_rate")]
489    pub sample_rate_hz: u32,
490    /// Requested interleaved output channel count.
491    #[serde(default = "default_offline_render_channels")]
492    pub channels: u8,
493    /// Stable name/version of the selected host provider, when already chosen.
494    #[serde(default, skip_serializing_if = "Option::is_none")]
495    pub provider_identity: Option<String>,
496    /// Stable provider asset identifier (for example a host preset or sample set).
497    #[serde(default, skip_serializing_if = "Option::is_none")]
498    pub asset_identity: Option<String>,
499    /// Optional linked view to resolve before event scheduling.
500    #[serde(default, skip_serializing_if = "Option::is_none")]
501    pub view_id: Option<String>,
502    #[serde(default)]
503    pub scope: OfflineRenderScope,
504    #[serde(default)]
505    pub navigation_policy: OfflineRenderNavigationPolicy,
506    /// Extra release tail after the final sounding event, in milliseconds.
507    #[serde(default)]
508    pub release_tail_millis: u32,
509    /// Full reproducible playback policy. When omitted, the legacy function
510    /// argument is used and copied into the manifest for migration clarity.
511    #[serde(default, skip_serializing_if = "Option::is_none")]
512    pub playback_options: Option<PlaybackOptions>,
513}
514
515fn default_offline_render_sample_rate() -> u32 {
516    48_000
517}
518
519fn default_offline_render_channels() -> u8 {
520    2
521}
522
523impl Default for OfflineRenderRequest {
524    fn default() -> Self {
525        Self {
526            format: OfflineRenderFormat::Wav,
527            sample_rate_hz: default_offline_render_sample_rate(),
528            channels: default_offline_render_channels(),
529            provider_identity: None,
530            asset_identity: None,
531            view_id: None,
532            scope: OfflineRenderScope::FullScore,
533            navigation_policy: OfflineRenderNavigationPolicy::Authored,
534            release_tail_millis: 0,
535            playback_options: None,
536        }
537    }
538}
539
540/// Sample-accurate event schedule for a host-side offline render.
541///
542/// `duration_frames` is derived from the final event's end time and deliberately excludes codec
543/// padding. A host owns synthesis, tail policy, file creation, and encoded bytes.
544#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
545pub struct OfflineRenderManifest {
546    pub contract_version: u16,
547    pub playback_contract_version: u16,
548    pub format: OfflineRenderFormat,
549    pub sample_rate_hz: u32,
550    pub channels: u8,
551    #[serde(default, skip_serializing_if = "Option::is_none")]
552    pub provider_identity: Option<String>,
553    #[serde(default, skip_serializing_if = "Option::is_none")]
554    pub asset_identity: Option<String>,
555    #[serde(default, skip_serializing_if = "Option::is_none")]
556    pub view_id: Option<String>,
557    #[serde(default)]
558    pub scope: OfflineRenderScope,
559    #[serde(default)]
560    pub navigation_policy: OfflineRenderNavigationPolicy,
561    #[serde(default)]
562    pub release_tail_frames: u64,
563    /// The exact timing policy that produced this manifest.
564    #[serde(default)]
565    pub playback_options: PlaybackOptions,
566    pub duration_frames: u64,
567    pub events: Vec<PlaybackEvent>,
568    /// Frame-quantized equivalents of `events`; retained alongside the legacy
569    /// seconds schedule so v1 clients can migrate without losing semantics.
570    #[serde(default)]
571    pub frame_events: Vec<OfflineRenderFrameEvent>,
572    /// Pedal, articulation, bend and release semantics on the exact output
573    /// sample clock.  Note attacks remain in [`Self::frame_events`].
574    #[serde(default)]
575    pub semantic_events: Vec<OfflineRenderSemanticFrameEvent>,
576    #[serde(default)]
577    pub diagnostics: Vec<OfflineRenderDiagnostic>,
578}
579
580impl OfflineRenderManifest {
581    /// Validate a persisted provider-neutral schedule before a host hands it to
582    /// a synthesizer. Legacy v1/v2 manifests remain readable; current manifests
583    /// additionally require a one-to-one frame schedule for every note event.
584    pub fn validate(&self) -> Result<(), crate::Error> {
585        if !(1..=OFFLINE_RENDER_CONTRACT_VERSION).contains(&self.contract_version)
586            || !(8_000..=384_000).contains(&self.sample_rate_hz)
587            || !(1..=8).contains(&self.channels)
588            || self.release_tail_frames > self.duration_frames
589        {
590            return Err(crate::Error::InvalidOfflineRenderRequest);
591        }
592
593        if self.contract_version >= 2 && self.frame_events.len() != self.events.len() {
594            return Err(crate::Error::InvalidOfflineRenderRequest);
595        }
596        let mut latest_event_end = 0_u64;
597        for (index, frame_event) in self.frame_events.iter().enumerate() {
598            let Some(expected_event) = self.events.get(index) else {
599                return Err(crate::Error::InvalidOfflineRenderRequest);
600            };
601            if frame_event.event != *expected_event
602                || frame_event
603                    .start_frame
604                    .checked_add(frame_event.duration_frames)
605                    != Some(frame_event.end_frame)
606                || frame_event.end_frame > self.duration_frames
607            {
608                return Err(crate::Error::InvalidOfflineRenderRequest);
609            }
610            latest_event_end = latest_event_end.max(frame_event.end_frame);
611        }
612        let latest_semantic_frame = self
613            .semantic_events
614            .iter()
615            .map(|event| event.frame)
616            .max()
617            .unwrap_or(0);
618        if latest_semantic_frame > self.duration_frames {
619            return Err(crate::Error::InvalidOfflineRenderRequest);
620        }
621        if self.contract_version >= 2
622            && latest_event_end
623                .max(latest_semantic_frame)
624                .checked_add(self.release_tail_frames)
625                != Some(self.duration_frames)
626        {
627            return Err(crate::Error::InvalidOfflineRenderRequest);
628        }
629        Ok(())
630    }
631}
632
633/// Host-reported result metadata for an [`OfflineRenderManifest`].
634///
635/// This is a result contract, not proof of audio quality or device-independent equivalence.
636#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
637pub struct OfflineRenderResult {
638    pub contract_version: u16,
639    pub format: OfflineRenderFormat,
640    pub sample_rate_hz: u32,
641    pub channels: u8,
642    #[serde(default, skip_serializing_if = "Option::is_none")]
643    pub provider_identity: Option<String>,
644    pub duration_frames: u64,
645    pub output_bytes: u64,
646}
647
648/// A host-side auxiliary send between two logical playback buses.
649#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
650pub struct PlaybackAuxSend {
651    pub source_bus: String,
652    pub destination_bus: String,
653    /// Gain applied before the destination effect chain, in dB.
654    pub gain_db: f64,
655}
656
657/// Identifies a host effect attached to a logical bus without carrying provider-specific settings.
658#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
659pub struct PlaybackEffectRoute {
660    pub bus_id: String,
661    pub effect_id: String,
662    #[serde(default = "default_effect_enabled")]
663    pub enabled: bool,
664}
665
666fn default_effect_enabled() -> bool {
667    true
668}
669
670/// Host-neutral routing choices resolved after score playback events are generated.
671#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
672pub struct PlaybackRoutingConfig {
673    #[serde(default, skip_serializing_if = "Option::is_none")]
674    pub provider_identity: Option<String>,
675    #[serde(default = "default_master_bus")]
676    pub master_bus: String,
677    #[serde(default = "default_metronome_bus")]
678    pub metronome_bus: String,
679    /// Part-index routes, overridden by a matching stable instrument ID.
680    #[serde(default)]
681    pub part_buses: BTreeMap<usize, String>,
682    #[serde(default)]
683    pub instrument_buses: BTreeMap<String, String>,
684    #[serde(default)]
685    pub aux_sends: Vec<PlaybackAuxSend>,
686    #[serde(default)]
687    pub effect_routes: Vec<PlaybackEffectRoute>,
688}
689
690fn default_master_bus() -> String {
691    "master".into()
692}
693
694fn default_metronome_bus() -> String {
695    "metronome".into()
696}
697
698impl Default for PlaybackRoutingConfig {
699    fn default() -> Self {
700        Self {
701            provider_identity: None,
702            master_bus: default_master_bus(),
703            metronome_bus: default_metronome_bus(),
704            part_buses: BTreeMap::new(),
705            instrument_buses: BTreeMap::new(),
706            aux_sends: Vec::new(),
707            effect_routes: Vec::new(),
708        }
709    }
710}
711
712/// One unique event source route in a [`PlaybackRoutingManifest`].
713#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
714pub struct PlaybackBusRoute {
715    pub part_index: usize,
716    pub channel: u8,
717    pub program: u8,
718    #[serde(default, skip_serializing_if = "Option::is_none")]
719    pub instrument_id: Option<String>,
720    pub is_metronome: bool,
721    pub bus_id: String,
722}
723
724/// Deterministic logical routing graph for score playback events.
725#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
726pub struct PlaybackRoutingManifest {
727    pub contract_version: u16,
728    #[serde(default, skip_serializing_if = "Option::is_none")]
729    pub provider_identity: Option<String>,
730    pub master_bus: String,
731    pub metronome_bus: String,
732    pub routes: Vec<PlaybackBusRoute>,
733    pub aux_sends: Vec<PlaybackAuxSend>,
734    pub effect_routes: Vec<PlaybackEffectRoute>,
735}
736
737/// Version of the host-neutral routing manifest contract.
738pub const PLAYBACK_ROUTING_CONTRACT_VERSION: u16 = 1;
739
740fn valid_routing_id(value: &str) -> bool {
741    !value.is_empty()
742        && value.len() <= 64
743        && value
744            .bytes()
745            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
746}
747
748fn validate_playback_routing(config: &PlaybackRoutingConfig) -> Result<(), crate::Error> {
749    if !valid_routing_id(&config.master_bus)
750        || !valid_routing_id(&config.metronome_bus)
751        || config.part_buses.values().any(|bus| !valid_routing_id(bus))
752        || config
753            .instrument_buses
754            .values()
755            .any(|bus| !valid_routing_id(bus))
756        || config.aux_sends.len() > 64
757        || config.effect_routes.len() > 128
758    {
759        return Err(crate::Error::InvalidPlaybackRouting);
760    }
761    for send in &config.aux_sends {
762        if !valid_routing_id(&send.source_bus)
763            || !valid_routing_id(&send.destination_bus)
764            || !send.gain_db.is_finite()
765            || !(-120.0..=24.0).contains(&send.gain_db)
766        {
767            return Err(crate::Error::InvalidPlaybackRouting);
768        }
769    }
770    if config
771        .effect_routes
772        .iter()
773        .any(|effect| !valid_routing_id(&effect.bus_id) || !valid_routing_id(&effect.effect_id))
774    {
775        return Err(crate::Error::InvalidPlaybackRouting);
776    }
777    Ok(())
778}
779
780/// Resolve a bounded, deterministic logical routing graph for a score schedule.
781pub fn build_playback_routing_manifest(
782    score: &Score,
783    playback_options: &PlaybackOptions,
784    config: &PlaybackRoutingConfig,
785) -> Result<PlaybackRoutingManifest, crate::Error> {
786    validate_playback_routing(config)?;
787    let events = to_playback_events_bounded(score, playback_options)?;
788    let mut routes = BTreeMap::new();
789    for event in events {
790        let bus_id = if event.is_metronome {
791            config.metronome_bus.clone()
792        } else if let Some(bus) = event
793            .instrument_id
794            .as_ref()
795            .and_then(|instrument_id| config.instrument_buses.get(instrument_id))
796        {
797            bus.clone()
798        } else if let Some(bus) = config.part_buses.get(&event.part_index) {
799            bus.clone()
800        } else {
801            config.master_bus.clone()
802        };
803        let route = PlaybackBusRoute {
804            part_index: event.part_index,
805            channel: event.channel,
806            program: event.program,
807            instrument_id: event.instrument_id,
808            is_metronome: event.is_metronome,
809            bus_id,
810        };
811        routes.insert(route.clone(), route);
812    }
813    Ok(PlaybackRoutingManifest {
814        contract_version: PLAYBACK_ROUTING_CONTRACT_VERSION,
815        provider_identity: config.provider_identity.clone(),
816        master_bus: config.master_bus.clone(),
817        metronome_bus: config.metronome_bus.clone(),
818        routes: routes.into_values().collect(),
819        aux_sends: config.aux_sends.clone(),
820        effect_routes: config.effect_routes.clone(),
821    })
822}
823
824/// Build a bounded sample-accurate schedule for a host-side offline render.
825pub fn build_offline_render_manifest(
826    score: &Score,
827    playback_options: &PlaybackOptions,
828    request: &OfflineRenderRequest,
829) -> Result<OfflineRenderManifest, crate::Error> {
830    if !(8_000..=384_000).contains(&request.sample_rate_hz) || !(1..=8).contains(&request.channels)
831    {
832        return Err(crate::Error::InvalidOfflineRenderRequest);
833    }
834    let resolved_score = match request.view_id.as_deref() {
835        Some(view_id) => score.resolve_view(view_id)?,
836        None => score.clone(),
837    };
838    let mut effective_options = request
839        .playback_options
840        .clone()
841        .unwrap_or_else(|| playback_options.clone());
842    if let OfflineRenderScope::MeasureRange { start, end } = request.scope {
843        if start > end {
844            return Err(crate::Error::InvalidOfflineRenderRequest);
845        }
846        effective_options.loop_region = Some((start, end));
847    }
848    let mut events = to_playback_events_bounded(&resolved_score, &effective_options)?;
849    let mut diagnostics = Vec::new();
850    let mut selection_origin_secs = 0.0;
851    if let OfflineRenderScope::Selection { ref addresses } = request.scope {
852        if addresses.is_empty() {
853            return Err(crate::Error::InvalidOfflineRenderRequest);
854        }
855        for address in addresses {
856            if !events
857                .iter()
858                .any(|event| event.source.as_ref() == Some(address))
859            {
860                diagnostics.push(OfflineRenderDiagnostic {
861                    kind: OfflineRenderDiagnosticKind::UnresolvedSelectionAddress,
862                    source: Some(address.clone()),
863                });
864            }
865        }
866        events.retain(|event| {
867            event
868                .source
869                .as_ref()
870                .is_some_and(|source| addresses.contains(source))
871        });
872        let origin_secs = events
873            .iter()
874            .map(|event| event.time_secs)
875            .fold(f64::INFINITY, f64::min);
876        if origin_secs.is_finite() {
877            selection_origin_secs = origin_secs;
878            for event in &mut events {
879                event.time_secs -= origin_secs;
880            }
881        }
882        if events.is_empty() {
883            diagnostics.push(OfflineRenderDiagnostic {
884                kind: OfflineRenderDiagnosticKind::EmptySelection,
885                source: None,
886            });
887        }
888    }
889    let duration_secs = events
890        .iter()
891        .map(|event| event.time_secs + event.duration_secs)
892        .fold(0.0_f64, f64::max);
893    if !duration_secs.is_finite() || duration_secs < 0.0 {
894        return Err(crate::Error::InvalidOfflineRenderRequest);
895    }
896    let release_tail_frames =
897        (f64::from(request.release_tail_millis) * f64::from(request.sample_rate_hz) / 1_000.0)
898            .ceil();
899    let duration_frames = (duration_secs * f64::from(request.sample_rate_hz)).ceil();
900    if !duration_frames.is_finite() || duration_frames > u64::MAX as f64 {
901        return Err(crate::Error::InvalidOfflineRenderRequest);
902    }
903    if !release_tail_frames.is_finite()
904        || release_tail_frames > u64::MAX as f64
905        || duration_frames > (u64::MAX as f64 - release_tail_frames)
906    {
907        return Err(crate::Error::InvalidOfflineRenderRequest);
908    }
909    let frame_events = events
910        .iter()
911        .cloned()
912        .map(|event| {
913            let start_frame = (event.time_secs * f64::from(request.sample_rate_hz)).round() as u64;
914            let end_frame = ((event.time_secs + event.duration_secs)
915                * f64::from(request.sample_rate_hz))
916            .ceil() as u64;
917            OfflineRenderFrameEvent {
918                duration_frames: end_frame.saturating_sub(start_frame),
919                event,
920                start_frame,
921                end_frame,
922            }
923        })
924        .collect::<Vec<_>>();
925    let mut semantic_events = Vec::new();
926    for event in &frame_events {
927        let source = event.event.source.clone();
928        if event.event.pedal {
929            semantic_events.push(OfflineRenderSemanticFrameEvent {
930                kind: OfflineRenderSemanticEventKind::Pedal { down: true },
931                frame: event.start_frame,
932                source: source.clone(),
933                measure: None,
934            });
935        }
936        if source.as_ref().is_some_and(|address| {
937            resolved_score
938                .parts
939                .get(address.part)
940                .and_then(|part| part.staves.get(address.staff))
941                .and_then(|staff| staff.measures.get(address.measure))
942                .and_then(|measure| measure.voices.get(address.voice))
943                .and_then(|voice| voice.get(address.note))
944                .is_some_and(|note| note.pedal_end)
945        }) {
946            semantic_events.push(OfflineRenderSemanticFrameEvent {
947                kind: OfflineRenderSemanticEventKind::Pedal { down: false },
948                frame: event.start_frame,
949                source: source.clone(),
950                measure: None,
951            });
952        }
953        for articulation in &event.event.articulations {
954            semantic_events.push(OfflineRenderSemanticFrameEvent {
955                kind: OfflineRenderSemanticEventKind::Articulation {
956                    articulation: articulation.clone(),
957                },
958                frame: event.start_frame,
959                source: source.clone(),
960                measure: None,
961            });
962        }
963        if !event.event.pitch_bend_curve.is_empty() {
964            semantic_events.push(OfflineRenderSemanticFrameEvent {
965                kind: OfflineRenderSemanticEventKind::GuitarBend {
966                    points: event.event.pitch_bend_curve.clone(),
967                },
968                frame: event.start_frame,
969                source,
970                measure: None,
971            });
972        }
973    }
974    append_measure_semantic_events(
975        &resolved_score,
976        &effective_options,
977        request.sample_rate_hz,
978        &request.scope,
979        selection_origin_secs,
980        &mut semantic_events,
981    );
982    // Automation can outlive the final sounding note (for example an imported
983    // program change on an otherwise silent measure). The manifest duration
984    // must retain those actions before adding any host release tail.
985    let semantic_end_frame = semantic_events
986        .iter()
987        .map(|event| event.frame)
988        .max()
989        .unwrap_or(0);
990    let content_duration_frames = (duration_frames as u64).max(semantic_end_frame);
991    if release_tail_frames > 0.0 {
992        semantic_events.push(OfflineRenderSemanticFrameEvent {
993            kind: OfflineRenderSemanticEventKind::ReleaseTail,
994            frame: content_duration_frames,
995            source: None,
996            measure: None,
997        });
998    }
999    let manifest = OfflineRenderManifest {
1000        contract_version: OFFLINE_RENDER_CONTRACT_VERSION,
1001        playback_contract_version: PLAYBACK_COMPARISON_CONTRACT_VERSION,
1002        format: request.format,
1003        sample_rate_hz: request.sample_rate_hz,
1004        channels: request.channels,
1005        provider_identity: request.provider_identity.clone(),
1006        asset_identity: request.asset_identity.clone(),
1007        view_id: request.view_id.clone(),
1008        scope: request.scope.clone(),
1009        navigation_policy: request.navigation_policy,
1010        release_tail_frames: release_tail_frames as u64,
1011        playback_options: effective_options,
1012        duration_frames: content_duration_frames + release_tail_frames as u64,
1013        events,
1014        frame_events,
1015        semantic_events,
1016        diagnostics,
1017    };
1018    manifest.validate()?;
1019    Ok(manifest)
1020}
1021
1022#[derive(Clone, Copy)]
1023struct OfflineMeasureTime {
1024    measure: usize,
1025    start_secs: f64,
1026    bpm: f64,
1027    ramp_to_bpm: Option<f64>,
1028    beats: f64,
1029}
1030
1031fn offline_measure_times(
1032    score: &Score,
1033    options: &PlaybackOptions,
1034    part: usize,
1035    staff: usize,
1036    tempo_marks: &[ScoreTempoMark],
1037) -> Vec<OfflineMeasureTime> {
1038    let Some(staff_ref) = score
1039        .parts
1040        .get(part)
1041        .and_then(|part| part.staves.get(staff))
1042    else {
1043        return Vec::new();
1044    };
1045    let sequence = measure_sequence(score).into_iter().filter(|measure| {
1046        options
1047            .loop_region
1048            .is_none_or(|(start, end)| *measure >= start && *measure <= end)
1049    });
1050    let mut bpm = initial_tempo_bpm(score, options);
1051    let mut secs = 0.0;
1052    let mut result = Vec::new();
1053    for measure_index in sequence {
1054        if staff_ref.measures.get(measure_index).is_none() {
1055            continue;
1056        }
1057        let mark = tempo_marks.get(measure_index).copied().unwrap_or_default();
1058        if let Some(tempo) = mark.bpm.and_then(|tempo| written_tempo_bpm(options, tempo)) {
1059            bpm = tempo;
1060        }
1061        let beats = score.playback_measure_beats(measure_index);
1062        let ramp_to_bpm = written_tempo_ramp_bpm(options, mark.ramp_to_bpm);
1063        result.push(OfflineMeasureTime {
1064            measure: measure_index,
1065            start_secs: secs,
1066            bpm,
1067            ramp_to_bpm,
1068            beats,
1069        });
1070        secs += tempo_ramp_seconds(bpm, ramp_to_bpm, beats, beats);
1071        if let Some(end_bpm) = ramp_to_bpm {
1072            bpm = end_bpm;
1073        }
1074    }
1075    result
1076}
1077
1078fn append_measure_semantic_events(
1079    score: &Score,
1080    options: &PlaybackOptions,
1081    sample_rate_hz: u32,
1082    scope: &OfflineRenderScope,
1083    origin_secs: f64,
1084    events: &mut Vec<OfflineRenderSemanticFrameEvent>,
1085) {
1086    let tempo_marks = score_tempo_marks(score);
1087    for (part_index, part) in score.parts.iter().enumerate() {
1088        let timeline = offline_measure_times(score, options, part_index, 0, &tempo_marks);
1089        let Some(staff) = part.staves.first() else {
1090            continue;
1091        };
1092        let mut tick_starts = Vec::with_capacity(staff.measures.len());
1093        let mut tick = 0u64;
1094        for measure_index in 0..staff.measures.len() {
1095            tick_starts.push(tick);
1096            let beats = score.playback_measure_beats(measure_index);
1097            tick = tick.saturating_add((beats * 480.0).round() as u64);
1098        }
1099        for time in timeline {
1100            if let OfflineRenderScope::Selection { addresses } = scope
1101                && !addresses
1102                    .iter()
1103                    .any(|address| address.part == part_index && address.measure == time.measure)
1104            {
1105                continue;
1106            }
1107            let address = OfflineRenderMeasureAddress {
1108                part: part_index,
1109                staff: 0,
1110                measure: time.measure,
1111            };
1112            let frame = ((time.start_secs - origin_secs).max(0.0) * f64::from(sample_rate_hz))
1113                .round() as u64;
1114            let measure = &staff.measures[time.measure];
1115            let mark = tempo_marks.get(time.measure).copied().unwrap_or_default();
1116            if part_index == 0
1117                && options.bpm_override.is_none()
1118                && (mark.bpm.is_some() || mark.ramp_to_bpm.is_some())
1119            {
1120                events.push(OfflineRenderSemanticFrameEvent {
1121                    kind: OfflineRenderSemanticEventKind::Tempo {
1122                        bpm: semantic_tempo_bpm(time.bpm),
1123                        ramp_to_bpm: time.ramp_to_bpm.map(semantic_tempo_bpm),
1124                    },
1125                    frame,
1126                    source: None,
1127                    measure: Some(address.clone()),
1128                });
1129            }
1130            if part_index == 0
1131                && let Some(marker) = &measure.navigation
1132            {
1133                events.push(OfflineRenderSemanticFrameEvent {
1134                    kind: OfflineRenderSemanticEventKind::Navigation {
1135                        marker: marker.clone(),
1136                    },
1137                    frame,
1138                    source: None,
1139                    measure: Some(address.clone()),
1140                });
1141            }
1142            if let Some(instrument) = &measure.instrument_change {
1143                events.push(OfflineRenderSemanticFrameEvent {
1144                    kind: OfflineRenderSemanticEventKind::InstrumentChange {
1145                        instrument_id: instrument.id.clone(),
1146                        channel: instrument.midi_channel,
1147                        program: instrument.midi_program,
1148                        transpose_semitones: instrument.transpose_semitones,
1149                    },
1150                    frame,
1151                    source: None,
1152                    measure: Some(address.clone()),
1153                });
1154            }
1155            let start_tick = tick_starts[time.measure];
1156            let end_tick = start_tick.saturating_add((time.beats * 480.0).round() as u64);
1157            for control in &part.midi_control_changes {
1158                if control.tick < start_tick || control.tick >= end_tick {
1159                    continue;
1160                }
1161                let local_beats = (control.tick - start_tick) as f64 / 480.0;
1162                let secs = time.start_secs
1163                    + tempo_ramp_seconds(time.bpm, time.ramp_to_bpm, time.beats, local_beats);
1164                events.push(OfflineRenderSemanticFrameEvent {
1165                    kind: OfflineRenderSemanticEventKind::Controller {
1166                        channel: control.channel,
1167                        controller: control.controller,
1168                        value: control.value,
1169                    },
1170                    frame: ((secs - origin_secs).max(0.0) * f64::from(sample_rate_hz)).round()
1171                        as u64,
1172                    source: None,
1173                    measure: Some(address.clone()),
1174                });
1175            }
1176            for bend in &part.midi_pitch_bends {
1177                if bend.tick < start_tick || bend.tick >= end_tick {
1178                    continue;
1179                }
1180                let local_beats = (bend.tick - start_tick) as f64 / 480.0;
1181                let secs = time.start_secs
1182                    + tempo_ramp_seconds(time.bpm, time.ramp_to_bpm, time.beats, local_beats);
1183                events.push(OfflineRenderSemanticFrameEvent {
1184                    kind: OfflineRenderSemanticEventKind::PitchBend {
1185                        channel: bend.channel,
1186                        value: bend.value,
1187                    },
1188                    frame: ((secs - origin_secs).max(0.0) * f64::from(sample_rate_hz)).round()
1189                        as u64,
1190                    source: None,
1191                    measure: Some(address.clone()),
1192                });
1193            }
1194            for program in &part.midi_program_changes {
1195                if program.tick < start_tick || program.tick >= end_tick {
1196                    continue;
1197                }
1198                let local_beats = (program.tick - start_tick) as f64 / 480.0;
1199                let secs = time.start_secs
1200                    + tempo_ramp_seconds(time.bpm, time.ramp_to_bpm, time.beats, local_beats);
1201                events.push(OfflineRenderSemanticFrameEvent {
1202                    kind: OfflineRenderSemanticEventKind::ProgramChange {
1203                        channel: program.channel,
1204                        program: program.program,
1205                    },
1206                    frame: ((secs - origin_secs).max(0.0) * f64::from(sample_rate_hz)).round()
1207                        as u64,
1208                    source: None,
1209                    measure: Some(address.clone()),
1210                });
1211            }
1212            for aftertouch in &part.midi_aftertouch {
1213                if aftertouch.tick < start_tick || aftertouch.tick >= end_tick {
1214                    continue;
1215                }
1216                let local_beats = (aftertouch.tick - start_tick) as f64 / 480.0;
1217                let secs = time.start_secs
1218                    + tempo_ramp_seconds(time.bpm, time.ramp_to_bpm, time.beats, local_beats);
1219                events.push(OfflineRenderSemanticFrameEvent {
1220                    kind: OfflineRenderSemanticEventKind::Aftertouch {
1221                        channel: aftertouch.channel,
1222                        key: aftertouch.key,
1223                        value: aftertouch.value,
1224                    },
1225                    frame: ((secs - origin_secs).max(0.0) * f64::from(sample_rate_hz)).round()
1226                        as u64,
1227                    source: None,
1228                    measure: Some(address.clone()),
1229                });
1230            }
1231        }
1232    }
1233    events.sort_by_key(|event| event.frame);
1234}
1235
1236/// Timing tolerances for comparing a host/backend event trace with acorde's schedule.
1237#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1238pub struct PlaybackTimingTolerance {
1239    pub start_secs: f64,
1240    pub duration_secs: f64,
1241}
1242
1243impl Default for PlaybackTimingTolerance {
1244    fn default() -> Self {
1245        Self {
1246            start_secs: 0.005,
1247            duration_secs: 0.005,
1248        }
1249    }
1250}
1251
1252/// A typed difference in a host playback trace. Audio rendering is deliberately not compared.
1253#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1254pub enum PlaybackTimingMismatch {
1255    EventCount { expected: usize, actual: usize },
1256    EventIdentity { index: usize },
1257    StartTime { index: usize, error_secs: f64 },
1258    Duration { index: usize, error_secs: f64 },
1259}
1260
1261/// Deterministic report for a bounded playback timing comparison.
1262#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1263pub struct PlaybackTimingReport {
1264    pub contract_version: u16,
1265    pub expected_events: usize,
1266    pub actual_events: usize,
1267    pub matched_events: usize,
1268    pub max_start_error_secs: f64,
1269    pub max_duration_error_secs: f64,
1270    pub within_tolerance: bool,
1271    pub mismatches: Vec<PlaybackTimingMismatch>,
1272}
1273
1274/// Compare a host-provided event trace with the deterministic score schedule.
1275///
1276/// This contract covers event identity and timing only. Web Audio scheduling,
1277/// SoundFont decoding, device latency, and rendered PCM remain host/provider concerns.
1278pub fn compare_playback_timing(
1279    expected: &[PlaybackEvent],
1280    actual: &[PlaybackEvent],
1281    tolerance: &PlaybackTimingTolerance,
1282) -> Result<PlaybackTimingReport, crate::Error> {
1283    if !tolerance.start_secs.is_finite()
1284        || tolerance.start_secs < 0.0
1285        || !tolerance.duration_secs.is_finite()
1286        || tolerance.duration_secs < 0.0
1287    {
1288        return Err(crate::Error::InvalidPlaybackComparison);
1289    }
1290    if expected.len() > MAX_PLAYBACK_COMPARISON_EVENTS {
1291        return Err(crate::Error::PlaybackComparisonTooLarge(expected.len()));
1292    }
1293    if actual.len() > MAX_PLAYBACK_COMPARISON_EVENTS {
1294        return Err(crate::Error::PlaybackComparisonTooLarge(actual.len()));
1295    }
1296    let mut mismatches = Vec::new();
1297    if expected.len() != actual.len() {
1298        mismatches.push(PlaybackTimingMismatch::EventCount {
1299            expected: expected.len(),
1300            actual: actual.len(),
1301        });
1302    }
1303    let mut matched_events = 0;
1304    let mut max_start_error_secs: f64 = 0.0;
1305    let mut max_duration_error_secs: f64 = 0.0;
1306    for (index, (expected_event, actual_event)) in expected.iter().zip(actual).enumerate() {
1307        let source_identity_matches = match (&expected_event.source, &actual_event.source) {
1308            (Some(expected), Some(actual)) => expected == actual,
1309            _ => expected_event.address == actual_event.address,
1310        };
1311        let identity_matches = source_identity_matches
1312            && expected_event.pitch_midi_cents == actual_event.pitch_midi_cents
1313            && expected_event.velocity == actual_event.velocity
1314            && expected_event.part_index == actual_event.part_index
1315            && expected_event.channel == actual_event.channel
1316            && expected_event.program == actual_event.program
1317            && expected_event.instrument_id == actual_event.instrument_id
1318            && expected_event.pitch_bend_curve == actual_event.pitch_bend_curve
1319            && expected_event.post_note_pause_beats == actual_event.post_note_pause_beats
1320            && expected_event.articulations == actual_event.articulations
1321            && expected_event.chord_symbol == actual_event.chord_symbol
1322            && expected_event.guitar_technique == actual_event.guitar_technique
1323            && expected_event.is_metronome == actual_event.is_metronome;
1324        let start_error_secs = (expected_event.time_secs - actual_event.time_secs).abs();
1325        let duration_error_secs = (expected_event.duration_secs - actual_event.duration_secs).abs();
1326        if start_error_secs.is_finite() {
1327            max_start_error_secs = max_start_error_secs.max(start_error_secs);
1328        }
1329        if duration_error_secs.is_finite() {
1330            max_duration_error_secs = max_duration_error_secs.max(duration_error_secs);
1331        }
1332        let start_matches =
1333            start_error_secs.is_finite() && start_error_secs <= tolerance.start_secs;
1334        let duration_matches =
1335            duration_error_secs.is_finite() && duration_error_secs <= tolerance.duration_secs;
1336        if identity_matches && start_matches && duration_matches {
1337            matched_events += 1;
1338            continue;
1339        }
1340        if mismatches.len() < MAX_PLAYBACK_MISMATCHES {
1341            if !identity_matches {
1342                mismatches.push(PlaybackTimingMismatch::EventIdentity { index });
1343            }
1344            if !start_matches && mismatches.len() < MAX_PLAYBACK_MISMATCHES {
1345                mismatches.push(PlaybackTimingMismatch::StartTime {
1346                    index,
1347                    error_secs: start_error_secs,
1348                });
1349            }
1350            if !duration_matches && mismatches.len() < MAX_PLAYBACK_MISMATCHES {
1351                mismatches.push(PlaybackTimingMismatch::Duration {
1352                    index,
1353                    error_secs: duration_error_secs,
1354                });
1355            }
1356        }
1357    }
1358    Ok(PlaybackTimingReport {
1359        contract_version: PLAYBACK_COMPARISON_CONTRACT_VERSION,
1360        expected_events: expected.len(),
1361        actual_events: actual.len(),
1362        matched_events,
1363        max_start_error_secs,
1364        max_duration_error_secs,
1365        within_tolerance: mismatches.is_empty(),
1366        mismatches,
1367    })
1368}
1369
1370/// Version of the score-schedule timing corpus contract.
1371pub const PLAYBACK_TIMING_CORPUS_CONTRACT_VERSION: u16 = 1;
1372const MAX_PLAYBACK_TIMING_CORPUS_CASES: usize = 256;
1373
1374/// One score-backed timing case, independent from an audio synthesis backend.
1375#[derive(Debug, Clone, Serialize, Deserialize)]
1376pub struct PlaybackTimingCase {
1377    pub id: String,
1378    pub score: Score,
1379    #[serde(default)]
1380    pub options: PlaybackOptions,
1381    pub expected_events: Vec<PlaybackEvent>,
1382    #[serde(default)]
1383    pub tolerance: PlaybackTimingTolerance,
1384}
1385
1386/// Result for one [`PlaybackTimingCase`].
1387#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1388pub struct PlaybackTimingCaseReport {
1389    pub id: String,
1390    pub report: PlaybackTimingReport,
1391}
1392
1393/// Deterministic aggregate result for a score-schedule timing corpus.
1394#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1395pub struct PlaybackTimingCorpusReport {
1396    pub contract_version: u16,
1397    pub total_cases: usize,
1398    pub passed_cases: usize,
1399    pub cases: Vec<PlaybackTimingCaseReport>,
1400}
1401
1402/// Compare score-generated schedules against a bounded corpus of expected event traces.
1403///
1404/// The corpus verifies notation-to-event semantics only. It makes no assertion about synthesized
1405/// PCM, codec output, browser scheduling, or real-time device latency.
1406pub fn evaluate_playback_timing_corpus(
1407    cases: &[PlaybackTimingCase],
1408) -> Result<PlaybackTimingCorpusReport, crate::Error> {
1409    if cases.is_empty() || cases.len() > MAX_PLAYBACK_TIMING_CORPUS_CASES {
1410        return Err(crate::Error::InvalidPlaybackTimingCorpus);
1411    }
1412    let mut ids = BTreeSet::new();
1413    let mut reports = Vec::with_capacity(cases.len());
1414    let mut passed_cases = 0;
1415    for case in cases {
1416        if !valid_routing_id(&case.id) || !ids.insert(case.id.clone()) {
1417            return Err(crate::Error::InvalidPlaybackTimingCorpus);
1418        }
1419        let actual = to_playback_events_bounded(&case.score, &case.options)?;
1420        let report = compare_playback_timing(&case.expected_events, &actual, &case.tolerance)?;
1421        if report.within_tolerance {
1422            passed_cases += 1;
1423        }
1424        reports.push(PlaybackTimingCaseReport {
1425            id: case.id.clone(),
1426            report,
1427        });
1428    }
1429    Ok(PlaybackTimingCorpusReport {
1430        contract_version: PLAYBACK_TIMING_CORPUS_CONTRACT_VERSION,
1431        total_cases: cases.len(),
1432        passed_cases,
1433        cases: reports,
1434    })
1435}
1436
1437/// Version of the host-neutral tablature performance projection contract.
1438pub const TAB_PERFORMANCE_CONTRACT_VERSION: u16 = 4;
1439
1440#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1441pub struct TablaturePerformanceEvent {
1442    pub playback: PlaybackEvent,
1443    pub string: u8,
1444    pub fret: u8,
1445    /// Authored guitar technique for the host playback adapter, when present.
1446    #[serde(default)]
1447    pub technique: Option<GuitarTechnique>,
1448    /// Authored bend alteration in cents, when the technique is `Bend`.
1449    #[serde(default)]
1450    pub bend_alter_cents: Option<i16>,
1451    /// Normalized bend/hold/release curve for a bend-capable host. Positions are per-mille of
1452    /// this event's sounding duration and values are cents relative to the written pitch.
1453    #[serde(default)]
1454    pub bend_curve: Vec<GuitarBendPoint>,
1455    pub expected_pitch_midi_cents: i32,
1456    pub pitch_error_cents: i32,
1457}
1458
1459#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1460pub enum TablaturePerformanceDiagnostic {
1461    NoTablatureStaff {
1462        address: String,
1463    },
1464    MissingPosition {
1465        address: String,
1466        pitch_index: usize,
1467    },
1468    StringOutOfRange {
1469        address: String,
1470        string: u8,
1471        lines: u8,
1472    },
1473    TuningUnavailable {
1474        address: String,
1475        string: u8,
1476    },
1477    PitchMismatch {
1478        address: String,
1479        pitch_index: usize,
1480        error_cents: i32,
1481    },
1482}
1483
1484#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1485pub struct TablaturePerformanceReport {
1486    pub contract_version: u16,
1487    pub events: Vec<TablaturePerformanceEvent>,
1488    pub diagnostics: Vec<TablaturePerformanceDiagnostic>,
1489}
1490
1491/// Version of the score-model tablature round-trip diagnostic contract.
1492pub const TAB_ROUND_TRIP_CONTRACT_VERSION: u16 = 1;
1493
1494#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1495pub struct TablatureRoundTripReport {
1496    pub contract_version: u16,
1497    pub checked_notes: usize,
1498    pub positioned_notes: usize,
1499    pub equivalent: bool,
1500    pub diagnostics: Vec<TablatureRoundTripDiagnostic>,
1501}
1502
1503#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1504pub enum TablatureRoundTripDiagnostic {
1505    StructureMismatch {
1506        address: String,
1507    },
1508    TablatureConfigMismatch {
1509        address: String,
1510    },
1511    PositionMismatch {
1512        address: String,
1513        pitch_index: usize,
1514        expected: Option<TabPosition>,
1515        actual: Option<TabPosition>,
1516    },
1517}
1518
1519/// Verify that authored tablature survives the canonical score JSON round-trip.
1520///
1521/// This checks score-model persistence only. It does not claim MusicXML/MSCX or external
1522/// application equivalence; those format and host comparisons remain separate gates.
1523pub fn tablature_round_trip_report(
1524    score: &Score,
1525) -> Result<TablatureRoundTripReport, crate::Error> {
1526    let encoded = serde_json::to_string(score)
1527        .map_err(|error| crate::Error::TabRoundTripSerialization(error.to_string()))?;
1528    let restored: Score = serde_json::from_str(&encoded)
1529        .map_err(|error| crate::Error::TabRoundTripSerialization(error.to_string()))?;
1530    let mut checked_notes = 0;
1531    let mut positioned_notes = 0;
1532    let mut diagnostics = Vec::new();
1533    for (part_index, part) in score.parts.iter().enumerate() {
1534        let Some(restored_part) = restored.parts.get(part_index) else {
1535            diagnostics.push(TablatureRoundTripDiagnostic::StructureMismatch {
1536                address: format!("{part_index}"),
1537            });
1538            continue;
1539        };
1540        for (staff_index, staff) in part.staves.iter().enumerate() {
1541            let address = format!("{part_index}:{staff_index}");
1542            let Some(restored_staff) = restored_part.staves.get(staff_index) else {
1543                diagnostics.push(TablatureRoundTripDiagnostic::StructureMismatch { address });
1544                continue;
1545            };
1546            if staff.tablature != restored_staff.tablature {
1547                diagnostics.push(TablatureRoundTripDiagnostic::TablatureConfigMismatch {
1548                    address: address.clone(),
1549                });
1550            }
1551            for (measure_index, measure) in staff.measures.iter().enumerate() {
1552                let Some(restored_measure) = restored_staff.measures.get(measure_index) else {
1553                    diagnostics.push(TablatureRoundTripDiagnostic::StructureMismatch {
1554                        address: format!("{address}:{measure_index}"),
1555                    });
1556                    continue;
1557                };
1558                for (voice_index, voice) in measure.voices.iter().enumerate() {
1559                    let Some(restored_voice) = restored_measure.voices.get(voice_index) else {
1560                        diagnostics.push(TablatureRoundTripDiagnostic::StructureMismatch {
1561                            address: format!("{address}:{measure_index}:{voice_index}"),
1562                        });
1563                        continue;
1564                    };
1565                    for (note_index, note) in voice.iter().enumerate() {
1566                        checked_notes += 1;
1567                        if !note.tab_positions.is_empty() || note.tab_position.is_some() {
1568                            positioned_notes += 1;
1569                        }
1570                        let note_address =
1571                            format!("{address}:{measure_index}:{voice_index}:{note_index}");
1572                        let Some(restored_note) = restored_voice.get(note_index) else {
1573                            diagnostics.push(TablatureRoundTripDiagnostic::StructureMismatch {
1574                                address: note_address,
1575                            });
1576                            continue;
1577                        };
1578                        let pitch_count = note.pitches.len().max(note.tab_positions.len()).max(1);
1579                        for pitch_index in 0..pitch_count {
1580                            let expected = note
1581                                .tab_positions
1582                                .get(pitch_index)
1583                                .cloned()
1584                                .or_else(|| note.tab_position.clone());
1585                            let actual = restored_note
1586                                .tab_positions
1587                                .get(pitch_index)
1588                                .cloned()
1589                                .or_else(|| restored_note.tab_position.clone());
1590                            if expected != actual {
1591                                diagnostics.push(TablatureRoundTripDiagnostic::PositionMismatch {
1592                                    address: note_address.clone(),
1593                                    pitch_index,
1594                                    expected,
1595                                    actual,
1596                                });
1597                            }
1598                        }
1599                    }
1600                }
1601            }
1602        }
1603    }
1604    Ok(TablatureRoundTripReport {
1605        contract_version: TAB_ROUND_TRIP_CONTRACT_VERSION,
1606        checked_notes,
1607        positioned_notes,
1608        equivalent: diagnostics.is_empty(),
1609        diagnostics,
1610    })
1611}
1612
1613/// Project score playback events onto authored tablature positions.
1614///
1615/// This operation never invents a string or fret. Callers may run
1616/// [`assign_tablature_positions`](crate::assign_tablature_positions) before this
1617/// projection when automatic positions are desired.
1618pub fn project_tablature_performance(
1619    score: &Score,
1620    options: &PlaybackOptions,
1621) -> Result<TablaturePerformanceReport, crate::Error> {
1622    let playback = to_playback_events(score, options);
1623    if playback.len() > MAX_TAB_PERFORMANCE_EVENTS {
1624        return Err(crate::Error::TabPerformanceTooLarge(playback.len()));
1625    }
1626    let mut events = Vec::new();
1627    let mut diagnostics = Vec::new();
1628    for event in playback {
1629        let Some(address) = event.address.as_deref() else {
1630            continue;
1631        };
1632        let Some((part_index, staff_index, measure_index, voice_index, note_index)) =
1633            parse_playback_address(address)
1634        else {
1635            continue;
1636        };
1637        let Some(staff) = score
1638            .parts
1639            .get(part_index)
1640            .and_then(|part| part.staves.get(staff_index))
1641        else {
1642            continue;
1643        };
1644        let Some(note) = staff
1645            .measures
1646            .get(measure_index)
1647            .and_then(|measure| measure.voices.get(voice_index))
1648            .and_then(|voice| voice.get(note_index))
1649        else {
1650            continue;
1651        };
1652        let Some(tab) = staff.tablature_at(measure_index) else {
1653            push_tab_diagnostic(
1654                &mut diagnostics,
1655                TablaturePerformanceDiagnostic::NoTablatureStaff {
1656                    address: address.into(),
1657                },
1658            );
1659            continue;
1660        };
1661        let transpose_cents = if event.channel == 9 {
1662            0
1663        } else {
1664            i32::from(staff.transpose_semitones) * 100
1665        };
1666        let written_pitch_cents = event.pitch_midi_cents - transpose_cents;
1667        let pitch_index = note
1668            .pitches
1669            .iter()
1670            .position(|pitch| pitch.to_midi_cents() == written_pitch_cents)
1671            .unwrap_or(0);
1672        let position = note
1673            .tab_positions
1674            .get(pitch_index)
1675            .or(note.tab_position.as_ref());
1676        let Some(position) = position else {
1677            push_tab_diagnostic(
1678                &mut diagnostics,
1679                TablaturePerformanceDiagnostic::MissingPosition {
1680                    address: address.into(),
1681                    pitch_index,
1682                },
1683            );
1684            continue;
1685        };
1686        if position.string == 0 || position.string > tab.lines {
1687            push_tab_diagnostic(
1688                &mut diagnostics,
1689                TablaturePerformanceDiagnostic::StringOutOfRange {
1690                    address: address.into(),
1691                    string: position.string,
1692                    lines: tab.lines,
1693                },
1694            );
1695            continue;
1696        }
1697        let Some(tuning) = tab.tuning_midi.get(usize::from(position.string - 1)) else {
1698            push_tab_diagnostic(
1699                &mut diagnostics,
1700                TablaturePerformanceDiagnostic::TuningUnavailable {
1701                    address: address.into(),
1702                    string: position.string,
1703                },
1704            );
1705            continue;
1706        };
1707        let expected_pitch_midi_cents =
1708            tuning.saturating_add(i16::from(position.fret) + i16::from(tab.capo)) as i32 * 100;
1709        let pitch_error_cents = event.pitch_midi_cents - expected_pitch_midi_cents;
1710        if pitch_error_cents != 0 {
1711            push_tab_diagnostic(
1712                &mut diagnostics,
1713                TablaturePerformanceDiagnostic::PitchMismatch {
1714                    address: address.into(),
1715                    pitch_index,
1716                    error_cents: pitch_error_cents,
1717                },
1718            );
1719        }
1720        events.push(TablaturePerformanceEvent {
1721            playback: event,
1722            string: position.string,
1723            fret: position.fret,
1724            technique: note.guitar_technique.clone(),
1725            bend_alter_cents: note.guitar_bend_alter_cents,
1726            bend_curve: note.guitar_bend_curve.clone(),
1727            expected_pitch_midi_cents,
1728            pitch_error_cents,
1729        });
1730    }
1731    Ok(TablaturePerformanceReport {
1732        contract_version: TAB_PERFORMANCE_CONTRACT_VERSION,
1733        events,
1734        diagnostics,
1735    })
1736}
1737
1738fn parse_playback_address(address: &str) -> Option<(usize, usize, usize, usize, usize)> {
1739    let mut fields = address.split(':').map(|field| field.parse::<usize>().ok());
1740    Some((
1741        fields.next()??,
1742        fields.next()??,
1743        fields.next()??,
1744        fields.next()??,
1745        fields.next()??,
1746    ))
1747}
1748
1749fn push_tab_diagnostic(
1750    diagnostics: &mut Vec<TablaturePerformanceDiagnostic>,
1751    diagnostic: TablaturePerformanceDiagnostic,
1752) {
1753    if diagnostics.len() < MAX_TAB_PERFORMANCE_DIAGNOSTICS {
1754        diagnostics.push(diagnostic);
1755    }
1756}
1757
1758/// Dynamic levels of one staff along a played measure order, so a marking carries on until
1759/// the next one as in engraved music: a level (p, f, …) holds, an accent (sf, sfz, fz, rfz,
1760/// sffz) lifts only its own moment, and a compound (fp, sfp, pf, …) attacks at the first
1761/// level and continues at the second. A hairpin ramps the level across its notes towards the
1762/// marking that follows it (or two dynamic steps up or down when none follows at once) and
1763/// leaves the level there. Before the first marking notes play at velocity 64.
1764#[derive(Debug, Clone, Default)]
1765pub struct DynamicTimeline {
1766    /// Start of each position of the order, in beats from the start.
1767    position_beats: Vec<f64>,
1768    /// (time, attack, level after), in time order.
1769    changes: Vec<(f64, u8, u8)>,
1770    /// Hairpins as (start, end, from velocity, to velocity).
1771    ramps: Vec<(f64, f64, u8, u8)>,
1772}
1773
1774/// One dynamic event on a staff timeline: a written marking or the level a hairpin reaches.
1775#[derive(Clone, Copy)]
1776enum DynamicMark {
1777    Marking(crate::Dynamic),
1778    Level(u8),
1779}
1780
1781impl DynamicTimeline {
1782    /// Velocity of a note before any dynamic marking.
1783    pub const DEFAULT_VELOCITY: u8 = 64;
1784    /// How far a hairpin moves the level when no marking follows it (two dynamic steps).
1785    const HAIRPIN_STEP: i16 = 24;
1786
1787    /// Collect the markings and hairpins of every voice of `staff` along `order` (measure
1788    /// indices as played, repeats expanded). Grace and cue notes take no time.
1789    pub fn for_staff(staff: &crate::Staff, order: &[usize]) -> Self {
1790        let mut position_beats = Vec::with_capacity(order.len());
1791        let mut marks: Vec<(f64, DynamicMark)> = Vec::new();
1792        // (start, end, crescendo)
1793        let mut hairpins: Vec<(f64, f64, bool)> = Vec::new();
1794        let mut open_hairpins: [Option<(f64, bool)>; 4] = [None; 4];
1795        let mut elapsed = 0.0;
1796        for &measure_index in order {
1797            position_beats.push(elapsed);
1798            let Some(measure) = staff.measures.get(measure_index) else {
1799                continue;
1800            };
1801            let mut length: f64 = 0.0;
1802            for (voice_index, voice) in measure.voices.iter().enumerate() {
1803                let mut beats = 0.0;
1804                for note in voice {
1805                    let onset = elapsed + beats;
1806                    if let Some(dynamic) = note.dynamic
1807                        && !note.is_rest
1808                    {
1809                        marks.push((onset, DynamicMark::Marking(dynamic)));
1810                    }
1811                    let end = onset + note.beats();
1812                    // A note may end one hairpin and start the next.
1813                    let ends_earlier = note.hairpin_end && open_hairpins[voice_index].is_some();
1814                    if ends_earlier && let Some((start, up)) = open_hairpins[voice_index].take() {
1815                        hairpins.push((start, end, up));
1816                    }
1817                    if let Some(kind) = note.hairpin_start {
1818                        open_hairpins[voice_index] =
1819                            Some((onset, kind == crate::HairpinKind::Crescendo));
1820                    }
1821                    if note.hairpin_end
1822                        && !ends_earlier
1823                        && let Some((start, up)) = open_hairpins[voice_index].take()
1824                    {
1825                        hairpins.push((start, end, up));
1826                    }
1827                    beats += note.beats();
1828                }
1829                length = length.max(beats);
1830            }
1831            elapsed += length;
1832        }
1833        marks.sort_by(|a, b| a.0.total_cmp(&b.0));
1834        let written = Self::resolve(&marks);
1835        let mut ramps = Vec::new();
1836        for (start, end, up) in hairpins {
1837            if end <= start {
1838                continue;
1839            }
1840            let from = Self::level_at(&written, start);
1841            // The marking the hairpin leads to, if one comes within a beat of its end.
1842            let next = written
1843                .iter()
1844                .find(|&&(time, _, _)| time >= end - 1e-9)
1845                .filter(|&&(time, _, _)| time <= end + 1.0)
1846                .map(|&(_, attack, _)| attack)
1847                .filter(|&attack| if up { attack > from } else { attack < from });
1848            let to = next.unwrap_or_else(|| {
1849                let step = if up {
1850                    Self::HAIRPIN_STEP
1851                } else {
1852                    -Self::HAIRPIN_STEP
1853                };
1854                (i16::from(from) + step).clamp(1, 127) as u8
1855            });
1856            if next.is_none() {
1857                marks.push((end, DynamicMark::Level(to)));
1858            }
1859            ramps.push((start, end, from, to));
1860        }
1861        marks.sort_by(|a, b| a.0.total_cmp(&b.0));
1862        Self {
1863            position_beats,
1864            changes: Self::resolve(&marks),
1865            ramps,
1866        }
1867    }
1868
1869    fn resolve(marks: &[(f64, DynamicMark)]) -> Vec<(f64, u8, u8)> {
1870        let mut level = Self::DEFAULT_VELOCITY;
1871        marks
1872            .iter()
1873            .map(|&(time, mark)| match mark {
1874                DynamicMark::Marking(dynamic) => {
1875                    if let Some(next) = dynamic.sustained_level() {
1876                        level = next.to_velocity();
1877                    }
1878                    (time, dynamic.to_velocity(), level)
1879                }
1880                DynamicMark::Level(velocity) => {
1881                    level = velocity;
1882                    (time, velocity, level)
1883                }
1884            })
1885            .collect()
1886    }
1887
1888    fn level_at(changes: &[(f64, u8, u8)], time: f64) -> u8 {
1889        let before = changes.partition_point(|&(t, _, _)| t <= time + 1e-9);
1890        before
1891            .checked_sub(1)
1892            .map_or(Self::DEFAULT_VELOCITY, |index| changes[index].2)
1893    }
1894
1895    /// Velocity of a note at `beats` into the measure at `position` of the order: its own
1896    /// marking's attack, the attack of a marking at the same moment in another voice, the
1897    /// point a hairpin has reached, or the level in force.
1898    pub fn velocity(&self, position: usize, beats: f64, own: Option<crate::Dynamic>) -> u8 {
1899        if let Some(dynamic) = own {
1900            return dynamic.to_velocity();
1901        }
1902        let time = self.position_beats.get(position).copied().unwrap_or(0.0) + beats;
1903        let before = self.changes.partition_point(|&(t, _, _)| t <= time + 1e-9);
1904        let current = before.checked_sub(1).map(|index| self.changes[index]);
1905        if let Some((t, attack, _)) = current
1906            && (t - time).abs() <= 1e-9
1907        {
1908            return attack;
1909        }
1910        if let Some(&(start, end, from, to)) = self
1911            .ramps
1912            .iter()
1913            .rev()
1914            .find(|&&(start, end, _, _)| start <= time + 1e-9 && time < end - 1e-9)
1915        {
1916            let fraction = ((time - start) / (end - start)).clamp(0.0, 1.0);
1917            let value = f64::from(from) + (f64::from(to) - f64::from(from)) * fraction;
1918            return value.round().clamp(1.0, 127.0) as u8;
1919        }
1920        current.map_or(Self::DEFAULT_VELOCITY, |(_, _, level)| level)
1921    }
1922}
1923
1924/// Convert a [`Score`] into a flat, time-ordered list of [`PlaybackEvent`]s.
1925///
1926/// All parts, staves, and voices are included unless excluded via [`PlaybackOptions`].
1927/// Repeat sections and volta brackets are expanded using [`measure_sequence`].
1928/// Events are sorted by `time_beats`.
1929pub fn to_playback_events(score: &Score, options: &PlaybackOptions) -> Vec<PlaybackEvent> {
1930    // A pedal held only as a typed spanner still presses the pedal on its first note.
1931    let materialized = score.with_legacy_spanner_flags();
1932    let score: &Score = &materialized;
1933    let bpm = initial_tempo_bpm(score, options);
1934    let tempo_marks = score_tempo_marks(score);
1935    let measure_lengths: Vec<f64> = (0..tempo_marks.len())
1936        .map(|index| score.playback_measure_beats(index))
1937        .collect();
1938    let mut measure_tick_starts = Vec::with_capacity(measure_lengths.len());
1939    let mut next_tick = 0_u64;
1940    for beats in &measure_lengths {
1941        measure_tick_starts.push(next_tick);
1942        next_tick = next_tick.saturating_add((beats * 480.0).round() as u64);
1943    }
1944    let swing_ratio = options
1945        .swing
1946        .filter(|ratio| ratio.is_finite() && *ratio > 0.5 && *ratio < 1.0);
1947    let full_seq = measure_sequence(score);
1948    let seq: Vec<usize> = if let Some((lo, hi)) = options.loop_region {
1949        full_seq
1950            .into_iter()
1951            .filter(|&idx| idx >= lo && idx <= hi)
1952            .collect()
1953    } else {
1954        full_seq
1955    };
1956    let mut events: Vec<PlaybackEvent> = Vec::new();
1957
1958    for (part_index, part) in score.parts.iter().enumerate() {
1959        if options.muted_parts.contains(&part_index) {
1960            continue;
1961        }
1962        for (staff_index, staff) in part.staves.iter().enumerate() {
1963            let dynamics = DynamicTimeline::for_staff(staff, &seq);
1964            for voice_idx in 0..4usize {
1965                let mut time_beats = 0.0f64;
1966                let mut time_secs_cursor = 0.0f64;
1967                let mut current_bpm = bpm;
1968                let mut prior_note_events = 0..0;
1969                for (seq_position, &idx) in seq.iter().enumerate() {
1970                    let measure = match staff.measures.get(idx) {
1971                        Some(m) => m,
1972                        None => continue,
1973                    };
1974                    let instrument = measure
1975                        .instrument_change
1976                        .as_ref()
1977                        .or(part.instrument.as_ref());
1978                    let channel = instrument.map_or(part.midi_channel, |value| value.midi_channel);
1979                    let default_program =
1980                        instrument.map_or(part.midi_program, |value| value.midi_program);
1981                    let instrument_id = instrument.map(|value| value.id.clone());
1982                    let mark = tempo_marks.get(idx).copied().unwrap_or_default();
1983                    if let Some(b) = mark.bpm.and_then(|tempo| written_tempo_bpm(options, tempo)) {
1984                        current_bpm = b;
1985                    }
1986                    let measure_beats = measure_lengths[idx];
1987                    let grid_offset_beats = if idx == 0
1988                        && measure_beats
1989                            < staff
1990                                .meter_at(idx, &score.settings.time_signature)
1991                                .total_beats()
1992                    {
1993                        (staff
1994                            .meter_at(idx, &score.settings.time_signature)
1995                            .total_beats()
1996                            - measure_beats)
1997                            .max(0.0)
1998                    } else {
1999                        0.0
2000                    };
2001                    let ramp_end_bpm = written_tempo_ramp_bpm(options, mark.ramp_to_bpm);
2002                    let measure_start_beats = time_beats;
2003                    let measure_start_secs = time_secs_cursor;
2004                    let mut local_beats = 0.0f64;
2005                    // Unswung position, which dynamic markings are keyed by.
2006                    let mut written_beats = 0.0f64;
2007                    let mut swing_second = false;
2008                    for (note_index, note) in measure.voices[voice_idx].iter().enumerate() {
2009                        if note.is_grace || note.is_cue {
2010                            continue;
2011                        }
2012                        let onset_written_beats = written_beats;
2013                        written_beats += note.beats();
2014                        let dur = if let Some(ratio) = swing_ratio {
2015                            if swing_second {
2016                                swing_second = false;
2017                                (1.0 - ratio) * note.beats() * 2.0
2018                            } else if starts_swing_pair(
2019                                &measure.voices[voice_idx],
2020                                note_index,
2021                                onset_written_beats,
2022                                grid_offset_beats,
2023                                measure_beats,
2024                                &options.swing_unit,
2025                            ) {
2026                                swing_second = true;
2027                                ratio * note.beats() * 2.0
2028                            } else {
2029                                note.beats()
2030                            }
2031                        } else {
2032                            note.beats()
2033                        };
2034                        if !note.is_rest {
2035                            // MIDI program changes are keyed to the source's physical tick
2036                            // timeline. Use the written onset, so opt-in swing does not move a
2037                            // change across the note it belongs to.
2038                            let source_tick = measure_tick_starts[idx]
2039                                .saturating_add((onset_written_beats * 480.0).round() as u64);
2040                            let mut program = default_program;
2041                            let mut latest_change_tick = 0_u64;
2042                            for change in &part.midi_program_changes {
2043                                if change.channel == channel
2044                                    && change.tick <= source_tick
2045                                    && change.tick >= latest_change_tick
2046                                {
2047                                    program = change.program;
2048                                    latest_change_tick = change.tick;
2049                                }
2050                            }
2051                            let mut velocity =
2052                                dynamics.velocity(seq_position, onset_written_beats, note.dynamic);
2053                            let mut sounding_dur = dur;
2054                            for art in &note.articulations {
2055                                match art {
2056                                    Articulation::Staccato | Articulation::Staccatissimo => {
2057                                        sounding_dur *= 0.5;
2058                                    }
2059                                    Articulation::Accent | Articulation::Marcato => {
2060                                        velocity = velocity.saturating_add(20).min(127);
2061                                    }
2062                                    Articulation::Fermata => {
2063                                        sounding_dur *= options.fermata_multiplier;
2064                                    }
2065                                    _ => {}
2066                                }
2067                            }
2068                            let pedal = note.pedal_start;
2069                            let fermata_hold_beats = if options.fermata_hold_beats.is_finite() {
2070                                options.fermata_hold_beats.max(0.0)
2071                            } else {
2072                                0.0
2073                            };
2074                            let post_note_pause_beats =
2075                                note.articulations
2076                                    .iter()
2077                                    .fold(0.0f64, |pause, articulation| {
2078                                        pause.max(match articulation {
2079                                            Articulation::BreathMark => 0.25,
2080                                            Articulation::Caesura => 0.5,
2081                                            Articulation::Fermata => fermata_hold_beats,
2082                                            _ => 0.0,
2083                                        })
2084                                    });
2085                            let transpose = if channel == 9 {
2086                                0i8
2087                            } else {
2088                                staff.transpose_semitones.saturating_add(
2089                                    instrument.map_or(0, |value| value.transpose_semitones),
2090                                )
2091                            };
2092                            let main_event_start = events.len();
2093                            for (pitch_index, pitch) in note.pitches.iter().enumerate() {
2094                                // An unpitched note sounds its instrument's percussion key.
2095                                let percussion = part.percussion_key(note, pitch_index);
2096                                let transpose = if percussion.is_some() { 0 } else { transpose };
2097                                let midi = percussion.map_or_else(
2098                                    || (pitch.to_midi() + transpose as i16).clamp(0, 127) as u8,
2099                                    |key| key.min(127),
2100                                );
2101                                events.push(PlaybackEvent {
2102                                    address: Some(format!(
2103                                        "{part_index}:{staff_index}:{idx}:{voice_idx}:{note_index}"
2104                                    )),
2105                                    source: Some(NoteAddr {
2106                                        part: part_index,
2107                                        staff: staff_index,
2108                                        measure: idx,
2109                                        voice: voice_idx,
2110                                        note: note_index,
2111                                    }),
2112                                    source_voice_number: measure.source_voice_numbers[voice_idx],
2113                                    time_beats: measure_start_beats + local_beats,
2114                                    time_secs: measure_start_secs
2115                                        + tempo_ramp_seconds(
2116                                            current_bpm,
2117                                            ramp_end_bpm,
2118                                            measure_beats,
2119                                            local_beats,
2120                                        ),
2121                                    pitch_midi: midi,
2122                                    pitch_midi_cents: if percussion.is_some() {
2123                                        i32::from(midi) * 100
2124                                    } else {
2125                                        pitch.to_midi_cents() + transpose as i32 * 100
2126                                    },
2127                                    pitch_bend_curve: note.guitar_bend_curve.clone(),
2128                                    post_note_pause_beats,
2129                                    articulations: note.articulations.clone(),
2130                                    chord_symbol: note.chord_symbol.clone(),
2131                                    guitar_technique: note.guitar_technique.clone(),
2132                                    velocity,
2133                                    duration_beats: sounding_dur,
2134                                    duration_secs: tempo_ramp_seconds(
2135                                        current_bpm,
2136                                        ramp_end_bpm,
2137                                        measure_beats,
2138                                        local_beats + sounding_dur,
2139                                    ) - tempo_ramp_seconds(
2140                                        current_bpm,
2141                                        ramp_end_bpm,
2142                                        measure_beats,
2143                                        local_beats,
2144                                    ),
2145                                    pedal,
2146                                    part_index,
2147                                    channel,
2148                                    program,
2149                                    instrument_id: instrument_id.clone(),
2150                                    is_metronome: false,
2151                                });
2152                            }
2153                            let main_event_end = events.len();
2154                            let voice = &measure.voices[voice_idx];
2155                            let mut grace_start = note_index;
2156                            while grace_start > 0 && voice[grace_start - 1].is_grace {
2157                                grace_start -= 1;
2158                            }
2159                            if grace_start < note_index && main_event_start < main_event_end {
2160                                let graces = &voice[grace_start..note_index];
2161                                // A short written grace group occupies part of the preceding
2162                                // rhythmic slot. At the start of playback it takes time from
2163                                // the following main note instead; the bar length is unchanged.
2164                                let written: Vec<f64> = graces
2165                                    .iter()
2166                                    .map(|grace| {
2167                                        (grace.duration.beats(grace.dot_count) * 0.25).min(0.25)
2168                                    })
2169                                    .collect();
2170                                let requested: f64 = written.iter().sum();
2171                                let prior_onset = prior_note_events
2172                                    .clone()
2173                                    .next()
2174                                    .map(|index| events[index].time_beats - measure_start_beats)
2175                                    .unwrap_or(0.0);
2176                                let before = requested
2177                                    .min(local_beats * 0.5)
2178                                    .min((local_beats - prior_onset).max(0.0) * 0.5);
2179                                let take_from_previous = before > 0.0;
2180                                let grace_total = if take_from_previous {
2181                                    before
2182                                } else {
2183                                    requested.min(sounding_dur * 0.5)
2184                                };
2185                                if grace_total > 0.0 {
2186                                    let grace_start_beat = if take_from_previous {
2187                                        local_beats - grace_total
2188                                    } else {
2189                                        local_beats
2190                                    };
2191                                    let grace_start_secs = measure_start_secs
2192                                        + tempo_ramp_seconds(
2193                                            current_bpm,
2194                                            ramp_end_bpm,
2195                                            measure_beats,
2196                                            grace_start_beat,
2197                                        );
2198                                    if take_from_previous {
2199                                        for event in &mut events[prior_note_events.clone()] {
2200                                            if event.time_beats + event.duration_beats
2201                                                > measure_start_beats + grace_start_beat
2202                                            {
2203                                                event.duration_beats = measure_start_beats
2204                                                    + grace_start_beat
2205                                                    - event.time_beats;
2206                                                event.duration_secs =
2207                                                    grace_start_secs - event.time_secs;
2208                                            }
2209                                        }
2210                                    } else {
2211                                        let shifted_beat = local_beats + grace_total;
2212                                        let shifted_secs = measure_start_secs
2213                                            + tempo_ramp_seconds(
2214                                                current_bpm,
2215                                                ramp_end_bpm,
2216                                                measure_beats,
2217                                                shifted_beat,
2218                                            );
2219                                        for event in &mut events[main_event_start..main_event_end] {
2220                                            event.time_beats = measure_start_beats + shifted_beat;
2221                                            event.time_secs = shifted_secs;
2222                                            event.duration_beats -= grace_total;
2223                                            event.duration_secs = tempo_ramp_seconds(
2224                                                current_bpm,
2225                                                ramp_end_bpm,
2226                                                measure_beats,
2227                                                local_beats + sounding_dur,
2228                                            ) - tempo_ramp_seconds(
2229                                                current_bpm,
2230                                                ramp_end_bpm,
2231                                                measure_beats,
2232                                                shifted_beat,
2233                                            );
2234                                        }
2235                                    }
2236                                    let mut cursor = grace_start_beat;
2237                                    for (offset, grace) in graces.iter().enumerate() {
2238                                        let grace_beats = grace_total * written[offset] / requested;
2239                                        let grace_end = cursor + grace_beats;
2240                                        for (pitch_index, pitch) in grace.pitches.iter().enumerate()
2241                                        {
2242                                            let percussion =
2243                                                part.percussion_key(grace, pitch_index);
2244                                            let pitch_transpose =
2245                                                if percussion.is_some() { 0 } else { transpose };
2246                                            let mut event = events[main_event_start].clone();
2247                                            event.address = Some(format!(
2248                                                "{part_index}:{staff_index}:{idx}:{voice_idx}:{}",
2249                                                grace_start + offset
2250                                            ));
2251                                            event.source = Some(NoteAddr {
2252                                                part: part_index,
2253                                                staff: staff_index,
2254                                                measure: idx,
2255                                                voice: voice_idx,
2256                                                note: grace_start + offset,
2257                                            });
2258                                            event.pitch_midi = percussion.map_or_else(
2259                                                || {
2260                                                    (pitch.to_midi() + pitch_transpose as i16)
2261                                                        .clamp(0, 127)
2262                                                        as u8
2263                                                },
2264                                                |key| key.min(127),
2265                                            );
2266                                            event.pitch_midi_cents = if percussion.is_some() {
2267                                                i32::from(event.pitch_midi) * 100
2268                                            } else {
2269                                                pitch.to_midi_cents()
2270                                                    + i32::from(pitch_transpose) * 100
2271                                            };
2272                                            event.time_beats = measure_start_beats + cursor;
2273                                            event.time_secs = measure_start_secs
2274                                                + tempo_ramp_seconds(
2275                                                    current_bpm,
2276                                                    ramp_end_bpm,
2277                                                    measure_beats,
2278                                                    cursor,
2279                                                );
2280                                            event.duration_beats = grace_beats;
2281                                            event.duration_secs = tempo_ramp_seconds(
2282                                                current_bpm,
2283                                                ramp_end_bpm,
2284                                                measure_beats,
2285                                                grace_end,
2286                                            ) - tempo_ramp_seconds(
2287                                                current_bpm,
2288                                                ramp_end_bpm,
2289                                                measure_beats,
2290                                                cursor,
2291                                            );
2292                                            event.articulations = grace.articulations.clone();
2293                                            event.pitch_bend_curve =
2294                                                grace.guitar_bend_curve.clone();
2295                                            event.guitar_technique = grace.guitar_technique.clone();
2296                                            event.chord_symbol = grace.chord_symbol.clone();
2297                                            event.post_note_pause_beats = 0.0;
2298                                            event.velocity = dynamics.velocity(
2299                                                seq_position,
2300                                                onset_written_beats,
2301                                                grace.dynamic,
2302                                            );
2303                                            event.pedal = grace.pedal_start;
2304                                            events.push(event);
2305                                        }
2306                                        cursor = grace_end;
2307                                    }
2308                                }
2309                            }
2310                            prior_note_events = main_event_start..main_event_end;
2311                        }
2312                        local_beats += dur;
2313                    }
2314                    // A voice may omit rests, but the next measure still starts
2315                    // at the notated bar boundary. This also keeps sparse voices
2316                    // aligned with compute_playback_position and other voices.
2317                    time_beats = measure_start_beats + measure_beats;
2318                    time_secs_cursor = measure_start_secs
2319                        + tempo_ramp_seconds(
2320                            current_bpm,
2321                            ramp_end_bpm,
2322                            measure_beats,
2323                            measure_beats,
2324                        );
2325                    if let Some(end_bpm) = ramp_end_bpm {
2326                        current_bpm = end_bpm;
2327                    }
2328                }
2329            }
2330        }
2331    }
2332
2333    if let Some(ref metro) = options.metronome {
2334        let mut cursor_secs = 0.0f64;
2335        let mut cursor_beats = 0.0f64;
2336        let mut metro_bpm = bpm;
2337        for &idx in &seq {
2338            let first_staff = score.parts.first().and_then(|p| p.staves.first());
2339            let mark = tempo_marks.get(idx).copied().unwrap_or_default();
2340            if let Some(t) = mark.bpm.and_then(|tempo| written_tempo_bpm(options, tempo)) {
2341                metro_bpm = t;
2342            }
2343            let ts = first_staff.map_or(&score.settings.time_signature, |s| {
2344                s.meter_at(idx, &score.settings.time_signature)
2345            });
2346            let measure_beats = measure_lengths[idx];
2347            let ramp_end_bpm = written_tempo_ramp_bpm(options, mark.ramp_to_bpm);
2348            let beat_unit = ts.beat_unit_beats();
2349            let num_beats = (measure_beats / beat_unit).round() as u32;
2350            for b in 0..num_beats {
2351                let is_accent = b == 0;
2352                let beat_offset_beats = b as f64 * beat_unit;
2353                let beat_offset_secs =
2354                    tempo_ramp_seconds(metro_bpm, ramp_end_bpm, measure_beats, beat_offset_beats);
2355                events.push(PlaybackEvent {
2356                    address: None,
2357                    source: None,
2358                    source_voice_number: None,
2359                    time_beats: cursor_beats + b as f64 * beat_unit,
2360                    time_secs: cursor_secs + beat_offset_secs,
2361                    pitch_midi: if is_accent {
2362                        metro.accent_pitch
2363                    } else {
2364                        metro.beat_pitch
2365                    },
2366                    pitch_midi_cents: i32::from(if is_accent {
2367                        metro.accent_pitch
2368                    } else {
2369                        metro.beat_pitch
2370                    }) * 100,
2371                    pitch_bend_curve: Vec::new(),
2372                    post_note_pause_beats: 0.0,
2373                    articulations: Vec::new(),
2374                    chord_symbol: None,
2375                    guitar_technique: None,
2376                    velocity: if is_accent {
2377                        metro.accent_velocity
2378                    } else {
2379                        metro.beat_velocity
2380                    },
2381                    duration_beats: beat_unit * 0.1,
2382                    duration_secs: tempo_ramp_seconds(
2383                        metro_bpm,
2384                        ramp_end_bpm,
2385                        measure_beats,
2386                        beat_offset_beats + beat_unit * 0.1,
2387                    ) - beat_offset_secs,
2388                    pedal: false,
2389                    part_index: usize::MAX,
2390                    channel: metro.channel,
2391                    program: 0,
2392                    instrument_id: None,
2393                    is_metronome: true,
2394                });
2395            }
2396            cursor_secs +=
2397                tempo_ramp_seconds(metro_bpm, ramp_end_bpm, measure_beats, measure_beats);
2398            cursor_beats += measure_beats;
2399            if let Some(end_bpm) = ramp_end_bpm {
2400                metro_bpm = end_bpm;
2401            }
2402        }
2403    }
2404
2405    // Parts, staves and voices are projected separately. Sort before resolving ties so a
2406    // continuation moved to another staff or voice follows its start on the shared clock.
2407    events.sort_by(|a, b| {
2408        a.time_beats
2409            .partial_cmp(&b.time_beats)
2410            .unwrap_or(std::cmp::Ordering::Equal)
2411    });
2412    events = merge_tied_events(score, options, events);
2413    events = apply_realization_profile(score, events, options.realization_profile);
2414    events.sort_by(|a, b| {
2415        a.time_beats
2416            .partial_cmp(&b.time_beats)
2417            .unwrap_or(std::cmp::Ordering::Equal)
2418    });
2419    events
2420}
2421
2422const ORNAMENT_ATTACK_COUNT: usize = 4;
2423const ARPEGGIO_MAX_SPREAD_BEATS: f64 = 0.18;
2424
2425fn apply_realization_profile(
2426    score: &Score,
2427    mut events: Vec<PlaybackEvent>,
2428    profile: PlaybackRealizationProfile,
2429) -> Vec<PlaybackEvent> {
2430    if profile == PlaybackRealizationProfile::Authored {
2431        return events;
2432    }
2433
2434    let mut chord_groups: BTreeMap<String, Vec<usize>> = BTreeMap::new();
2435    for (index, event) in events.iter().enumerate() {
2436        if !event.is_metronome
2437            && let Some(address) = &event.address
2438        {
2439            chord_groups.entry(address.clone()).or_default().push(index);
2440        }
2441    }
2442    for indices in chord_groups.into_values() {
2443        let Some(source) = events[indices[0]].source.as_ref() else {
2444            continue;
2445        };
2446        let Some(direction) = score
2447            .parts
2448            .get(source.part)
2449            .and_then(|part| part.staves.get(source.staff))
2450            .and_then(|staff| staff.measures.get(source.measure))
2451            .and_then(|measure| measure.voices.get(source.voice))
2452            .and_then(|voice| voice.get(source.note))
2453            .and_then(|note| note.arpeggiate)
2454        else {
2455            continue;
2456        };
2457        if indices.len() < 2 {
2458            continue;
2459        }
2460        let mut ordered = indices;
2461        ordered.sort_by_key(|&index| events[index].pitch_midi_cents);
2462        if !direction {
2463            ordered.reverse();
2464        }
2465        let max_spread = events[ordered[0]]
2466            .duration_beats
2467            .max(0.0)
2468            .mul_add(0.5, 0.0)
2469            .min(ARPEGGIO_MAX_SPREAD_BEATS);
2470        let step = max_spread / (ordered.len() - 1) as f64;
2471        for (ordinal, index) in ordered.into_iter().enumerate() {
2472            shift_event_time(&mut events[index], step * ordinal as f64);
2473        }
2474    }
2475
2476    let mut realized = Vec::with_capacity(events.len());
2477    for event in events {
2478        let offsets = ornament_offsets(&event.articulations);
2479        if offsets.is_empty()
2480            || event.is_metronome
2481            || event.duration_beats <= 0.0
2482            || event
2483                .articulations
2484                .iter()
2485                .filter(|mark| is_playback_ornament(mark))
2486                .count()
2487                != 1
2488        {
2489            realized.push(event);
2490            continue;
2491        }
2492        let attack_beats = event.duration_beats / ORNAMENT_ATTACK_COUNT as f64;
2493        let attack_secs = event.duration_secs / ORNAMENT_ATTACK_COUNT as f64;
2494        for (ordinal, semitones) in offsets.into_iter().enumerate() {
2495            let mut attack = event.clone();
2496            let displacement = i32::from(semitones) * 100;
2497            attack.pitch_midi_cents = (attack.pitch_midi_cents + displacement).clamp(0, 12_700);
2498            attack.pitch_midi =
2499                ((i32::from(attack.pitch_midi) + i32::from(semitones)).clamp(0, 127)) as u8;
2500            attack.time_beats += attack_beats * ordinal as f64;
2501            attack.time_secs += attack_secs * ordinal as f64;
2502            attack.duration_beats = attack_beats;
2503            attack.duration_secs = attack_secs;
2504            // The selected ornament is already represented by these attacks.
2505            // Keep unrelated articulations, so a renderer can still apply them.
2506            attack
2507                .articulations
2508                .retain(|mark| !is_playback_ornament(mark));
2509            if ordinal > 0 {
2510                attack.chord_symbol = None;
2511            }
2512            realized.push(attack);
2513        }
2514    }
2515    realized
2516}
2517
2518fn shift_event_time(event: &mut PlaybackEvent, offset_beats: f64) {
2519    if offset_beats <= 0.0 || event.duration_beats <= 0.0 {
2520        return;
2521    }
2522    event.time_beats += offset_beats;
2523    event.time_secs += event.duration_secs * (offset_beats / event.duration_beats);
2524}
2525
2526fn is_playback_ornament(mark: &Articulation) -> bool {
2527    matches!(
2528        mark,
2529        Articulation::Trill
2530            | Articulation::Shake
2531            | Articulation::Mordent
2532            | Articulation::InvertedMordent
2533            | Articulation::Turn
2534            | Articulation::InvertedTurn
2535    )
2536}
2537
2538fn ornament_offsets(articulations: &[Articulation]) -> Vec<i8> {
2539    if articulations
2540        .iter()
2541        .any(|articulation| matches!(articulation, Articulation::Trill | Articulation::Shake))
2542    {
2543        return vec![0, 1, 0, 1];
2544    }
2545    if articulations
2546        .iter()
2547        .any(|articulation| matches!(articulation, Articulation::Mordent))
2548    {
2549        return vec![0, 1, 0, 0];
2550    }
2551    if articulations
2552        .iter()
2553        .any(|articulation| matches!(articulation, Articulation::InvertedMordent))
2554    {
2555        return vec![0, -1, 0, 0];
2556    }
2557    if articulations
2558        .iter()
2559        .any(|articulation| matches!(articulation, Articulation::Turn))
2560    {
2561        return vec![1, 0, -1, 0];
2562    }
2563    if articulations
2564        .iter()
2565        .any(|articulation| matches!(articulation, Articulation::InvertedTurn))
2566    {
2567        return vec![-1, 0, 1, 0];
2568    }
2569    Vec::new()
2570}
2571
2572/// Integrate seconds over `beats` while BPM changes linearly across a measure.
2573fn tempo_ramp_seconds(start_bpm: f64, end_bpm: Option<f64>, measure_beats: f64, beats: f64) -> f64 {
2574    let beats = beats.max(0.0);
2575    let Some(end_bpm) = end_bpm else {
2576        return beats / start_bpm * 60.0;
2577    };
2578    if measure_beats <= 0.0 || (end_bpm - start_bpm).abs() < f64::EPSILON {
2579        return beats / start_bpm * 60.0;
2580    }
2581    let delta = end_bpm - start_bpm;
2582    let bpm_at_beats = start_bpm + delta * beats / measure_beats;
2583    if bpm_at_beats <= 0.0 {
2584        return beats / start_bpm * 60.0;
2585    }
2586    60.0 * measure_beats / delta * (bpm_at_beats / start_bpm).ln()
2587}
2588
2589/// Inverse of [`tempo_ramp_seconds`] within one measure.
2590///
2591/// This keeps visual/browser cursors on the same clock as emitted playback events when a written
2592/// tempo ramp is active. Invalid or degenerate ramps fall back to constant-tempo conversion.
2593fn tempo_ramp_beats_at_seconds(
2594    start_bpm: f64,
2595    end_bpm: Option<f64>,
2596    measure_beats: f64,
2597    seconds: f64,
2598) -> f64 {
2599    let seconds = seconds.max(0.0);
2600    let Some(end_bpm) = end_bpm else {
2601        return seconds * start_bpm / 60.0;
2602    };
2603    if measure_beats <= 0.0 || (end_bpm - start_bpm).abs() < f64::EPSILON {
2604        return seconds * start_bpm / 60.0;
2605    }
2606    let delta = end_bpm - start_bpm;
2607    let exponent = seconds * delta / (60.0 * measure_beats);
2608    let ratio = exponent.exp();
2609    if !ratio.is_finite() {
2610        return measure_beats;
2611    }
2612    let beats = measure_beats * start_bpm * (ratio - 1.0) / delta;
2613    if beats.is_finite() {
2614        beats
2615    } else {
2616        measure_beats
2617    }
2618}
2619
2620/// Coalesce adjacent playback events that are connected by authored ties.
2621///
2622/// Ties are a notational continuation, not repeated attacks. The event keeps the first
2623/// source address. The notated duration determines continuity; articulations on any segment
2624/// affect the combined note once. Malformed or non-contiguous tie endings remain independent.
2625fn merge_tied_events(
2626    score: &Score,
2627    options: &PlaybackOptions,
2628    events: Vec<PlaybackEvent>,
2629) -> Vec<PlaybackEvent> {
2630    use std::collections::HashMap;
2631
2632    struct PendingTie {
2633        index: usize,
2634        staff: usize,
2635        voice: usize,
2636        notated_end_beats: f64,
2637        staccato: bool,
2638    }
2639
2640    let mut merged = Vec::with_capacity(events.len());
2641    let mut pending: HashMap<(usize, i32), Vec<PendingTie>> = HashMap::new();
2642    let mut clock = None;
2643    for event in events {
2644        let Some(source) = event.source.as_ref() else {
2645            merged.push(event);
2646            continue;
2647        };
2648        let tied_staff = score
2649            .parts
2650            .get(source.part)
2651            .and_then(|part| part.staves.get(source.staff));
2652        let tied_note = tied_staff
2653            .and_then(|staff| staff.measures.get(source.measure))
2654            .and_then(|measure| measure.voices.get(source.voice))
2655            .and_then(|voice| voice.get(source.note));
2656        let (Some(staff), Some(note)) = (tied_staff, tied_note) else {
2657            merged.push(event);
2658            continue;
2659        };
2660        // A chord may tie only some of its pitches: use the flags of the pitch that sounds here.
2661        let transpose_cents = i32::from(staff.transpose_semitones) * 100;
2662        let pitch_index = note
2663            .pitches
2664            .iter()
2665            .position(|pitch| pitch.to_midi_cents() + transpose_cents == event.pitch_midi_cents);
2666        let (tie_start, tie_end) = match pitch_index {
2667            Some(index) => (note.pitch_tie_start(index), note.pitch_tie_end(index)),
2668            None => (note.tie_start, note.tie_end),
2669        };
2670        let mut articulation_scale = 1.0;
2671        let mut staccato = false;
2672        for articulation in &note.articulations {
2673            match articulation {
2674                Articulation::Staccato | Articulation::Staccatissimo => {
2675                    articulation_scale *= 0.5;
2676                    staccato = true;
2677                }
2678                Articulation::Fermata => articulation_scale *= options.fermata_multiplier,
2679                _ => {}
2680            }
2681        }
2682        let notated_duration = if articulation_scale.is_finite()
2683            && articulation_scale > 0.0
2684            && event.duration_beats.is_finite()
2685        {
2686            event.duration_beats / articulation_scale
2687        } else {
2688            note.beats()
2689        };
2690        let source_staff = source.staff;
2691        let source_voice = source.voice;
2692        let key = (source.part, event.pitch_midi_cents);
2693        if let Some(candidates) = pending.get_mut(&key) {
2694            candidates.retain(|candidate| candidate.notated_end_beats >= event.time_beats - 1e-9);
2695            if tie_end {
2696                let matching = candidates
2697                    .iter()
2698                    .enumerate()
2699                    .filter(|(_, candidate)| {
2700                        (candidate.notated_end_beats - event.time_beats).abs() < 1e-9
2701                    })
2702                    .max_by_key(|(_, candidate)| {
2703                        (
2704                            candidate.staff == source.staff,
2705                            candidate.voice == source.voice,
2706                        )
2707                    })
2708                    .map(|(position, _)| position);
2709                if let Some(position) = matching {
2710                    let mut candidate = candidates.remove(position);
2711                    let previous = &mut merged[candidate.index];
2712                    for articulation in event.articulations.iter().filter(|articulation| {
2713                        matches!(
2714                            articulation,
2715                            Articulation::Staccato | Articulation::Staccatissimo
2716                        )
2717                    }) {
2718                        if !previous.articulations.contains(articulation) {
2719                            previous.articulations.push(articulation.clone());
2720                        }
2721                    }
2722                    candidate.notated_end_beats = event.time_beats + notated_duration;
2723                    candidate.staccato |= staccato;
2724                    previous.duration_beats += event.duration_beats;
2725                    previous.duration_secs += event.duration_secs;
2726                    if candidate.staccato {
2727                        previous.duration_beats =
2728                            (candidate.notated_end_beats - previous.time_beats) * 0.5;
2729                        let timeline =
2730                            clock.get_or_insert_with(|| playback_measure_clock(score, options));
2731                        if let Some(end_secs) = playback_secs_at_beat(
2732                            timeline,
2733                            previous.time_beats + previous.duration_beats,
2734                        ) {
2735                            previous.duration_secs = end_secs - previous.time_secs;
2736                        }
2737                    }
2738                    if tie_start {
2739                        candidate.staff = source.staff;
2740                        candidate.voice = source.voice;
2741                        candidates.push(candidate);
2742                    }
2743                    continue;
2744                }
2745            }
2746        }
2747
2748        let index = merged.len();
2749        merged.push(event);
2750        if tie_start {
2751            pending.entry(key).or_default().push(PendingTie {
2752                index,
2753                staff: source_staff,
2754                voice: source_voice,
2755                notated_end_beats: merged[index].time_beats + notated_duration,
2756                staccato,
2757            });
2758        }
2759    }
2760    merged
2761}
2762
2763#[derive(Clone, Copy)]
2764struct PlaybackClockMeasure {
2765    start_beats: f64,
2766    start_secs: f64,
2767    bpm: f64,
2768    ramp_to_bpm: Option<f64>,
2769    beats: f64,
2770}
2771
2772fn playback_measure_clock(score: &Score, options: &PlaybackOptions) -> Vec<PlaybackClockMeasure> {
2773    let marks = score_tempo_marks(score);
2774    let mut bpm = initial_tempo_bpm(score, options);
2775    let mut start_beats = 0.0;
2776    let mut start_secs = 0.0;
2777    let mut clock = Vec::new();
2778    for index in measure_sequence(score).into_iter().filter(|index| {
2779        options
2780            .loop_region
2781            .is_none_or(|(start, end)| *index >= start && *index <= end)
2782    }) {
2783        let mark = marks.get(index).copied().unwrap_or_default();
2784        if let Some(next) = mark.bpm.and_then(|tempo| written_tempo_bpm(options, tempo)) {
2785            bpm = next;
2786        }
2787        let beats = score.playback_measure_beats(index);
2788        let ramp_to_bpm = written_tempo_ramp_bpm(options, mark.ramp_to_bpm);
2789        clock.push(PlaybackClockMeasure {
2790            start_beats,
2791            start_secs,
2792            bpm,
2793            ramp_to_bpm,
2794            beats,
2795        });
2796        start_beats += beats;
2797        start_secs += tempo_ramp_seconds(bpm, ramp_to_bpm, beats, beats);
2798        if let Some(next) = ramp_to_bpm {
2799            bpm = next;
2800        }
2801    }
2802    clock
2803}
2804
2805fn playback_secs_at_beat(clock: &[PlaybackClockMeasure], beat: f64) -> Option<f64> {
2806    let index = clock.partition_point(|measure| measure.start_beats <= beat);
2807    let measure = clock.get(index.saturating_sub(1))?;
2808    let offset = beat - measure.start_beats;
2809    if offset > measure.beats + 1e-9 {
2810        return None;
2811    }
2812    Some(
2813        measure.start_secs
2814            + tempo_ramp_seconds(
2815                measure.bpm,
2816                measure.ramp_to_bpm,
2817                measure.beats,
2818                offset.clamp(0.0, measure.beats),
2819            ),
2820    )
2821}
2822
2823/// Convert a score into playback events while enforcing the host-comparison event bound.
2824pub fn to_playback_events_bounded(
2825    score: &Score,
2826    options: &PlaybackOptions,
2827) -> Result<Vec<PlaybackEvent>, crate::Error> {
2828    let events = to_playback_events(score, options);
2829    if events.len() > MAX_PLAYBACK_COMPARISON_EVENTS {
2830        return Err(crate::Error::PlaybackComparisonTooLarge(events.len()));
2831    }
2832    Ok(events)
2833}
2834
2835// ── PlaybackPosition + compute_playback_position ──────────────────────────────
2836
2837/// Score position at a specific elapsed time.
2838#[derive(Debug, Clone, Serialize, Deserialize)]
2839pub struct PlaybackPosition {
2840    /// Physical measure index (0-based), same coordinate space as [`PlaybackEvent`] fields.
2841    pub measure_index: usize,
2842    /// Beat offset within the measure (`0.0 … time_sig.total_beats()`).
2843    pub beat: f64,
2844}
2845
2846struct MeasureSegment {
2847    measure_idx: usize,
2848    start_secs: f64,
2849    duration_secs: f64,
2850    beats: f64,
2851    bpm: f64,
2852    ramp_to_bpm: Option<f64>,
2853}
2854
2855fn build_measure_segments(score: &Score, options: &PlaybackOptions) -> Vec<MeasureSegment> {
2856    let init_bpm = initial_tempo_bpm(score, options);
2857    let tempo_marks = score_tempo_marks(score);
2858    let full_seq = measure_sequence(score);
2859    let seq: Vec<usize> = if let Some((lo, hi)) = options.loop_region {
2860        full_seq
2861            .into_iter()
2862            .filter(|&i| i >= lo && i <= hi)
2863            .collect()
2864    } else {
2865        full_seq
2866    };
2867
2868    let mut segments = Vec::with_capacity(seq.len());
2869    let mut cursor_secs = 0.0f64;
2870    let mut current_bpm = init_bpm;
2871
2872    for idx in seq {
2873        let mark = tempo_marks.get(idx).copied().unwrap_or_default();
2874        if let Some(t) = mark.bpm.and_then(|tempo| written_tempo_bpm(options, tempo)) {
2875            current_bpm = t;
2876        }
2877        let beats = score.playback_measure_beats(idx);
2878        let ramp_to_bpm = written_tempo_ramp_bpm(options, mark.ramp_to_bpm);
2879        let duration_secs = tempo_ramp_seconds(current_bpm, ramp_to_bpm, beats, beats);
2880
2881        segments.push(MeasureSegment {
2882            measure_idx: idx,
2883            start_secs: cursor_secs,
2884            duration_secs,
2885            beats,
2886            bpm: current_bpm,
2887            ramp_to_bpm,
2888        });
2889        cursor_secs += duration_secs;
2890        if let Some(end_bpm) = ramp_to_bpm {
2891            current_bpm = end_bpm;
2892        }
2893    }
2894    segments
2895}
2896
2897/// Map `elapsed_secs` to a position within the score.
2898///
2899/// Returns `None` if `elapsed_secs` is negative or past the end of the last measure.
2900/// Pass the same [`PlaybackOptions`] used for [`to_playback_events`] so that `loop_region`
2901/// and tempo overrides are applied consistently.
2902pub fn compute_playback_position(
2903    score: &Score,
2904    options: &PlaybackOptions,
2905    elapsed_secs: f64,
2906) -> Option<PlaybackPosition> {
2907    if elapsed_secs < 0.0 {
2908        return None;
2909    }
2910    let segments = build_measure_segments(score, options);
2911    for seg in &segments {
2912        if elapsed_secs < seg.start_secs + seg.duration_secs + 1e-9 {
2913            let beat = tempo_ramp_beats_at_seconds(
2914                seg.bpm,
2915                seg.ramp_to_bpm,
2916                seg.beats,
2917                elapsed_secs - seg.start_secs,
2918            )
2919            .clamp(0.0, seg.beats);
2920            return Some(PlaybackPosition {
2921                measure_index: seg.measure_idx,
2922                beat,
2923            });
2924        }
2925    }
2926    None
2927}
2928
2929#[cfg(test)]
2930mod tests {
2931    use super::*;
2932    use crate::model::{
2933        duration::Duration,
2934        pitch::{Pitch, Step},
2935        score::{MidiAftertouch, MidiControlChange, MidiPitchBend, MidiProgramChange, Note, Score},
2936    };
2937
2938    fn opts(bpm: Option<u16>) -> PlaybackOptions {
2939        PlaybackOptions {
2940            bpm_override: bpm,
2941            ..Default::default()
2942        }
2943    }
2944
2945    #[test]
2946    fn playback_options_accepts_an_empty_json_object_as_defaults() {
2947        let parsed: PlaybackOptions = serde_json::from_str("{}").expect("default playback options");
2948        assert_eq!(parsed, PlaybackOptions::default());
2949    }
2950
2951    #[test]
2952    fn bounded_playback_events_match_unbounded_schedule_within_limit() {
2953        let score = Score::new("bounded", 120, 4, 4, 0, 1);
2954        let options = opts(Some(120));
2955        let unbounded = to_playback_events(&score, &options);
2956        let bounded = to_playback_events_bounded(&score, &options).expect("bounded schedule");
2957        assert_eq!(bounded, unbounded);
2958    }
2959
2960    #[test]
2961    fn bars_after_a_meter_change_keep_the_new_meter() {
2962        use crate::model::duration::Duration;
2963        use crate::{Note, Pitch, Step, TimeSignature};
2964        let mut score = Score::new("meter", 120, 4, 4, 0, 3);
2965        let staff = &mut score.parts[0].staves[0];
2966        staff.measures[1].time_sig = Some(TimeSignature {
2967            numerator: 3,
2968            denominator: 4,
2969        });
2970        for measure in &mut staff.measures {
2971            measure.voices[0] = vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
2972        }
2973        let onsets: Vec<f64> = to_playback_events(&score, &opts(Some(120)))
2974            .iter()
2975            .filter(|event| event.address.is_some())
2976            .map(|event| event.time_beats)
2977            .collect();
2978        // 4/4, then two bars of 3/4: the third bar starts at 4 + 3, not 4 + 4.
2979        assert_eq!(onsets, vec![0.0, 4.0, 7.0]);
2980    }
2981
2982    #[test]
2983    fn tempo_written_on_either_part_governs_the_whole_score() {
2984        let mut score = Score::new("shared tempo", 120, 4, 4, 0, 2);
2985        let mut second = score.parts[0].clone();
2986        second.id = "second".into();
2987        score.parts.push(second);
2988        for part in &mut score.parts {
2989            for measure in &mut part.staves[0].measures {
2990                measure.voices[0] = vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
2991            }
2992        }
2993        score.parts[0].staves[0].measures[0].tempo = Some(92);
2994        score.parts[1].staves[0].measures[1].tempo = Some(60);
2995        let options = PlaybackOptions {
2996            metronome: Some(MetronomeConfig::default()),
2997            ..Default::default()
2998        };
2999        let events = to_playback_events(&score, &options);
3000        for part_index in 0..2 {
3001            let first = events
3002                .iter()
3003                .find(|event| {
3004                    event.part_index == part_index
3005                        && event
3006                            .source
3007                            .as_ref()
3008                            .is_some_and(|source| source.measure == 0)
3009                })
3010                .unwrap();
3011            let second = events
3012                .iter()
3013                .find(|event| {
3014                    event.part_index == part_index
3015                        && event
3016                            .source
3017                            .as_ref()
3018                            .is_some_and(|source| source.measure == 1)
3019                })
3020                .unwrap();
3021            assert!((first.duration_secs - 60.0 / 92.0).abs() < 1e-9);
3022            assert!((second.time_secs - 4.0 * 60.0 / 92.0).abs() < 1e-9);
3023            assert!((second.duration_secs - 1.0).abs() < 1e-9);
3024        }
3025        let clicks: Vec<_> = events.iter().filter(|event| event.is_metronome).collect();
3026        assert!((clicks[1].time_secs - 60.0 / 92.0).abs() < 1e-9);
3027        assert!((clicks[4].time_secs - 4.0 * 60.0 / 92.0).abs() < 1e-9);
3028        let position = compute_playback_position(&score, &options, 1.5 * 60.0 / 92.0).unwrap();
3029        assert_eq!(position.measure_index, 0);
3030        assert!((position.beat - 1.5).abs() < 1e-9);
3031        let timeline_1 = offline_measure_times(&score, &options, 1, 0, &score_tempo_marks(&score));
3032        assert!((timeline_1[0].bpm - 92.0).abs() < 1e-9);
3033        assert!((timeline_1[1].bpm - 60.0).abs() < 1e-9);
3034    }
3035
3036    #[test]
3037    fn empty_score_no_events() {
3038        let score = Score::new("T", 120, 4, 4, 0, 1);
3039        assert!(to_playback_events(&score, &opts(None)).is_empty());
3040    }
3041
3042    #[test]
3043    fn single_note_at_beat_zero() {
3044        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3045        score.parts[0].staves[0].measures[0].voices[0] =
3046            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
3047        let events = to_playback_events(&score, &opts(None));
3048        assert_eq!(events.len(), 1);
3049        assert_eq!(events[0].address.as_deref(), Some("0:0:0:0:0"));
3050        assert_eq!(
3051            events[0].source,
3052            Some(NoteAddr {
3053                part: 0,
3054                staff: 0,
3055                measure: 0,
3056                voice: 0,
3057                note: 0,
3058            })
3059        );
3060        assert!((events[0].time_beats).abs() < 1e-9);
3061        assert_eq!(events[0].pitch_midi, 60);
3062        assert_eq!(events[0].velocity, 64);
3063        assert!((events[0].duration_beats - 1.0).abs() < 1e-9);
3064        assert_eq!(events[0].part_index, 0);
3065    }
3066
3067    #[test]
3068    fn tied_notes_are_one_continuous_playback_event() {
3069        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3070        let mut first = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3071        first.tie_start = true;
3072        let mut second = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3073        second.tie_end = true;
3074        score.parts[0].staves[0].measures[0].voices[0] = vec![first, second];
3075
3076        let events = to_playback_events(&score, &opts(None));
3077        assert_eq!(events.len(), 1);
3078        assert_eq!(
3079            events[0].source.as_ref().map(|address| address.note),
3080            Some(0)
3081        );
3082        assert!((events[0].time_beats).abs() < 1e-9);
3083        assert!((events[0].duration_beats - 2.0).abs() < 1e-9);
3084        assert!((events[0].duration_secs - 1.0).abs() < 1e-9);
3085    }
3086
3087    #[test]
3088    fn staccato_tied_across_barline_has_one_half_length_attack() {
3089        for marks in [[true, false], [false, true], [true, true]] {
3090            let mut score = Score::new("tied staccato", 120, 4, 4, 0, 2);
3091            let mut first = Note::new(Pitch::new(Step::D, 5), Duration::Eighth);
3092            first.tie_start = true;
3093            let mut second = Note::new(Pitch::new(Step::D, 5), Duration::Eighth);
3094            second.tie_end = true;
3095            if marks[0] {
3096                first.articulations.push(Articulation::Staccato);
3097            }
3098            if marks[1] {
3099                second.articulations.push(Articulation::Staccato);
3100            }
3101            score.parts[0].staves[0].measures[0].voices[0] = vec![
3102                Note::rest(Duration::Half),
3103                Note::rest(Duration::Quarter),
3104                Note::rest(Duration::Eighth),
3105                first,
3106            ];
3107            score.parts[0].staves[0].measures[1].voices[0] = vec![second];
3108            let events = to_playback_events(&score, &opts(None));
3109            assert_eq!(events.len(), 1, "articulations: {marks:?}");
3110            assert_eq!(events[0].pitch_midi, 74);
3111            assert_eq!(events[0].time_beats, 3.5);
3112            assert!(events[0].articulations.contains(&Articulation::Staccato));
3113            assert!((events[0].duration_beats - 0.5).abs() < 1e-9);
3114            assert!((events[0].duration_secs - 0.25).abs() < 1e-9);
3115        }
3116    }
3117
3118    #[test]
3119    fn tied_staccato_uses_tempo_at_sounding_end() {
3120        let mut score = Score::new("tied tempo", 120, 4, 4, 0, 2);
3121        let mut first = Note::new(Pitch::new(Step::D, 5), Duration::Eighth);
3122        first.tie_start = true;
3123        first.articulations.push(Articulation::Staccato);
3124        let mut second = Note::new(Pitch::new(Step::D, 5), Duration::Quarter);
3125        second.dot_count = 1;
3126        second.tie_end = true;
3127        score.parts[0].staves[0].measures[0].voices[0] = vec![
3128            Note::rest(Duration::Half),
3129            Note::rest(Duration::Quarter),
3130            Note::rest(Duration::Eighth),
3131            first,
3132        ];
3133        score.parts[0].staves[0].measures[1].tempo = Some(60);
3134        score.parts[0].staves[0].measures[1].voices[0] = vec![second];
3135        let events = to_playback_events(&score, &opts(None));
3136        assert_eq!(events.len(), 1);
3137        assert!((events[0].duration_beats - 1.0).abs() < 1e-9);
3138        assert!((events[0].duration_secs - 0.75).abs() < 1e-9);
3139    }
3140
3141    #[test]
3142    fn a_tie_on_one_chord_pitch_holds_only_that_pitch() {
3143        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3144        // C–G chord tied on C only into another C–G chord: G re-strikes, C sustains.
3145        let chord = || {
3146            let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3147            note.pitches.push(Pitch::new(Step::G, 4));
3148            note
3149        };
3150        let mut first = chord();
3151        first.set_pitch_ties(&[true, false], &[false, false]);
3152        let mut second = chord();
3153        second.set_pitch_ties(&[false, false], &[true, false]);
3154        assert!(first.tie_start && second.tie_end);
3155        score.parts[0].staves[0].measures[0].voices[0] = vec![first, second];
3156
3157        let events = to_playback_events(&score, &opts(None));
3158        let durations = |midi: u8| {
3159            events
3160                .iter()
3161                .filter(|event| event.pitch_midi == midi)
3162                .map(|event| event.duration_beats)
3163                .collect::<Vec<_>>()
3164        };
3165        assert_eq!(durations(60), vec![2.0]);
3166        assert_eq!(durations(67), vec![1.0, 1.0]);
3167    }
3168
3169    #[test]
3170    fn ties_cross_measure_boundaries_and_tempo_changes_without_retriggering() {
3171        let mut score = Score::new("T", 120, 4, 4, 0, 2);
3172        let first_measure = &mut score.parts[0].staves[0].measures[0];
3173        let mut first = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
3174        first.tie_start = true;
3175        first_measure.voices[0] = vec![first];
3176
3177        let second_measure = &mut score.parts[0].staves[0].measures[1];
3178        second_measure.tempo = Some(60);
3179        let mut second = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
3180        second.tie_end = true;
3181        second_measure.voices[0] = vec![second];
3182
3183        let events = to_playback_events(&score, &opts(None));
3184        assert_eq!(events.len(), 1);
3185        assert_eq!(
3186            events[0].source.as_ref().map(|address| address.measure),
3187            Some(0)
3188        );
3189        assert!((events[0].time_beats).abs() < 1e-9);
3190        assert!((events[0].duration_beats - 8.0).abs() < 1e-9);
3191        assert!((events[0].duration_secs - 6.0).abs() < 1e-9);
3192    }
3193
3194    #[test]
3195    fn ties_continue_across_voice_and_staff_changes() {
3196        let whole_c = || Note::new(Pitch::new(Step::C, 4), Duration::Whole);
3197
3198        let mut voice_score = Score::new("voice change", 120, 4, 4, 0, 2);
3199        let mut start = whole_c();
3200        start.tie_start = true;
3201        let mut end = whole_c();
3202        end.tie_end = true;
3203        voice_score.parts[0].staves[0].measures[0].voices[0] = vec![start.clone()];
3204        voice_score.parts[0].staves[0].measures[1].voices[1] = vec![end.clone()];
3205        let events = to_playback_events(&voice_score, &opts(None));
3206        assert_eq!(events.len(), 1);
3207        assert_eq!(events[0].time_beats, 0.0);
3208        assert_eq!(events[0].duration_beats, 8.0);
3209
3210        let mut staff_score = Score::new("staff change", 120, 4, 4, 0, 2);
3211        let second_staff = staff_score.parts[0].staves[0].clone();
3212        staff_score.parts[0].staves.push(second_staff);
3213        staff_score.parts[0].staves[0].measures[0].voices[0] = vec![start];
3214        staff_score.parts[0].staves[1].measures[1].voices[0] = vec![end];
3215        let events = to_playback_events(&staff_score, &opts(None));
3216        assert_eq!(events.len(), 1);
3217        assert_eq!(events[0].time_beats, 0.0);
3218        assert_eq!(events[0].duration_beats, 8.0);
3219    }
3220
3221    #[test]
3222    fn cross_voice_tie_keeps_independent_unison_attacks() {
3223        let mut score = Score::new("unison", 120, 4, 4, 0, 2);
3224        let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
3225        start.tie_start = true;
3226        let mut end = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
3227        end.tie_end = true;
3228        score.parts[0].staves[0].measures[0].voices[0] = vec![start];
3229        score.parts[0].staves[0].measures[0].voices[1] =
3230            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
3231        score.parts[0].staves[0].measures[1].voices[1] = vec![end];
3232        score.parts[0].staves[0].measures[1].voices[2] =
3233            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
3234
3235        let mut durations: Vec<_> = to_playback_events(&score, &opts(None))
3236            .into_iter()
3237            .map(|event| event.duration_beats)
3238            .collect();
3239        durations.sort_by(f64::total_cmp);
3240        assert_eq!(durations, vec![4.0, 4.0, 8.0]);
3241    }
3242
3243    #[test]
3244    fn malformed_tie_end_does_not_drop_playback_event() {
3245        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3246        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3247        note.tie_end = true;
3248        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
3249        let events = to_playback_events(&score, &opts(None));
3250        assert_eq!(events.len(), 1);
3251        assert!((events[0].duration_beats - 1.0).abs() < 1e-9);
3252    }
3253
3254    #[test]
3255    fn microtonal_playback_event_keeps_exact_midi_cents() {
3256        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3257        score.parts[0].staves[0].measures[0].voices[0] = vec![Note::new(
3258            Pitch::with_microtone(Step::C, 4, 0, 50),
3259            Duration::Quarter,
3260        )];
3261        let events = to_playback_events(&score, &opts(None));
3262        assert_eq!(events[0].pitch_midi, 61);
3263        assert_eq!(events[0].pitch_midi_cents, 6050);
3264    }
3265
3266    #[test]
3267    fn chord_expands_to_multiple_events() {
3268        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3269        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3270        note.pitches.push(Pitch::new(Step::E, 4));
3271        note.pitches.push(Pitch::new(Step::G, 4));
3272        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
3273        let events = to_playback_events(&score, &opts(None));
3274        assert_eq!(events.len(), 3);
3275        assert!(
3276            events
3277                .iter()
3278                .all(|event| event.address.as_deref() == Some("0:0:0:0:0"))
3279        );
3280        assert!(events.iter().all(|e| e.time_beats.abs() < 1e-9));
3281    }
3282
3283    #[test]
3284    fn playback_events_preserve_authored_chord_symbol_without_inventing_notes() {
3285        let mut score = Score::new("Harmony", 120, 4, 4, 0, 1);
3286        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3287        note.chord_symbol = Some(ChordSymbol {
3288            root: "C".into(),
3289            kind: "major-seventh".into(),
3290            bass: Some("E".into()),
3291            placement: None,
3292            extender: false,
3293            harmonic_degree: None,
3294            harmony_function: None,
3295            harmony_type: None,
3296            chord_ref: None,
3297            range_end: None,
3298            degrees: Vec::new(),
3299        });
3300        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
3301
3302        let events = to_playback_events(&score, &PlaybackOptions::default());
3303        assert_eq!(events.len(), 1);
3304        assert_eq!(events[0].pitch_midi, 60);
3305        assert_eq!(
3306            events[0]
3307                .chord_symbol
3308                .as_ref()
3309                .map(ChordSymbol::display_text),
3310            Some("Cmaj7/E".into())
3311        );
3312    }
3313
3314    #[test]
3315    fn authored_realization_default_keeps_ornament_as_one_semantic_event() {
3316        let mut score = Score::new("authored ornament", 120, 4, 4, 0, 1);
3317        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3318        note.articulations.push(Articulation::Trill);
3319        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
3320
3321        let events = to_playback_events(&score, &PlaybackOptions::default());
3322        assert_eq!(events.len(), 1);
3323        assert_eq!(events[0].pitch_midi, 60);
3324        assert_eq!(events[0].articulations, vec![Articulation::Trill]);
3325    }
3326
3327    #[test]
3328    fn ornament_arpeggio_profile_realizes_trill_with_pinned_timing() {
3329        let mut score = Score::new("realized ornament", 120, 4, 4, 0, 1);
3330        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3331        note.articulations.push(Articulation::Trill);
3332        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
3333
3334        let events = to_playback_events(
3335            &score,
3336            &PlaybackOptions {
3337                realization_profile: PlaybackRealizationProfile::OrnamentArpeggioV1,
3338                ..Default::default()
3339            },
3340        );
3341        assert_eq!(events.len(), 4);
3342        assert_eq!(
3343            events
3344                .iter()
3345                .map(|event| event.pitch_midi)
3346                .collect::<Vec<_>>(),
3347            vec![60, 61, 60, 61]
3348        );
3349        for (index, event) in events.iter().enumerate() {
3350            assert!((event.time_beats - index as f64 * 0.25).abs() < 1e-9);
3351            assert!((event.duration_beats - 0.25).abs() < 1e-9);
3352            assert!((event.time_secs - index as f64 * 0.125).abs() < 1e-9);
3353            assert!((event.duration_secs - 0.125).abs() < 1e-9);
3354            assert!(!event.articulations.contains(&Articulation::Trill));
3355        }
3356    }
3357
3358    #[test]
3359    fn ornament_arpeggio_profile_staggers_chord_in_authored_direction() {
3360        let mut score = Score::new("realized arpeggio", 120, 4, 4, 0, 1);
3361        let mut chord = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3362        chord.pitches.push(Pitch::new(Step::E, 4));
3363        chord.pitches.push(Pitch::new(Step::G, 4));
3364        chord.arpeggiate = Some(false);
3365        score.parts[0].staves[0].measures[0].voices[0] = vec![chord];
3366
3367        let events = to_playback_events(
3368            &score,
3369            &PlaybackOptions {
3370                realization_profile: PlaybackRealizationProfile::OrnamentArpeggioV1,
3371                ..Default::default()
3372            },
3373        );
3374        assert_eq!(events.len(), 3);
3375        let by_pitch: BTreeMap<_, _> = events
3376            .iter()
3377            .map(|event| (event.pitch_midi, event.time_beats))
3378            .collect();
3379        assert!((by_pitch[&67] - 0.0).abs() < 1e-9);
3380        assert!((by_pitch[&64] - 0.09).abs() < 1e-9);
3381        assert!((by_pitch[&60] - 0.18).abs() < 1e-9);
3382    }
3383
3384    #[test]
3385    fn written_grace_steals_time_from_preceding_note() {
3386        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3387        let previous = Note::new(Pitch::new(Step::F, 4), Duration::Half);
3388        let mut grace = Note::new(Pitch::new(Step::B, 4), Duration::Eighth);
3389        grace.is_grace = true;
3390        let regular = Note::new(Pitch::new(Step::A, 4), Duration::Quarter);
3391        score.parts[0].staves[0].measures[0].voices[0] = vec![previous, grace, regular];
3392        let events = to_playback_events(&score, &opts(None));
3393        assert_eq!(events.len(), 3);
3394        assert_eq!(
3395            events
3396                .iter()
3397                .map(|event| event.pitch_midi)
3398                .collect::<Vec<_>>(),
3399            vec![65, 71, 69]
3400        );
3401        assert!((events[0].duration_beats - 1.875).abs() < 1e-9);
3402        assert!((events[1].time_beats - 1.875).abs() < 1e-9);
3403        assert!((events[1].duration_beats - 0.125).abs() < 1e-9);
3404        assert!((events[2].time_beats - 2.0).abs() < 1e-9);
3405        assert!((events[1].time_secs - 0.9375).abs() < 1e-9);
3406    }
3407
3408    #[test]
3409    fn opening_grace_borrows_from_main_note_without_moving_later_notes() {
3410        let mut score = Score::new("opening grace", 120, 4, 4, 0, 1);
3411        let mut grace = Note::new(Pitch::new(Step::D, 4), Duration::Eighth);
3412        grace.is_grace = true;
3413        let main = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3414        let next = Note::new(Pitch::new(Step::E, 4), Duration::Quarter);
3415        score.parts[0].staves[0].measures[0].voices[0] = vec![grace, main, next];
3416        let events = to_playback_events(&score, &opts(None));
3417        assert_eq!(events.len(), 3);
3418        assert_eq!(
3419            events
3420                .iter()
3421                .map(|event| event.pitch_midi)
3422                .collect::<Vec<_>>(),
3423            vec![62, 60, 64]
3424        );
3425        assert!((events[0].time_beats).abs() < 1e-9);
3426        assert!((events[0].duration_beats - 0.125).abs() < 1e-9);
3427        assert!((events[1].time_beats - 0.125).abs() < 1e-9);
3428        assert!((events[1].duration_beats - 0.875).abs() < 1e-9);
3429        assert!((events[2].time_beats - 1.0).abs() < 1e-9);
3430    }
3431
3432    #[test]
3433    fn consecutive_graces_are_ordered_and_cue_notes_remain_silent() {
3434        let mut score = Score::new("two graces", 120, 4, 4, 0, 1);
3435        let mut first_grace = Note::new(Pitch::new(Step::D, 4), Duration::Sixteenth);
3436        first_grace.is_grace = true;
3437        let mut second_grace = Note::new(Pitch::new(Step::E, 4), Duration::Sixteenth);
3438        second_grace.is_grace = true;
3439        let mut cue = Note::new(Pitch::new(Step::B, 4), Duration::Eighth);
3440        cue.is_cue = true;
3441        score.parts[0].staves[0].measures[0].voices[0] = vec![
3442            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
3443            first_grace,
3444            second_grace,
3445            Note::new(Pitch::new(Step::F, 4), Duration::Quarter),
3446            cue,
3447        ];
3448        let events = to_playback_events(&score, &opts(None));
3449        assert_eq!(
3450            events
3451                .iter()
3452                .map(|event| event.pitch_midi)
3453                .collect::<Vec<_>>(),
3454            vec![60, 62, 64, 65]
3455        );
3456        assert_eq!(
3457            events
3458                .iter()
3459                .map(|event| event.time_beats)
3460                .collect::<Vec<_>>(),
3461            vec![0.0, 0.875, 0.9375, 1.0]
3462        );
3463        assert!((events[0].duration_beats - 0.875).abs() < 1e-9);
3464    }
3465
3466    #[test]
3467    fn metronome_events_have_no_source_address() {
3468        let score = Score::new("T", 120, 4, 4, 0, 1);
3469        let options = PlaybackOptions {
3470            metronome: Some(MetronomeConfig::default()),
3471            ..Default::default()
3472        };
3473        let events = to_playback_events(&score, &options);
3474        assert!(!events.is_empty());
3475        assert!(events.iter().all(|event| event.address.is_none()));
3476    }
3477
3478    #[test]
3479    fn second_note_has_correct_time() {
3480        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3481        score.parts[0].staves[0].measures[0].voices[0] = vec![
3482            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
3483            Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
3484        ];
3485        let events = to_playback_events(&score, &opts(None));
3486        assert_eq!(events.len(), 2);
3487        assert!((events[0].time_beats).abs() < 1e-9);
3488        assert!((events[1].time_beats - 1.0).abs() < 1e-9);
3489    }
3490
3491    #[test]
3492    fn sparse_voice_keeps_measure_boundaries() {
3493        let mut score = Score::new("T", 120, 4, 4, 0, 2);
3494        let note = || Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3495        score.parts[0].staves[0].measures[0].voices[1] = vec![note()];
3496        score.parts[0].staves[0].measures[1].voices[1] = vec![note()];
3497
3498        let events = to_playback_events(&score, &opts(None));
3499        assert_eq!(events.len(), 2);
3500        assert!((events[0].time_beats).abs() < 1e-9);
3501        assert!((events[1].time_beats - 4.0).abs() < 1e-9);
3502        assert!((events[1].time_secs - 2.0).abs() < 1e-9);
3503    }
3504
3505    #[test]
3506    fn time_secs_120_bpm_quarter_note_is_half_second() {
3507        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3508        score.parts[0].staves[0].measures[0].voices[0] =
3509            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
3510        let events = to_playback_events(&score, &opts(None));
3511        assert!((events[0].time_secs).abs() < 1e-9);
3512        assert!((events[0].duration_secs - 0.5).abs() < 1e-9);
3513    }
3514
3515    #[test]
3516    fn bpm_override_changes_time_secs() {
3517        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3518        score.parts[0].staves[0].measures[0].voices[0] = vec![
3519            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
3520            Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
3521        ];
3522        let events = to_playback_events(&score, &opts(Some(60)));
3523        assert!((events[0].time_secs).abs() < 1e-9);
3524        assert!((events[1].time_secs - 1.0).abs() < 1e-9);
3525    }
3526
3527    #[test]
3528    fn tempo_multiplier_preserves_written_tempo_changes_and_ramps() {
3529        let mut score = Score::new("T", 120, 4, 4, 0, 2);
3530        score.parts[0].staves[0].measures[0].tempo_ramp_to = Some(60);
3531        score.parts[0].staves[0].measures[1].tempo = Some(90);
3532        score.parts[0].staves[0].measures[0].voices[0] =
3533            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
3534        score.parts[0].staves[0].measures[1].voices[0] =
3535            vec![Note::new(Pitch::new(Step::D, 4), Duration::Quarter)];
3536        let options = PlaybackOptions {
3537            tempo_multiplier: 0.5,
3538            ..Default::default()
3539        };
3540
3541        let events = to_playback_events(&score, &options);
3542        let second_measure_start = tempo_ramp_seconds(60.0, Some(30.0), 4.0, 4.0);
3543        assert!((events[1].time_secs - second_measure_start).abs() < 1e-9);
3544
3545        let one_beat_into_ramp = tempo_ramp_seconds(60.0, Some(30.0), 4.0, 1.0);
3546        let position = compute_playback_position(&score, &options, one_beat_into_ramp).unwrap();
3547        assert_eq!(position.measure_index, 0);
3548        assert!((position.beat - 1.0).abs() < 1e-9);
3549
3550        let second_measure_position =
3551            compute_playback_position(&score, &options, second_measure_start + 60.0 / 45.0)
3552                .unwrap();
3553        assert_eq!(second_measure_position.measure_index, 1);
3554        assert!((second_measure_position.beat - 1.0).abs() < 1e-9);
3555    }
3556
3557    #[test]
3558    fn bpm_override_suppresses_written_tempo_changes_and_ramps() {
3559        let mut score = Score::new("T", 120, 4, 4, 0, 2);
3560        score.parts[0].staves[0].measures[0].tempo_ramp_to = Some(60);
3561        score.parts[0].staves[0].measures[1].tempo = Some(90);
3562        score.parts[0].staves[0].measures[0].voices[0] =
3563            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
3564        score.parts[0].staves[0].measures[1].voices[0] =
3565            vec![Note::new(Pitch::new(Step::D, 4), Duration::Quarter)];
3566        let options = PlaybackOptions {
3567            bpm_override: Some(120),
3568            ..Default::default()
3569        };
3570
3571        let events = to_playback_events(&score, &options);
3572        assert!((events[1].time_secs - 2.0).abs() < 1e-9);
3573        let position = compute_playback_position(&score, &options, 2.5).unwrap();
3574        assert_eq!(position.measure_index, 1);
3575        assert!((position.beat - 1.0).abs() < 1e-9);
3576    }
3577
3578    #[test]
3579    fn playback_position_tracks_repeats_rests_and_meter_changes_with_a_tempo_multiplier() {
3580        use crate::model::notation::{Barline, TimeSignature};
3581
3582        let mut score = Score::new("transport", 120, 4, 4, 0, 3);
3583        let staff = &mut score.parts[0].staves[0];
3584        staff.measures[0].barline_left = Barline::RepeatStart;
3585        staff.measures[1].time_sig = Some(TimeSignature {
3586            numerator: 3,
3587            denominator: 4,
3588        });
3589        staff.measures[2].barline_right = Barline::RepeatEnd;
3590        staff.measures[0].voices[0] = vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
3591        staff.measures[1].voices[0] = vec![Note::rest(Duration::Quarter)];
3592        staff.measures[2].voices[0] = vec![Note::new(Pitch::new(Step::D, 4), Duration::Quarter)];
3593        let options = PlaybackOptions {
3594            tempo_multiplier: 0.5,
3595            ..Default::default()
3596        };
3597
3598        let events = to_playback_events(&score, &options);
3599        let note_starts: Vec<f64> = events
3600            .iter()
3601            .filter(|event| event.address.is_some())
3602            .map(|event| event.time_secs)
3603            .collect();
3604        // 4/4 (4s), then 3/4 rest (3s) and note (3s). The repeat restarts at 10s.
3605        assert_eq!(note_starts, vec![0.0, 7.0, 10.0, 17.0]);
3606
3607        let middle = compute_playback_position(&score, &options, 5.0).unwrap();
3608        assert_eq!(middle.measure_index, 1);
3609        assert!((middle.beat - 1.0).abs() < 1e-9);
3610
3611        let repeated = compute_playback_position(&score, &options, 10.5).unwrap();
3612        assert_eq!(repeated.measure_index, 0);
3613        assert!((repeated.beat - 0.5).abs() < 1e-9);
3614    }
3615
3616    #[test]
3617    fn transpose_semitones_shifts_midi_output() {
3618        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3619        score.parts[0].staves[0].transpose_semitones = -2;
3620        score.parts[0].staves[0].measures[0].voices[0] =
3621            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
3622        let events = to_playback_events(&score, &opts(None));
3623        assert_eq!(events.len(), 1);
3624        assert_eq!(events[0].pitch_midi, 58);
3625    }
3626
3627    #[test]
3628    fn percussion_channel_9_ignores_transpose_semitones() {
3629        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3630        score.parts[0].midi_channel = 9;
3631        score.parts[0].staves[0].transpose_semitones = -2;
3632        score.parts[0].staves[0].measures[0].voices[0] =
3633            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
3634        let events = to_playback_events(&score, &opts(None));
3635        assert_eq!(events.len(), 1);
3636        assert_eq!(events[0].pitch_midi, 60);
3637    }
3638
3639    #[test]
3640    fn staccato_halves_duration_beats() {
3641        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3642        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3643        note.articulations
3644            .push(crate::model::notation::Articulation::Staccato);
3645        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
3646        let events = to_playback_events(&score, &opts(None));
3647        assert_eq!(events.len(), 1);
3648        assert!((events[0].duration_beats - 0.5).abs() < 1e-9);
3649    }
3650
3651    #[test]
3652    fn staccatissimo_also_halves_duration() {
3653        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3654        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3655        note.articulations
3656            .push(crate::model::notation::Articulation::Staccatissimo);
3657        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
3658        let events = to_playback_events(&score, &opts(None));
3659        assert!((events[0].duration_beats - 0.5).abs() < 1e-9);
3660    }
3661
3662    #[test]
3663    fn staccato_does_not_shift_next_note_time() {
3664        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3665        let mut n1 = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3666        n1.articulations
3667            .push(crate::model::notation::Articulation::Staccato);
3668        let n2 = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
3669        score.parts[0].staves[0].measures[0].voices[0] = vec![n1, n2];
3670        let events = to_playback_events(&score, &opts(None));
3671        assert_eq!(events.len(), 2);
3672        assert!((events[1].time_beats - 1.0).abs() < 1e-9);
3673    }
3674
3675    #[test]
3676    fn dynamics_hold_until_the_next_marking_across_voices_and_bars() {
3677        use crate::model::notation::Dynamic;
3678        let mut score = Score::new("T", 120, 4, 4, 0, 2);
3679        let quarters = |dynamics: [Option<Dynamic>; 4]| -> Vec<Note> {
3680            dynamics
3681                .into_iter()
3682                .map(|dynamic| {
3683                    let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3684                    note.dynamic = dynamic;
3685                    note
3686                })
3687                .collect()
3688        };
3689        // Bar 1: p, (p), sf, (p) ; bar 2: fp, (p), pf, (f). Voice 2 in bar 1 holds a half
3690        // note at beat 2, when voice 1's sf sounds, and one at beat 0.
3691        score.parts[0].staves[0].measures[0].voices[0] =
3692            quarters([Some(Dynamic::P), None, Some(Dynamic::Sf), None]);
3693        score.parts[0].staves[0].measures[0].voices[1] = vec![
3694            Note::new(Pitch::new(Step::C, 3), Duration::Half),
3695            Note::new(Pitch::new(Step::C, 3), Duration::Half),
3696        ];
3697        score.parts[0].staves[0].measures[1].voices[0] =
3698            quarters([Some(Dynamic::Fp), None, Some(Dynamic::Pf), None]);
3699        let events = to_playback_events(&score, &opts(None));
3700        let velocities = |pitch: u8| -> Vec<u8> {
3701            events
3702                .iter()
3703                .filter(|event| event.pitch_midi == pitch)
3704                .map(|event| event.velocity)
3705                .collect()
3706        };
3707        let (p, f, sf) = (
3708            Dynamic::P.to_velocity(),
3709            Dynamic::F.to_velocity(),
3710            Dynamic::Sf.to_velocity(),
3711        );
3712        assert_eq!(velocities(60), vec![p, p, sf, p, f, p, p, f]);
3713        assert_eq!(velocities(48), vec![p, sf]);
3714        // Before any marking, notes keep the historical default.
3715        assert_eq!(
3716            DynamicTimeline::default().velocity(0, 0.0, None),
3717            DynamicTimeline::DEFAULT_VELOCITY
3718        );
3719    }
3720
3721    #[test]
3722    fn hairpins_ramp_towards_the_following_marking_or_two_steps() {
3723        use crate::model::notation::{Dynamic, HairpinKind};
3724        let mut score = Score::new("T", 120, 4, 4, 0, 2);
3725        let mut bar = |index: usize, dynamics: [Option<Dynamic>; 4], end: Option<Dynamic>| {
3726            let mut notes: Vec<Note> = dynamics
3727                .into_iter()
3728                .map(|dynamic| {
3729                    let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3730                    note.dynamic = dynamic;
3731                    note
3732                })
3733                .collect();
3734            notes[0].hairpin_start = Some(HairpinKind::Crescendo);
3735            notes[2].hairpin_end = true;
3736            notes[3].dynamic = end;
3737            score.parts[0].staves[0].measures[index].voices[0] = notes;
3738        };
3739        // Bar 1: p < (three beats) f. Bar 2: < with nothing after: two steps above f.
3740        bar(0, [Some(Dynamic::P), None, None, None], Some(Dynamic::F));
3741        bar(1, [None, None, None, None], None);
3742        let events = to_playback_events(&score, &opts(None));
3743        let velocities: Vec<u8> = events.iter().map(|event| event.velocity).collect();
3744        assert_eq!(&velocities[..4], &[48, 60, 72, 84]);
3745        assert_eq!(&velocities[4..], &[84, 92, 100, 108]);
3746    }
3747
3748    #[test]
3749    fn dynamic_names_round_trip_and_compounds_continue_at_their_second_level() {
3750        use crate::model::notation::Dynamic;
3751        for dynamic in Dynamic::ALL {
3752            assert_eq!(
3753                Dynamic::from_musicxml_str(dynamic.to_musicxml_str()),
3754                Some(dynamic)
3755            );
3756        }
3757        assert_eq!(Dynamic::from_musicxml_str("fffff"), Some(Dynamic::Ffff));
3758        assert_eq!(Dynamic::Sfpp.sustained_level(), Some(Dynamic::Pp));
3759        assert_eq!(Dynamic::Sfz.sustained_level(), None);
3760        assert_eq!(Dynamic::from_musicxml_str("other"), None);
3761    }
3762
3763    #[test]
3764    fn accent_boosts_velocity_clamped() {
3765        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3766        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3767        note.articulations
3768            .push(crate::model::notation::Articulation::Accent);
3769        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
3770        let events = to_playback_events(&score, &opts(None));
3771        assert_eq!(events[0].velocity, 84);
3772    }
3773
3774    #[test]
3775    fn accent_clamped_at_127() {
3776        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3777        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3778        note.dynamic = Some(crate::model::notation::Dynamic::Ffff);
3779        note.articulations
3780            .push(crate::model::notation::Articulation::Accent);
3781        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
3782        let events = to_playback_events(&score, &opts(None));
3783        assert_eq!(events[0].velocity, 127);
3784    }
3785
3786    #[test]
3787    fn tenuto_keeps_full_duration() {
3788        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3789        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3790        note.articulations
3791            .push(crate::model::notation::Articulation::Tenuto);
3792        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
3793        let events = to_playback_events(&score, &opts(None));
3794        assert!((events[0].duration_beats - 1.0).abs() < 1e-9);
3795    }
3796
3797    #[test]
3798    fn pedal_start_sets_pedal_field() {
3799        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3800        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3801        note.pedal_start = true;
3802        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
3803        let events = to_playback_events(&score, &opts(None));
3804        assert!(events[0].pedal);
3805    }
3806
3807    #[test]
3808    fn no_pedal_start_pedal_is_false() {
3809        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3810        score.parts[0].staves[0].measures[0].voices[0] =
3811            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
3812        let events = to_playback_events(&score, &opts(None));
3813        assert!(!events[0].pedal);
3814    }
3815
3816    #[test]
3817    fn set_tempo_at_measure_changes_time_secs() {
3818        let mut score = Score::new("T", 120, 4, 4, 0, 2);
3819        score.parts[0].staves[0].measures[0].voices[0] =
3820            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
3821        score.parts[0].staves[0].measures[1].tempo = Some(60);
3822        score.parts[0].staves[0].measures[1].voices[0] =
3823            vec![Note::new(Pitch::new(Step::D, 4), Duration::Whole)];
3824        let events = to_playback_events(&score, &opts(None));
3825        assert_eq!(events.len(), 2);
3826        assert!((events[0].time_secs).abs() < 1e-9);
3827        assert!((events[0].duration_secs - 2.0).abs() < 1e-9);
3828        assert!((events[1].time_secs - 2.0).abs() < 1e-9);
3829        assert!((events[1].duration_secs - 4.0).abs() < 1e-9);
3830    }
3831
3832    #[test]
3833    fn muted_part_produces_no_events() {
3834        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3835        score.parts[0].staves[0].measures[0].voices[0] =
3836            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
3837        let options = PlaybackOptions {
3838            muted_parts: vec![0],
3839            ..Default::default()
3840        };
3841        assert!(to_playback_events(&score, &options).is_empty());
3842    }
3843
3844    #[test]
3845    fn part_index_field_set_correctly() {
3846        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3847        score.parts[0].staves[0].measures[0].voices[0] =
3848            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
3849        let events = to_playback_events(&score, &opts(None));
3850        assert_eq!(events[0].part_index, 0);
3851    }
3852
3853    // ── loop_region ───────────────────────────────────────────────────────────
3854
3855    #[test]
3856    fn loop_region_filters_measures() {
3857        // 3 measures; notes in measures 0, 1, 2. Loop on [1,2] → only events from 1,2.
3858        let mut score = Score::new("T", 120, 4, 4, 0, 3);
3859        for mi in 0..3 {
3860            score.parts[0].staves[0].measures[mi].voices[0] =
3861                vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
3862        }
3863        let options = PlaybackOptions {
3864            loop_region: Some((1, 2)),
3865            ..Default::default()
3866        };
3867        let events = to_playback_events(&score, &options);
3868        assert_eq!(events.len(), 2);
3869        // First event in the region should start at beat 0 (region-relative)
3870        assert!((events[0].time_beats).abs() < 1e-9);
3871    }
3872
3873    #[test]
3874    fn loop_region_none_plays_all_measures() {
3875        let mut score = Score::new("T", 120, 4, 4, 0, 3);
3876        for mi in 0..3 {
3877            score.parts[0].staves[0].measures[mi].voices[0] =
3878                vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
3879        }
3880        let events = to_playback_events(&score, &opts(None));
3881        assert_eq!(events.len(), 3);
3882    }
3883
3884    // ── Fermata ───────────────────────────────────────────────────────────────
3885
3886    #[test]
3887    fn fermata_multiplier_extends_duration() {
3888        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3889        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3890        note.articulations
3891            .push(crate::model::notation::Articulation::Fermata);
3892        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
3893        let options = PlaybackOptions {
3894            fermata_multiplier: 2.0,
3895            ..Default::default()
3896        };
3897        let events = to_playback_events(&score, &options);
3898        assert_eq!(events.len(), 1);
3899        assert!((events[0].duration_beats - 2.0).abs() < 1e-9);
3900    }
3901
3902    #[test]
3903    fn fermata_default_multiplier_is_1_5() {
3904        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3905        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3906        note.articulations
3907            .push(crate::model::notation::Articulation::Fermata);
3908        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
3909        let events = to_playback_events(&score, &PlaybackOptions::default());
3910        assert_eq!(events.len(), 1);
3911        assert!((events[0].duration_beats - 1.5).abs() < 1e-9);
3912    }
3913
3914    #[test]
3915    fn fermata_hold_is_an_explicit_post_note_request_separate_from_extension() {
3916        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3917        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3918        note.articulations
3919            .push(crate::model::notation::Articulation::Fermata);
3920        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
3921        let options = PlaybackOptions {
3922            fermata_multiplier: 2.0,
3923            fermata_hold_beats: 0.75,
3924            ..Default::default()
3925        };
3926        let events = to_playback_events(&score, &options);
3927        assert!((events[0].duration_beats - 2.0).abs() < 1e-9);
3928        assert!((events[0].post_note_pause_beats - 0.75).abs() < 1e-9);
3929    }
3930
3931    #[test]
3932    fn invalid_fermata_hold_is_safely_ignored() {
3933        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3934        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3935        note.articulations
3936            .push(crate::model::notation::Articulation::Fermata);
3937        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
3938        let options = PlaybackOptions {
3939            fermata_hold_beats: f64::NAN,
3940            ..Default::default()
3941        };
3942        assert_eq!(
3943            to_playback_events(&score, &options)[0].post_note_pause_beats,
3944            0.0
3945        );
3946    }
3947
3948    #[test]
3949    fn non_fermata_note_unaffected_by_fermata_multiplier() {
3950        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3951        score.parts[0].staves[0].measures[0].voices[0] =
3952            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
3953        let options = PlaybackOptions {
3954            fermata_multiplier: 3.0,
3955            ..Default::default()
3956        };
3957        let events = to_playback_events(&score, &options);
3958        assert!((events[0].duration_beats - 1.0).abs() < 1e-9);
3959    }
3960
3961    // ── swing ─────────────────────────────────────────────────────────────────
3962
3963    #[test]
3964    fn swing_triplet_first_eighth_is_long() {
3965        // Two eighth notes in one measure; swing=0.67 → first=0.67, second=0.33
3966        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3967        score.parts[0].staves[0].measures[0].voices[0] = vec![
3968            Note::new(Pitch::new(Step::C, 4), Duration::Eighth),
3969            Note::new(Pitch::new(Step::D, 4), Duration::Eighth),
3970        ];
3971        let options = PlaybackOptions {
3972            swing: Some(0.67),
3973            ..Default::default()
3974        };
3975        let events = to_playback_events(&score, &options);
3976        // events are sorted by time_beats; C comes first (time_beats=0), D second
3977        let e_c = events.iter().find(|e| e.pitch_midi == 60).unwrap();
3978        let e_d = events.iter().find(|e| e.pitch_midi == 62).unwrap();
3979        assert!(
3980            (e_c.duration_beats - 0.67).abs() < 1e-9,
3981            "first eighth should be 0.67"
3982        );
3983        assert!(
3984            (e_d.duration_beats - 0.33).abs() < 1e-9,
3985            "second eighth should be 0.33"
3986        );
3987        // D starts at 0.67, not 0.5
3988        assert!(
3989            (e_d.time_beats - 0.67).abs() < 1e-9,
3990            "second note start should be at 0.67"
3991        );
3992    }
3993
3994    #[test]
3995    fn swing_does_not_stretch_an_unpaired_eighth() {
3996        let mut score = Score::new("unpaired", 120, 4, 4, 0, 1);
3997        score.parts[0].staves[0].measures[0].voices[0] = vec![
3998            Note::new(Pitch::new(Step::C, 4), Duration::Eighth),
3999            Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
4000        ];
4001        let events = to_playback_events(
4002            &score,
4003            &PlaybackOptions {
4004                swing: Some(2.0 / 3.0),
4005                ..Default::default()
4006            },
4007        );
4008        assert!((events[0].duration_beats - 0.5).abs() < 1e-9);
4009        assert!((events[1].time_beats - 0.5).abs() < 1e-9);
4010    }
4011
4012    #[test]
4013    fn cue_note_does_not_sound_or_pair_with_a_written_eighth() {
4014        let mut score = Score::new("cue", 120, 4, 4, 0, 1);
4015        let mut cue = Note::new(Pitch::new(Step::D, 4), Duration::Eighth);
4016        cue.is_cue = true;
4017        score.parts[0].staves[0].measures[0].voices[0] = vec![
4018            Note::new(Pitch::new(Step::C, 4), Duration::Eighth),
4019            cue,
4020            Note::new(Pitch::new(Step::E, 4), Duration::Eighth),
4021        ];
4022        let events = to_playback_events(
4023            &score,
4024            &PlaybackOptions {
4025                swing: Some(2.0 / 3.0),
4026                ..Default::default()
4027            },
4028        );
4029        assert_eq!(events.len(), 2);
4030        assert_eq!(events[0].pitch_midi, 60);
4031        assert_eq!(events[0].duration_beats, 0.5);
4032        assert_eq!(events[1].pitch_midi, 64);
4033        assert_eq!(events[1].time_beats, 0.5);
4034    }
4035
4036    #[test]
4037    fn swing_requires_metric_alignment_and_a_matching_next_note() {
4038        let mut score = Score::new("metric boundary", 120, 4, 4, 0, 1);
4039        score.parts[0].staves[0].measures[0].voices[0] = vec![
4040            Note::new(Pitch::new(Step::C, 4), Duration::Eighth),
4041            Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
4042            Note::new(Pitch::new(Step::E, 4), Duration::Eighth),
4043            Note::new(Pitch::new(Step::F, 4), Duration::Eighth),
4044            Note::new(Pitch::new(Step::G, 4), Duration::Eighth),
4045            Note::new(Pitch::new(Step::A, 4), Duration::Eighth),
4046        ];
4047        let events = to_playback_events(
4048            &score,
4049            &PlaybackOptions {
4050                swing: Some(2.0 / 3.0),
4051                ..Default::default()
4052            },
4053        );
4054        assert!((events[0].time_beats).abs() < 1e-9);
4055        assert!((events[0].duration_beats - 0.5).abs() < 1e-9);
4056        assert!((events[2].time_beats - 1.5).abs() < 1e-9);
4057        assert!((events[2].duration_beats - 0.5).abs() < 1e-9);
4058        assert!((events[3].time_beats - 2.0).abs() < 1e-9);
4059        assert!((events[3].duration_beats - 2.0 / 3.0).abs() < 1e-9);
4060        assert!((events[4].time_beats - 8.0 / 3.0).abs() < 1e-9);
4061        assert!((events[4].duration_beats - 1.0 / 3.0).abs() < 1e-9);
4062        assert!((events[5].time_beats - 3.0).abs() < 1e-9);
4063        assert!((events[5].duration_beats - 0.5).abs() < 1e-9);
4064    }
4065
4066    #[test]
4067    fn swing_keeps_the_offbeat_delay_after_an_eighth_rest() {
4068        let mut score = Score::new("rest swing", 120, 4, 4, 0, 1);
4069        score.parts[0].staves[0].measures[0].voices[0] = vec![
4070            Note::rest(Duration::Eighth),
4071            Note::new(Pitch::new(Step::C, 4), Duration::Eighth),
4072            Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
4073        ];
4074        let events = to_playback_events(
4075            &score,
4076            &PlaybackOptions {
4077                swing: Some(2.0 / 3.0),
4078                ..Default::default()
4079            },
4080        );
4081        assert!((events[0].time_beats - 2.0 / 3.0).abs() < 1e-9);
4082        assert!((events[0].duration_beats - 1.0 / 3.0).abs() < 1e-9);
4083        assert!((events[1].time_beats - 1.0).abs() < 1e-9);
4084    }
4085
4086    #[test]
4087    fn invalid_swing_ratios_keep_straight_finite_timing() {
4088        let mut score = Score::new("invalid swing", 120, 4, 4, 0, 1);
4089        score.parts[0].staves[0].measures[0].voices[0] = vec![
4090            Note::new(Pitch::new(Step::C, 4), Duration::Eighth),
4091            Note::new(Pitch::new(Step::D, 4), Duration::Eighth),
4092        ];
4093        for ratio in [f64::NAN, f64::INFINITY, -1.0, 0.5, 1.0] {
4094            let events = to_playback_events(
4095                &score,
4096                &PlaybackOptions {
4097                    swing: Some(ratio),
4098                    ..Default::default()
4099                },
4100            );
4101            assert_eq!(events.len(), 2);
4102            assert_eq!(events[0].duration_beats, 0.5);
4103            assert_eq!(events[1].time_beats, 0.5);
4104            assert_eq!(events[1].duration_beats, 0.5);
4105            assert!(events.iter().all(|event| event.time_secs.is_finite()));
4106        }
4107    }
4108
4109    #[test]
4110    fn pickup_swing_uses_metric_position_in_the_full_bar() {
4111        let mut score = Score::new("pickup swing", 120, 4, 4, 0, 1);
4112        let measure = &mut score.parts[0].staves[0].measures[0];
4113        measure.actual_length = Some(crate::MeasureLength {
4114            numerator: 3,
4115            denominator: 8,
4116        });
4117        measure.voices[0] = vec![
4118            Note::new(Pitch::new(Step::C, 4), Duration::Eighth),
4119            Note::new(Pitch::new(Step::D, 4), Duration::Eighth),
4120            Note::new(Pitch::new(Step::E, 4), Duration::Eighth),
4121        ];
4122        let events = to_playback_events(
4123            &score,
4124            &PlaybackOptions {
4125                swing: Some(2.0 / 3.0),
4126                ..Default::default()
4127            },
4128        );
4129        assert_eq!(events[0].duration_beats, 0.5);
4130        assert!((events[1].time_beats - 0.5).abs() < 1e-9);
4131        assert!((events[1].duration_beats - 2.0 / 3.0).abs() < 1e-9);
4132        assert!((events[2].time_beats - 7.0 / 6.0).abs() < 1e-9);
4133        assert!((events[2].duration_beats - 1.0 / 3.0).abs() < 1e-9);
4134    }
4135
4136    #[test]
4137    fn swing_non_eighth_not_affected() {
4138        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4139        score.parts[0].staves[0].measures[0].voices[0] =
4140            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
4141        let options = PlaybackOptions {
4142            swing: Some(0.67),
4143            ..Default::default()
4144        };
4145        let events = to_playback_events(&score, &options);
4146        assert!(
4147            (events[0].duration_beats - 1.0).abs() < 1e-9,
4148            "quarter note unaffected"
4149        );
4150    }
4151
4152    #[test]
4153    fn swing_none_is_straight() {
4154        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4155        score.parts[0].staves[0].measures[0].voices[0] =
4156            vec![Note::new(Pitch::new(Step::C, 4), Duration::Eighth)];
4157        let options = PlaybackOptions {
4158            swing: None,
4159            ..Default::default()
4160        };
4161        let events = to_playback_events(&score, &options);
4162        assert!(
4163            (events[0].duration_beats - 0.5).abs() < 1e-9,
4164            "no swing = straight eighth"
4165        );
4166    }
4167
4168    #[test]
4169    fn channel_matches_part_midi_channel() {
4170        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4171        score.parts[0].midi_channel = 3;
4172        score.parts[0].staves[0].measures[0].voices[0] =
4173            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
4174        let events = to_playback_events(&score, &opts(None));
4175        assert_eq!(events[0].channel, 3);
4176    }
4177
4178    #[test]
4179    fn swing_unit_default_is_eighth() {
4180        assert_eq!(PlaybackOptions::default().swing_unit, Duration::Eighth);
4181    }
4182
4183    #[test]
4184    fn swing_unit_sixteenth() {
4185        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4186        score.parts[0].staves[0].measures[0].voices[0] = vec![
4187            Note::new(Pitch::new(Step::C, 4), Duration::Sixteenth),
4188            Note::new(Pitch::new(Step::D, 4), Duration::Sixteenth),
4189        ];
4190        let options = PlaybackOptions {
4191            swing: Some(0.67),
4192            swing_unit: Duration::Sixteenth,
4193            ..Default::default()
4194        };
4195        let events = to_playback_events(&score, &options);
4196        let e_c = events.iter().find(|e| e.pitch_midi == 60).unwrap();
4197        let e_d = events.iter().find(|e| e.pitch_midi == 62).unwrap();
4198        assert!(
4199            (e_c.duration_beats - 0.335).abs() < 1e-9,
4200            "first 16th should be 0.335"
4201        );
4202        assert!(
4203            (e_d.duration_beats - 0.165).abs() < 1e-9,
4204            "second 16th should be 0.165"
4205        );
4206    }
4207
4208    #[test]
4209    fn swing_resets_per_measure() {
4210        // 2 measures each with 2 eighth notes; each measure's first eighth should be "long"
4211        let mut score = Score::new("T", 120, 4, 4, 0, 2);
4212        let pair = || {
4213            vec![
4214                Note::new(Pitch::new(Step::C, 4), Duration::Eighth),
4215                Note::new(Pitch::new(Step::D, 4), Duration::Eighth),
4216            ]
4217        };
4218        score.parts[0].staves[0].measures[0].voices[0] = pair();
4219        score.parts[0].staves[0].measures[1].voices[0] = pair();
4220        let options = PlaybackOptions {
4221            swing: Some(0.67),
4222            ..Default::default()
4223        };
4224        let events = to_playback_events(&score, &options);
4225        // Four events sorted by time: m0-C, m0-D, m1-C, m1-D
4226        let durations: Vec<f64> = events.iter().map(|e| e.duration_beats).collect();
4227        // m0 first (long)
4228        assert!((durations[0] - 0.67).abs() < 1e-9, "m0 first note long");
4229        // m0 second (short)
4230        assert!((durations[1] - 0.33).abs() < 1e-9, "m0 second note short");
4231        // m1 first (long again — reset)
4232        assert!(
4233            (durations[2] - 0.67).abs() < 1e-9,
4234            "m1 first note long (reset)"
4235        );
4236        // m1 second (short)
4237        assert!((durations[3] - 0.33).abs() < 1e-9, "m1 second note short");
4238    }
4239
4240    #[test]
4241    fn multi_voice_events_preserve_source_voice_addresses() {
4242        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4243        score.parts[0].staves[0].measures[0].voices[0] =
4244            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
4245        score.parts[0].staves[0].measures[0].voices[1] =
4246            vec![Note::new(Pitch::new(Step::E, 4), Duration::Quarter)];
4247
4248        let events = to_playback_events(&score, &PlaybackOptions::default());
4249        let addresses: Vec<&str> = events
4250            .iter()
4251            .filter_map(|event| event.address.as_deref())
4252            .collect();
4253        assert!(addresses.contains(&"0:0:0:0:0"));
4254        assert!(addresses.contains(&"0:0:0:1:0"));
4255    }
4256
4257    #[test]
4258    fn playback_exposes_original_musicxml_voice_number_alongside_slot_address() {
4259        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4260        let measure = &mut score.parts[0].staves[0].measures[0];
4261        measure.voices[1] = vec![Note::new(Pitch::new(Step::E, 4), Duration::Quarter)];
4262        measure.source_voice_numbers[1] = Some(5);
4263
4264        let events = to_playback_events(&score, &PlaybackOptions::default());
4265        let event = events
4266            .iter()
4267            .find(|event| event.address.as_deref() == Some("0:0:0:1:0"))
4268            .expect("event from slot 1");
4269        assert_eq!(event.source.as_ref().map(|source| source.voice), Some(1));
4270        assert_eq!(event.source_voice_number, Some(5));
4271    }
4272
4273    // ── compute_playback_position ─────────────────────────────────────────────
4274
4275    #[test]
4276    fn playback_position_at_zero_is_measure_0_beat_0() {
4277        let score = Score::new("T", 120, 4, 4, 0, 4);
4278        let pos = compute_playback_position(&score, &PlaybackOptions::default(), 0.0).unwrap();
4279        assert_eq!(pos.measure_index, 0);
4280        assert!(pos.beat.abs() < 1e-9);
4281    }
4282
4283    #[test]
4284    fn playback_position_at_half_measure_is_beat_2() {
4285        // 4/4, 120 BPM → 1 measure = 2.0 s; 0.5 s = beat 1.0
4286        let score = Score::new("T", 120, 4, 4, 0, 4);
4287        let pos = compute_playback_position(&score, &PlaybackOptions::default(), 0.5).unwrap();
4288        assert_eq!(pos.measure_index, 0);
4289        assert!(
4290            (pos.beat - 1.0).abs() < 1e-9,
4291            "expected beat 1.0, got {}",
4292            pos.beat
4293        );
4294    }
4295
4296    #[test]
4297    fn playback_position_beyond_score_is_none() {
4298        // 4/4, 120 BPM, 1 measure = 2.0 s; 10.0 s is beyond
4299        let score = Score::new("T", 120, 4, 4, 0, 1);
4300        assert!(compute_playback_position(&score, &PlaybackOptions::default(), 10.0).is_none());
4301    }
4302
4303    #[test]
4304    fn playback_position_tempo_change_takes_effect() {
4305        // measure 0: 120 BPM (2.0 s), measure 1: 60 BPM (4.0 s)
4306        // At elapsed=2.5 s → inside measure 1, 0.5 s into it → beat 0.5
4307        let mut score = Score::new("T", 120, 4, 4, 0, 2);
4308        score.parts[0].staves[0].measures[1].tempo = Some(60);
4309        let pos = compute_playback_position(&score, &PlaybackOptions::default(), 2.5).unwrap();
4310        assert_eq!(pos.measure_index, 1);
4311        assert!(
4312            (pos.beat - 0.5).abs() < 1e-9,
4313            "expected beat 0.5, got {}",
4314            pos.beat
4315        );
4316    }
4317
4318    #[test]
4319    fn playback_position_loop_region_starts_at_zero() {
4320        // loop_region=[1,2] → elapsed=0 should map to measure 1, beat 0
4321        let score = Score::new("T", 120, 4, 4, 0, 4);
4322        let options = PlaybackOptions {
4323            loop_region: Some((1, 2)),
4324            ..Default::default()
4325        };
4326        let pos = compute_playback_position(&score, &options, 0.0).unwrap();
4327        assert_eq!(pos.measure_index, 1);
4328        assert!(pos.beat.abs() < 1e-9);
4329    }
4330
4331    // ── MetronomeConfig ───────────────────────────────────────────────────────
4332
4333    #[test]
4334    fn metronome_injects_beat_events() {
4335        // 4/4, 1 measure → should inject 4 metronome events
4336        let score = Score::new("T", 120, 4, 4, 0, 1);
4337        let options = PlaybackOptions {
4338            metronome: Some(MetronomeConfig::default()),
4339            ..Default::default()
4340        };
4341        let events = to_playback_events(&score, &options);
4342        let metro_events: Vec<_> = events.iter().filter(|e| e.is_metronome).collect();
4343        assert_eq!(metro_events.len(), 4, "expected 4 metronome clicks in 4/4");
4344    }
4345
4346    #[test]
4347    fn metronome_accent_is_first_beat() {
4348        let score = Score::new("T", 120, 4, 4, 0, 1);
4349        let metro = MetronomeConfig::default();
4350        let options = PlaybackOptions {
4351            metronome: Some(metro.clone()),
4352            ..Default::default()
4353        };
4354        let events = to_playback_events(&score, &options);
4355        let mut metro_events: Vec<_> = events.iter().filter(|e| e.is_metronome).collect();
4356        metro_events.sort_by(|a, b| a.time_beats.partial_cmp(&b.time_beats).unwrap());
4357        assert_eq!(metro_events[0].pitch_midi, metro.accent_pitch);
4358        assert_eq!(metro_events[0].velocity, metro.accent_velocity);
4359    }
4360
4361    #[test]
4362    fn metronome_regular_beat_pitch() {
4363        let score = Score::new("T", 120, 4, 4, 0, 1);
4364        let metro = MetronomeConfig::default();
4365        let options = PlaybackOptions {
4366            metronome: Some(metro.clone()),
4367            ..Default::default()
4368        };
4369        let events = to_playback_events(&score, &options);
4370        let mut metro_events: Vec<_> = events.iter().filter(|e| e.is_metronome).collect();
4371        metro_events.sort_by(|a, b| a.time_beats.partial_cmp(&b.time_beats).unwrap());
4372        for ev in &metro_events[1..] {
4373            assert_eq!(ev.pitch_midi, metro.beat_pitch);
4374            assert_eq!(ev.velocity, metro.beat_velocity);
4375        }
4376    }
4377
4378    #[test]
4379    fn metronome_integrates_measure_tempo_ramp_and_carries_ending_tempo() {
4380        let mut score = Score::new("T", 120, 4, 4, 0, 2);
4381        score.parts[0].staves[0].measures[0].tempo_ramp_to = Some(60);
4382        let options = PlaybackOptions {
4383            metronome: Some(MetronomeConfig::default()),
4384            ..Default::default()
4385        };
4386
4387        let mut clicks: Vec<_> = to_playback_events(&score, &options)
4388            .into_iter()
4389            .filter(|event| event.is_metronome)
4390            .collect();
4391        clicks.sort_by(|left, right| left.time_beats.total_cmp(&right.time_beats));
4392
4393        let expected_second_beat = tempo_ramp_seconds(120.0, Some(60.0), 4.0, 1.0);
4394        let expected_second_measure = tempo_ramp_seconds(120.0, Some(60.0), 4.0, 4.0);
4395        assert!((clicks[1].time_secs - expected_second_beat).abs() < 1e-9);
4396        assert!((clicks[4].time_secs - expected_second_measure).abs() < 1e-9);
4397        assert!((clicks[5].time_secs - (expected_second_measure + 1.0)).abs() < 1e-9);
4398    }
4399
4400    #[test]
4401    fn metronome_events_are_marked() {
4402        let score = Score::new("T", 120, 4, 4, 0, 1);
4403        let options = PlaybackOptions {
4404            metronome: Some(MetronomeConfig::default()),
4405            ..Default::default()
4406        };
4407        let events = to_playback_events(&score, &options);
4408        assert!(events.iter().any(|e| e.is_metronome));
4409    }
4410
4411    #[test]
4412    fn metronome_none_produces_no_extra_events() {
4413        let score = Score::new("T", 120, 4, 4, 0, 1);
4414        let events = to_playback_events(&score, &PlaybackOptions::default());
4415        assert!(events.iter().all(|e| !e.is_metronome));
4416    }
4417
4418    #[test]
4419    fn offline_render_manifest_uses_event_end_as_exact_frame_length() {
4420        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4421        score.parts[0].staves[0].measures[0].voices[0][0] =
4422            Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4423        let request = OfflineRenderRequest {
4424            format: OfflineRenderFormat::Flac,
4425            sample_rate_hz: 48_000,
4426            channels: 2,
4427            provider_identity: Some("test-provider@1".into()),
4428            ..Default::default()
4429        };
4430
4431        let manifest = build_offline_render_manifest(&score, &PlaybackOptions::default(), &request)
4432            .expect("valid render manifest");
4433        assert_eq!(manifest.contract_version, OFFLINE_RENDER_CONTRACT_VERSION);
4434        assert!(manifest.validate().is_ok());
4435        assert_eq!(manifest.format, OfflineRenderFormat::Flac);
4436        assert_eq!(manifest.duration_frames, 24_000);
4437        assert_eq!(manifest.events.len(), 1);
4438        assert_eq!(manifest.frame_events.len(), 1);
4439        assert_eq!(manifest.frame_events[0].start_frame, 0);
4440        assert_eq!(manifest.frame_events[0].duration_frames, 24_000);
4441        assert_eq!(manifest.frame_events[0].end_frame, 24_000);
4442        assert_eq!(manifest.playback_options, PlaybackOptions::default());
4443        assert_eq!(
4444            manifest.provider_identity.as_deref(),
4445            Some("test-provider@1")
4446        );
4447    }
4448
4449    #[test]
4450    fn offline_render_manifest_rejects_out_of_range_audio_shape() {
4451        let score = Score::new("T", 120, 4, 4, 0, 1);
4452        let request = OfflineRenderRequest {
4453            sample_rate_hz: 0,
4454            ..Default::default()
4455        };
4456        assert!(matches!(
4457            build_offline_render_manifest(&score, &PlaybackOptions::default(), &request),
4458            Err(crate::Error::InvalidOfflineRenderRequest)
4459        ));
4460    }
4461
4462    #[test]
4463    fn offline_render_manifest_selection_keeps_source_address_and_release_tail() {
4464        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4465        score.parts[0].staves[0].measures[0].voices[0] = vec![
4466            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
4467            Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
4468        ];
4469        let events = to_playback_events(&score, &PlaybackOptions::default());
4470        score.parts[0].midi_control_changes.push(MidiControlChange {
4471            tick: 480,
4472            channel: 0,
4473            controller: 7,
4474            value: 100,
4475        });
4476        let request = OfflineRenderRequest {
4477            sample_rate_hz: 8_000,
4478            release_tail_millis: 125,
4479            scope: OfflineRenderScope::Selection {
4480                addresses: vec![events[1].source.clone().expect("source address")],
4481            },
4482            ..Default::default()
4483        };
4484        let manifest = build_offline_render_manifest(&score, &PlaybackOptions::default(), &request)
4485            .expect("selection manifest");
4486        assert_eq!(manifest.events.len(), 1);
4487        assert_eq!(manifest.events[0].source, events[1].source);
4488        assert_eq!(manifest.frame_events[0].start_frame, 0);
4489        assert!(manifest.semantic_events.iter().any(|event| matches!(
4490            event.kind,
4491            OfflineRenderSemanticEventKind::Controller {
4492                channel: 0,
4493                controller: 7,
4494                value: 100
4495            }
4496        ) && event.frame == 0));
4497        assert_eq!(manifest.release_tail_frames, 1_000);
4498        assert_eq!(manifest.duration_frames, 5_000);
4499    }
4500
4501    #[test]
4502    fn offline_render_request_owned_options_override_legacy_argument() {
4503        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4504        score.parts[0].staves[0].measures[0].voices[0][0] =
4505            Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4506        let request_options = PlaybackOptions {
4507            bpm_override: Some(60),
4508            ..Default::default()
4509        };
4510        let manifest = build_offline_render_manifest(
4511            &score,
4512            &PlaybackOptions::default(),
4513            &OfflineRenderRequest {
4514                sample_rate_hz: 48_000,
4515                playback_options: Some(request_options.clone()),
4516                ..Default::default()
4517            },
4518        )
4519        .expect("request-owned playback options");
4520        assert_eq!(manifest.playback_options, request_options);
4521        assert_eq!(manifest.frame_events[0].duration_frames, 48_000);
4522    }
4523
4524    #[test]
4525    fn offline_manifest_projects_typed_midi_events_to_exact_frames() {
4526        let mut score = Score::new("MIDI semantics", 120, 4, 4, 0, 1);
4527        score.parts[0].midi_pitch_bends.push(MidiPitchBend {
4528            tick: 480,
4529            channel: 2,
4530            value: -512,
4531        });
4532        score.parts[0].midi_program_changes.push(MidiProgramChange {
4533            tick: 480,
4534            channel: 2,
4535            program: 41,
4536        });
4537        score.parts[0].midi_aftertouch.push(MidiAftertouch {
4538            tick: 480,
4539            channel: 2,
4540            key: Some(64),
4541            value: 88,
4542        });
4543        let manifest = build_offline_render_manifest(
4544            &score,
4545            &PlaybackOptions::default(),
4546            &OfflineRenderRequest {
4547                sample_rate_hz: 48_000,
4548                ..Default::default()
4549            },
4550        )
4551        .expect("manifest builds");
4552        for kind in [
4553            OfflineRenderSemanticEventKind::PitchBend {
4554                channel: 2,
4555                value: -512,
4556            },
4557            OfflineRenderSemanticEventKind::ProgramChange {
4558                channel: 2,
4559                program: 41,
4560            },
4561            OfflineRenderSemanticEventKind::Aftertouch {
4562                channel: 2,
4563                key: Some(64),
4564                value: 88,
4565            },
4566        ] {
4567            assert!(manifest.semantic_events.iter().any(|event| {
4568                event.kind == kind
4569                    && event.frame == 24_000
4570                    && event
4571                        .measure
4572                        .as_ref()
4573                        .is_some_and(|address| address.part == 0 && address.measure == 0)
4574            }));
4575        }
4576    }
4577
4578    #[test]
4579    fn offline_manifest_frames_cover_repeat_ramp_fermata_pedal_bend_and_instrument_change() {
4580        let mut score = Score::new("fixture", 120, 4, 4, 0, 2);
4581        let mut first = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4582        first.pedal_start = true;
4583        first.articulations = vec![Articulation::Fermata, Articulation::BreathMark];
4584        first.guitar_bend_curve = vec![GuitarBendPoint {
4585            position_per_mille: 500,
4586            alter_cents: 100,
4587        }];
4588        score.parts[0].staves[0].measures[0].voices[0] = vec![first];
4589        score.parts[0].staves[0].measures[0].barline_left =
4590            super::super::notation::Barline::RepeatStart;
4591        score.parts[0].staves[0].measures[0].tempo_ramp_to = Some(60);
4592        score.parts[0].staves[0].measures[0].navigation = Some("Segno".to_string());
4593        score.parts[0].midi_control_changes.push(MidiControlChange {
4594            tick: 480,
4595            channel: 0,
4596            controller: 11,
4597            value: 96,
4598        });
4599
4600        let mut second = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
4601        second.pedal_end = true;
4602        score.parts[0].staves[0].measures[1].voices[0] = vec![second];
4603        score.parts[0].staves[0].measures[1].barline_right =
4604            super::super::notation::Barline::RepeatEnd;
4605        let mut flute = crate::InstrumentDefinition::new("flute", "Flute");
4606        flute.midi_channel = 2;
4607        flute.midi_program = 73;
4608        score.parts[0].staves[0].measures[1].instrument_change = Some(flute);
4609
4610        let request = OfflineRenderRequest {
4611            sample_rate_hz: 48_000,
4612            playback_options: Some(PlaybackOptions {
4613                fermata_multiplier: 1.5,
4614                fermata_hold_beats: 0.25,
4615                ..Default::default()
4616            }),
4617            ..Default::default()
4618        };
4619        let manifest = build_offline_render_manifest(&score, &PlaybackOptions::default(), &request)
4620            .expect("fixture manifest");
4621        assert_eq!(manifest.frame_events.len(), 4);
4622        let measures: Vec<usize> = manifest
4623            .frame_events
4624            .iter()
4625            .map(|event| event.event.source.as_ref().unwrap().measure)
4626            .collect();
4627        assert_eq!(measures, vec![0, 1, 0, 1]);
4628        assert!(manifest.frame_events[0].event.pedal);
4629        assert_eq!(manifest.frame_events[0].event.post_note_pause_beats, 0.25);
4630        assert_eq!(manifest.frame_events[0].event.pitch_bend_curve.len(), 1);
4631        assert_eq!(
4632            manifest.frame_events[1].event.instrument_id.as_deref(),
4633            Some("flute")
4634        );
4635        assert_eq!(manifest.frame_events[3].event.program, 73);
4636        assert!(manifest.semantic_events.iter().any(|event| {
4637            matches!(
4638                &event.kind,
4639                OfflineRenderSemanticEventKind::InstrumentChange {
4640                    instrument_id,
4641                    channel: 2,
4642                    program: 73,
4643                    transpose_semitones: 0,
4644                } if instrument_id == "flute"
4645            ) && event
4646                .measure
4647                .as_ref()
4648                .is_some_and(|address| address.measure == 1)
4649                && event.frame == manifest.frame_events[1].start_frame
4650        }));
4651        assert!(manifest.semantic_events.iter().any(|event| matches!(
4652            event.kind,
4653            OfflineRenderSemanticEventKind::Pedal { down: true }
4654        ) && event.frame
4655            == manifest.frame_events[0].start_frame));
4656        assert!(manifest.semantic_events.iter().any(|event| matches!(
4657            event.kind,
4658            OfflineRenderSemanticEventKind::Pedal { down: false }
4659        ) && event.frame
4660            == manifest.frame_events[1].start_frame));
4661        assert!(manifest.semantic_events.iter().any(|event| matches!(
4662            event.kind,
4663            OfflineRenderSemanticEventKind::Articulation {
4664                articulation: Articulation::Fermata
4665            }
4666        )));
4667        assert!(manifest.semantic_events.iter().any(|event| matches!(
4668            event.kind,
4669            OfflineRenderSemanticEventKind::GuitarBend { .. }
4670        )));
4671        let tempo_events = manifest
4672            .semantic_events
4673            .iter()
4674            .filter(|event| matches!(event.kind, OfflineRenderSemanticEventKind::Tempo { .. }))
4675            .count();
4676        let navigation_events = manifest
4677            .semantic_events
4678            .iter()
4679            .filter(|event| {
4680                matches!(
4681                    event.kind,
4682                    OfflineRenderSemanticEventKind::Navigation { .. }
4683                )
4684            })
4685            .count();
4686        let control_frames: Vec<u64> = manifest
4687            .semantic_events
4688            .iter()
4689            .filter_map(|event| {
4690                matches!(
4691                    event.kind,
4692                    OfflineRenderSemanticEventKind::Controller {
4693                        channel: 0,
4694                        controller: 11,
4695                        value: 96
4696                    }
4697                )
4698                .then_some(event.frame)
4699            })
4700            .collect();
4701        assert_eq!(tempo_events, 2);
4702        assert_eq!(navigation_events, 2);
4703        assert_eq!(control_frames.len(), 2);
4704        let ramp_duration = tempo_ramp_seconds(120.0, Some(60.0), 4.0, 4.0);
4705        assert_eq!(
4706            control_frames[0],
4707            (tempo_ramp_seconds(120.0, Some(60.0), 4.0, 1.0) * 48_000.0).round() as u64
4708        );
4709        assert_eq!(control_frames[1], 373_084);
4710
4711        let expected_starts = [0.0, ramp_duration, ramp_duration + 4.0, ramp_duration + 8.0];
4712        for (frame_event, expected_secs) in manifest.frame_events.iter().zip(expected_starts) {
4713            assert_eq!(
4714                frame_event.start_frame,
4715                (expected_secs * 48_000.0).round() as u64
4716            );
4717        }
4718    }
4719
4720    #[test]
4721    fn offline_render_manifest_reports_unresolved_selection_address() {
4722        let score = Score::new("T", 120, 4, 4, 0, 1);
4723        let request = OfflineRenderRequest {
4724            scope: OfflineRenderScope::Selection {
4725                addresses: vec![NoteAddr {
4726                    part: 0,
4727                    staff: 0,
4728                    measure: 0,
4729                    voice: 0,
4730                    note: 99,
4731                }],
4732            },
4733            ..Default::default()
4734        };
4735        let manifest = build_offline_render_manifest(&score, &PlaybackOptions::default(), &request)
4736            .expect("empty selection is representable");
4737        assert_eq!(manifest.events.len(), 0);
4738        assert_eq!(
4739            manifest.diagnostics,
4740            vec![
4741                OfflineRenderDiagnostic {
4742                    kind: OfflineRenderDiagnosticKind::UnresolvedSelectionAddress,
4743                    source: Some(NoteAddr {
4744                        part: 0,
4745                        staff: 0,
4746                        measure: 0,
4747                        voice: 0,
4748                        note: 99,
4749                    }),
4750                },
4751                OfflineRenderDiagnostic {
4752                    kind: OfflineRenderDiagnosticKind::EmptySelection,
4753                    source: None,
4754                },
4755            ]
4756        );
4757    }
4758
4759    #[test]
4760    fn offline_render_manifest_v1_json_migrates_with_v2_defaults() {
4761        let legacy = serde_json::json!({
4762            "contract_version": 1,
4763            "playback_contract_version": 2,
4764            "format": "Wav",
4765            "sample_rate_hz": 48_000,
4766            "channels": 2,
4767            "duration_frames": 0,
4768            "events": []
4769        });
4770        let manifest: OfflineRenderManifest =
4771            serde_json::from_value(legacy).expect("v1 manifest remains readable");
4772        assert_eq!(manifest.contract_version, 1);
4773        assert_eq!(manifest.scope, OfflineRenderScope::FullScore);
4774        assert_eq!(manifest.release_tail_frames, 0);
4775        assert_eq!(manifest.playback_options, PlaybackOptions::default());
4776        assert!(manifest.frame_events.is_empty());
4777        assert!(manifest.diagnostics.is_empty());
4778        assert!(manifest.validate().is_ok());
4779    }
4780
4781    #[test]
4782    fn offline_render_manifest_validation_rejects_tampered_frame_and_tail_boundaries() {
4783        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4784        score.parts[0].staves[0].measures[0].voices[0] =
4785            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
4786        let manifest = build_offline_render_manifest(
4787            &score,
4788            &PlaybackOptions::default(),
4789            &OfflineRenderRequest {
4790                sample_rate_hz: 48_000,
4791                release_tail_millis: 100,
4792                ..Default::default()
4793            },
4794        )
4795        .expect("manifest builds");
4796        assert!(manifest.validate().is_ok());
4797
4798        let mut invalid_tail = manifest.clone();
4799        invalid_tail.duration_frames = invalid_tail.duration_frames.saturating_sub(1);
4800        assert!(matches!(
4801            invalid_tail.validate(),
4802            Err(crate::Error::InvalidOfflineRenderRequest)
4803        ));
4804
4805        let mut invalid_frame = manifest;
4806        invalid_frame.frame_events[0].end_frame =
4807            invalid_frame.frame_events[0].end_frame.saturating_add(1);
4808        assert!(matches!(
4809            invalid_frame.validate(),
4810            Err(crate::Error::InvalidOfflineRenderRequest)
4811        ));
4812    }
4813
4814    #[test]
4815    fn routing_manifest_prefers_instrument_bus_and_isolates_metronome() {
4816        let mut score = Score::new("routing", 120, 4, 4, 0, 1);
4817        let mut instrument = crate::InstrumentDefinition::new("flute", "Flute");
4818        instrument.midi_channel = 2;
4819        instrument.midi_program = 73;
4820        score.parts[0].instrument = Some(instrument);
4821        score.parts[0].staves[0].measures[0].voices[0] =
4822            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
4823        let options = PlaybackOptions {
4824            metronome: Some(MetronomeConfig::default()),
4825            ..Default::default()
4826        };
4827        let mut config = PlaybackRoutingConfig {
4828            provider_identity: Some("sf2-host@1".into()),
4829            ..Default::default()
4830        };
4831        config.part_buses.insert(0, "parts".into());
4832        config
4833            .instrument_buses
4834            .insert("flute".into(), "winds".into());
4835        config.aux_sends.push(PlaybackAuxSend {
4836            source_bus: "winds".into(),
4837            destination_bus: "reverb".into(),
4838            gain_db: -12.0,
4839        });
4840        config.effect_routes.push(PlaybackEffectRoute {
4841            bus_id: "reverb".into(),
4842            effect_id: "convolution".into(),
4843            enabled: true,
4844        });
4845
4846        let manifest = build_playback_routing_manifest(&score, &options, &config)
4847            .expect("valid routing manifest");
4848        assert_eq!(manifest.contract_version, PLAYBACK_ROUTING_CONTRACT_VERSION);
4849        assert_eq!(manifest.provider_identity.as_deref(), Some("sf2-host@1"));
4850        assert!(manifest.routes.iter().any(|route| {
4851            route.instrument_id.as_deref() == Some("flute") && route.bus_id == "winds"
4852        }));
4853        assert!(
4854            manifest
4855                .routes
4856                .iter()
4857                .any(|route| route.is_metronome && route.bus_id == "metronome")
4858        );
4859        assert_eq!(manifest.aux_sends.len(), 1);
4860        assert_eq!(manifest.effect_routes.len(), 1);
4861    }
4862
4863    #[test]
4864    fn routing_manifest_rejects_unsafe_bus_and_non_finite_send_gain() {
4865        let score = Score::new("routing", 120, 4, 4, 0, 1);
4866        let config = PlaybackRoutingConfig {
4867            master_bus: "not a bus".into(),
4868            ..Default::default()
4869        };
4870        assert!(matches!(
4871            build_playback_routing_manifest(&score, &PlaybackOptions::default(), &config),
4872            Err(crate::Error::InvalidPlaybackRouting)
4873        ));
4874
4875        let mut config = PlaybackRoutingConfig::default();
4876        config.aux_sends.push(PlaybackAuxSend {
4877            source_bus: "part".into(),
4878            destination_bus: "master".into(),
4879            gain_db: f64::NAN,
4880        });
4881        assert!(matches!(
4882            build_playback_routing_manifest(&score, &PlaybackOptions::default(), &config),
4883            Err(crate::Error::InvalidPlaybackRouting)
4884        ));
4885    }
4886
4887    #[test]
4888    fn timing_corpus_reports_score_schedule_results_without_audio_claims() {
4889        let mut score = Score::new("corpus", 120, 4, 4, 0, 1);
4890        score.parts[0].staves[0].measures[0].voices[0] =
4891            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
4892        let expected_events = to_playback_events(&score, &PlaybackOptions::default());
4893        let report = evaluate_playback_timing_corpus(&[PlaybackTimingCase {
4894            id: "quarter-note".into(),
4895            score,
4896            options: PlaybackOptions::default(),
4897            expected_events,
4898            tolerance: PlaybackTimingTolerance::default(),
4899        }])
4900        .expect("valid timing corpus");
4901        assert_eq!(
4902            report.contract_version,
4903            PLAYBACK_TIMING_CORPUS_CONTRACT_VERSION
4904        );
4905        assert_eq!(report.total_cases, 1);
4906        assert_eq!(report.passed_cases, 1);
4907        assert!(report.cases[0].report.within_tolerance);
4908    }
4909
4910    #[test]
4911    fn timing_corpus_rejects_empty_or_duplicate_case_ids() {
4912        assert!(matches!(
4913            evaluate_playback_timing_corpus(&[]),
4914            Err(crate::Error::InvalidPlaybackTimingCorpus)
4915        ));
4916        let score = Score::new("corpus", 120, 4, 4, 0, 1);
4917        let case = PlaybackTimingCase {
4918            id: "duplicate".into(),
4919            expected_events: to_playback_events(&score, &PlaybackOptions::default()),
4920            score,
4921            options: PlaybackOptions::default(),
4922            tolerance: PlaybackTimingTolerance::default(),
4923        };
4924        assert!(matches!(
4925            evaluate_playback_timing_corpus(&[case.clone(), case]),
4926            Err(crate::Error::InvalidPlaybackTimingCorpus)
4927        ));
4928    }
4929
4930    fn comparison_event(time_secs: f64, duration_secs: f64) -> PlaybackEvent {
4931        PlaybackEvent {
4932            address: Some("0:0:0:0:0".into()),
4933            source: Some(NoteAddr {
4934                part: 0,
4935                staff: 0,
4936                measure: 0,
4937                voice: 0,
4938                note: 0,
4939            }),
4940            source_voice_number: None,
4941            time_beats: 0.0,
4942            time_secs,
4943            pitch_midi: 60,
4944            pitch_midi_cents: 6000,
4945            pitch_bend_curve: Vec::new(),
4946            post_note_pause_beats: 0.0,
4947            articulations: Vec::new(),
4948            chord_symbol: None,
4949            guitar_technique: None,
4950            velocity: 64,
4951            duration_beats: 1.0,
4952            duration_secs,
4953            pedal: false,
4954            part_index: 0,
4955            channel: 0,
4956            program: 0,
4957            instrument_id: None,
4958            is_metronome: false,
4959        }
4960    }
4961
4962    #[test]
4963    fn legacy_playback_event_json_defaults_bend_curve() {
4964        let event: PlaybackEvent = serde_json::from_str(
4965            r#"{"address":null,"source":null,"source_voice_number":null,"time_beats":0.0,"time_secs":0.0,"pitch_midi":60,"pitch_midi_cents":6000,"velocity":64,"duration_beats":1.0,"duration_secs":0.5,"pedal":false,"part_index":0,"channel":0,"is_metronome":false}"#,
4966        )
4967        .expect("legacy playback event remains readable");
4968        assert!(event.pitch_bend_curve.is_empty());
4969        assert_eq!(event.guitar_technique, None);
4970    }
4971
4972    #[test]
4973    fn tempo_ramp_uses_integrated_event_timing_and_carries_target_forward() {
4974        let mut score = Score::new("ramp", 120, 4, 4, 0, 2);
4975        for measure in &mut score.parts[0].staves[0].measures {
4976            measure.voices[0] = vec![
4977                crate::Note::new(crate::Pitch::new(crate::Step::C, 4), crate::Duration::Half),
4978                crate::Note::new(crate::Pitch::new(crate::Step::D, 4), crate::Duration::Half),
4979            ];
4980        }
4981        score.parts[0].staves[0].measures[0].tempo_ramp_to = Some(60);
4982        let events = to_playback_events(&score, &PlaybackOptions::default());
4983        assert_eq!(events.len(), 4);
4984        let expected_measure_secs = 4.0 * 60.0 / (60.0 - 120.0) * (60.0f64 / 120.0).ln();
4985        assert!((events[2].time_secs - expected_measure_secs).abs() < 1e-9);
4986        assert!((events[0].duration_secs - 1.1507282898).abs() < 1e-6);
4987        assert!((events[2].duration_secs - 2.0).abs() < 1e-9);
4988    }
4989
4990    #[test]
4991    fn breath_and_caesura_expose_deterministic_post_note_pause() {
4992        let mut score = Score::new("pause", 120, 4, 4, 0, 1);
4993        let mut breath = crate::Note::new(
4994            crate::Pitch::new(crate::Step::C, 4),
4995            crate::Duration::Quarter,
4996        );
4997        breath.articulations.push(Articulation::BreathMark);
4998        let mut caesura = crate::Note::new(
4999            crate::Pitch::new(crate::Step::D, 4),
5000            crate::Duration::Quarter,
5001        );
5002        caesura
5003            .articulations
5004            .extend([Articulation::BreathMark, Articulation::Caesura]);
5005        score.parts[0].staves[0].measures[0].voices[0] = vec![breath, caesura];
5006
5007        let events = to_playback_events(&score, &PlaybackOptions::default());
5008        assert_eq!(events.len(), 2);
5009        assert_eq!(events[0].post_note_pause_beats, 0.25);
5010        assert_eq!(events[1].post_note_pause_beats, 0.5);
5011        assert_eq!(
5012            events[1].articulations,
5013            vec![Articulation::BreathMark, Articulation::Caesura]
5014        );
5015    }
5016
5017    #[test]
5018    fn measure_instrument_change_resolves_on_playback_events() {
5019        let mut score = Score::new("instrument", 120, 4, 4, 0, 2);
5020        let mut base = crate::InstrumentDefinition::new("piano", "Piano");
5021        base.midi_channel = 1;
5022        base.midi_program = 0;
5023        score.parts[0].instrument = Some(base);
5024        let mut change = crate::InstrumentDefinition::new("flute", "Flute");
5025        change.midi_channel = 2;
5026        change.midi_program = 73;
5027        change.transpose_semitones = -2;
5028        score.parts[0].staves[0].measures[1].instrument_change = Some(change);
5029        for measure in &mut score.parts[0].staves[0].measures {
5030            measure.voices[0] = vec![crate::Note::new(
5031                crate::Pitch::new(crate::Step::C, 4),
5032                crate::Duration::Whole,
5033            )];
5034        }
5035
5036        let events = to_playback_events(&score, &PlaybackOptions::default());
5037        assert_eq!(events.len(), 2);
5038        assert_eq!(
5039            (
5040                events[0].channel,
5041                events[0].program,
5042                events[0].instrument_id.as_deref()
5043            ),
5044            (1, 0, Some("piano"))
5045        );
5046        assert_eq!(
5047            (
5048                events[1].channel,
5049                events[1].program,
5050                events[1].instrument_id.as_deref()
5051            ),
5052            (2, 73, Some("flute"))
5053        );
5054        assert_eq!(events[0].pitch_midi, 60);
5055        assert_eq!(events[1].pitch_midi, 58);
5056    }
5057
5058    #[test]
5059    fn authored_midi_program_change_selects_the_next_note_at_its_source_tick() {
5060        let mut score = Score::new("program", 120, 4, 4, 0, 1);
5061        score.parts[0].staves[0].measures[0].voices[0] = vec![
5062            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
5063            Note::new(Pitch::new(Step::E, 4), Duration::Quarter),
5064        ];
5065        score.parts[0].midi_program_changes = vec![
5066            MidiProgramChange {
5067                tick: 480,
5068                channel: 1,
5069                program: 73,
5070            },
5071            MidiProgramChange {
5072                tick: 480,
5073                channel: 0,
5074                program: 40,
5075            },
5076        ];
5077        let events = to_playback_events(&score, &PlaybackOptions::default());
5078        assert_eq!(events.len(), 2);
5079        assert_eq!((events[0].program, events[1].program), (0, 40));
5080        assert_eq!((events[0].time_secs, events[1].time_secs), (0.0, 0.5));
5081    }
5082
5083    #[test]
5084    fn generic_playback_event_preserves_authored_guitar_technique() {
5085        let mut score = Score::new("technique", 120, 4, 4, 0, 1);
5086        let mut note = Note::new(Pitch::new(Step::E, 4), Duration::Quarter);
5087        note.guitar_technique = Some(GuitarTechnique::HammerOn);
5088        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
5089
5090        let event = to_playback_events(&score, &PlaybackOptions::default())
5091            .into_iter()
5092            .next()
5093            .expect("one note event");
5094        assert_eq!(event.guitar_technique, Some(GuitarTechnique::HammerOn));
5095    }
5096
5097    #[test]
5098    fn playback_timing_comparison_reports_tolerance_and_identity() {
5099        let expected = [comparison_event(1.0, 0.5)];
5100        let actual = [comparison_event(1.004, 0.503)];
5101        let report = compare_playback_timing(
5102            &expected,
5103            &actual,
5104            &PlaybackTimingTolerance {
5105                start_secs: 0.005,
5106                duration_secs: 0.005,
5107            },
5108        )
5109        .expect("comparison");
5110        assert!(report.within_tolerance);
5111        assert_eq!(report.matched_events, 1);
5112        assert_eq!(
5113            report.contract_version,
5114            PLAYBACK_COMPARISON_CONTRACT_VERSION
5115        );
5116
5117        let mut typed_only = comparison_event(1.004, 0.503);
5118        typed_only.address = None;
5119        let report = compare_playback_timing(
5120            &expected,
5121            &[typed_only],
5122            &PlaybackTimingTolerance::default(),
5123        )
5124        .expect("typed source comparison");
5125        assert_eq!(report.matched_events, 1);
5126
5127        let actual = [comparison_event(1.02, 0.6)];
5128        let report =
5129            compare_playback_timing(&expected, &actual, &PlaybackTimingTolerance::default())
5130                .expect("comparison");
5131        assert!(!report.within_tolerance);
5132        assert!(
5133            report
5134                .mismatches
5135                .iter()
5136                .any(|m| matches!(m, PlaybackTimingMismatch::StartTime { index: 0, .. }))
5137        );
5138        assert!(
5139            report
5140                .mismatches
5141                .iter()
5142                .any(|m| matches!(m, PlaybackTimingMismatch::Duration { index: 0, .. }))
5143        );
5144    }
5145
5146    #[test]
5147    fn playback_timing_comparison_rejects_instrument_route_mismatch() {
5148        let expected = comparison_event(0.0, 0.5);
5149        let mut actual = expected.clone();
5150        actual.program = 73;
5151        assert!(
5152            compare_playback_timing(&[expected], &[actual], &PlaybackTimingTolerance::default(),)
5153                .expect("comparison")
5154                .mismatches
5155                .iter()
5156                .any(|mismatch| matches!(
5157                    mismatch,
5158                    PlaybackTimingMismatch::EventIdentity { index: 0 }
5159                ))
5160        );
5161    }
5162
5163    #[test]
5164    fn playback_timing_comparison_rejects_invalid_tolerance() {
5165        let error = compare_playback_timing(
5166            &[],
5167            &[],
5168            &PlaybackTimingTolerance {
5169                start_secs: -0.1,
5170                duration_secs: 0.0,
5171            },
5172        );
5173        assert!(matches!(
5174            error,
5175            Err(crate::Error::InvalidPlaybackComparison)
5176        ));
5177    }
5178
5179    #[test]
5180    fn tablature_performance_projection_keeps_position_and_reports_pitch_error() {
5181        let mut score = Score::new("Tab", 120, 4, 4, 0, 1);
5182        score.parts[0].staves[0].tablature = Some(super::super::notation::TablatureConfig {
5183            lines: 6,
5184            tuning_midi: vec![40, 45, 50, 55, 59, 64],
5185            capo: 0,
5186        });
5187        let notes = &mut score.parts[0].staves[0].measures[0].voices[0];
5188        *notes = vec![crate::Note::new(
5189            crate::Pitch::new(crate::Step::C, 4),
5190            crate::Duration::Quarter,
5191        )];
5192        notes[0].tab_position = Some(super::super::notation::TabPosition {
5193            string: 1,
5194            fret: 20,
5195        });
5196        notes[0].guitar_technique = Some(super::super::notation::GuitarTechnique::Bend);
5197        notes[0].guitar_bend_alter_cents = Some(150);
5198        notes[0].guitar_bend_curve = vec![
5199            super::super::score::GuitarBendPoint {
5200                position_per_mille: 0,
5201                alter_cents: 0,
5202            },
5203            super::super::score::GuitarBendPoint {
5204                position_per_mille: 500,
5205                alter_cents: 150,
5206            },
5207            super::super::score::GuitarBendPoint {
5208                position_per_mille: 1_000,
5209                alter_cents: 0,
5210            },
5211        ];
5212        let report = project_tablature_performance(&score, &PlaybackOptions::default())
5213            .expect("tablature projection");
5214        assert_eq!(report.events.len(), 1);
5215        assert_eq!(report.events[0].string, 1);
5216        assert_eq!(report.events[0].fret, 20);
5217        assert_eq!(
5218            report.events[0].technique,
5219            Some(super::super::notation::GuitarTechnique::Bend)
5220        );
5221        assert_eq!(report.events[0].bend_alter_cents, Some(150));
5222        assert_eq!(report.events[0].bend_curve.len(), 3);
5223        assert_eq!(report.events[0].bend_curve[1].position_per_mille, 500);
5224        assert_eq!(report.events[0].bend_curve[1].alter_cents, 150);
5225        assert_eq!(
5226            report.events[0].playback.pitch_bend_curve,
5227            report.events[0].bend_curve
5228        );
5229        assert!(report.diagnostics.is_empty());
5230
5231        score.parts[0].staves[0].measures[0].voices[0][0].tab_position =
5232            Some(super::super::notation::TabPosition {
5233                string: 1,
5234                fret: 19,
5235            });
5236        let report = project_tablature_performance(&score, &PlaybackOptions::default())
5237            .expect("tablature projection");
5238        assert_eq!(report.events[0].pitch_error_cents, 100);
5239        assert!(matches!(
5240            report.diagnostics[0],
5241            TablaturePerformanceDiagnostic::PitchMismatch {
5242                error_cents: 100,
5243                ..
5244            }
5245        ));
5246    }
5247
5248    #[test]
5249    fn tablature_performance_projection_does_not_invent_missing_positions() {
5250        let mut score = Score::new("Tab", 120, 4, 4, 0, 1);
5251        score.parts[0].staves[0].tablature = Some(super::super::notation::TablatureConfig {
5252            lines: 6,
5253            tuning_midi: vec![40, 45, 50, 55, 59, 64],
5254            capo: 0,
5255        });
5256        score.parts[0].staves[0].measures[0].voices[0].push(crate::Note::new(
5257            crate::Pitch::new(crate::Step::C, 4),
5258            crate::Duration::Quarter,
5259        ));
5260        let report = project_tablature_performance(&score, &PlaybackOptions::default())
5261            .expect("tablature projection");
5262        assert!(report.events.is_empty());
5263        assert!(matches!(
5264            report.diagnostics[0],
5265            TablaturePerformanceDiagnostic::MissingPosition { .. }
5266        ));
5267    }
5268
5269    #[test]
5270    fn tablature_performance_event_accepts_legacy_json_without_technique() {
5271        let mut score = Score::new("Tab", 120, 4, 4, 0, 1);
5272        score.parts[0].staves[0].tablature = Some(super::super::notation::TablatureConfig {
5273            lines: 6,
5274            tuning_midi: vec![40, 45, 50, 55, 59, 64],
5275            capo: 0,
5276        });
5277        let mut note = crate::Note::new(
5278            crate::Pitch::new(crate::Step::C, 4),
5279            crate::Duration::Quarter,
5280        );
5281        note.tab_position = Some(super::super::notation::TabPosition {
5282            string: 1,
5283            fret: 20,
5284        });
5285        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
5286        let report = project_tablature_performance(&score, &PlaybackOptions::default())
5287            .expect("tablature projection");
5288        let mut legacy = serde_json::to_value(&report.events[0]).expect("event JSON");
5289        legacy
5290            .as_object_mut()
5291            .expect("event object")
5292            .remove("technique");
5293        let restored: TablaturePerformanceEvent =
5294            serde_json::from_value(legacy).expect("legacy event JSON");
5295        assert_eq!(restored.technique, None);
5296    }
5297
5298    #[test]
5299    fn tablature_round_trip_report_preserves_authored_positions() {
5300        let mut score = Score::new("Tab", 120, 4, 4, 0, 1);
5301        score.parts[0].staves[0].tablature = Some(super::super::notation::TablatureConfig {
5302            lines: 6,
5303            tuning_midi: vec![40, 45, 50, 55, 59, 64],
5304            capo: 2,
5305        });
5306        let mut note = crate::Note::new(
5307            crate::Pitch::new(crate::Step::C, 4),
5308            crate::Duration::Quarter,
5309        );
5310        note.tab_positions = vec![crate::TabPosition { string: 5, fret: 1 }];
5311        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
5312        let report = tablature_round_trip_report(&score).expect("tab round-trip report");
5313        assert!(report.equivalent);
5314        assert_eq!(report.checked_notes, 1);
5315        assert_eq!(report.positioned_notes, 1);
5316        assert!(report.diagnostics.is_empty());
5317    }
5318
5319    #[test]
5320    fn tablature_performance_uses_measure_local_tuning_change() {
5321        let mut score = Score::new("Local tuning", 120, 4, 4, 0, 2);
5322        let staff = &mut score.parts[0].staves[0];
5323        staff.tablature = Some(super::super::notation::TablatureConfig {
5324            lines: 6,
5325            tuning_midi: vec![40, 45, 50, 55, 59, 64],
5326            capo: 0,
5327        });
5328        staff.measures[1].tablature_change = Some(super::super::notation::TablatureConfig {
5329            lines: 6,
5330            tuning_midi: vec![38, 45, 50, 55, 59, 64],
5331            capo: 0,
5332        });
5333        let mut note = Note::new(Pitch::new(Step::C, 3), Duration::Quarter);
5334        note.tab_position = Some(TabPosition {
5335            string: 1,
5336            fret: 10,
5337        });
5338        staff.measures[1].voices[0] = vec![note];
5339
5340        let report = project_tablature_performance(&score, &PlaybackOptions::default())
5341            .expect("tablature projection");
5342        assert_eq!(report.events.len(), 1);
5343        assert_eq!(report.events[0].expected_pitch_midi_cents, 4_800);
5344        assert!(!report.diagnostics.iter().any(|diagnostic| matches!(
5345            diagnostic,
5346            TablaturePerformanceDiagnostic::PitchMismatch { .. }
5347        )));
5348    }
5349}