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