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