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