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