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