Skip to main content

acorde_core/model/
playback.rs

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