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