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