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        notated_end_beats: f64,
3138        staccato: bool,
3139    }
3140
3141    let mut merged = Vec::with_capacity(events.len());
3142    let mut pending: HashMap<(usize, i32), Vec<PendingTie>> = HashMap::new();
3143    let mut clock = None;
3144    for event in events {
3145        let Some(source) = event.source.as_ref() else {
3146            merged.push(event);
3147            continue;
3148        };
3149        let tied_staff = score
3150            .parts
3151            .get(source.part)
3152            .and_then(|part| part.staves.get(source.staff));
3153        let tied_note = tied_staff
3154            .and_then(|staff| staff.measures.get(source.measure))
3155            .and_then(|measure| measure.voices.get(source.voice))
3156            .and_then(|voice| voice.get(source.note));
3157        let (Some(staff), Some(note)) = (tied_staff, tied_note) else {
3158            merged.push(event);
3159            continue;
3160        };
3161        // A chord may tie only some of its pitches: use the flags of the pitch that sounds here.
3162        let transpose_cents = i32::from(staff.transpose_semitones) * 100;
3163        let pitch_index = note
3164            .pitches
3165            .iter()
3166            .position(|pitch| pitch.to_midi_cents() + transpose_cents == event.pitch_midi_cents);
3167        let (tie_start, tie_end) = match pitch_index {
3168            Some(index) => (note.pitch_tie_start(index), note.pitch_tie_end(index)),
3169            None => (note.tie_start, note.tie_end),
3170        };
3171        let mut articulation_scale = 1.0;
3172        let mut staccato = false;
3173        for articulation in &note.articulations {
3174            match articulation {
3175                Articulation::Staccato | Articulation::Staccatissimo => {
3176                    articulation_scale *= 0.5;
3177                    staccato = true;
3178                }
3179                Articulation::Fermata
3180                    if options.fermata_multiplier.is_finite()
3181                        && options.fermata_multiplier < 1.0 =>
3182                {
3183                    articulation_scale *= options.fermata_multiplier.max(0.0)
3184                }
3185                _ => {}
3186            }
3187        }
3188        let notated_duration = if articulation_scale.is_finite()
3189            && articulation_scale > 0.0
3190            && event.duration_beats.is_finite()
3191        {
3192            event.duration_beats / articulation_scale
3193        } else {
3194            note.beats()
3195        };
3196        let source_staff = source.staff;
3197        let source_voice = source.voice;
3198        let key = (source.part, event.pitch_midi_cents);
3199        if let Some(candidates) = pending.get_mut(&key) {
3200            candidates.retain(|candidate| candidate.notated_end_beats >= event.time_beats - 1e-9);
3201            if tie_end {
3202                let matching = candidates
3203                    .iter()
3204                    .enumerate()
3205                    .filter(|(_, candidate)| {
3206                        (candidate.notated_end_beats - event.time_beats).abs() < 1e-9
3207                    })
3208                    .max_by_key(|(_, candidate)| {
3209                        (
3210                            candidate.staff == source.staff,
3211                            candidate.voice == source.voice,
3212                        )
3213                    })
3214                    .map(|(position, _)| position);
3215                if let Some(position) = matching {
3216                    let mut candidate = candidates.remove(position);
3217                    let previous = &mut merged[candidate.index];
3218                    for articulation in event.articulations.iter().filter(|articulation| {
3219                        matches!(
3220                            articulation,
3221                            Articulation::Staccato | Articulation::Staccatissimo
3222                        )
3223                    }) {
3224                        if !previous.articulations.contains(articulation) {
3225                            previous.articulations.push(articulation.clone());
3226                        }
3227                    }
3228                    candidate.notated_end_beats = event.time_beats + notated_duration;
3229                    candidate.staccato |= staccato;
3230                    previous.duration_beats += event.duration_beats;
3231                    previous.duration_secs += event.duration_secs;
3232                    if candidate.staccato {
3233                        previous.duration_beats =
3234                            (candidate.notated_end_beats - previous.time_beats) * 0.5;
3235                        let timeline =
3236                            clock.get_or_insert_with(|| playback_measure_clock(score, options));
3237                        if let Some(end_secs) = playback_secs_at_beat(
3238                            timeline,
3239                            previous.time_beats + previous.duration_beats,
3240                        ) {
3241                            previous.duration_secs = end_secs - previous.time_secs;
3242                        }
3243                    }
3244                    if tie_start {
3245                        candidate.staff = source.staff;
3246                        candidate.voice = source.voice;
3247                        candidates.push(candidate);
3248                    }
3249                    continue;
3250                }
3251            }
3252        }
3253
3254        let index = merged.len();
3255        merged.push(event);
3256        if tie_start {
3257            pending.entry(key).or_default().push(PendingTie {
3258                index,
3259                staff: source_staff,
3260                voice: source_voice,
3261                notated_end_beats: merged[index].time_beats + notated_duration,
3262                staccato,
3263            });
3264        }
3265    }
3266    merged
3267}
3268
3269#[derive(Clone, Copy)]
3270struct PlaybackClockMeasure {
3271    start_beats: f64,
3272    start_secs: f64,
3273    bpm: f64,
3274    ramp_to_bpm: Option<f64>,
3275    beats: f64,
3276}
3277
3278fn playback_measure_clock(score: &Score, options: &PlaybackOptions) -> Vec<PlaybackClockMeasure> {
3279    let marks = score_tempo_marks(score);
3280    let mut bpm = initial_tempo_bpm(score, options);
3281    let mut start_beats = 0.0;
3282    let mut start_secs = 0.0;
3283    let mut clock = Vec::new();
3284    for index in measure_sequence(score).into_iter().filter(|index| {
3285        options
3286            .loop_region
3287            .is_none_or(|(start, end)| *index >= start && *index <= end)
3288    }) {
3289        let mark = marks.get(index).copied().unwrap_or_default();
3290        if let Some(next) = mark.bpm.and_then(|tempo| written_tempo_bpm(options, tempo)) {
3291            bpm = next;
3292        }
3293        let beats = score.playback_measure_beats(index);
3294        let ramp_to_bpm = written_tempo_ramp_bpm(options, mark.ramp_to_bpm);
3295        clock.push(PlaybackClockMeasure {
3296            start_beats,
3297            start_secs,
3298            bpm,
3299            ramp_to_bpm,
3300            beats,
3301        });
3302        start_beats += beats;
3303        start_secs += tempo_ramp_seconds(bpm, ramp_to_bpm, beats, beats);
3304        if let Some(next) = ramp_to_bpm {
3305            bpm = next;
3306        }
3307    }
3308    clock
3309}
3310
3311/// A pause inserted into the shared score clock at a written note/rest end.
3312#[derive(Clone, Copy, Debug)]
3313struct FermataHold {
3314    at_beat: f64,
3315    at_secs: f64,
3316    extra_beats: f64,
3317    extra_secs: f64,
3318    cumulative_beats: f64,
3319    cumulative_secs: f64,
3320}
3321
3322fn score_fermata_holds(score: &Score, options: &PlaybackOptions) -> Vec<FermataHold> {
3323    let multiplier = options.fermata_multiplier;
3324    if !multiplier.is_finite()
3325        || multiplier <= 1.0
3326        || !score
3327            .parts
3328            .iter()
3329            .flat_map(|part| &part.staves)
3330            .flat_map(|staff| &staff.measures)
3331            .flat_map(|measure| &measure.voices)
3332            .flatten()
3333            .any(|note| note.articulations.contains(&Articulation::Fermata))
3334    {
3335        return Vec::new();
3336    }
3337    let sequence = measure_sequence(score).into_iter().filter(|index| {
3338        options
3339            .loop_region
3340            .is_none_or(|(start, end)| *index >= start && *index <= end)
3341    });
3342    let clock = playback_measure_clock(score, options);
3343    let swing = options
3344        .swing
3345        .filter(|ratio| ratio.is_finite() && *ratio > 0.5 && *ratio < 1.0);
3346    let mut holds = Vec::new();
3347    for (index, measure_clock) in sequence.zip(clock.iter()) {
3348        for staff in score.parts.iter().flat_map(|part| &part.staves) {
3349            let Some(measure) = staff.measures.get(index) else {
3350                continue;
3351            };
3352            let grid_offset = if index == 0 {
3353                let meter = staff
3354                    .meter_at(index, &score.settings.time_signature)
3355                    .total_beats();
3356                (meter - measure_clock.beats).max(0.0)
3357            } else {
3358                0.0
3359            };
3360            for voice in &measure.voices {
3361                let (mut local, mut written) = (0.0, 0.0);
3362                let mut swing_second = false;
3363                for (note_index, note) in voice.iter().enumerate() {
3364                    if note.is_grace || note.is_cue {
3365                        continue;
3366                    }
3367                    let duration = if let Some(ratio) = swing {
3368                        if swing_second {
3369                            swing_second = false;
3370                            (1.0 - ratio) * note.beats() * 2.0
3371                        } else if starts_swing_pair(
3372                            voice,
3373                            note_index,
3374                            written,
3375                            grid_offset,
3376                            measure_clock.beats,
3377                            &options.swing_unit,
3378                        ) {
3379                            swing_second = true;
3380                            ratio * note.beats() * 2.0
3381                        } else {
3382                            note.beats()
3383                        }
3384                    } else {
3385                        note.beats()
3386                    };
3387                    written += note.beats();
3388                    local += duration;
3389                    if note.articulations.contains(&Articulation::Fermata) && duration > 0.0 {
3390                        let at_beat = measure_clock.start_beats + local;
3391                        let Some(at_secs) = playback_secs_at_beat(&clock, at_beat) else {
3392                            continue;
3393                        };
3394                        let bpm = measure_clock.ramp_to_bpm.map_or(measure_clock.bpm, |end| {
3395                            measure_clock.bpm
3396                                + (end - measure_clock.bpm)
3397                                    * (local / measure_clock.beats.max(f64::EPSILON))
3398                                        .clamp(0.0, 1.0)
3399                        });
3400                        let extra_beats = duration * (multiplier - 1.0);
3401                        holds.push(FermataHold {
3402                            at_beat,
3403                            at_secs,
3404                            extra_beats,
3405                            extra_secs: extra_beats * 60.0 / bpm,
3406                            cumulative_beats: 0.0,
3407                            cumulative_secs: 0.0,
3408                        });
3409                    }
3410                }
3411            }
3412        }
3413    }
3414    holds.sort_by(|a, b| a.at_beat.total_cmp(&b.at_beat));
3415    let mut unique: Vec<FermataHold> = Vec::new();
3416    for hold in holds {
3417        if let Some(last) = unique.last_mut()
3418            && (last.at_beat - hold.at_beat).abs() < 1e-9
3419        {
3420            if hold.extra_beats > last.extra_beats {
3421                *last = hold;
3422            }
3423        } else {
3424            unique.push(hold);
3425        }
3426    }
3427    let (mut beats, mut seconds) = (0.0, 0.0);
3428    for hold in &mut unique {
3429        beats += hold.extra_beats;
3430        seconds += hold.extra_secs;
3431        hold.cumulative_beats = beats;
3432        hold.cumulative_secs = seconds;
3433    }
3434    unique
3435}
3436
3437pub(super) fn fermata_total_secs(score: &Score, region: Option<(usize, usize)>) -> f64 {
3438    let options = PlaybackOptions {
3439        loop_region: region,
3440        ..Default::default()
3441    };
3442    score_fermata_holds(score, &options)
3443        .last()
3444        .map_or(0.0, |hold| hold.cumulative_secs)
3445}
3446
3447/// Score-clock pauses as (written beat, added beats), including repeats and loop filtering.
3448/// Use these when projecting the same score to a separate beat-based transport such as MIDI.
3449pub fn playback_fermata_pauses(score: &Score, options: &PlaybackOptions) -> Vec<(f64, f64)> {
3450    score_fermata_holds(score, options)
3451        .into_iter()
3452        .map(|hold| (hold.at_beat, hold.extra_beats))
3453        .collect()
3454}
3455
3456fn apply_fermata_holds(events: &mut [PlaybackEvent], holds: &[FermataHold]) {
3457    let before = |index: usize| index.checked_sub(1).and_then(|i| holds.get(i));
3458    for event in events {
3459        let start = event.time_beats;
3460        let end = start + event.duration_beats;
3461        let first = holds.partition_point(|hold| hold.at_beat <= start + 1e-9);
3462        let last = holds.partition_point(|hold| hold.at_beat <= end + 1e-9);
3463        let start_beats = before(first).map_or(0.0, |hold| hold.cumulative_beats);
3464        let start_secs = before(first).map_or(0.0, |hold| hold.cumulative_secs);
3465        let end_beats = before(last).map_or(0.0, |hold| hold.cumulative_beats);
3466        let end_secs = before(last).map_or(0.0, |hold| hold.cumulative_secs);
3467        event.time_beats += start_beats;
3468        event.time_secs += start_secs;
3469        event.duration_beats += end_beats - start_beats;
3470        event.duration_secs += end_secs - start_secs;
3471    }
3472}
3473
3474fn fermata_warp_secs(seconds: f64, holds: &[FermataHold]) -> f64 {
3475    let count = holds.partition_point(|hold| hold.at_secs <= seconds + 1e-9);
3476    seconds
3477        + count
3478            .checked_sub(1)
3479            .map_or(0.0, |index| holds[index].cumulative_secs)
3480}
3481
3482fn fermata_unwarp_secs(seconds: f64, holds: &[FermataHold]) -> f64 {
3483    let count = holds
3484        .partition_point(|hold| hold.at_secs + hold.cumulative_secs - hold.extra_secs <= seconds);
3485    if count == 0 {
3486        return seconds;
3487    }
3488    let hold = holds[count - 1];
3489    let start = hold.at_secs + hold.cumulative_secs - hold.extra_secs;
3490    if seconds < start + hold.extra_secs {
3491        hold.at_secs
3492    } else {
3493        seconds - hold.cumulative_secs
3494    }
3495}
3496
3497fn playback_secs_at_beat(clock: &[PlaybackClockMeasure], beat: f64) -> Option<f64> {
3498    let index = clock.partition_point(|measure| measure.start_beats <= beat);
3499    let measure = clock.get(index.saturating_sub(1))?;
3500    let offset = beat - measure.start_beats;
3501    if offset > measure.beats + 1e-9 {
3502        return None;
3503    }
3504    Some(
3505        measure.start_secs
3506            + tempo_ramp_seconds(
3507                measure.bpm,
3508                measure.ramp_to_bpm,
3509                measure.beats,
3510                offset.clamp(0.0, measure.beats),
3511            ),
3512    )
3513}
3514
3515/// Convert a score into playback events while enforcing the host-comparison event bound.
3516pub fn to_playback_events_bounded(
3517    score: &Score,
3518    options: &PlaybackOptions,
3519) -> Result<Vec<PlaybackEvent>, crate::Error> {
3520    let events = to_playback_events(score, options);
3521    if events.len() > MAX_PLAYBACK_COMPARISON_EVENTS {
3522        return Err(crate::Error::PlaybackComparisonTooLarge(events.len()));
3523    }
3524    Ok(events)
3525}
3526
3527/// One opt-in shared-unison attack and all notated notes represented by it.
3528#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
3529pub struct SharedUnisonPlaybackEvent {
3530    pub event: PlaybackEvent,
3531    pub sources: Vec<NoteAddr>,
3532}
3533
3534/// Coalesce otherwise identical simultaneous events from different voices or staves of the
3535/// same part. This is an explicit host policy: the ordinary playback schedule stays unchanged.
3536/// Events with different expression, routing, duration or pedal data remain separate.
3537/// At most 256 candidate groups are compared per simultaneous pitch; additional distinct
3538/// groups remain separate to bound work on untrusted scores. Each group also retains
3539/// at most 256 sources; excess events remain separate.
3540pub fn coalesce_shared_unisons(events: Vec<PlaybackEvent>) -> Vec<SharedUnisonPlaybackEvent> {
3541    use std::collections::HashMap;
3542    let mut groups: Vec<SharedUnisonPlaybackEvent> = Vec::with_capacity(events.len());
3543    let mut buckets: HashMap<(usize, u64, i32, u8, u8), Vec<usize>> = HashMap::new();
3544    for event in events {
3545        let Some(source) = event.source.clone().filter(|_| !event.is_metronome) else {
3546            groups.push(SharedUnisonPlaybackEvent {
3547                event,
3548                sources: Vec::new(),
3549            });
3550            continue;
3551        };
3552        let key = (
3553            event.part_index,
3554            event.time_beats.to_bits(),
3555            event.pitch_midi_cents,
3556            event.channel,
3557            event.program,
3558        );
3559        let mut normalized = event.clone();
3560        normalized.address = None;
3561        normalized.source = None;
3562        normalized.source_voice_number = None;
3563        let candidates = buckets.entry(key).or_default();
3564        // Bound work for hostile scores with many simultaneous events at one pitch.
3565        let matching = candidates.iter().take(256).copied().find(|&index| {
3566            let group = &groups[index];
3567            if group.sources.len() >= 256
3568                || group
3569                    .sources
3570                    .iter()
3571                    .any(|other| other.staff == source.staff && other.voice == source.voice)
3572            {
3573                return false;
3574            }
3575            let mut other = group.event.clone();
3576            other.address = None;
3577            other.source = None;
3578            other.source_voice_number = None;
3579            normalized == other
3580        });
3581        if let Some(index) = matching {
3582            groups[index].sources.push(source);
3583        } else {
3584            if candidates.len() < 256 {
3585                candidates.push(groups.len());
3586            }
3587            groups.push(SharedUnisonPlaybackEvent {
3588                event,
3589                sources: vec![source],
3590            });
3591        }
3592    }
3593    groups
3594}
3595
3596// ── PlaybackPosition + compute_playback_position ──────────────────────────────
3597
3598/// Score position at a specific elapsed time.
3599#[derive(Debug, Clone, Serialize, Deserialize)]
3600pub struct PlaybackPosition {
3601    /// Physical measure index (0-based), same coordinate space as [`PlaybackEvent`] fields.
3602    pub measure_index: usize,
3603    /// Beat offset within the measure (`0.0 … time_sig.total_beats()`).
3604    pub beat: f64,
3605}
3606
3607struct MeasureSegment {
3608    measure_idx: usize,
3609    start_secs: f64,
3610    duration_secs: f64,
3611    beats: f64,
3612    bpm: f64,
3613    ramp_to_bpm: Option<f64>,
3614}
3615
3616fn build_measure_segments(score: &Score, options: &PlaybackOptions) -> Vec<MeasureSegment> {
3617    let init_bpm = initial_tempo_bpm(score, options);
3618    let tempo_marks = score_tempo_marks(score);
3619    let full_seq = measure_sequence(score);
3620    let seq: Vec<usize> = if let Some((lo, hi)) = options.loop_region {
3621        full_seq
3622            .into_iter()
3623            .filter(|&i| i >= lo && i <= hi)
3624            .collect()
3625    } else {
3626        full_seq
3627    };
3628
3629    let mut segments = Vec::with_capacity(seq.len());
3630    let mut cursor_secs = 0.0f64;
3631    let mut current_bpm = init_bpm;
3632
3633    for idx in seq {
3634        let mark = tempo_marks.get(idx).copied().unwrap_or_default();
3635        if let Some(t) = mark.bpm.and_then(|tempo| written_tempo_bpm(options, tempo)) {
3636            current_bpm = t;
3637        }
3638        let beats = score.playback_measure_beats(idx);
3639        let ramp_to_bpm = written_tempo_ramp_bpm(options, mark.ramp_to_bpm);
3640        let duration_secs = tempo_ramp_seconds(current_bpm, ramp_to_bpm, beats, beats);
3641
3642        segments.push(MeasureSegment {
3643            measure_idx: idx,
3644            start_secs: cursor_secs,
3645            duration_secs,
3646            beats,
3647            bpm: current_bpm,
3648            ramp_to_bpm,
3649        });
3650        cursor_secs += duration_secs;
3651        if let Some(end_bpm) = ramp_to_bpm {
3652            current_bpm = end_bpm;
3653        }
3654    }
3655    segments
3656}
3657
3658/// Map `elapsed_secs` to a position within the score.
3659///
3660/// Returns `None` if `elapsed_secs` is negative or past the end of the last measure.
3661/// Pass the same [`PlaybackOptions`] used for [`to_playback_events`] so that `loop_region`
3662/// and tempo overrides are applied consistently.
3663pub fn compute_playback_position(
3664    score: &Score,
3665    options: &PlaybackOptions,
3666    elapsed_secs: f64,
3667) -> Option<PlaybackPosition> {
3668    if elapsed_secs < 0.0 {
3669        return None;
3670    }
3671    let holds = score_fermata_holds(score, options);
3672    let elapsed_secs = fermata_unwarp_secs(elapsed_secs, &holds);
3673    let segments = build_measure_segments(score, options);
3674    for seg in &segments {
3675        if elapsed_secs < seg.start_secs + seg.duration_secs + 1e-9 {
3676            let beat = tempo_ramp_beats_at_seconds(
3677                seg.bpm,
3678                seg.ramp_to_bpm,
3679                seg.beats,
3680                elapsed_secs - seg.start_secs,
3681            )
3682            .clamp(0.0, seg.beats);
3683            return Some(PlaybackPosition {
3684                measure_index: seg.measure_idx,
3685                beat,
3686            });
3687        }
3688    }
3689    None
3690}
3691
3692#[cfg(test)]
3693mod tests {
3694    use super::*;
3695    use crate::model::{
3696        duration::Duration,
3697        pitch::{Pitch, Step},
3698        score::{MidiAftertouch, MidiControlChange, MidiPitchBend, MidiProgramChange, Note, Score},
3699    };
3700
3701    fn opts(bpm: Option<u16>) -> PlaybackOptions {
3702        PlaybackOptions {
3703            bpm_override: bpm,
3704            ..Default::default()
3705        }
3706    }
3707
3708    #[test]
3709    fn shared_unison_policy_never_merges_different_routing_or_same_voice() {
3710        let mut score = Score::new("unison", 120, 4, 4, 0, 1);
3711        let note = Note::new(Pitch::new(Step::A, 3), Duration::Whole);
3712        score.parts[0].staves[0].measures[0].voices[0] = vec![note.clone()];
3713        score.parts[0].staves[0].measures[0].voices[1] = vec![note];
3714        let events = to_playback_events(&score, &PlaybackOptions::default());
3715        assert_eq!(coalesce_shared_unisons(events.clone()).len(), 1);
3716        let mut different_program = events.clone();
3717        different_program[1].program += 1;
3718        assert_eq!(coalesce_shared_unisons(different_program).len(), 2);
3719        let mut different_expression = events.clone();
3720        different_expression[1].velocity += 1;
3721        assert_eq!(coalesce_shared_unisons(different_expression).len(), 2);
3722        let mut same_voice = events;
3723        let voice = same_voice[0].source.as_ref().unwrap().voice;
3724        same_voice[1].source.as_mut().unwrap().voice = voice;
3725        assert_eq!(coalesce_shared_unisons(same_voice).len(), 2);
3726    }
3727
3728    #[test]
3729    fn rest_pedal_controls_follow_swing_and_muted_parts() {
3730        let mut score = Score::new("rest pedal", 120, 2, 4, 0, 1);
3731        let first = Note::rest(Duration::Eighth);
3732        let mut second = Note::rest(Duration::Eighth);
3733        second.pedal_start = true;
3734        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3735        note.pedal_end = true;
3736        score.parts[0].staves[0].measures[0].voices[0] = vec![first, second, note];
3737        let mut options = PlaybackOptions {
3738            swing: Some(0.67),
3739            ..Default::default()
3740        };
3741        let manifest =
3742            build_offline_render_manifest(&score, &options, &OfflineRenderRequest::default())
3743                .unwrap();
3744        let controls: Vec<_> = manifest
3745            .semantic_events
3746            .iter()
3747            .filter_map(|event| match event.kind {
3748                OfflineRenderSemanticEventKind::Pedal { down } => Some((event.frame, down)),
3749                _ => None,
3750            })
3751            .collect();
3752        assert_eq!(controls, vec![(16080, true), (24000, false)]);
3753        options.muted_parts.push(0);
3754        let muted =
3755            build_offline_render_manifest(&score, &options, &OfflineRenderRequest::default())
3756                .unwrap();
3757        assert!(
3758            !muted
3759                .semantic_events
3760                .iter()
3761                .any(|event| matches!(event.kind, OfflineRenderSemanticEventKind::Pedal { .. }))
3762        );
3763    }
3764
3765    #[test]
3766    fn playback_options_accepts_an_empty_json_object_as_defaults() {
3767        let parsed: PlaybackOptions = serde_json::from_str("{}").expect("default playback options");
3768        assert_eq!(parsed, PlaybackOptions::default());
3769    }
3770
3771    #[test]
3772    fn bounded_playback_events_match_unbounded_schedule_within_limit() {
3773        let score = Score::new("bounded", 120, 4, 4, 0, 1);
3774        let options = opts(Some(120));
3775        let unbounded = to_playback_events(&score, &options);
3776        let bounded = to_playback_events_bounded(&score, &options).expect("bounded schedule");
3777        assert_eq!(bounded, unbounded);
3778    }
3779
3780    #[test]
3781    fn bars_after_a_meter_change_keep_the_new_meter() {
3782        use crate::model::duration::Duration;
3783        use crate::{Note, Pitch, Step, TimeSignature};
3784        let mut score = Score::new("meter", 120, 4, 4, 0, 3);
3785        let staff = &mut score.parts[0].staves[0];
3786        staff.measures[1].time_sig = Some(TimeSignature {
3787            numerator: 3,
3788            denominator: 4,
3789        });
3790        for measure in &mut staff.measures {
3791            measure.voices[0] = vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
3792        }
3793        let onsets: Vec<f64> = to_playback_events(&score, &opts(Some(120)))
3794            .iter()
3795            .filter(|event| event.address.is_some())
3796            .map(|event| event.time_beats)
3797            .collect();
3798        // 4/4, then two bars of 3/4: the third bar starts at 4 + 3, not 4 + 4.
3799        assert_eq!(onsets, vec![0.0, 4.0, 7.0]);
3800    }
3801
3802    #[test]
3803    fn tempo_written_on_either_part_governs_the_whole_score() {
3804        let mut score = Score::new("shared tempo", 120, 4, 4, 0, 2);
3805        let mut second = score.parts[0].clone();
3806        second.id = "second".into();
3807        score.parts.push(second);
3808        for part in &mut score.parts {
3809            for measure in &mut part.staves[0].measures {
3810                measure.voices[0] = vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
3811            }
3812        }
3813        score.parts[0].staves[0].measures[0].tempo = Some(92);
3814        score.parts[1].staves[0].measures[1].tempo = Some(60);
3815        let options = PlaybackOptions {
3816            metronome: Some(MetronomeConfig::default()),
3817            ..Default::default()
3818        };
3819        let events = to_playback_events(&score, &options);
3820        for part_index in 0..2 {
3821            let first = events
3822                .iter()
3823                .find(|event| {
3824                    event.part_index == part_index
3825                        && event
3826                            .source
3827                            .as_ref()
3828                            .is_some_and(|source| source.measure == 0)
3829                })
3830                .unwrap();
3831            let second = events
3832                .iter()
3833                .find(|event| {
3834                    event.part_index == part_index
3835                        && event
3836                            .source
3837                            .as_ref()
3838                            .is_some_and(|source| source.measure == 1)
3839                })
3840                .unwrap();
3841            assert!((first.duration_secs - 60.0 / 92.0).abs() < 1e-9);
3842            assert!((second.time_secs - 4.0 * 60.0 / 92.0).abs() < 1e-9);
3843            assert!((second.duration_secs - 1.0).abs() < 1e-9);
3844        }
3845        let clicks: Vec<_> = events.iter().filter(|event| event.is_metronome).collect();
3846        assert!((clicks[1].time_secs - 60.0 / 92.0).abs() < 1e-9);
3847        assert!((clicks[4].time_secs - 4.0 * 60.0 / 92.0).abs() < 1e-9);
3848        let position = compute_playback_position(&score, &options, 1.5 * 60.0 / 92.0).unwrap();
3849        assert_eq!(position.measure_index, 0);
3850        assert!((position.beat - 1.5).abs() < 1e-9);
3851        let timeline_1 = offline_measure_times(&score, &options, 1, 0, &score_tempo_marks(&score));
3852        assert!((timeline_1[0].bpm - 92.0).abs() < 1e-9);
3853        assert!((timeline_1[1].bpm - 60.0).abs() < 1e-9);
3854    }
3855
3856    #[test]
3857    fn empty_score_no_events() {
3858        let score = Score::new("T", 120, 4, 4, 0, 1);
3859        assert!(to_playback_events(&score, &opts(None)).is_empty());
3860    }
3861
3862    #[test]
3863    fn single_note_at_beat_zero() {
3864        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3865        score.parts[0].staves[0].measures[0].voices[0] =
3866            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
3867        let events = to_playback_events(&score, &opts(None));
3868        assert_eq!(events.len(), 1);
3869        assert_eq!(events[0].address.as_deref(), Some("0:0:0:0:0"));
3870        assert_eq!(
3871            events[0].source,
3872            Some(NoteAddr {
3873                part: 0,
3874                staff: 0,
3875                measure: 0,
3876                voice: 0,
3877                note: 0,
3878            })
3879        );
3880        assert!((events[0].time_beats).abs() < 1e-9);
3881        assert_eq!(events[0].pitch_midi, 60);
3882        assert_eq!(events[0].velocity, 64);
3883        assert!((events[0].duration_beats - 1.0).abs() < 1e-9);
3884        assert_eq!(events[0].part_index, 0);
3885    }
3886
3887    #[test]
3888    fn tied_notes_are_one_continuous_playback_event() {
3889        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3890        let mut first = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3891        first.tie_start = true;
3892        let mut second = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3893        second.tie_end = true;
3894        score.parts[0].staves[0].measures[0].voices[0] = vec![first, second];
3895
3896        let events = to_playback_events(&score, &opts(None));
3897        assert_eq!(events.len(), 1);
3898        assert_eq!(
3899            events[0].source.as_ref().map(|address| address.note),
3900            Some(0)
3901        );
3902        assert!((events[0].time_beats).abs() < 1e-9);
3903        assert!((events[0].duration_beats - 2.0).abs() < 1e-9);
3904        assert!((events[0].duration_secs - 1.0).abs() < 1e-9);
3905    }
3906
3907    #[test]
3908    fn staccato_tied_across_barline_has_one_half_length_attack() {
3909        for marks in [[true, false], [false, true], [true, true]] {
3910            let mut score = Score::new("tied staccato", 120, 4, 4, 0, 2);
3911            let mut first = Note::new(Pitch::new(Step::D, 5), Duration::Eighth);
3912            first.tie_start = true;
3913            let mut second = Note::new(Pitch::new(Step::D, 5), Duration::Eighth);
3914            second.tie_end = true;
3915            if marks[0] {
3916                first.articulations.push(Articulation::Staccato);
3917            }
3918            if marks[1] {
3919                second.articulations.push(Articulation::Staccato);
3920            }
3921            score.parts[0].staves[0].measures[0].voices[0] = vec![
3922                Note::rest(Duration::Half),
3923                Note::rest(Duration::Quarter),
3924                Note::rest(Duration::Eighth),
3925                first,
3926            ];
3927            score.parts[0].staves[0].measures[1].voices[0] = vec![second];
3928            let events = to_playback_events(&score, &opts(None));
3929            assert_eq!(events.len(), 1, "articulations: {marks:?}");
3930            assert_eq!(events[0].pitch_midi, 74);
3931            assert_eq!(events[0].time_beats, 3.5);
3932            assert!(events[0].articulations.contains(&Articulation::Staccato));
3933            assert!((events[0].duration_beats - 0.5).abs() < 1e-9);
3934            assert!((events[0].duration_secs - 0.25).abs() < 1e-9);
3935        }
3936    }
3937
3938    #[test]
3939    fn tied_staccato_uses_tempo_at_sounding_end() {
3940        let mut score = Score::new("tied tempo", 120, 4, 4, 0, 2);
3941        let mut first = Note::new(Pitch::new(Step::D, 5), Duration::Eighth);
3942        first.tie_start = true;
3943        first.articulations.push(Articulation::Staccato);
3944        let mut second = Note::new(Pitch::new(Step::D, 5), Duration::Quarter);
3945        second.dot_count = 1;
3946        second.tie_end = true;
3947        score.parts[0].staves[0].measures[0].voices[0] = vec![
3948            Note::rest(Duration::Half),
3949            Note::rest(Duration::Quarter),
3950            Note::rest(Duration::Eighth),
3951            first,
3952        ];
3953        score.parts[0].staves[0].measures[1].tempo = Some(60);
3954        score.parts[0].staves[0].measures[1].voices[0] = vec![second];
3955        let events = to_playback_events(&score, &opts(None));
3956        assert_eq!(events.len(), 1);
3957        assert!((events[0].duration_beats - 1.0).abs() < 1e-9);
3958        assert!((events[0].duration_secs - 0.75).abs() < 1e-9);
3959    }
3960
3961    #[test]
3962    fn a_tie_on_one_chord_pitch_holds_only_that_pitch() {
3963        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3964        // C–G chord tied on C only into another C–G chord: G re-strikes, C sustains.
3965        let chord = || {
3966            let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3967            note.pitches.push(Pitch::new(Step::G, 4));
3968            note
3969        };
3970        let mut first = chord();
3971        first.set_pitch_ties(&[true, false], &[false, false]);
3972        let mut second = chord();
3973        second.set_pitch_ties(&[false, false], &[true, false]);
3974        assert!(first.tie_start && second.tie_end);
3975        score.parts[0].staves[0].measures[0].voices[0] = vec![first, second];
3976
3977        let events = to_playback_events(&score, &opts(None));
3978        let durations = |midi: u8| {
3979            events
3980                .iter()
3981                .filter(|event| event.pitch_midi == midi)
3982                .map(|event| event.duration_beats)
3983                .collect::<Vec<_>>()
3984        };
3985        assert_eq!(durations(60), vec![2.0]);
3986        assert_eq!(durations(67), vec![1.0, 1.0]);
3987    }
3988
3989    #[test]
3990    fn ties_cross_measure_boundaries_and_tempo_changes_without_retriggering() {
3991        let mut score = Score::new("T", 120, 4, 4, 0, 2);
3992        let first_measure = &mut score.parts[0].staves[0].measures[0];
3993        let mut first = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
3994        first.tie_start = true;
3995        first_measure.voices[0] = vec![first];
3996
3997        let second_measure = &mut score.parts[0].staves[0].measures[1];
3998        second_measure.tempo = Some(60);
3999        let mut second = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
4000        second.tie_end = true;
4001        second_measure.voices[0] = vec![second];
4002
4003        let events = to_playback_events(&score, &opts(None));
4004        assert_eq!(events.len(), 1);
4005        assert_eq!(
4006            events[0].source.as_ref().map(|address| address.measure),
4007            Some(0)
4008        );
4009        assert!((events[0].time_beats).abs() < 1e-9);
4010        assert!((events[0].duration_beats - 8.0).abs() < 1e-9);
4011        assert!((events[0].duration_secs - 6.0).abs() < 1e-9);
4012    }
4013
4014    #[test]
4015    fn ties_continue_across_voice_and_staff_changes() {
4016        let whole_c = || Note::new(Pitch::new(Step::C, 4), Duration::Whole);
4017
4018        let mut voice_score = Score::new("voice change", 120, 4, 4, 0, 2);
4019        let mut start = whole_c();
4020        start.tie_start = true;
4021        let mut end = whole_c();
4022        end.tie_end = true;
4023        voice_score.parts[0].staves[0].measures[0].voices[0] = vec![start.clone()];
4024        voice_score.parts[0].staves[0].measures[1].voices[1] = vec![end.clone()];
4025        let events = to_playback_events(&voice_score, &opts(None));
4026        assert_eq!(events.len(), 1);
4027        assert_eq!(events[0].time_beats, 0.0);
4028        assert_eq!(events[0].duration_beats, 8.0);
4029
4030        let mut staff_score = Score::new("staff change", 120, 4, 4, 0, 2);
4031        let second_staff = staff_score.parts[0].staves[0].clone();
4032        staff_score.parts[0].staves.push(second_staff);
4033        staff_score.parts[0].staves[0].measures[0].voices[0] = vec![start];
4034        staff_score.parts[0].staves[1].measures[1].voices[0] = vec![end];
4035        let events = to_playback_events(&staff_score, &opts(None));
4036        assert_eq!(events.len(), 1);
4037        assert_eq!(events[0].time_beats, 0.0);
4038        assert_eq!(events[0].duration_beats, 8.0);
4039    }
4040
4041    #[test]
4042    fn cross_voice_tie_keeps_independent_unison_attacks() {
4043        let mut score = Score::new("unison", 120, 4, 4, 0, 2);
4044        let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
4045        start.tie_start = true;
4046        let mut end = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
4047        end.tie_end = true;
4048        score.parts[0].staves[0].measures[0].voices[0] = vec![start];
4049        score.parts[0].staves[0].measures[0].voices[1] =
4050            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
4051        score.parts[0].staves[0].measures[1].voices[1] = vec![end];
4052        score.parts[0].staves[0].measures[1].voices[2] =
4053            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
4054
4055        let mut durations: Vec<_> = to_playback_events(&score, &opts(None))
4056            .into_iter()
4057            .map(|event| event.duration_beats)
4058            .collect();
4059        durations.sort_by(f64::total_cmp);
4060        assert_eq!(durations, vec![4.0, 4.0, 8.0]);
4061    }
4062
4063    #[test]
4064    fn malformed_tie_end_does_not_drop_playback_event() {
4065        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4066        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4067        note.tie_end = true;
4068        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
4069        let events = to_playback_events(&score, &opts(None));
4070        assert_eq!(events.len(), 1);
4071        assert!((events[0].duration_beats - 1.0).abs() < 1e-9);
4072    }
4073
4074    #[test]
4075    fn microtonal_playback_event_keeps_exact_midi_cents() {
4076        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4077        score.parts[0].staves[0].measures[0].voices[0] = vec![Note::new(
4078            Pitch::with_microtone(Step::C, 4, 0, 50),
4079            Duration::Quarter,
4080        )];
4081        let events = to_playback_events(&score, &opts(None));
4082        assert_eq!(events[0].pitch_midi, 61);
4083        assert_eq!(events[0].pitch_midi_cents, 6050);
4084    }
4085
4086    #[test]
4087    fn chord_expands_to_multiple_events() {
4088        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4089        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4090        note.pitches.push(Pitch::new(Step::E, 4));
4091        note.pitches.push(Pitch::new(Step::G, 4));
4092        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
4093        let events = to_playback_events(&score, &opts(None));
4094        assert_eq!(events.len(), 3);
4095        assert!(
4096            events
4097                .iter()
4098                .all(|event| event.address.as_deref() == Some("0:0:0:0:0"))
4099        );
4100        assert!(events.iter().all(|e| e.time_beats.abs() < 1e-9));
4101    }
4102
4103    #[test]
4104    fn playback_events_preserve_authored_chord_symbol_without_inventing_notes() {
4105        let mut score = Score::new("Harmony", 120, 4, 4, 0, 1);
4106        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4107        note.chord_symbol = Some(ChordSymbol {
4108            root: "C".into(),
4109            kind: "major-seventh".into(),
4110            bass: Some("E".into()),
4111            placement: None,
4112            extender: false,
4113            harmonic_degree: None,
4114            harmony_function: None,
4115            harmony_type: None,
4116            chord_ref: None,
4117            range_end: None,
4118            degrees: Vec::new(),
4119        });
4120        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
4121
4122        let events = to_playback_events(&score, &PlaybackOptions::default());
4123        assert_eq!(events.len(), 1);
4124        assert_eq!(events[0].pitch_midi, 60);
4125        assert_eq!(
4126            events[0]
4127                .chord_symbol
4128                .as_ref()
4129                .map(ChordSymbol::display_text),
4130            Some("Cmaj7/E".into())
4131        );
4132    }
4133
4134    #[test]
4135    fn authored_realization_default_keeps_ornament_as_one_semantic_event() {
4136        let mut score = Score::new("authored ornament", 120, 4, 4, 0, 1);
4137        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4138        note.articulations.push(Articulation::Trill);
4139        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
4140
4141        let events = to_playback_events(&score, &PlaybackOptions::default());
4142        assert_eq!(events.len(), 1);
4143        assert_eq!(events[0].pitch_midi, 60);
4144        assert_eq!(events[0].articulations, vec![Articulation::Trill]);
4145    }
4146
4147    #[test]
4148    fn ornament_arpeggio_profile_realizes_trill_with_pinned_timing() {
4149        let mut score = Score::new("realized ornament", 120, 4, 4, 0, 1);
4150        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4151        note.articulations.push(Articulation::Trill);
4152        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
4153
4154        let events = to_playback_events(
4155            &score,
4156            &PlaybackOptions {
4157                realization_profile: PlaybackRealizationProfile::OrnamentArpeggioV1,
4158                ..Default::default()
4159            },
4160        );
4161        assert_eq!(events.len(), 4);
4162        assert_eq!(
4163            events
4164                .iter()
4165                .map(|event| event.pitch_midi)
4166                .collect::<Vec<_>>(),
4167            vec![60, 61, 60, 61]
4168        );
4169        for (index, event) in events.iter().enumerate() {
4170            assert!((event.time_beats - index as f64 * 0.25).abs() < 1e-9);
4171            assert!((event.duration_beats - 0.25).abs() < 1e-9);
4172            assert!((event.time_secs - index as f64 * 0.125).abs() < 1e-9);
4173            assert!((event.duration_secs - 0.125).abs() < 1e-9);
4174            assert!(!event.articulations.contains(&Articulation::Trill));
4175        }
4176    }
4177
4178    #[test]
4179    fn ornament_arpeggio_profile_staggers_chord_in_authored_direction() {
4180        let mut score = Score::new("realized arpeggio", 120, 4, 4, 0, 1);
4181        let mut chord = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4182        chord.pitches.push(Pitch::new(Step::E, 4));
4183        chord.pitches.push(Pitch::new(Step::G, 4));
4184        chord.arpeggiate = Some(false);
4185        score.parts[0].staves[0].measures[0].voices[0] = vec![chord];
4186
4187        let events = to_playback_events(
4188            &score,
4189            &PlaybackOptions {
4190                realization_profile: PlaybackRealizationProfile::OrnamentArpeggioV1,
4191                ..Default::default()
4192            },
4193        );
4194        assert_eq!(events.len(), 3);
4195        let by_pitch: BTreeMap<_, _> = events
4196            .iter()
4197            .map(|event| (event.pitch_midi, event.time_beats))
4198            .collect();
4199        assert!((by_pitch[&67] - 0.0).abs() < 1e-9);
4200        assert!((by_pitch[&64] - 0.09).abs() < 1e-9);
4201        assert!((by_pitch[&60] - 0.18).abs() < 1e-9);
4202    }
4203
4204    #[test]
4205    fn written_grace_steals_time_from_preceding_note() {
4206        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4207        let previous = Note::new(Pitch::new(Step::F, 4), Duration::Half);
4208        let mut grace = Note::new(Pitch::new(Step::B, 4), Duration::Eighth);
4209        grace.is_grace = true;
4210        let regular = Note::new(Pitch::new(Step::A, 4), Duration::Quarter);
4211        score.parts[0].staves[0].measures[0].voices[0] = vec![previous, grace, regular];
4212        let events = to_playback_events(&score, &opts(None));
4213        assert_eq!(events.len(), 3);
4214        assert_eq!(
4215            events
4216                .iter()
4217                .map(|event| event.pitch_midi)
4218                .collect::<Vec<_>>(),
4219            vec![65, 71, 69]
4220        );
4221        assert!((events[0].duration_beats - 1.875).abs() < 1e-9);
4222        assert!((events[1].time_beats - 1.875).abs() < 1e-9);
4223        assert!((events[1].duration_beats - 0.125).abs() < 1e-9);
4224        assert!((events[2].time_beats - 2.0).abs() < 1e-9);
4225        assert!((events[1].time_secs - 0.9375).abs() < 1e-9);
4226    }
4227
4228    #[test]
4229    fn opening_grace_borrows_from_main_note_without_moving_later_notes() {
4230        let mut score = Score::new("opening grace", 120, 4, 4, 0, 1);
4231        let mut grace = Note::new(Pitch::new(Step::D, 4), Duration::Eighth);
4232        grace.is_grace = true;
4233        let main = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4234        let next = Note::new(Pitch::new(Step::E, 4), Duration::Quarter);
4235        score.parts[0].staves[0].measures[0].voices[0] = vec![grace, main, next];
4236        let events = to_playback_events(&score, &opts(None));
4237        assert_eq!(events.len(), 3);
4238        assert_eq!(
4239            events
4240                .iter()
4241                .map(|event| event.pitch_midi)
4242                .collect::<Vec<_>>(),
4243            vec![62, 60, 64]
4244        );
4245        assert!((events[0].time_beats).abs() < 1e-9);
4246        assert!((events[0].duration_beats - 0.125).abs() < 1e-9);
4247        assert!((events[1].time_beats - 0.125).abs() < 1e-9);
4248        assert!((events[1].duration_beats - 0.875).abs() < 1e-9);
4249        assert!((events[2].time_beats - 1.0).abs() < 1e-9);
4250    }
4251
4252    #[test]
4253    fn consecutive_graces_are_ordered_and_cue_notes_remain_silent() {
4254        let mut score = Score::new("two graces", 120, 4, 4, 0, 1);
4255        let mut first_grace = Note::new(Pitch::new(Step::D, 4), Duration::Sixteenth);
4256        first_grace.is_grace = true;
4257        let mut second_grace = Note::new(Pitch::new(Step::E, 4), Duration::Sixteenth);
4258        second_grace.is_grace = true;
4259        let mut cue = Note::new(Pitch::new(Step::B, 4), Duration::Eighth);
4260        cue.is_cue = true;
4261        score.parts[0].staves[0].measures[0].voices[0] = vec![
4262            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
4263            first_grace,
4264            second_grace,
4265            Note::new(Pitch::new(Step::F, 4), Duration::Quarter),
4266            cue,
4267        ];
4268        let events = to_playback_events(&score, &opts(None));
4269        assert_eq!(
4270            events
4271                .iter()
4272                .map(|event| event.pitch_midi)
4273                .collect::<Vec<_>>(),
4274            vec![60, 62, 64, 65]
4275        );
4276        assert_eq!(
4277            events
4278                .iter()
4279                .map(|event| event.time_beats)
4280                .collect::<Vec<_>>(),
4281            vec![0.0, 0.875, 0.9375, 1.0]
4282        );
4283        assert!((events[0].duration_beats - 0.875).abs() < 1e-9);
4284    }
4285
4286    #[test]
4287    fn metronome_events_have_no_source_address() {
4288        let score = Score::new("T", 120, 4, 4, 0, 1);
4289        let options = PlaybackOptions {
4290            metronome: Some(MetronomeConfig::default()),
4291            ..Default::default()
4292        };
4293        let events = to_playback_events(&score, &options);
4294        assert!(!events.is_empty());
4295        assert!(events.iter().all(|event| event.address.is_none()));
4296    }
4297
4298    #[test]
4299    fn second_note_has_correct_time() {
4300        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4301        score.parts[0].staves[0].measures[0].voices[0] = vec![
4302            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
4303            Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
4304        ];
4305        let events = to_playback_events(&score, &opts(None));
4306        assert_eq!(events.len(), 2);
4307        assert!((events[0].time_beats).abs() < 1e-9);
4308        assert!((events[1].time_beats - 1.0).abs() < 1e-9);
4309    }
4310
4311    #[test]
4312    fn sparse_voice_keeps_measure_boundaries() {
4313        let mut score = Score::new("T", 120, 4, 4, 0, 2);
4314        let note = || Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4315        score.parts[0].staves[0].measures[0].voices[1] = vec![note()];
4316        score.parts[0].staves[0].measures[1].voices[1] = vec![note()];
4317
4318        let events = to_playback_events(&score, &opts(None));
4319        assert_eq!(events.len(), 2);
4320        assert!((events[0].time_beats).abs() < 1e-9);
4321        assert!((events[1].time_beats - 4.0).abs() < 1e-9);
4322        assert!((events[1].time_secs - 2.0).abs() < 1e-9);
4323    }
4324
4325    #[test]
4326    fn time_secs_120_bpm_quarter_note_is_half_second() {
4327        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4328        score.parts[0].staves[0].measures[0].voices[0] =
4329            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
4330        let events = to_playback_events(&score, &opts(None));
4331        assert!((events[0].time_secs).abs() < 1e-9);
4332        assert!((events[0].duration_secs - 0.5).abs() < 1e-9);
4333    }
4334
4335    #[test]
4336    fn bpm_override_changes_time_secs() {
4337        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4338        score.parts[0].staves[0].measures[0].voices[0] = vec![
4339            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
4340            Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
4341        ];
4342        let events = to_playback_events(&score, &opts(Some(60)));
4343        assert!((events[0].time_secs).abs() < 1e-9);
4344        assert!((events[1].time_secs - 1.0).abs() < 1e-9);
4345    }
4346
4347    #[test]
4348    fn tempo_multiplier_preserves_written_tempo_changes_and_ramps() {
4349        let mut score = Score::new("T", 120, 4, 4, 0, 2);
4350        score.parts[0].staves[0].measures[0].tempo_ramp_to = Some(60);
4351        score.parts[0].staves[0].measures[1].tempo = Some(90);
4352        score.parts[0].staves[0].measures[0].voices[0] =
4353            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
4354        score.parts[0].staves[0].measures[1].voices[0] =
4355            vec![Note::new(Pitch::new(Step::D, 4), Duration::Quarter)];
4356        let options = PlaybackOptions {
4357            tempo_multiplier: 0.5,
4358            ..Default::default()
4359        };
4360
4361        let events = to_playback_events(&score, &options);
4362        let second_measure_start = tempo_ramp_seconds(60.0, Some(30.0), 4.0, 4.0);
4363        assert!((events[1].time_secs - second_measure_start).abs() < 1e-9);
4364
4365        let one_beat_into_ramp = tempo_ramp_seconds(60.0, Some(30.0), 4.0, 1.0);
4366        let position = compute_playback_position(&score, &options, one_beat_into_ramp).unwrap();
4367        assert_eq!(position.measure_index, 0);
4368        assert!((position.beat - 1.0).abs() < 1e-9);
4369
4370        let second_measure_position =
4371            compute_playback_position(&score, &options, second_measure_start + 60.0 / 45.0)
4372                .unwrap();
4373        assert_eq!(second_measure_position.measure_index, 1);
4374        assert!((second_measure_position.beat - 1.0).abs() < 1e-9);
4375    }
4376
4377    #[test]
4378    fn bpm_override_suppresses_written_tempo_changes_and_ramps() {
4379        let mut score = Score::new("T", 120, 4, 4, 0, 2);
4380        score.parts[0].staves[0].measures[0].tempo_ramp_to = Some(60);
4381        score.parts[0].staves[0].measures[1].tempo = Some(90);
4382        score.parts[0].staves[0].measures[0].voices[0] =
4383            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
4384        score.parts[0].staves[0].measures[1].voices[0] =
4385            vec![Note::new(Pitch::new(Step::D, 4), Duration::Quarter)];
4386        let options = PlaybackOptions {
4387            bpm_override: Some(120),
4388            ..Default::default()
4389        };
4390
4391        let events = to_playback_events(&score, &options);
4392        assert!((events[1].time_secs - 2.0).abs() < 1e-9);
4393        let position = compute_playback_position(&score, &options, 2.5).unwrap();
4394        assert_eq!(position.measure_index, 1);
4395        assert!((position.beat - 1.0).abs() < 1e-9);
4396    }
4397
4398    #[test]
4399    fn playback_position_tracks_repeats_rests_and_meter_changes_with_a_tempo_multiplier() {
4400        use crate::model::notation::{Barline, TimeSignature};
4401
4402        let mut score = Score::new("transport", 120, 4, 4, 0, 3);
4403        let staff = &mut score.parts[0].staves[0];
4404        staff.measures[0].barline_left = Barline::RepeatStart;
4405        staff.measures[1].time_sig = Some(TimeSignature {
4406            numerator: 3,
4407            denominator: 4,
4408        });
4409        staff.measures[2].barline_right = Barline::RepeatEnd;
4410        staff.measures[0].voices[0] = vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
4411        staff.measures[1].voices[0] = vec![Note::rest(Duration::Quarter)];
4412        staff.measures[2].voices[0] = vec![Note::new(Pitch::new(Step::D, 4), Duration::Quarter)];
4413        let options = PlaybackOptions {
4414            tempo_multiplier: 0.5,
4415            ..Default::default()
4416        };
4417
4418        let events = to_playback_events(&score, &options);
4419        let note_starts: Vec<f64> = events
4420            .iter()
4421            .filter(|event| event.address.is_some())
4422            .map(|event| event.time_secs)
4423            .collect();
4424        // 4/4 (4s), then 3/4 rest (3s) and note (3s). The repeat restarts at 10s.
4425        assert_eq!(note_starts, vec![0.0, 7.0, 10.0, 17.0]);
4426
4427        let middle = compute_playback_position(&score, &options, 5.0).unwrap();
4428        assert_eq!(middle.measure_index, 1);
4429        assert!((middle.beat - 1.0).abs() < 1e-9);
4430
4431        let repeated = compute_playback_position(&score, &options, 10.5).unwrap();
4432        assert_eq!(repeated.measure_index, 0);
4433        assert!((repeated.beat - 0.5).abs() < 1e-9);
4434    }
4435
4436    #[test]
4437    fn transpose_semitones_shifts_midi_output() {
4438        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4439        score.parts[0].staves[0].transpose_semitones = -2;
4440        score.parts[0].staves[0].measures[0].voices[0] =
4441            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
4442        let events = to_playback_events(&score, &opts(None));
4443        assert_eq!(events.len(), 1);
4444        assert_eq!(events[0].pitch_midi, 58);
4445    }
4446
4447    #[test]
4448    fn percussion_channel_9_ignores_transpose_semitones() {
4449        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4450        score.parts[0].midi_channel = 9;
4451        score.parts[0].staves[0].transpose_semitones = -2;
4452        score.parts[0].staves[0].measures[0].voices[0] =
4453            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
4454        let events = to_playback_events(&score, &opts(None));
4455        assert_eq!(events.len(), 1);
4456        assert_eq!(events[0].pitch_midi, 60);
4457    }
4458
4459    #[test]
4460    fn staccato_halves_duration_beats() {
4461        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4462        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4463        note.articulations
4464            .push(crate::model::notation::Articulation::Staccato);
4465        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
4466        let events = to_playback_events(&score, &opts(None));
4467        assert_eq!(events.len(), 1);
4468        assert!((events[0].duration_beats - 0.5).abs() < 1e-9);
4469    }
4470
4471    #[test]
4472    fn staccatissimo_also_halves_duration() {
4473        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4474        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4475        note.articulations
4476            .push(crate::model::notation::Articulation::Staccatissimo);
4477        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
4478        let events = to_playback_events(&score, &opts(None));
4479        assert!((events[0].duration_beats - 0.5).abs() < 1e-9);
4480    }
4481
4482    #[test]
4483    fn staccato_does_not_shift_next_note_time() {
4484        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4485        let mut n1 = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4486        n1.articulations
4487            .push(crate::model::notation::Articulation::Staccato);
4488        let n2 = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
4489        score.parts[0].staves[0].measures[0].voices[0] = vec![n1, n2];
4490        let events = to_playback_events(&score, &opts(None));
4491        assert_eq!(events.len(), 2);
4492        assert!((events[1].time_beats - 1.0).abs() < 1e-9);
4493    }
4494
4495    #[test]
4496    fn dynamics_hold_until_the_next_marking_across_voices_and_bars() {
4497        use crate::model::notation::Dynamic;
4498        let mut score = Score::new("T", 120, 4, 4, 0, 2);
4499        let quarters = |dynamics: [Option<Dynamic>; 4]| -> Vec<Note> {
4500            dynamics
4501                .into_iter()
4502                .map(|dynamic| {
4503                    let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4504                    note.dynamic = dynamic;
4505                    note
4506                })
4507                .collect()
4508        };
4509        // Bar 1: p, (p), sf, (p) ; bar 2: fp, (p), pf, (f). Voice 2 in bar 1 holds a half
4510        // note at beat 2, when voice 1's sf sounds, and one at beat 0.
4511        score.parts[0].staves[0].measures[0].voices[0] =
4512            quarters([Some(Dynamic::P), None, Some(Dynamic::Sf), None]);
4513        score.parts[0].staves[0].measures[0].voices[1] = vec![
4514            Note::new(Pitch::new(Step::C, 3), Duration::Half),
4515            Note::new(Pitch::new(Step::C, 3), Duration::Half),
4516        ];
4517        score.parts[0].staves[0].measures[1].voices[0] =
4518            quarters([Some(Dynamic::Fp), None, Some(Dynamic::Pf), None]);
4519        let events = to_playback_events(&score, &opts(None));
4520        let velocities = |pitch: u8| -> Vec<u8> {
4521            events
4522                .iter()
4523                .filter(|event| event.pitch_midi == pitch)
4524                .map(|event| event.velocity)
4525                .collect()
4526        };
4527        let (p, f, sf) = (
4528            Dynamic::P.to_velocity(),
4529            Dynamic::F.to_velocity(),
4530            Dynamic::Sf.to_velocity(),
4531        );
4532        assert_eq!(velocities(60), vec![p, p, sf, p, f, p, p, f]);
4533        assert_eq!(velocities(48), vec![p, sf]);
4534        // Before any marking, notes keep the historical default.
4535        assert_eq!(
4536            DynamicTimeline::default().velocity(0, 0.0, None),
4537            DynamicTimeline::DEFAULT_VELOCITY
4538        );
4539    }
4540
4541    #[test]
4542    fn hairpins_ramp_towards_the_following_marking_or_two_steps() {
4543        use crate::model::notation::{Dynamic, HairpinKind};
4544        let mut score = Score::new("T", 120, 4, 4, 0, 2);
4545        let mut bar = |index: usize, dynamics: [Option<Dynamic>; 4], end: Option<Dynamic>| {
4546            let mut notes: Vec<Note> = dynamics
4547                .into_iter()
4548                .map(|dynamic| {
4549                    let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4550                    note.dynamic = dynamic;
4551                    note
4552                })
4553                .collect();
4554            notes[0].hairpin_start = Some(HairpinKind::Crescendo);
4555            notes[2].hairpin_end = true;
4556            notes[3].dynamic = end;
4557            score.parts[0].staves[0].measures[index].voices[0] = notes;
4558        };
4559        // Bar 1: p < (three beats) f. Bar 2: < with nothing after: two steps above f.
4560        bar(0, [Some(Dynamic::P), None, None, None], Some(Dynamic::F));
4561        bar(1, [None, None, None, None], None);
4562        let events = to_playback_events(&score, &opts(None));
4563        let velocities: Vec<u8> = events.iter().map(|event| event.velocity).collect();
4564        assert_eq!(&velocities[..4], &[48, 60, 72, 84]);
4565        assert_eq!(&velocities[4..], &[84, 92, 100, 108]);
4566    }
4567
4568    #[test]
4569    fn explicit_dynamic_at_hairpin_end_overrides_terminal_level_in_all_voices() {
4570        use crate::{Dynamic, HairpinKind};
4571        for (hairpin, dynamic, attack, sustain) in [
4572            (HairpinKind::Crescendo, Dynamic::P, 48, 48),
4573            (HairpinKind::Decrescendo, Dynamic::F, 84, 84),
4574            (
4575                HairpinKind::Decrescendo,
4576                Dynamic::Sfz,
4577                Dynamic::Sfz.to_velocity(),
4578                40,
4579            ),
4580            (
4581                HairpinKind::Crescendo,
4582                Dynamic::Pf,
4583                Dynamic::Pf.to_velocity(),
4584                84,
4585            ),
4586        ] {
4587            let mut score = Score::new("endpoint", 120, 4, 4, 0, 2);
4588            let mut first = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
4589            first.hairpin_start = Some(hairpin);
4590            first.hairpin_end = true;
4591            score.parts[0].staves[0].measures[0].voices[0] = vec![first];
4592            let mut upper = Note::new(Pitch::new(Step::C, 5), Duration::Half);
4593            upper.dynamic = Some(dynamic);
4594            score.parts[0].staves[0].measures[1].voices[0] =
4595                vec![upper, Note::new(Pitch::new(Step::D, 5), Duration::Half)];
4596            score.parts[0].staves[0].measures[1].voices[1] = vec![
4597                Note::new(Pitch::new(Step::E, 4), Duration::Half),
4598                Note::new(Pitch::new(Step::F, 4), Duration::Half),
4599            ];
4600            let events = to_playback_events(&score, &opts(None));
4601            let at_start: Vec<_> = events
4602                .iter()
4603                .filter(|e| (e.time_beats - 4.0).abs() < 1e-9)
4604                .map(|e| e.velocity)
4605                .collect();
4606            let later: Vec<_> = events
4607                .iter()
4608                .filter(|e| (e.time_beats - 6.0).abs() < 1e-9)
4609                .map(|e| e.velocity)
4610                .collect();
4611            assert_eq!(at_start, vec![attack, attack]);
4612            assert_eq!(later, vec![sustain, sustain]);
4613        }
4614    }
4615
4616    #[test]
4617    fn dynamic_names_round_trip_and_compounds_continue_at_their_second_level() {
4618        use crate::model::notation::Dynamic;
4619        for dynamic in Dynamic::ALL {
4620            assert_eq!(
4621                Dynamic::from_musicxml_str(dynamic.to_musicxml_str()),
4622                Some(dynamic)
4623            );
4624        }
4625        assert_eq!(Dynamic::from_musicxml_str("fffff"), Some(Dynamic::Ffff));
4626        assert_eq!(Dynamic::Sfpp.sustained_level(), Some(Dynamic::Pp));
4627        assert_eq!(Dynamic::Sfz.sustained_level(), None);
4628        assert_eq!(Dynamic::from_musicxml_str("other"), None);
4629    }
4630
4631    #[test]
4632    fn accent_boosts_velocity_clamped() {
4633        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4634        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4635        note.articulations
4636            .push(crate::model::notation::Articulation::Accent);
4637        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
4638        let events = to_playback_events(&score, &opts(None));
4639        assert_eq!(events[0].velocity, 84);
4640    }
4641
4642    #[test]
4643    fn accent_clamped_at_127() {
4644        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4645        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4646        note.dynamic = Some(crate::model::notation::Dynamic::Ffff);
4647        note.articulations
4648            .push(crate::model::notation::Articulation::Accent);
4649        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
4650        let events = to_playback_events(&score, &opts(None));
4651        assert_eq!(events[0].velocity, 127);
4652    }
4653
4654    #[test]
4655    fn tenuto_keeps_full_duration() {
4656        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4657        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4658        note.articulations
4659            .push(crate::model::notation::Articulation::Tenuto);
4660        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
4661        let events = to_playback_events(&score, &opts(None));
4662        assert!((events[0].duration_beats - 1.0).abs() < 1e-9);
4663    }
4664
4665    #[test]
4666    fn pedal_start_sets_pedal_field() {
4667        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4668        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4669        note.pedal_start = true;
4670        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
4671        let events = to_playback_events(&score, &opts(None));
4672        assert!(events[0].pedal);
4673    }
4674
4675    #[test]
4676    fn no_pedal_start_pedal_is_false() {
4677        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4678        score.parts[0].staves[0].measures[0].voices[0] =
4679            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
4680        let events = to_playback_events(&score, &opts(None));
4681        assert!(!events[0].pedal);
4682    }
4683
4684    #[test]
4685    fn set_tempo_at_measure_changes_time_secs() {
4686        let mut score = Score::new("T", 120, 4, 4, 0, 2);
4687        score.parts[0].staves[0].measures[0].voices[0] =
4688            vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
4689        score.parts[0].staves[0].measures[1].tempo = Some(60);
4690        score.parts[0].staves[0].measures[1].voices[0] =
4691            vec![Note::new(Pitch::new(Step::D, 4), Duration::Whole)];
4692        let events = to_playback_events(&score, &opts(None));
4693        assert_eq!(events.len(), 2);
4694        assert!((events[0].time_secs).abs() < 1e-9);
4695        assert!((events[0].duration_secs - 2.0).abs() < 1e-9);
4696        assert!((events[1].time_secs - 2.0).abs() < 1e-9);
4697        assert!((events[1].duration_secs - 4.0).abs() < 1e-9);
4698    }
4699
4700    #[test]
4701    fn muted_part_produces_no_events() {
4702        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4703        score.parts[0].staves[0].measures[0].voices[0] =
4704            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
4705        let options = PlaybackOptions {
4706            muted_parts: vec![0],
4707            ..Default::default()
4708        };
4709        assert!(to_playback_events(&score, &options).is_empty());
4710    }
4711
4712    #[test]
4713    fn part_index_field_set_correctly() {
4714        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4715        score.parts[0].staves[0].measures[0].voices[0] =
4716            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
4717        let events = to_playback_events(&score, &opts(None));
4718        assert_eq!(events[0].part_index, 0);
4719    }
4720
4721    // ── loop_region ───────────────────────────────────────────────────────────
4722
4723    #[test]
4724    fn loop_region_filters_measures() {
4725        // 3 measures; notes in measures 0, 1, 2. Loop on [1,2] → only events from 1,2.
4726        let mut score = Score::new("T", 120, 4, 4, 0, 3);
4727        for mi in 0..3 {
4728            score.parts[0].staves[0].measures[mi].voices[0] =
4729                vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
4730        }
4731        let options = PlaybackOptions {
4732            loop_region: Some((1, 2)),
4733            ..Default::default()
4734        };
4735        let events = to_playback_events(&score, &options);
4736        assert_eq!(events.len(), 2);
4737        // First event in the region should start at beat 0 (region-relative)
4738        assert!((events[0].time_beats).abs() < 1e-9);
4739    }
4740
4741    #[test]
4742    fn loop_region_none_plays_all_measures() {
4743        let mut score = Score::new("T", 120, 4, 4, 0, 3);
4744        for mi in 0..3 {
4745            score.parts[0].staves[0].measures[mi].voices[0] =
4746                vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
4747        }
4748        let events = to_playback_events(&score, &opts(None));
4749        assert_eq!(events.len(), 3);
4750    }
4751
4752    // ── Fermata ───────────────────────────────────────────────────────────────
4753
4754    #[test]
4755    fn fermata_holds_a_shared_clock_across_voices_and_repeat_traversal() {
4756        use crate::model::notation::Barline;
4757        let mut score = Score::new("Shared fermata", 120, 3, 4, 0, 1);
4758        let measure = &mut score.parts[0].staves[0].measures[0];
4759        measure.barline_left = Barline::RepeatStart;
4760        measure.barline_right = Barline::RepeatEnd;
4761        let mut held = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4762        held.articulations.push(Articulation::Fermata);
4763        measure.voices[0] = vec![
4764            held,
4765            Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
4766            Note::new(Pitch::new(Step::E, 4), Duration::Quarter),
4767        ];
4768        measure.voices[1] = vec![
4769            Note::new(Pitch::new(Step::C, 3), Duration::Half),
4770            Note::new(Pitch::new(Step::G, 3), Duration::Quarter),
4771        ];
4772        let events = to_playback_events(&score, &PlaybackOptions::default());
4773        let onsets = |pitch| {
4774            events
4775                .iter()
4776                .filter(|event| event.pitch_midi == pitch)
4777                .map(|event| event.time_beats)
4778                .collect::<Vec<_>>()
4779        };
4780        assert_eq!(onsets(60), vec![0.0, 3.5]);
4781        assert_eq!(onsets(62), vec![1.5, 5.0]);
4782        assert_eq!(onsets(64), vec![2.5, 6.0]);
4783        assert_eq!(onsets(55), vec![2.5, 6.0]);
4784        let bass = events.iter().find(|event| event.pitch_midi == 48).unwrap();
4785        assert_eq!(bass.duration_beats, 2.5);
4786        assert_eq!(bass.duration_secs, 1.25);
4787        assert!((super::super::score::score_duration_secs(&score) - 3.5).abs() < 1e-9);
4788        assert!(
4789            (super::super::score::score_duration_secs_region(&score, (0, 0)) - 3.5).abs() < 1e-9
4790        );
4791    }
4792
4793    #[test]
4794    fn simultaneous_fermatas_hold_once_and_delay_rest_pedal_and_position() {
4795        let mut score = Score::new("Two staves", 120, 3, 4, 0, 1);
4796        let mut held = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4797        held.articulations.push(Articulation::Fermata);
4798        score.parts[0].staves[0].measures[0].voices[0] = vec![
4799            held.clone(),
4800            Note::new(Pitch::new(Step::D, 4), Duration::Half),
4801        ];
4802        let mut lower = score.parts[0].staves[0].clone();
4803        held.pitches[0] = Pitch::new(Step::C, 3);
4804        let mut rest = Note::rest(Duration::Quarter);
4805        rest.pedal_start = true;
4806        lower.measures[0].voices[0] = vec![
4807            held,
4808            rest,
4809            Note::new(Pitch::new(Step::E, 3), Duration::Quarter),
4810        ];
4811        lower.measures[0].voices[1] = vec![
4812            Note::new(Pitch::new(Step::C, 2), Duration::Half),
4813            Note::rest(Duration::Quarter),
4814        ];
4815        score.parts[0].staves.push(lower);
4816        let opts = PlaybackOptions::default();
4817        let events = to_playback_events(&score, &opts);
4818        assert_eq!(
4819            events
4820                .iter()
4821                .find(|event| event.pitch_midi == 62)
4822                .unwrap()
4823                .time_beats,
4824            1.5
4825        );
4826        assert_eq!(
4827            events
4828                .iter()
4829                .find(|event| event.pitch_midi == 36)
4830                .unwrap()
4831                .duration_beats,
4832            2.5
4833        );
4834        let manifest = build_offline_render_manifest(
4835            &score,
4836            &opts,
4837            &OfflineRenderRequest {
4838                sample_rate_hz: 48_000,
4839                ..Default::default()
4840            },
4841        )
4842        .unwrap();
4843        assert!(manifest.semantic_events.iter().any(|event| matches!(
4844            event.kind,
4845            OfflineRenderSemanticEventKind::Pedal { down: true }
4846        ) && event.frame == 36_000));
4847        let position = compute_playback_position(&score, &opts, 0.625).unwrap();
4848        assert!((position.beat - 1.0).abs() < 1e-9);
4849        let position = compute_playback_position(&score, &opts, 1.0).unwrap();
4850        assert!((position.beat - 1.5).abs() < 1e-9);
4851    }
4852
4853    #[test]
4854    fn fermata_multiplier_extends_duration() {
4855        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4856        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4857        note.articulations
4858            .push(crate::model::notation::Articulation::Fermata);
4859        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
4860        let options = PlaybackOptions {
4861            fermata_multiplier: 2.0,
4862            ..Default::default()
4863        };
4864        let events = to_playback_events(&score, &options);
4865        assert_eq!(events.len(), 1);
4866        assert!((events[0].duration_beats - 2.0).abs() < 1e-9);
4867    }
4868
4869    #[test]
4870    fn fermata_default_multiplier_is_1_5() {
4871        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4872        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4873        note.articulations
4874            .push(crate::model::notation::Articulation::Fermata);
4875        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
4876        let events = to_playback_events(&score, &PlaybackOptions::default());
4877        assert_eq!(events.len(), 1);
4878        assert!((events[0].duration_beats - 1.5).abs() < 1e-9);
4879    }
4880
4881    #[test]
4882    fn fermata_hold_is_an_explicit_post_note_request_separate_from_extension() {
4883        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4884        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4885        note.articulations
4886            .push(crate::model::notation::Articulation::Fermata);
4887        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
4888        let options = PlaybackOptions {
4889            fermata_multiplier: 2.0,
4890            fermata_hold_beats: 0.75,
4891            ..Default::default()
4892        };
4893        let events = to_playback_events(&score, &options);
4894        assert!((events[0].duration_beats - 2.0).abs() < 1e-9);
4895        assert!((events[0].post_note_pause_beats - 0.75).abs() < 1e-9);
4896    }
4897
4898    #[test]
4899    fn invalid_fermata_hold_is_safely_ignored() {
4900        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4901        let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4902        note.articulations
4903            .push(crate::model::notation::Articulation::Fermata);
4904        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
4905        let options = PlaybackOptions {
4906            fermata_hold_beats: f64::NAN,
4907            ..Default::default()
4908        };
4909        assert_eq!(
4910            to_playback_events(&score, &options)[0].post_note_pause_beats,
4911            0.0
4912        );
4913    }
4914
4915    #[test]
4916    fn non_fermata_note_unaffected_by_fermata_multiplier() {
4917        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4918        score.parts[0].staves[0].measures[0].voices[0] =
4919            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
4920        let options = PlaybackOptions {
4921            fermata_multiplier: 3.0,
4922            ..Default::default()
4923        };
4924        let events = to_playback_events(&score, &options);
4925        assert!((events[0].duration_beats - 1.0).abs() < 1e-9);
4926    }
4927
4928    // ── swing ─────────────────────────────────────────────────────────────────
4929
4930    #[test]
4931    fn swing_triplet_first_eighth_is_long() {
4932        // Two eighth notes in one measure; swing=0.67 → first=0.67, second=0.33
4933        let mut score = Score::new("T", 120, 4, 4, 0, 1);
4934        score.parts[0].staves[0].measures[0].voices[0] = vec![
4935            Note::new(Pitch::new(Step::C, 4), Duration::Eighth),
4936            Note::new(Pitch::new(Step::D, 4), Duration::Eighth),
4937        ];
4938        let options = PlaybackOptions {
4939            swing: Some(0.67),
4940            ..Default::default()
4941        };
4942        let events = to_playback_events(&score, &options);
4943        // events are sorted by time_beats; C comes first (time_beats=0), D second
4944        let e_c = events.iter().find(|e| e.pitch_midi == 60).unwrap();
4945        let e_d = events.iter().find(|e| e.pitch_midi == 62).unwrap();
4946        assert!(
4947            (e_c.duration_beats - 0.67).abs() < 1e-9,
4948            "first eighth should be 0.67"
4949        );
4950        assert!(
4951            (e_d.duration_beats - 0.33).abs() < 1e-9,
4952            "second eighth should be 0.33"
4953        );
4954        // D starts at 0.67, not 0.5
4955        assert!(
4956            (e_d.time_beats - 0.67).abs() < 1e-9,
4957            "second note start should be at 0.67"
4958        );
4959    }
4960
4961    #[test]
4962    fn swing_does_not_stretch_an_unpaired_eighth() {
4963        let mut score = Score::new("unpaired", 120, 4, 4, 0, 1);
4964        score.parts[0].staves[0].measures[0].voices[0] = vec![
4965            Note::new(Pitch::new(Step::C, 4), Duration::Eighth),
4966            Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
4967        ];
4968        let events = to_playback_events(
4969            &score,
4970            &PlaybackOptions {
4971                swing: Some(2.0 / 3.0),
4972                ..Default::default()
4973            },
4974        );
4975        assert!((events[0].duration_beats - 0.5).abs() < 1e-9);
4976        assert!((events[1].time_beats - 0.5).abs() < 1e-9);
4977    }
4978
4979    #[test]
4980    fn cue_note_does_not_sound_or_pair_with_a_written_eighth() {
4981        let mut score = Score::new("cue", 120, 4, 4, 0, 1);
4982        let mut cue = Note::new(Pitch::new(Step::D, 4), Duration::Eighth);
4983        cue.is_cue = true;
4984        score.parts[0].staves[0].measures[0].voices[0] = vec![
4985            Note::new(Pitch::new(Step::C, 4), Duration::Eighth),
4986            cue,
4987            Note::new(Pitch::new(Step::E, 4), Duration::Eighth),
4988        ];
4989        let events = to_playback_events(
4990            &score,
4991            &PlaybackOptions {
4992                swing: Some(2.0 / 3.0),
4993                ..Default::default()
4994            },
4995        );
4996        assert_eq!(events.len(), 2);
4997        assert_eq!(events[0].pitch_midi, 60);
4998        assert_eq!(events[0].duration_beats, 0.5);
4999        assert_eq!(events[1].pitch_midi, 64);
5000        assert_eq!(events[1].time_beats, 0.5);
5001    }
5002
5003    #[test]
5004    fn swing_requires_metric_alignment_and_a_matching_next_note() {
5005        let mut score = Score::new("metric boundary", 120, 4, 4, 0, 1);
5006        score.parts[0].staves[0].measures[0].voices[0] = vec![
5007            Note::new(Pitch::new(Step::C, 4), Duration::Eighth),
5008            Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
5009            Note::new(Pitch::new(Step::E, 4), Duration::Eighth),
5010            Note::new(Pitch::new(Step::F, 4), Duration::Eighth),
5011            Note::new(Pitch::new(Step::G, 4), Duration::Eighth),
5012            Note::new(Pitch::new(Step::A, 4), Duration::Eighth),
5013        ];
5014        let events = to_playback_events(
5015            &score,
5016            &PlaybackOptions {
5017                swing: Some(2.0 / 3.0),
5018                ..Default::default()
5019            },
5020        );
5021        assert!((events[0].time_beats).abs() < 1e-9);
5022        assert!((events[0].duration_beats - 0.5).abs() < 1e-9);
5023        assert!((events[2].time_beats - 1.5).abs() < 1e-9);
5024        assert!((events[2].duration_beats - 0.5).abs() < 1e-9);
5025        assert!((events[3].time_beats - 2.0).abs() < 1e-9);
5026        assert!((events[3].duration_beats - 2.0 / 3.0).abs() < 1e-9);
5027        assert!((events[4].time_beats - 8.0 / 3.0).abs() < 1e-9);
5028        assert!((events[4].duration_beats - 1.0 / 3.0).abs() < 1e-9);
5029        assert!((events[5].time_beats - 3.0).abs() < 1e-9);
5030        assert!((events[5].duration_beats - 0.5).abs() < 1e-9);
5031    }
5032
5033    #[test]
5034    fn swing_keeps_the_offbeat_delay_after_an_eighth_rest() {
5035        let mut score = Score::new("rest swing", 120, 4, 4, 0, 1);
5036        score.parts[0].staves[0].measures[0].voices[0] = vec![
5037            Note::rest(Duration::Eighth),
5038            Note::new(Pitch::new(Step::C, 4), Duration::Eighth),
5039            Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
5040        ];
5041        let events = to_playback_events(
5042            &score,
5043            &PlaybackOptions {
5044                swing: Some(2.0 / 3.0),
5045                ..Default::default()
5046            },
5047        );
5048        assert!((events[0].time_beats - 2.0 / 3.0).abs() < 1e-9);
5049        assert!((events[0].duration_beats - 1.0 / 3.0).abs() < 1e-9);
5050        assert!((events[1].time_beats - 1.0).abs() < 1e-9);
5051    }
5052
5053    #[test]
5054    fn invalid_swing_ratios_keep_straight_finite_timing() {
5055        let mut score = Score::new("invalid swing", 120, 4, 4, 0, 1);
5056        score.parts[0].staves[0].measures[0].voices[0] = vec![
5057            Note::new(Pitch::new(Step::C, 4), Duration::Eighth),
5058            Note::new(Pitch::new(Step::D, 4), Duration::Eighth),
5059        ];
5060        for ratio in [f64::NAN, f64::INFINITY, -1.0, 0.5, 1.0] {
5061            let events = to_playback_events(
5062                &score,
5063                &PlaybackOptions {
5064                    swing: Some(ratio),
5065                    ..Default::default()
5066                },
5067            );
5068            assert_eq!(events.len(), 2);
5069            assert_eq!(events[0].duration_beats, 0.5);
5070            assert_eq!(events[1].time_beats, 0.5);
5071            assert_eq!(events[1].duration_beats, 0.5);
5072            assert!(events.iter().all(|event| event.time_secs.is_finite()));
5073        }
5074    }
5075
5076    #[test]
5077    fn pickup_swing_uses_metric_position_in_the_full_bar() {
5078        let mut score = Score::new("pickup swing", 120, 4, 4, 0, 1);
5079        let measure = &mut score.parts[0].staves[0].measures[0];
5080        measure.actual_length = Some(crate::MeasureLength {
5081            numerator: 3,
5082            denominator: 8,
5083        });
5084        measure.voices[0] = vec![
5085            Note::new(Pitch::new(Step::C, 4), Duration::Eighth),
5086            Note::new(Pitch::new(Step::D, 4), Duration::Eighth),
5087            Note::new(Pitch::new(Step::E, 4), Duration::Eighth),
5088        ];
5089        let events = to_playback_events(
5090            &score,
5091            &PlaybackOptions {
5092                swing: Some(2.0 / 3.0),
5093                ..Default::default()
5094            },
5095        );
5096        assert_eq!(events[0].duration_beats, 0.5);
5097        assert!((events[1].time_beats - 0.5).abs() < 1e-9);
5098        assert!((events[1].duration_beats - 2.0 / 3.0).abs() < 1e-9);
5099        assert!((events[2].time_beats - 7.0 / 6.0).abs() < 1e-9);
5100        assert!((events[2].duration_beats - 1.0 / 3.0).abs() < 1e-9);
5101    }
5102
5103    #[test]
5104    fn swing_non_eighth_not_affected() {
5105        let mut score = Score::new("T", 120, 4, 4, 0, 1);
5106        score.parts[0].staves[0].measures[0].voices[0] =
5107            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
5108        let options = PlaybackOptions {
5109            swing: Some(0.67),
5110            ..Default::default()
5111        };
5112        let events = to_playback_events(&score, &options);
5113        assert!(
5114            (events[0].duration_beats - 1.0).abs() < 1e-9,
5115            "quarter note unaffected"
5116        );
5117    }
5118
5119    #[test]
5120    fn swing_none_is_straight() {
5121        let mut score = Score::new("T", 120, 4, 4, 0, 1);
5122        score.parts[0].staves[0].measures[0].voices[0] =
5123            vec![Note::new(Pitch::new(Step::C, 4), Duration::Eighth)];
5124        let options = PlaybackOptions {
5125            swing: None,
5126            ..Default::default()
5127        };
5128        let events = to_playback_events(&score, &options);
5129        assert!(
5130            (events[0].duration_beats - 0.5).abs() < 1e-9,
5131            "no swing = straight eighth"
5132        );
5133    }
5134
5135    #[test]
5136    fn channel_matches_part_midi_channel() {
5137        let mut score = Score::new("T", 120, 4, 4, 0, 1);
5138        score.parts[0].midi_channel = 3;
5139        score.parts[0].staves[0].measures[0].voices[0] =
5140            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
5141        let events = to_playback_events(&score, &opts(None));
5142        assert_eq!(events[0].channel, 3);
5143    }
5144
5145    #[test]
5146    fn swing_unit_default_is_eighth() {
5147        assert_eq!(PlaybackOptions::default().swing_unit, Duration::Eighth);
5148    }
5149
5150    #[test]
5151    fn swing_unit_sixteenth() {
5152        let mut score = Score::new("T", 120, 4, 4, 0, 1);
5153        score.parts[0].staves[0].measures[0].voices[0] = vec![
5154            Note::new(Pitch::new(Step::C, 4), Duration::Sixteenth),
5155            Note::new(Pitch::new(Step::D, 4), Duration::Sixteenth),
5156        ];
5157        let options = PlaybackOptions {
5158            swing: Some(0.67),
5159            swing_unit: Duration::Sixteenth,
5160            ..Default::default()
5161        };
5162        let events = to_playback_events(&score, &options);
5163        let e_c = events.iter().find(|e| e.pitch_midi == 60).unwrap();
5164        let e_d = events.iter().find(|e| e.pitch_midi == 62).unwrap();
5165        assert!(
5166            (e_c.duration_beats - 0.335).abs() < 1e-9,
5167            "first 16th should be 0.335"
5168        );
5169        assert!(
5170            (e_d.duration_beats - 0.165).abs() < 1e-9,
5171            "second 16th should be 0.165"
5172        );
5173    }
5174
5175    #[test]
5176    fn swing_resets_per_measure() {
5177        // 2 measures each with 2 eighth notes; each measure's first eighth should be "long"
5178        let mut score = Score::new("T", 120, 4, 4, 0, 2);
5179        let pair = || {
5180            vec![
5181                Note::new(Pitch::new(Step::C, 4), Duration::Eighth),
5182                Note::new(Pitch::new(Step::D, 4), Duration::Eighth),
5183            ]
5184        };
5185        score.parts[0].staves[0].measures[0].voices[0] = pair();
5186        score.parts[0].staves[0].measures[1].voices[0] = pair();
5187        let options = PlaybackOptions {
5188            swing: Some(0.67),
5189            ..Default::default()
5190        };
5191        let events = to_playback_events(&score, &options);
5192        // Four events sorted by time: m0-C, m0-D, m1-C, m1-D
5193        let durations: Vec<f64> = events.iter().map(|e| e.duration_beats).collect();
5194        // m0 first (long)
5195        assert!((durations[0] - 0.67).abs() < 1e-9, "m0 first note long");
5196        // m0 second (short)
5197        assert!((durations[1] - 0.33).abs() < 1e-9, "m0 second note short");
5198        // m1 first (long again — reset)
5199        assert!(
5200            (durations[2] - 0.67).abs() < 1e-9,
5201            "m1 first note long (reset)"
5202        );
5203        // m1 second (short)
5204        assert!((durations[3] - 0.33).abs() < 1e-9, "m1 second note short");
5205    }
5206
5207    #[test]
5208    fn multi_voice_events_preserve_source_voice_addresses() {
5209        let mut score = Score::new("T", 120, 4, 4, 0, 1);
5210        score.parts[0].staves[0].measures[0].voices[0] =
5211            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
5212        score.parts[0].staves[0].measures[0].voices[1] =
5213            vec![Note::new(Pitch::new(Step::E, 4), Duration::Quarter)];
5214
5215        let events = to_playback_events(&score, &PlaybackOptions::default());
5216        let addresses: Vec<&str> = events
5217            .iter()
5218            .filter_map(|event| event.address.as_deref())
5219            .collect();
5220        assert!(addresses.contains(&"0:0:0:0:0"));
5221        assert!(addresses.contains(&"0:0:0:1:0"));
5222    }
5223
5224    #[test]
5225    fn playback_exposes_original_musicxml_voice_number_alongside_slot_address() {
5226        let mut score = Score::new("T", 120, 4, 4, 0, 1);
5227        let measure = &mut score.parts[0].staves[0].measures[0];
5228        measure.voices[1] = vec![Note::new(Pitch::new(Step::E, 4), Duration::Quarter)];
5229        measure.source_voice_numbers[1] = Some(5);
5230
5231        let events = to_playback_events(&score, &PlaybackOptions::default());
5232        let event = events
5233            .iter()
5234            .find(|event| event.address.as_deref() == Some("0:0:0:1:0"))
5235            .expect("event from slot 1");
5236        assert_eq!(event.source.as_ref().map(|source| source.voice), Some(1));
5237        assert_eq!(event.source_voice_number, Some(5));
5238    }
5239
5240    // ── compute_playback_position ─────────────────────────────────────────────
5241
5242    #[test]
5243    fn playback_position_at_zero_is_measure_0_beat_0() {
5244        let score = Score::new("T", 120, 4, 4, 0, 4);
5245        let pos = compute_playback_position(&score, &PlaybackOptions::default(), 0.0).unwrap();
5246        assert_eq!(pos.measure_index, 0);
5247        assert!(pos.beat.abs() < 1e-9);
5248    }
5249
5250    #[test]
5251    fn playback_position_at_half_measure_is_beat_2() {
5252        // 4/4, 120 BPM → 1 measure = 2.0 s; 0.5 s = beat 1.0
5253        let score = Score::new("T", 120, 4, 4, 0, 4);
5254        let pos = compute_playback_position(&score, &PlaybackOptions::default(), 0.5).unwrap();
5255        assert_eq!(pos.measure_index, 0);
5256        assert!(
5257            (pos.beat - 1.0).abs() < 1e-9,
5258            "expected beat 1.0, got {}",
5259            pos.beat
5260        );
5261    }
5262
5263    #[test]
5264    fn playback_position_beyond_score_is_none() {
5265        // 4/4, 120 BPM, 1 measure = 2.0 s; 10.0 s is beyond
5266        let score = Score::new("T", 120, 4, 4, 0, 1);
5267        assert!(compute_playback_position(&score, &PlaybackOptions::default(), 10.0).is_none());
5268    }
5269
5270    #[test]
5271    fn playback_position_tempo_change_takes_effect() {
5272        // measure 0: 120 BPM (2.0 s), measure 1: 60 BPM (4.0 s)
5273        // At elapsed=2.5 s → inside measure 1, 0.5 s into it → beat 0.5
5274        let mut score = Score::new("T", 120, 4, 4, 0, 2);
5275        score.parts[0].staves[0].measures[1].tempo = Some(60);
5276        let pos = compute_playback_position(&score, &PlaybackOptions::default(), 2.5).unwrap();
5277        assert_eq!(pos.measure_index, 1);
5278        assert!(
5279            (pos.beat - 0.5).abs() < 1e-9,
5280            "expected beat 0.5, got {}",
5281            pos.beat
5282        );
5283    }
5284
5285    #[test]
5286    fn playback_position_loop_region_starts_at_zero() {
5287        // loop_region=[1,2] → elapsed=0 should map to measure 1, beat 0
5288        let score = Score::new("T", 120, 4, 4, 0, 4);
5289        let options = PlaybackOptions {
5290            loop_region: Some((1, 2)),
5291            ..Default::default()
5292        };
5293        let pos = compute_playback_position(&score, &options, 0.0).unwrap();
5294        assert_eq!(pos.measure_index, 1);
5295        assert!(pos.beat.abs() < 1e-9);
5296    }
5297
5298    // ── MetronomeConfig ───────────────────────────────────────────────────────
5299
5300    #[test]
5301    fn metronome_injects_beat_events() {
5302        // 4/4, 1 measure → should inject 4 metronome events
5303        let score = Score::new("T", 120, 4, 4, 0, 1);
5304        let options = PlaybackOptions {
5305            metronome: Some(MetronomeConfig::default()),
5306            ..Default::default()
5307        };
5308        let events = to_playback_events(&score, &options);
5309        let metro_events: Vec<_> = events.iter().filter(|e| e.is_metronome).collect();
5310        assert_eq!(metro_events.len(), 4, "expected 4 metronome clicks in 4/4");
5311    }
5312
5313    #[test]
5314    fn metronome_accent_is_first_beat() {
5315        let score = Score::new("T", 120, 4, 4, 0, 1);
5316        let metro = MetronomeConfig::default();
5317        let options = PlaybackOptions {
5318            metronome: Some(metro.clone()),
5319            ..Default::default()
5320        };
5321        let events = to_playback_events(&score, &options);
5322        let mut metro_events: Vec<_> = events.iter().filter(|e| e.is_metronome).collect();
5323        metro_events.sort_by(|a, b| a.time_beats.partial_cmp(&b.time_beats).unwrap());
5324        assert_eq!(metro_events[0].pitch_midi, metro.accent_pitch);
5325        assert_eq!(metro_events[0].velocity, metro.accent_velocity);
5326    }
5327
5328    #[test]
5329    fn metronome_regular_beat_pitch() {
5330        let score = Score::new("T", 120, 4, 4, 0, 1);
5331        let metro = MetronomeConfig::default();
5332        let options = PlaybackOptions {
5333            metronome: Some(metro.clone()),
5334            ..Default::default()
5335        };
5336        let events = to_playback_events(&score, &options);
5337        let mut metro_events: Vec<_> = events.iter().filter(|e| e.is_metronome).collect();
5338        metro_events.sort_by(|a, b| a.time_beats.partial_cmp(&b.time_beats).unwrap());
5339        for ev in &metro_events[1..] {
5340            assert_eq!(ev.pitch_midi, metro.beat_pitch);
5341            assert_eq!(ev.velocity, metro.beat_velocity);
5342        }
5343    }
5344
5345    #[test]
5346    fn metronome_integrates_measure_tempo_ramp_and_carries_ending_tempo() {
5347        let mut score = Score::new("T", 120, 4, 4, 0, 2);
5348        score.parts[0].staves[0].measures[0].tempo_ramp_to = Some(60);
5349        let options = PlaybackOptions {
5350            metronome: Some(MetronomeConfig::default()),
5351            ..Default::default()
5352        };
5353
5354        let mut clicks: Vec<_> = to_playback_events(&score, &options)
5355            .into_iter()
5356            .filter(|event| event.is_metronome)
5357            .collect();
5358        clicks.sort_by(|left, right| left.time_beats.total_cmp(&right.time_beats));
5359
5360        let expected_second_beat = tempo_ramp_seconds(120.0, Some(60.0), 4.0, 1.0);
5361        let expected_second_measure = tempo_ramp_seconds(120.0, Some(60.0), 4.0, 4.0);
5362        assert!((clicks[1].time_secs - expected_second_beat).abs() < 1e-9);
5363        assert!((clicks[4].time_secs - expected_second_measure).abs() < 1e-9);
5364        assert!((clicks[5].time_secs - (expected_second_measure + 1.0)).abs() < 1e-9);
5365    }
5366
5367    #[test]
5368    fn metronome_events_are_marked() {
5369        let score = Score::new("T", 120, 4, 4, 0, 1);
5370        let options = PlaybackOptions {
5371            metronome: Some(MetronomeConfig::default()),
5372            ..Default::default()
5373        };
5374        let events = to_playback_events(&score, &options);
5375        assert!(events.iter().any(|e| e.is_metronome));
5376    }
5377
5378    #[test]
5379    fn metronome_none_produces_no_extra_events() {
5380        let score = Score::new("T", 120, 4, 4, 0, 1);
5381        let events = to_playback_events(&score, &PlaybackOptions::default());
5382        assert!(events.iter().all(|e| !e.is_metronome));
5383    }
5384
5385    #[test]
5386    fn offline_render_manifest_uses_event_end_as_exact_frame_length() {
5387        let mut score = Score::new("T", 120, 4, 4, 0, 1);
5388        score.parts[0].staves[0].measures[0].voices[0][0] =
5389            Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
5390        let request = OfflineRenderRequest {
5391            format: OfflineRenderFormat::Flac,
5392            sample_rate_hz: 48_000,
5393            channels: 2,
5394            provider_identity: Some("test-provider@1".into()),
5395            ..Default::default()
5396        };
5397
5398        let manifest = build_offline_render_manifest(&score, &PlaybackOptions::default(), &request)
5399            .expect("valid render manifest");
5400        assert_eq!(manifest.contract_version, OFFLINE_RENDER_CONTRACT_VERSION);
5401        assert!(manifest.validate().is_ok());
5402        assert_eq!(manifest.format, OfflineRenderFormat::Flac);
5403        assert_eq!(manifest.duration_frames, 24_000);
5404        assert_eq!(manifest.events.len(), 1);
5405        assert_eq!(manifest.frame_events.len(), 1);
5406        assert_eq!(manifest.frame_events[0].start_frame, 0);
5407        assert_eq!(manifest.frame_events[0].duration_frames, 24_000);
5408        assert_eq!(manifest.frame_events[0].end_frame, 24_000);
5409        assert_eq!(manifest.playback_options, PlaybackOptions::default());
5410        assert_eq!(
5411            manifest.provider_identity.as_deref(),
5412            Some("test-provider@1")
5413        );
5414    }
5415
5416    #[test]
5417    fn offline_render_manifest_rejects_out_of_range_audio_shape() {
5418        let score = Score::new("T", 120, 4, 4, 0, 1);
5419        let request = OfflineRenderRequest {
5420            sample_rate_hz: 0,
5421            ..Default::default()
5422        };
5423        assert!(matches!(
5424            build_offline_render_manifest(&score, &PlaybackOptions::default(), &request),
5425            Err(crate::Error::InvalidOfflineRenderRequest)
5426        ));
5427    }
5428
5429    #[test]
5430    fn offline_render_manifest_selection_keeps_source_address_and_release_tail() {
5431        let mut score = Score::new("T", 120, 4, 4, 0, 1);
5432        score.parts[0].staves[0].measures[0].voices[0] = vec![
5433            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
5434            Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
5435        ];
5436        let events = to_playback_events(&score, &PlaybackOptions::default());
5437        score.parts[0].midi_control_changes.push(MidiControlChange {
5438            tick: 480,
5439            channel: 0,
5440            controller: 7,
5441            value: 100,
5442        });
5443        let request = OfflineRenderRequest {
5444            sample_rate_hz: 8_000,
5445            release_tail_millis: 125,
5446            scope: OfflineRenderScope::Selection {
5447                addresses: vec![events[1].source.clone().expect("source address")],
5448            },
5449            ..Default::default()
5450        };
5451        let manifest = build_offline_render_manifest(&score, &PlaybackOptions::default(), &request)
5452            .expect("selection manifest");
5453        assert_eq!(manifest.events.len(), 1);
5454        assert_eq!(manifest.events[0].source, events[1].source);
5455        assert_eq!(manifest.frame_events[0].start_frame, 0);
5456        assert!(manifest.semantic_events.iter().any(|event| matches!(
5457            event.kind,
5458            OfflineRenderSemanticEventKind::Controller {
5459                channel: 0,
5460                controller: 7,
5461                value: 100
5462            }
5463        ) && event.frame == 0));
5464        assert_eq!(manifest.release_tail_frames, 1_000);
5465        assert_eq!(manifest.duration_frames, 5_000);
5466    }
5467
5468    #[test]
5469    fn offline_render_request_owned_options_override_legacy_argument() {
5470        let mut score = Score::new("T", 120, 4, 4, 0, 1);
5471        score.parts[0].staves[0].measures[0].voices[0][0] =
5472            Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
5473        let request_options = PlaybackOptions {
5474            bpm_override: Some(60),
5475            ..Default::default()
5476        };
5477        let manifest = build_offline_render_manifest(
5478            &score,
5479            &PlaybackOptions::default(),
5480            &OfflineRenderRequest {
5481                sample_rate_hz: 48_000,
5482                playback_options: Some(request_options.clone()),
5483                ..Default::default()
5484            },
5485        )
5486        .expect("request-owned playback options");
5487        assert_eq!(manifest.playback_options, request_options);
5488        assert_eq!(manifest.frame_events[0].duration_frames, 48_000);
5489    }
5490
5491    #[test]
5492    fn offline_manifest_projects_typed_midi_events_to_exact_frames() {
5493        let mut score = Score::new("MIDI semantics", 120, 4, 4, 0, 1);
5494        score.parts[0].midi_pitch_bends.push(MidiPitchBend {
5495            tick: 480,
5496            channel: 2,
5497            value: -512,
5498        });
5499        score.parts[0].midi_program_changes.push(MidiProgramChange {
5500            tick: 480,
5501            channel: 2,
5502            program: 41,
5503        });
5504        score.parts[0].midi_aftertouch.push(MidiAftertouch {
5505            tick: 480,
5506            channel: 2,
5507            key: Some(64),
5508            value: 88,
5509        });
5510        let manifest = build_offline_render_manifest(
5511            &score,
5512            &PlaybackOptions::default(),
5513            &OfflineRenderRequest {
5514                sample_rate_hz: 48_000,
5515                ..Default::default()
5516            },
5517        )
5518        .expect("manifest builds");
5519        for kind in [
5520            OfflineRenderSemanticEventKind::PitchBend {
5521                channel: 2,
5522                value: -512,
5523            },
5524            OfflineRenderSemanticEventKind::ProgramChange {
5525                channel: 2,
5526                program: 41,
5527            },
5528            OfflineRenderSemanticEventKind::Aftertouch {
5529                channel: 2,
5530                key: Some(64),
5531                value: 88,
5532            },
5533        ] {
5534            assert!(manifest.semantic_events.iter().any(|event| {
5535                event.kind == kind
5536                    && event.frame == 24_000
5537                    && event
5538                        .measure
5539                        .as_ref()
5540                        .is_some_and(|address| address.part == 0 && address.measure == 0)
5541            }));
5542        }
5543    }
5544
5545    #[test]
5546    fn offline_manifest_frames_cover_repeat_ramp_fermata_pedal_bend_and_instrument_change() {
5547        let mut score = Score::new("fixture", 120, 4, 4, 0, 2);
5548        let mut first = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
5549        first.pedal_start = true;
5550        first.articulations = vec![Articulation::Fermata, Articulation::BreathMark];
5551        first.guitar_bend_curve = vec![GuitarBendPoint {
5552            position_per_mille: 500,
5553            alter_cents: 100,
5554        }];
5555        score.parts[0].staves[0].measures[0].voices[0] = vec![first];
5556        score.parts[0].staves[0].measures[0].barline_left =
5557            super::super::notation::Barline::RepeatStart;
5558        score.parts[0].staves[0].measures[0].tempo_ramp_to = Some(60);
5559        score.parts[0].staves[0].measures[0].navigation = Some("Segno".to_string());
5560        score.parts[0].midi_control_changes.push(MidiControlChange {
5561            tick: 480,
5562            channel: 0,
5563            controller: 11,
5564            value: 96,
5565        });
5566
5567        let mut second = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
5568        second.pedal_end = true;
5569        score.parts[0].staves[0].measures[1].voices[0] = vec![second];
5570        score.parts[0].staves[0].measures[1].barline_right =
5571            super::super::notation::Barline::RepeatEnd;
5572        let mut flute = crate::InstrumentDefinition::new("flute", "Flute");
5573        flute.midi_channel = 2;
5574        flute.midi_program = 73;
5575        score.parts[0].staves[0].measures[1].instrument_change = Some(flute);
5576
5577        let request = OfflineRenderRequest {
5578            sample_rate_hz: 48_000,
5579            playback_options: Some(PlaybackOptions {
5580                fermata_multiplier: 1.5,
5581                fermata_hold_beats: 0.25,
5582                ..Default::default()
5583            }),
5584            ..Default::default()
5585        };
5586        let manifest = build_offline_render_manifest(&score, &PlaybackOptions::default(), &request)
5587            .expect("fixture manifest");
5588        assert_eq!(manifest.frame_events.len(), 4);
5589        let measures: Vec<usize> = manifest
5590            .frame_events
5591            .iter()
5592            .map(|event| event.event.source.as_ref().unwrap().measure)
5593            .collect();
5594        assert_eq!(measures, vec![0, 1, 0, 1]);
5595        assert!(manifest.frame_events[0].event.pedal);
5596        assert_eq!(manifest.frame_events[0].event.post_note_pause_beats, 0.25);
5597        assert_eq!(manifest.frame_events[0].event.pitch_bend_curve.len(), 1);
5598        assert_eq!(
5599            manifest.frame_events[1].event.instrument_id.as_deref(),
5600            Some("flute")
5601        );
5602        assert_eq!(manifest.frame_events[3].event.program, 73);
5603        assert!(manifest.semantic_events.iter().any(|event| {
5604            matches!(
5605                &event.kind,
5606                OfflineRenderSemanticEventKind::InstrumentChange {
5607                    instrument_id,
5608                    channel: 2,
5609                    program: 73,
5610                    transpose_semitones: 0,
5611                } if instrument_id == "flute"
5612            ) && event
5613                .measure
5614                .as_ref()
5615                .is_some_and(|address| address.measure == 1)
5616                && event.frame == manifest.frame_events[1].start_frame
5617        }));
5618        assert!(manifest.semantic_events.iter().any(|event| matches!(
5619            event.kind,
5620            OfflineRenderSemanticEventKind::Pedal { down: true }
5621        ) && event.frame
5622            == manifest.frame_events[0].start_frame));
5623        assert!(manifest.semantic_events.iter().any(|event| matches!(
5624            event.kind,
5625            OfflineRenderSemanticEventKind::Pedal { down: false }
5626        ) && event.frame
5627            == manifest.frame_events[1].start_frame));
5628        assert!(manifest.semantic_events.iter().any(|event| matches!(
5629            event.kind,
5630            OfflineRenderSemanticEventKind::Articulation {
5631                articulation: Articulation::Fermata
5632            }
5633        )));
5634        assert!(manifest.semantic_events.iter().any(|event| matches!(
5635            event.kind,
5636            OfflineRenderSemanticEventKind::GuitarBend { .. }
5637        )));
5638        let tempo_events = manifest
5639            .semantic_events
5640            .iter()
5641            .filter(|event| matches!(event.kind, OfflineRenderSemanticEventKind::Tempo { .. }))
5642            .count();
5643        let navigation_events = manifest
5644            .semantic_events
5645            .iter()
5646            .filter(|event| {
5647                matches!(
5648                    event.kind,
5649                    OfflineRenderSemanticEventKind::Navigation { .. }
5650                )
5651            })
5652            .count();
5653        let control_frames: Vec<u64> = manifest
5654            .semantic_events
5655            .iter()
5656            .filter_map(|event| {
5657                matches!(
5658                    event.kind,
5659                    OfflineRenderSemanticEventKind::Controller {
5660                        channel: 0,
5661                        controller: 11,
5662                        value: 96
5663                    }
5664                )
5665                .then_some(event.frame)
5666            })
5667            .collect();
5668        assert_eq!(tempo_events, 2);
5669        assert_eq!(navigation_events, 2);
5670        assert_eq!(control_frames.len(), 2);
5671        let ramp_duration = tempo_ramp_seconds(120.0, Some(60.0), 4.0, 4.0);
5672        // The first fermata holds for half a beat at the ramp's beat-one tempo.
5673        // On the repeat the ramp starts at 60 BPM, so the second hold is 0.5 s.
5674        let first_hold = 0.5 * 60.0 / 105.0;
5675        let second_hold = 0.5;
5676        assert_eq!(
5677            control_frames[0],
5678            ((tempo_ramp_seconds(120.0, Some(60.0), 4.0, 1.0) + first_hold) * 48_000.0).round()
5679                as u64
5680        );
5681        assert_eq!(
5682            control_frames[1],
5683            ((ramp_duration + 5.0 + first_hold + second_hold) * 48_000.0).round() as u64
5684        );
5685
5686        let expected_starts = [
5687            0.0,
5688            ramp_duration + first_hold,
5689            ramp_duration + first_hold + 4.0,
5690            ramp_duration + first_hold + 8.0 + second_hold,
5691        ];
5692        for (frame_event, expected_secs) in manifest.frame_events.iter().zip(expected_starts) {
5693            assert_eq!(
5694                frame_event.start_frame,
5695                (expected_secs * 48_000.0).round() as u64
5696            );
5697        }
5698    }
5699
5700    #[test]
5701    fn offline_render_manifest_reports_unresolved_selection_address() {
5702        let score = Score::new("T", 120, 4, 4, 0, 1);
5703        let request = OfflineRenderRequest {
5704            scope: OfflineRenderScope::Selection {
5705                addresses: vec![NoteAddr {
5706                    part: 0,
5707                    staff: 0,
5708                    measure: 0,
5709                    voice: 0,
5710                    note: 99,
5711                }],
5712            },
5713            ..Default::default()
5714        };
5715        let manifest = build_offline_render_manifest(&score, &PlaybackOptions::default(), &request)
5716            .expect("empty selection is representable");
5717        assert_eq!(manifest.events.len(), 0);
5718        assert_eq!(
5719            manifest.diagnostics,
5720            vec![
5721                OfflineRenderDiagnostic {
5722                    kind: OfflineRenderDiagnosticKind::UnresolvedSelectionAddress,
5723                    source: Some(NoteAddr {
5724                        part: 0,
5725                        staff: 0,
5726                        measure: 0,
5727                        voice: 0,
5728                        note: 99,
5729                    }),
5730                },
5731                OfflineRenderDiagnostic {
5732                    kind: OfflineRenderDiagnosticKind::EmptySelection,
5733                    source: None,
5734                },
5735            ]
5736        );
5737    }
5738
5739    #[test]
5740    fn offline_render_manifest_v1_json_migrates_with_v2_defaults() {
5741        let legacy = serde_json::json!({
5742            "contract_version": 1,
5743            "playback_contract_version": 2,
5744            "format": "Wav",
5745            "sample_rate_hz": 48_000,
5746            "channels": 2,
5747            "duration_frames": 0,
5748            "events": []
5749        });
5750        let manifest: OfflineRenderManifest =
5751            serde_json::from_value(legacy).expect("v1 manifest remains readable");
5752        assert_eq!(manifest.contract_version, 1);
5753        assert_eq!(manifest.scope, OfflineRenderScope::FullScore);
5754        assert_eq!(manifest.release_tail_frames, 0);
5755        assert_eq!(manifest.playback_options, PlaybackOptions::default());
5756        assert!(manifest.frame_events.is_empty());
5757        assert!(manifest.diagnostics.is_empty());
5758        assert!(manifest.validate().is_ok());
5759    }
5760
5761    #[test]
5762    fn offline_render_manifest_validation_rejects_tampered_frame_and_tail_boundaries() {
5763        let mut score = Score::new("T", 120, 4, 4, 0, 1);
5764        score.parts[0].staves[0].measures[0].voices[0] =
5765            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
5766        let manifest = build_offline_render_manifest(
5767            &score,
5768            &PlaybackOptions::default(),
5769            &OfflineRenderRequest {
5770                sample_rate_hz: 48_000,
5771                release_tail_millis: 100,
5772                ..Default::default()
5773            },
5774        )
5775        .expect("manifest builds");
5776        assert!(manifest.validate().is_ok());
5777
5778        let mut invalid_tail = manifest.clone();
5779        invalid_tail.duration_frames = invalid_tail.duration_frames.saturating_sub(1);
5780        assert!(matches!(
5781            invalid_tail.validate(),
5782            Err(crate::Error::InvalidOfflineRenderRequest)
5783        ));
5784
5785        let mut invalid_frame = manifest;
5786        invalid_frame.frame_events[0].end_frame =
5787            invalid_frame.frame_events[0].end_frame.saturating_add(1);
5788        assert!(matches!(
5789            invalid_frame.validate(),
5790            Err(crate::Error::InvalidOfflineRenderRequest)
5791        ));
5792    }
5793
5794    #[test]
5795    fn routing_manifest_prefers_instrument_bus_and_isolates_metronome() {
5796        let mut score = Score::new("routing", 120, 4, 4, 0, 1);
5797        let mut instrument = crate::InstrumentDefinition::new("flute", "Flute");
5798        instrument.midi_channel = 2;
5799        instrument.midi_program = 73;
5800        score.parts[0].instrument = Some(instrument);
5801        score.parts[0].staves[0].measures[0].voices[0] =
5802            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
5803        let options = PlaybackOptions {
5804            metronome: Some(MetronomeConfig::default()),
5805            ..Default::default()
5806        };
5807        let mut config = PlaybackRoutingConfig {
5808            provider_identity: Some("sf2-host@1".into()),
5809            ..Default::default()
5810        };
5811        config.part_buses.insert(0, "parts".into());
5812        config
5813            .instrument_buses
5814            .insert("flute".into(), "winds".into());
5815        config.aux_sends.push(PlaybackAuxSend {
5816            source_bus: "winds".into(),
5817            destination_bus: "reverb".into(),
5818            gain_db: -12.0,
5819        });
5820        config.effect_routes.push(PlaybackEffectRoute {
5821            bus_id: "reverb".into(),
5822            effect_id: "convolution".into(),
5823            enabled: true,
5824        });
5825
5826        let manifest = build_playback_routing_manifest(&score, &options, &config)
5827            .expect("valid routing manifest");
5828        assert_eq!(manifest.contract_version, PLAYBACK_ROUTING_CONTRACT_VERSION);
5829        assert_eq!(manifest.provider_identity.as_deref(), Some("sf2-host@1"));
5830        assert!(manifest.routes.iter().any(|route| {
5831            route.instrument_id.as_deref() == Some("flute") && route.bus_id == "winds"
5832        }));
5833        assert!(
5834            manifest
5835                .routes
5836                .iter()
5837                .any(|route| route.is_metronome && route.bus_id == "metronome")
5838        );
5839        assert_eq!(manifest.aux_sends.len(), 1);
5840        assert_eq!(manifest.effect_routes.len(), 1);
5841    }
5842
5843    #[test]
5844    fn routing_manifest_rejects_unsafe_bus_and_non_finite_send_gain() {
5845        let score = Score::new("routing", 120, 4, 4, 0, 1);
5846        let config = PlaybackRoutingConfig {
5847            master_bus: "not a bus".into(),
5848            ..Default::default()
5849        };
5850        assert!(matches!(
5851            build_playback_routing_manifest(&score, &PlaybackOptions::default(), &config),
5852            Err(crate::Error::InvalidPlaybackRouting)
5853        ));
5854
5855        let mut config = PlaybackRoutingConfig::default();
5856        config.aux_sends.push(PlaybackAuxSend {
5857            source_bus: "part".into(),
5858            destination_bus: "master".into(),
5859            gain_db: f64::NAN,
5860        });
5861        assert!(matches!(
5862            build_playback_routing_manifest(&score, &PlaybackOptions::default(), &config),
5863            Err(crate::Error::InvalidPlaybackRouting)
5864        ));
5865    }
5866
5867    #[test]
5868    fn timing_corpus_reports_score_schedule_results_without_audio_claims() {
5869        let mut score = Score::new("corpus", 120, 4, 4, 0, 1);
5870        score.parts[0].staves[0].measures[0].voices[0] =
5871            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
5872        let expected_events = to_playback_events(&score, &PlaybackOptions::default());
5873        let report = evaluate_playback_timing_corpus(&[PlaybackTimingCase {
5874            id: "quarter-note".into(),
5875            score,
5876            options: PlaybackOptions::default(),
5877            expected_events,
5878            tolerance: PlaybackTimingTolerance::default(),
5879        }])
5880        .expect("valid timing corpus");
5881        assert_eq!(
5882            report.contract_version,
5883            PLAYBACK_TIMING_CORPUS_CONTRACT_VERSION
5884        );
5885        assert_eq!(report.total_cases, 1);
5886        assert_eq!(report.passed_cases, 1);
5887        assert!(report.cases[0].report.within_tolerance);
5888    }
5889
5890    #[test]
5891    fn timing_corpus_rejects_empty_or_duplicate_case_ids() {
5892        assert!(matches!(
5893            evaluate_playback_timing_corpus(&[]),
5894            Err(crate::Error::InvalidPlaybackTimingCorpus)
5895        ));
5896        let score = Score::new("corpus", 120, 4, 4, 0, 1);
5897        let case = PlaybackTimingCase {
5898            id: "duplicate".into(),
5899            expected_events: to_playback_events(&score, &PlaybackOptions::default()),
5900            score,
5901            options: PlaybackOptions::default(),
5902            tolerance: PlaybackTimingTolerance::default(),
5903        };
5904        assert!(matches!(
5905            evaluate_playback_timing_corpus(&[case.clone(), case]),
5906            Err(crate::Error::InvalidPlaybackTimingCorpus)
5907        ));
5908    }
5909
5910    fn comparison_event(time_secs: f64, duration_secs: f64) -> PlaybackEvent {
5911        PlaybackEvent {
5912            address: Some("0:0:0:0:0".into()),
5913            source: Some(NoteAddr {
5914                part: 0,
5915                staff: 0,
5916                measure: 0,
5917                voice: 0,
5918                note: 0,
5919            }),
5920            source_voice_number: None,
5921            time_beats: 0.0,
5922            time_secs,
5923            pitch_midi: 60,
5924            pitch_midi_cents: 6000,
5925            pitch_bend_curve: Vec::new(),
5926            post_note_pause_beats: 0.0,
5927            articulations: Vec::new(),
5928            chord_symbol: None,
5929            guitar_technique: None,
5930            velocity: 64,
5931            duration_beats: 1.0,
5932            duration_secs,
5933            pedal: false,
5934            part_index: 0,
5935            channel: 0,
5936            program: 0,
5937            instrument_id: None,
5938            is_metronome: false,
5939        }
5940    }
5941
5942    #[test]
5943    fn legacy_playback_event_json_defaults_bend_curve() {
5944        let event: PlaybackEvent = serde_json::from_str(
5945            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}"#,
5946        )
5947        .expect("legacy playback event remains readable");
5948        assert!(event.pitch_bend_curve.is_empty());
5949        assert_eq!(event.guitar_technique, None);
5950    }
5951
5952    #[test]
5953    fn tempo_ramp_uses_integrated_event_timing_and_carries_target_forward() {
5954        let mut score = Score::new("ramp", 120, 4, 4, 0, 2);
5955        for measure in &mut score.parts[0].staves[0].measures {
5956            measure.voices[0] = vec![
5957                crate::Note::new(crate::Pitch::new(crate::Step::C, 4), crate::Duration::Half),
5958                crate::Note::new(crate::Pitch::new(crate::Step::D, 4), crate::Duration::Half),
5959            ];
5960        }
5961        score.parts[0].staves[0].measures[0].tempo_ramp_to = Some(60);
5962        let events = to_playback_events(&score, &PlaybackOptions::default());
5963        assert_eq!(events.len(), 4);
5964        let expected_measure_secs = 4.0 * 60.0 / (60.0 - 120.0) * (60.0f64 / 120.0).ln();
5965        assert!((events[2].time_secs - expected_measure_secs).abs() < 1e-9);
5966        assert!((events[0].duration_secs - 1.1507282898).abs() < 1e-6);
5967        assert!((events[2].duration_secs - 2.0).abs() < 1e-9);
5968    }
5969
5970    #[test]
5971    fn breath_and_caesura_expose_deterministic_post_note_pause() {
5972        let mut score = Score::new("pause", 120, 4, 4, 0, 1);
5973        let mut breath = crate::Note::new(
5974            crate::Pitch::new(crate::Step::C, 4),
5975            crate::Duration::Quarter,
5976        );
5977        breath.articulations.push(Articulation::BreathMark);
5978        let mut caesura = crate::Note::new(
5979            crate::Pitch::new(crate::Step::D, 4),
5980            crate::Duration::Quarter,
5981        );
5982        caesura
5983            .articulations
5984            .extend([Articulation::BreathMark, Articulation::Caesura]);
5985        score.parts[0].staves[0].measures[0].voices[0] = vec![breath, caesura];
5986
5987        let events = to_playback_events(&score, &PlaybackOptions::default());
5988        assert_eq!(events.len(), 2);
5989        assert_eq!(events[0].post_note_pause_beats, 0.25);
5990        assert_eq!(events[1].post_note_pause_beats, 0.5);
5991        assert_eq!(
5992            events[1].articulations,
5993            vec![Articulation::BreathMark, Articulation::Caesura]
5994        );
5995    }
5996
5997    #[test]
5998    fn measure_instrument_change_resolves_on_playback_events() {
5999        let mut score = Score::new("instrument", 120, 4, 4, 0, 2);
6000        let mut base = crate::InstrumentDefinition::new("piano", "Piano");
6001        base.midi_channel = 1;
6002        base.midi_program = 0;
6003        score.parts[0].instrument = Some(base);
6004        let mut change = crate::InstrumentDefinition::new("flute", "Flute");
6005        change.midi_channel = 2;
6006        change.midi_program = 73;
6007        change.transpose_semitones = -2;
6008        score.parts[0].staves[0].measures[1].instrument_change = Some(change);
6009        for measure in &mut score.parts[0].staves[0].measures {
6010            measure.voices[0] = vec![crate::Note::new(
6011                crate::Pitch::new(crate::Step::C, 4),
6012                crate::Duration::Whole,
6013            )];
6014        }
6015
6016        let events = to_playback_events(&score, &PlaybackOptions::default());
6017        assert_eq!(events.len(), 2);
6018        assert_eq!(
6019            (
6020                events[0].channel,
6021                events[0].program,
6022                events[0].instrument_id.as_deref()
6023            ),
6024            (1, 0, Some("piano"))
6025        );
6026        assert_eq!(
6027            (
6028                events[1].channel,
6029                events[1].program,
6030                events[1].instrument_id.as_deref()
6031            ),
6032            (2, 73, Some("flute"))
6033        );
6034        assert_eq!(events[0].pitch_midi, 60);
6035        assert_eq!(events[1].pitch_midi, 58);
6036    }
6037
6038    #[test]
6039    fn authored_midi_program_change_selects_the_next_note_at_its_source_tick() {
6040        let mut score = Score::new("program", 120, 4, 4, 0, 1);
6041        score.parts[0].staves[0].measures[0].voices[0] = vec![
6042            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
6043            Note::new(Pitch::new(Step::E, 4), Duration::Quarter),
6044        ];
6045        score.parts[0].midi_program_changes = vec![
6046            MidiProgramChange {
6047                tick: 480,
6048                channel: 1,
6049                program: 73,
6050            },
6051            MidiProgramChange {
6052                tick: 480,
6053                channel: 0,
6054                program: 40,
6055            },
6056        ];
6057        let events = to_playback_events(&score, &PlaybackOptions::default());
6058        assert_eq!(events.len(), 2);
6059        assert_eq!((events[0].program, events[1].program), (0, 40));
6060        assert_eq!((events[0].time_secs, events[1].time_secs), (0.0, 0.5));
6061    }
6062
6063    #[test]
6064    fn generic_playback_event_preserves_authored_guitar_technique() {
6065        let mut score = Score::new("technique", 120, 4, 4, 0, 1);
6066        let mut note = Note::new(Pitch::new(Step::E, 4), Duration::Quarter);
6067        note.guitar_technique = Some(GuitarTechnique::HammerOn);
6068        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
6069
6070        let event = to_playback_events(&score, &PlaybackOptions::default())
6071            .into_iter()
6072            .next()
6073            .expect("one note event");
6074        assert_eq!(event.guitar_technique, Some(GuitarTechnique::HammerOn));
6075    }
6076
6077    #[test]
6078    fn playback_timing_comparison_reports_tolerance_and_identity() {
6079        let expected = [comparison_event(1.0, 0.5)];
6080        let actual = [comparison_event(1.004, 0.503)];
6081        let report = compare_playback_timing(
6082            &expected,
6083            &actual,
6084            &PlaybackTimingTolerance {
6085                start_secs: 0.005,
6086                duration_secs: 0.005,
6087            },
6088        )
6089        .expect("comparison");
6090        assert!(report.within_tolerance);
6091        assert_eq!(report.matched_events, 1);
6092        assert_eq!(
6093            report.contract_version,
6094            PLAYBACK_COMPARISON_CONTRACT_VERSION
6095        );
6096
6097        let mut typed_only = comparison_event(1.004, 0.503);
6098        typed_only.address = None;
6099        let report = compare_playback_timing(
6100            &expected,
6101            &[typed_only],
6102            &PlaybackTimingTolerance::default(),
6103        )
6104        .expect("typed source comparison");
6105        assert_eq!(report.matched_events, 1);
6106
6107        let actual = [comparison_event(1.02, 0.6)];
6108        let report =
6109            compare_playback_timing(&expected, &actual, &PlaybackTimingTolerance::default())
6110                .expect("comparison");
6111        assert!(!report.within_tolerance);
6112        assert!(
6113            report
6114                .mismatches
6115                .iter()
6116                .any(|m| matches!(m, PlaybackTimingMismatch::StartTime { index: 0, .. }))
6117        );
6118        assert!(
6119            report
6120                .mismatches
6121                .iter()
6122                .any(|m| matches!(m, PlaybackTimingMismatch::Duration { index: 0, .. }))
6123        );
6124    }
6125
6126    #[test]
6127    fn playback_timing_comparison_rejects_instrument_route_mismatch() {
6128        let expected = comparison_event(0.0, 0.5);
6129        let mut actual = expected.clone();
6130        actual.program = 73;
6131        assert!(
6132            compare_playback_timing(&[expected], &[actual], &PlaybackTimingTolerance::default(),)
6133                .expect("comparison")
6134                .mismatches
6135                .iter()
6136                .any(|mismatch| matches!(
6137                    mismatch,
6138                    PlaybackTimingMismatch::EventIdentity { index: 0 }
6139                ))
6140        );
6141    }
6142
6143    #[test]
6144    fn playback_timing_comparison_rejects_invalid_tolerance() {
6145        let error = compare_playback_timing(
6146            &[],
6147            &[],
6148            &PlaybackTimingTolerance {
6149                start_secs: -0.1,
6150                duration_secs: 0.0,
6151            },
6152        );
6153        assert!(matches!(
6154            error,
6155            Err(crate::Error::InvalidPlaybackComparison)
6156        ));
6157    }
6158
6159    #[test]
6160    fn tablature_performance_projection_keeps_position_and_reports_pitch_error() {
6161        let mut score = Score::new("Tab", 120, 4, 4, 0, 1);
6162        score.parts[0].staves[0].tablature = Some(super::super::notation::TablatureConfig {
6163            lines: 6,
6164            tuning_midi: vec![40, 45, 50, 55, 59, 64],
6165            capo: 0,
6166        });
6167        let notes = &mut score.parts[0].staves[0].measures[0].voices[0];
6168        *notes = vec![crate::Note::new(
6169            crate::Pitch::new(crate::Step::C, 4),
6170            crate::Duration::Quarter,
6171        )];
6172        notes[0].tab_position = Some(super::super::notation::TabPosition {
6173            string: 1,
6174            fret: 20,
6175        });
6176        notes[0].guitar_technique = Some(super::super::notation::GuitarTechnique::Bend);
6177        notes[0].guitar_bend_alter_cents = Some(150);
6178        notes[0].guitar_bend_curve = vec![
6179            super::super::score::GuitarBendPoint {
6180                position_per_mille: 0,
6181                alter_cents: 0,
6182            },
6183            super::super::score::GuitarBendPoint {
6184                position_per_mille: 500,
6185                alter_cents: 150,
6186            },
6187            super::super::score::GuitarBendPoint {
6188                position_per_mille: 1_000,
6189                alter_cents: 0,
6190            },
6191        ];
6192        let report = project_tablature_performance(&score, &PlaybackOptions::default())
6193            .expect("tablature projection");
6194        assert_eq!(report.events.len(), 1);
6195        assert_eq!(report.events[0].string, 1);
6196        assert_eq!(report.events[0].fret, 20);
6197        assert_eq!(
6198            report.events[0].technique,
6199            Some(super::super::notation::GuitarTechnique::Bend)
6200        );
6201        assert_eq!(report.events[0].bend_alter_cents, Some(150));
6202        assert_eq!(report.events[0].bend_curve.len(), 3);
6203        assert_eq!(report.events[0].bend_curve[1].position_per_mille, 500);
6204        assert_eq!(report.events[0].bend_curve[1].alter_cents, 150);
6205        assert_eq!(
6206            report.events[0].playback.pitch_bend_curve,
6207            report.events[0].bend_curve
6208        );
6209        assert!(report.diagnostics.is_empty());
6210
6211        score.parts[0].staves[0].measures[0].voices[0][0].tab_position =
6212            Some(super::super::notation::TabPosition {
6213                string: 1,
6214                fret: 19,
6215            });
6216        let report = project_tablature_performance(&score, &PlaybackOptions::default())
6217            .expect("tablature projection");
6218        assert_eq!(report.events[0].pitch_error_cents, 100);
6219        assert!(matches!(
6220            report.diagnostics[0],
6221            TablaturePerformanceDiagnostic::PitchMismatch {
6222                error_cents: 100,
6223                ..
6224            }
6225        ));
6226    }
6227
6228    #[test]
6229    fn tablature_performance_projection_does_not_invent_missing_positions() {
6230        let mut score = Score::new("Tab", 120, 4, 4, 0, 1);
6231        score.parts[0].staves[0].tablature = Some(super::super::notation::TablatureConfig {
6232            lines: 6,
6233            tuning_midi: vec![40, 45, 50, 55, 59, 64],
6234            capo: 0,
6235        });
6236        score.parts[0].staves[0].measures[0].voices[0].push(crate::Note::new(
6237            crate::Pitch::new(crate::Step::C, 4),
6238            crate::Duration::Quarter,
6239        ));
6240        let report = project_tablature_performance(&score, &PlaybackOptions::default())
6241            .expect("tablature projection");
6242        assert!(report.events.is_empty());
6243        assert!(matches!(
6244            report.diagnostics[0],
6245            TablaturePerformanceDiagnostic::MissingPosition { .. }
6246        ));
6247    }
6248
6249    #[test]
6250    fn tablature_performance_event_accepts_legacy_json_without_technique() {
6251        let mut score = Score::new("Tab", 120, 4, 4, 0, 1);
6252        score.parts[0].staves[0].tablature = Some(super::super::notation::TablatureConfig {
6253            lines: 6,
6254            tuning_midi: vec![40, 45, 50, 55, 59, 64],
6255            capo: 0,
6256        });
6257        let mut note = crate::Note::new(
6258            crate::Pitch::new(crate::Step::C, 4),
6259            crate::Duration::Quarter,
6260        );
6261        note.tab_position = Some(super::super::notation::TabPosition {
6262            string: 1,
6263            fret: 20,
6264        });
6265        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
6266        let report = project_tablature_performance(&score, &PlaybackOptions::default())
6267            .expect("tablature projection");
6268        let mut legacy = serde_json::to_value(&report.events[0]).expect("event JSON");
6269        legacy
6270            .as_object_mut()
6271            .expect("event object")
6272            .remove("technique");
6273        let restored: TablaturePerformanceEvent =
6274            serde_json::from_value(legacy).expect("legacy event JSON");
6275        assert_eq!(restored.technique, None);
6276    }
6277
6278    #[test]
6279    fn tablature_round_trip_report_preserves_authored_positions() {
6280        let mut score = Score::new("Tab", 120, 4, 4, 0, 1);
6281        score.parts[0].staves[0].tablature = Some(super::super::notation::TablatureConfig {
6282            lines: 6,
6283            tuning_midi: vec![40, 45, 50, 55, 59, 64],
6284            capo: 2,
6285        });
6286        let mut note = crate::Note::new(
6287            crate::Pitch::new(crate::Step::C, 4),
6288            crate::Duration::Quarter,
6289        );
6290        note.tab_positions = vec![crate::TabPosition { string: 5, fret: 1 }];
6291        score.parts[0].staves[0].measures[0].voices[0] = vec![note];
6292        let report = tablature_round_trip_report(&score).expect("tab round-trip report");
6293        assert!(report.equivalent);
6294        assert_eq!(report.checked_notes, 1);
6295        assert_eq!(report.positioned_notes, 1);
6296        assert!(report.diagnostics.is_empty());
6297    }
6298
6299    #[test]
6300    fn tablature_performance_uses_measure_local_tuning_change() {
6301        let mut score = Score::new("Local tuning", 120, 4, 4, 0, 2);
6302        let staff = &mut score.parts[0].staves[0];
6303        staff.tablature = Some(super::super::notation::TablatureConfig {
6304            lines: 6,
6305            tuning_midi: vec![40, 45, 50, 55, 59, 64],
6306            capo: 0,
6307        });
6308        staff.measures[1].tablature_change = Some(super::super::notation::TablatureConfig {
6309            lines: 6,
6310            tuning_midi: vec![38, 45, 50, 55, 59, 64],
6311            capo: 0,
6312        });
6313        let mut note = Note::new(Pitch::new(Step::C, 3), Duration::Quarter);
6314        note.tab_position = Some(TabPosition {
6315            string: 1,
6316            fret: 10,
6317        });
6318        staff.measures[1].voices[0] = vec![note];
6319
6320        let report = project_tablature_performance(&score, &PlaybackOptions::default())
6321            .expect("tablature projection");
6322        assert_eq!(report.events.len(), 1);
6323        assert_eq!(report.events[0].expected_pitch_midi_cents, 4_800);
6324        assert!(!report.diagnostics.iter().any(|diagnostic| matches!(
6325            diagnostic,
6326            TablaturePerformanceDiagnostic::PitchMismatch { .. }
6327        )));
6328    }
6329}