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