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