1use super::duration::Duration;
2use super::notation::{Articulation, GuitarTechnique, TabPosition};
3use super::repeat::measure_sequence;
4use super::score::{NoteAddr, Score};
5use serde::{Deserialize, Serialize};
6
7fn default_fermata_multiplier() -> f64 {
8 1.5
9}
10fn default_swing_unit() -> Duration {
11 Duration::Eighth
12}
13fn default_metronome_channel() -> u8 {
14 9
15}
16fn default_accent_pitch() -> u8 {
17 76
18}
19fn default_beat_pitch() -> u8 {
20 77
21}
22fn default_accent_velocity() -> u8 {
23 100
24}
25fn default_beat_velocity() -> u8 {
26 70
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct MetronomeConfig {
35 #[serde(default = "default_metronome_channel")]
37 pub channel: u8,
38 #[serde(default = "default_accent_pitch")]
40 pub accent_pitch: u8,
41 #[serde(default = "default_beat_pitch")]
43 pub beat_pitch: u8,
44 #[serde(default = "default_accent_velocity")]
46 pub accent_velocity: u8,
47 #[serde(default = "default_beat_velocity")]
49 pub beat_velocity: u8,
50}
51
52impl Default for MetronomeConfig {
53 fn default() -> Self {
54 Self {
55 channel: 9,
56 accent_pitch: 76,
57 beat_pitch: 77,
58 accent_velocity: 100,
59 beat_velocity: 70,
60 }
61 }
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct PlaybackOptions {
67 pub bpm_override: Option<u16>,
69 pub muted_parts: Vec<usize>,
71 #[serde(default)]
75 pub loop_region: Option<(usize, usize)>,
76 #[serde(default = "default_fermata_multiplier")]
78 pub fermata_multiplier: f64,
79 #[serde(default)]
83 pub swing: Option<f64>,
84 #[serde(default = "default_swing_unit")]
87 pub swing_unit: Duration,
88 #[serde(default)]
91 pub metronome: Option<MetronomeConfig>,
92}
93
94impl Default for PlaybackOptions {
95 fn default() -> Self {
96 Self {
97 bpm_override: None,
98 muted_parts: Vec::new(),
99 loop_region: None,
100 fermata_multiplier: 1.5,
101 swing: None,
102 swing_unit: Duration::Eighth,
103 metronome: None,
104 }
105 }
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
112pub struct PlaybackEvent {
113 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub address: Option<String>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub source: Option<NoteAddr>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub source_voice_number: Option<u32>,
124 pub time_beats: f64,
126 pub time_secs: f64,
128 pub pitch_midi: u8,
130 #[serde(default)]
135 pub pitch_midi_cents: i32,
136 pub velocity: u8,
139 pub duration_beats: f64,
141 pub duration_secs: f64,
143 pub pedal: bool,
145 pub part_index: usize,
147 pub channel: u8,
149 #[serde(default)]
151 pub is_metronome: bool,
152}
153
154pub const PLAYBACK_COMPARISON_CONTRACT_VERSION: u16 = 1;
156pub const MAX_PLAYBACK_COMPARISON_EVENTS: usize = 1_000_000;
158const MAX_PLAYBACK_MISMATCHES: usize = 256;
159pub const MAX_TAB_PERFORMANCE_EVENTS: usize = 1_000_000;
161const MAX_TAB_PERFORMANCE_DIAGNOSTICS: usize = 1_024;
162
163#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
165pub struct PlaybackTimingTolerance {
166 pub start_secs: f64,
167 pub duration_secs: f64,
168}
169
170impl Default for PlaybackTimingTolerance {
171 fn default() -> Self {
172 Self {
173 start_secs: 0.005,
174 duration_secs: 0.005,
175 }
176 }
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
181pub enum PlaybackTimingMismatch {
182 EventCount { expected: usize, actual: usize },
183 EventIdentity { index: usize },
184 StartTime { index: usize, error_secs: f64 },
185 Duration { index: usize, error_secs: f64 },
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
190pub struct PlaybackTimingReport {
191 pub contract_version: u16,
192 pub expected_events: usize,
193 pub actual_events: usize,
194 pub matched_events: usize,
195 pub max_start_error_secs: f64,
196 pub max_duration_error_secs: f64,
197 pub within_tolerance: bool,
198 pub mismatches: Vec<PlaybackTimingMismatch>,
199}
200
201pub fn compare_playback_timing(
206 expected: &[PlaybackEvent],
207 actual: &[PlaybackEvent],
208 tolerance: &PlaybackTimingTolerance,
209) -> Result<PlaybackTimingReport, crate::Error> {
210 if !tolerance.start_secs.is_finite()
211 || tolerance.start_secs < 0.0
212 || !tolerance.duration_secs.is_finite()
213 || tolerance.duration_secs < 0.0
214 {
215 return Err(crate::Error::InvalidPlaybackComparison);
216 }
217 if expected.len() > MAX_PLAYBACK_COMPARISON_EVENTS {
218 return Err(crate::Error::PlaybackComparisonTooLarge(expected.len()));
219 }
220 if actual.len() > MAX_PLAYBACK_COMPARISON_EVENTS {
221 return Err(crate::Error::PlaybackComparisonTooLarge(actual.len()));
222 }
223 let mut mismatches = Vec::new();
224 if expected.len() != actual.len() {
225 mismatches.push(PlaybackTimingMismatch::EventCount {
226 expected: expected.len(),
227 actual: actual.len(),
228 });
229 }
230 let mut matched_events = 0;
231 let mut max_start_error_secs: f64 = 0.0;
232 let mut max_duration_error_secs: f64 = 0.0;
233 for (index, (expected_event, actual_event)) in expected.iter().zip(actual).enumerate() {
234 let source_identity_matches = match (&expected_event.source, &actual_event.source) {
235 (Some(expected), Some(actual)) => expected == actual,
236 _ => expected_event.address == actual_event.address,
237 };
238 let identity_matches = source_identity_matches
239 && expected_event.pitch_midi_cents == actual_event.pitch_midi_cents
240 && expected_event.velocity == actual_event.velocity
241 && expected_event.part_index == actual_event.part_index
242 && expected_event.channel == actual_event.channel
243 && expected_event.is_metronome == actual_event.is_metronome;
244 let start_error_secs = (expected_event.time_secs - actual_event.time_secs).abs();
245 let duration_error_secs = (expected_event.duration_secs - actual_event.duration_secs).abs();
246 if start_error_secs.is_finite() {
247 max_start_error_secs = max_start_error_secs.max(start_error_secs);
248 }
249 if duration_error_secs.is_finite() {
250 max_duration_error_secs = max_duration_error_secs.max(duration_error_secs);
251 }
252 let start_matches =
253 start_error_secs.is_finite() && start_error_secs <= tolerance.start_secs;
254 let duration_matches =
255 duration_error_secs.is_finite() && duration_error_secs <= tolerance.duration_secs;
256 if identity_matches && start_matches && duration_matches {
257 matched_events += 1;
258 continue;
259 }
260 if mismatches.len() < MAX_PLAYBACK_MISMATCHES {
261 if !identity_matches {
262 mismatches.push(PlaybackTimingMismatch::EventIdentity { index });
263 }
264 if !start_matches && mismatches.len() < MAX_PLAYBACK_MISMATCHES {
265 mismatches.push(PlaybackTimingMismatch::StartTime {
266 index,
267 error_secs: start_error_secs,
268 });
269 }
270 if !duration_matches && mismatches.len() < MAX_PLAYBACK_MISMATCHES {
271 mismatches.push(PlaybackTimingMismatch::Duration {
272 index,
273 error_secs: duration_error_secs,
274 });
275 }
276 }
277 }
278 Ok(PlaybackTimingReport {
279 contract_version: PLAYBACK_COMPARISON_CONTRACT_VERSION,
280 expected_events: expected.len(),
281 actual_events: actual.len(),
282 matched_events,
283 max_start_error_secs,
284 max_duration_error_secs,
285 within_tolerance: mismatches.is_empty(),
286 mismatches,
287 })
288}
289
290pub const TAB_PERFORMANCE_CONTRACT_VERSION: u16 = 3;
292
293#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
294pub struct TablaturePerformanceEvent {
295 pub playback: PlaybackEvent,
296 pub string: u8,
297 pub fret: u8,
298 #[serde(default)]
300 pub technique: Option<GuitarTechnique>,
301 #[serde(default)]
303 pub bend_alter_cents: Option<i16>,
304 pub expected_pitch_midi_cents: i32,
305 pub pitch_error_cents: i32,
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
309pub enum TablaturePerformanceDiagnostic {
310 NoTablatureStaff {
311 address: String,
312 },
313 MissingPosition {
314 address: String,
315 pitch_index: usize,
316 },
317 StringOutOfRange {
318 address: String,
319 string: u8,
320 lines: u8,
321 },
322 TuningUnavailable {
323 address: String,
324 string: u8,
325 },
326 PitchMismatch {
327 address: String,
328 pitch_index: usize,
329 error_cents: i32,
330 },
331}
332
333#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
334pub struct TablaturePerformanceReport {
335 pub contract_version: u16,
336 pub events: Vec<TablaturePerformanceEvent>,
337 pub diagnostics: Vec<TablaturePerformanceDiagnostic>,
338}
339
340pub const TAB_ROUND_TRIP_CONTRACT_VERSION: u16 = 1;
342
343#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
344pub struct TablatureRoundTripReport {
345 pub contract_version: u16,
346 pub checked_notes: usize,
347 pub positioned_notes: usize,
348 pub equivalent: bool,
349 pub diagnostics: Vec<TablatureRoundTripDiagnostic>,
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
353pub enum TablatureRoundTripDiagnostic {
354 StructureMismatch {
355 address: String,
356 },
357 TablatureConfigMismatch {
358 address: String,
359 },
360 PositionMismatch {
361 address: String,
362 pitch_index: usize,
363 expected: Option<TabPosition>,
364 actual: Option<TabPosition>,
365 },
366}
367
368pub fn tablature_round_trip_report(
373 score: &Score,
374) -> Result<TablatureRoundTripReport, crate::Error> {
375 let encoded = serde_json::to_string(score)
376 .map_err(|error| crate::Error::TabRoundTripSerialization(error.to_string()))?;
377 let restored: Score = serde_json::from_str(&encoded)
378 .map_err(|error| crate::Error::TabRoundTripSerialization(error.to_string()))?;
379 let mut checked_notes = 0;
380 let mut positioned_notes = 0;
381 let mut diagnostics = Vec::new();
382 for (part_index, part) in score.parts.iter().enumerate() {
383 let Some(restored_part) = restored.parts.get(part_index) else {
384 diagnostics.push(TablatureRoundTripDiagnostic::StructureMismatch {
385 address: format!("{part_index}"),
386 });
387 continue;
388 };
389 for (staff_index, staff) in part.staves.iter().enumerate() {
390 let address = format!("{part_index}:{staff_index}");
391 let Some(restored_staff) = restored_part.staves.get(staff_index) else {
392 diagnostics.push(TablatureRoundTripDiagnostic::StructureMismatch { address });
393 continue;
394 };
395 if staff.tablature != restored_staff.tablature {
396 diagnostics.push(TablatureRoundTripDiagnostic::TablatureConfigMismatch {
397 address: address.clone(),
398 });
399 }
400 for (measure_index, measure) in staff.measures.iter().enumerate() {
401 let Some(restored_measure) = restored_staff.measures.get(measure_index) else {
402 diagnostics.push(TablatureRoundTripDiagnostic::StructureMismatch {
403 address: format!("{address}:{measure_index}"),
404 });
405 continue;
406 };
407 for (voice_index, voice) in measure.voices.iter().enumerate() {
408 let Some(restored_voice) = restored_measure.voices.get(voice_index) else {
409 diagnostics.push(TablatureRoundTripDiagnostic::StructureMismatch {
410 address: format!("{address}:{measure_index}:{voice_index}"),
411 });
412 continue;
413 };
414 for (note_index, note) in voice.iter().enumerate() {
415 checked_notes += 1;
416 if !note.tab_positions.is_empty() || note.tab_position.is_some() {
417 positioned_notes += 1;
418 }
419 let note_address =
420 format!("{address}:{measure_index}:{voice_index}:{note_index}");
421 let Some(restored_note) = restored_voice.get(note_index) else {
422 diagnostics.push(TablatureRoundTripDiagnostic::StructureMismatch {
423 address: note_address,
424 });
425 continue;
426 };
427 let pitch_count = note.pitches.len().max(note.tab_positions.len()).max(1);
428 for pitch_index in 0..pitch_count {
429 let expected = note
430 .tab_positions
431 .get(pitch_index)
432 .cloned()
433 .or_else(|| note.tab_position.clone());
434 let actual = restored_note
435 .tab_positions
436 .get(pitch_index)
437 .cloned()
438 .or_else(|| restored_note.tab_position.clone());
439 if expected != actual {
440 diagnostics.push(TablatureRoundTripDiagnostic::PositionMismatch {
441 address: note_address.clone(),
442 pitch_index,
443 expected,
444 actual,
445 });
446 }
447 }
448 }
449 }
450 }
451 }
452 }
453 Ok(TablatureRoundTripReport {
454 contract_version: TAB_ROUND_TRIP_CONTRACT_VERSION,
455 checked_notes,
456 positioned_notes,
457 equivalent: diagnostics.is_empty(),
458 diagnostics,
459 })
460}
461
462pub fn project_tablature_performance(
468 score: &Score,
469 options: &PlaybackOptions,
470) -> Result<TablaturePerformanceReport, crate::Error> {
471 let playback = to_playback_events(score, options);
472 if playback.len() > MAX_TAB_PERFORMANCE_EVENTS {
473 return Err(crate::Error::TabPerformanceTooLarge(playback.len()));
474 }
475 let mut events = Vec::new();
476 let mut diagnostics = Vec::new();
477 for event in playback {
478 let Some(address) = event.address.as_deref() else {
479 continue;
480 };
481 let Some((part_index, staff_index, measure_index, voice_index, note_index)) =
482 parse_playback_address(address)
483 else {
484 continue;
485 };
486 let Some(staff) = score
487 .parts
488 .get(part_index)
489 .and_then(|part| part.staves.get(staff_index))
490 else {
491 continue;
492 };
493 let Some(note) = staff
494 .measures
495 .get(measure_index)
496 .and_then(|measure| measure.voices.get(voice_index))
497 .and_then(|voice| voice.get(note_index))
498 else {
499 continue;
500 };
501 let Some(tab) = staff.tablature.as_ref() else {
502 push_tab_diagnostic(
503 &mut diagnostics,
504 TablaturePerformanceDiagnostic::NoTablatureStaff {
505 address: address.into(),
506 },
507 );
508 continue;
509 };
510 let transpose_cents = if event.channel == 9 {
511 0
512 } else {
513 i32::from(staff.transpose_semitones) * 100
514 };
515 let written_pitch_cents = event.pitch_midi_cents - transpose_cents;
516 let pitch_index = note
517 .pitches
518 .iter()
519 .position(|pitch| pitch.to_midi_cents() == written_pitch_cents)
520 .unwrap_or(0);
521 let position = note
522 .tab_positions
523 .get(pitch_index)
524 .or(note.tab_position.as_ref());
525 let Some(position) = position else {
526 push_tab_diagnostic(
527 &mut diagnostics,
528 TablaturePerformanceDiagnostic::MissingPosition {
529 address: address.into(),
530 pitch_index,
531 },
532 );
533 continue;
534 };
535 if position.string == 0 || position.string > tab.lines {
536 push_tab_diagnostic(
537 &mut diagnostics,
538 TablaturePerformanceDiagnostic::StringOutOfRange {
539 address: address.into(),
540 string: position.string,
541 lines: tab.lines,
542 },
543 );
544 continue;
545 }
546 let Some(tuning) = tab.tuning_midi.get(usize::from(position.string - 1)) else {
547 push_tab_diagnostic(
548 &mut diagnostics,
549 TablaturePerformanceDiagnostic::TuningUnavailable {
550 address: address.into(),
551 string: position.string,
552 },
553 );
554 continue;
555 };
556 let expected_pitch_midi_cents =
557 tuning.saturating_add(i16::from(position.fret) + i16::from(tab.capo)) as i32 * 100;
558 let pitch_error_cents = event.pitch_midi_cents - expected_pitch_midi_cents;
559 if pitch_error_cents != 0 {
560 push_tab_diagnostic(
561 &mut diagnostics,
562 TablaturePerformanceDiagnostic::PitchMismatch {
563 address: address.into(),
564 pitch_index,
565 error_cents: pitch_error_cents,
566 },
567 );
568 }
569 events.push(TablaturePerformanceEvent {
570 playback: event,
571 string: position.string,
572 fret: position.fret,
573 technique: note.guitar_technique.clone(),
574 bend_alter_cents: note.guitar_bend_alter_cents,
575 expected_pitch_midi_cents,
576 pitch_error_cents,
577 });
578 }
579 Ok(TablaturePerformanceReport {
580 contract_version: TAB_PERFORMANCE_CONTRACT_VERSION,
581 events,
582 diagnostics,
583 })
584}
585
586fn parse_playback_address(address: &str) -> Option<(usize, usize, usize, usize, usize)> {
587 let mut fields = address.split(':').map(|field| field.parse::<usize>().ok());
588 Some((
589 fields.next()??,
590 fields.next()??,
591 fields.next()??,
592 fields.next()??,
593 fields.next()??,
594 ))
595}
596
597fn push_tab_diagnostic(
598 diagnostics: &mut Vec<TablaturePerformanceDiagnostic>,
599 diagnostic: TablaturePerformanceDiagnostic,
600) {
601 if diagnostics.len() < MAX_TAB_PERFORMANCE_DIAGNOSTICS {
602 diagnostics.push(diagnostic);
603 }
604}
605
606pub fn to_playback_events(score: &Score, options: &PlaybackOptions) -> Vec<PlaybackEvent> {
612 let bpm = options
613 .bpm_override
614 .unwrap_or(score.settings.tempo_bpm)
615 .max(1) as f64;
616 let full_seq = measure_sequence(score);
617 let seq: Vec<usize> = if let Some((lo, hi)) = options.loop_region {
618 full_seq
619 .into_iter()
620 .filter(|&idx| idx >= lo && idx <= hi)
621 .collect()
622 } else {
623 full_seq
624 };
625 let mut events: Vec<PlaybackEvent> = Vec::new();
626
627 for (part_index, part) in score.parts.iter().enumerate() {
628 if options.muted_parts.contains(&part_index) {
629 continue;
630 }
631 for (staff_index, staff) in part.staves.iter().enumerate() {
632 for voice_idx in 0..4usize {
633 let mut time_beats = 0.0f64;
634 let mut time_secs_cursor = 0.0f64;
635 let mut current_bpm = bpm;
636 for &idx in &seq {
637 let measure = match staff.measures.get(idx) {
638 Some(m) => m,
639 None => continue,
640 };
641 if let Some(b) = measure.tempo {
642 current_bpm = b.max(1) as f64;
643 }
644 let measure_beats = measure
645 .time_sig
646 .as_ref()
647 .unwrap_or(&score.settings.time_signature)
648 .total_beats();
649 let measure_start_beats = time_beats;
650 let measure_start_secs = time_secs_cursor;
651 let mut local_beats = 0.0f64;
652 let mut local_secs = 0.0f64;
653 let mut swing_first = true;
654 for (note_index, note) in measure.voices[voice_idx].iter().enumerate() {
655 if note.is_grace {
656 continue;
657 }
658 let dur = match options.swing {
659 Some(ratio)
660 if note.tuplet.is_none()
661 && note.dot_count == 0
662 && note.duration == options.swing_unit =>
663 {
664 let pair = note.beats() * 2.0;
665 let d = if swing_first {
666 ratio * pair
667 } else {
668 (1.0 - ratio) * pair
669 };
670 swing_first = !swing_first;
671 d
672 }
673 Some(_) => {
674 swing_first = true;
675 note.beats()
676 }
677 None => note.beats(),
678 };
679 if !note.is_rest {
680 let mut velocity = note
681 .dynamic
682 .as_ref()
683 .map(|d| d.to_velocity())
684 .unwrap_or(64u8);
685 let mut sounding_dur = dur;
686 for art in ¬e.articulations {
687 match art {
688 Articulation::Staccato | Articulation::Staccatissimo => {
689 sounding_dur *= 0.5;
690 }
691 Articulation::Accent | Articulation::Marcato => {
692 velocity = velocity.saturating_add(20).min(127);
693 }
694 Articulation::Fermata => {
695 sounding_dur *= options.fermata_multiplier;
696 }
697 _ => {}
698 }
699 }
700 let pedal = note.pedal_start;
701 let transpose = if part.midi_channel == 9 {
702 0i8
703 } else {
704 staff.transpose_semitones
705 };
706 for pitch in ¬e.pitches {
707 let midi = (pitch.to_midi() + transpose as i16).clamp(0, 127) as u8;
708 events.push(PlaybackEvent {
709 address: Some(format!(
710 "{part_index}:{staff_index}:{idx}:{voice_idx}:{note_index}"
711 )),
712 source: Some(NoteAddr {
713 part: part_index,
714 staff: staff_index,
715 measure: idx,
716 voice: voice_idx,
717 note: note_index,
718 }),
719 source_voice_number: measure.source_voice_numbers[voice_idx],
720 time_beats: measure_start_beats + local_beats,
721 time_secs: measure_start_secs + local_secs,
722 pitch_midi: midi,
723 pitch_midi_cents: pitch.to_midi_cents()
724 + transpose as i32 * 100,
725 velocity,
726 duration_beats: sounding_dur,
727 duration_secs: sounding_dur / current_bpm * 60.0,
728 pedal,
729 part_index,
730 channel: part.midi_channel,
731 is_metronome: false,
732 });
733 }
734 }
735 local_beats += dur;
736 local_secs += dur / current_bpm * 60.0;
737 }
738 time_beats = measure_start_beats + measure_beats;
742 time_secs_cursor = measure_start_secs + measure_beats / current_bpm * 60.0;
743 }
744 }
745 }
746 }
747
748 if let Some(ref metro) = options.metronome {
749 let mut cursor_secs = 0.0f64;
750 let mut cursor_beats = 0.0f64;
751 let mut metro_bpm = bpm;
752 for &idx in &seq {
753 let first_staff = score.parts.first().and_then(|p| p.staves.first());
754 if let Some(t) = first_staff
755 .and_then(|s| s.measures.get(idx))
756 .and_then(|m| m.tempo)
757 {
758 metro_bpm = t.max(1) as f64;
759 }
760 let ts = first_staff
761 .and_then(|s| s.measures.get(idx))
762 .and_then(|m| m.time_sig.as_ref())
763 .unwrap_or(&score.settings.time_signature);
764 let beat_unit = ts.beat_unit_beats();
765 let num_beats = (ts.total_beats() / beat_unit).round() as u32;
766 for b in 0..num_beats {
767 let is_accent = b == 0;
768 let beat_offset_secs = b as f64 * beat_unit / metro_bpm * 60.0;
769 events.push(PlaybackEvent {
770 address: None,
771 source: None,
772 source_voice_number: None,
773 time_beats: cursor_beats + b as f64 * beat_unit,
774 time_secs: cursor_secs + beat_offset_secs,
775 pitch_midi: if is_accent {
776 metro.accent_pitch
777 } else {
778 metro.beat_pitch
779 },
780 pitch_midi_cents: i32::from(if is_accent {
781 metro.accent_pitch
782 } else {
783 metro.beat_pitch
784 }) * 100,
785 velocity: if is_accent {
786 metro.accent_velocity
787 } else {
788 metro.beat_velocity
789 },
790 duration_beats: beat_unit * 0.1,
791 duration_secs: beat_unit * 0.1 / metro_bpm * 60.0,
792 pedal: false,
793 part_index: usize::MAX,
794 channel: metro.channel,
795 is_metronome: true,
796 });
797 }
798 let measure_beats = ts.total_beats();
799 cursor_secs += measure_beats / metro_bpm * 60.0;
800 cursor_beats += measure_beats;
801 }
802 }
803
804 events = merge_tied_events(score, events);
805 events.sort_by(|a, b| {
806 a.time_beats
807 .partial_cmp(&b.time_beats)
808 .unwrap_or(std::cmp::Ordering::Equal)
809 });
810 events
811}
812
813fn merge_tied_events(score: &Score, events: Vec<PlaybackEvent>) -> Vec<PlaybackEvent> {
820 use std::collections::HashMap;
821
822 let mut merged = Vec::with_capacity(events.len());
823 let mut pending: HashMap<(usize, usize, usize, i32), usize> = HashMap::new();
824 for event in events {
825 let Some(source) = event.source.as_ref() else {
826 merged.push(event);
827 continue;
828 };
829 let tied_note = score
830 .parts
831 .get(source.part)
832 .and_then(|part| part.staves.get(source.staff))
833 .and_then(|staff| staff.measures.get(source.measure))
834 .and_then(|measure| measure.voices.get(source.voice))
835 .and_then(|voice| voice.get(source.note));
836 let Some(note) = tied_note else {
837 merged.push(event);
838 continue;
839 };
840 let key = (
841 source.part,
842 source.staff,
843 source.voice,
844 event.pitch_midi_cents,
845 );
846 if note.tie_end
847 && pending.get(&key).is_some_and(|&index| {
848 merged.get(index).is_some_and(|previous| {
849 (previous.time_beats + previous.duration_beats - event.time_beats).abs() < 1e-9
850 })
851 })
852 {
853 let index = pending[&key];
854 let previous = &mut merged[index];
855 previous.duration_beats += event.duration_beats;
856 previous.duration_secs += event.duration_secs;
857 if !note.tie_start {
858 pending.remove(&key);
859 }
860 continue;
861 }
862
863 let index = merged.len();
864 merged.push(event);
865 if note.tie_start {
866 pending.insert(key, index);
867 } else {
868 pending.remove(&key);
869 }
870 }
871 merged
872}
873
874pub fn to_playback_events_bounded(
876 score: &Score,
877 options: &PlaybackOptions,
878) -> Result<Vec<PlaybackEvent>, crate::Error> {
879 let events = to_playback_events(score, options);
880 if events.len() > MAX_PLAYBACK_COMPARISON_EVENTS {
881 return Err(crate::Error::PlaybackComparisonTooLarge(events.len()));
882 }
883 Ok(events)
884}
885
886#[derive(Debug, Clone, Serialize, Deserialize)]
890pub struct PlaybackPosition {
891 pub measure_index: usize,
893 pub beat: f64,
895}
896
897struct MeasureSegment {
898 measure_idx: usize,
899 start_secs: f64,
900 duration_secs: f64,
901 beats: f64,
902 bpm: f64,
903}
904
905fn build_measure_segments(score: &Score, options: &PlaybackOptions) -> Vec<MeasureSegment> {
906 let init_bpm = options
907 .bpm_override
908 .unwrap_or(score.settings.tempo_bpm)
909 .max(1) as f64;
910 let full_seq = measure_sequence(score);
911 let seq: Vec<usize> = if let Some((lo, hi)) = options.loop_region {
912 full_seq
913 .into_iter()
914 .filter(|&i| i >= lo && i <= hi)
915 .collect()
916 } else {
917 full_seq
918 };
919
920 let mut segments = Vec::with_capacity(seq.len());
921 let mut cursor_secs = 0.0f64;
922 let mut current_bpm = init_bpm;
923
924 for idx in seq {
925 let first_measure = score
926 .parts
927 .first()
928 .and_then(|p| p.staves.first())
929 .and_then(|s| s.measures.get(idx));
930 if let Some(t) = first_measure.and_then(|m| m.tempo) {
931 current_bpm = t.max(1) as f64;
932 }
933 let ts = first_measure
934 .and_then(|m| m.time_sig.as_ref())
935 .unwrap_or(&score.settings.time_signature);
936 let beats = ts.total_beats();
937 let duration_secs = beats / current_bpm * 60.0;
938
939 segments.push(MeasureSegment {
940 measure_idx: idx,
941 start_secs: cursor_secs,
942 duration_secs,
943 beats,
944 bpm: current_bpm,
945 });
946 cursor_secs += duration_secs;
947 }
948 segments
949}
950
951pub fn compute_playback_position(
957 score: &Score,
958 options: &PlaybackOptions,
959 elapsed_secs: f64,
960) -> Option<PlaybackPosition> {
961 if elapsed_secs < 0.0 {
962 return None;
963 }
964 let segments = build_measure_segments(score, options);
965 for seg in &segments {
966 if elapsed_secs < seg.start_secs + seg.duration_secs + 1e-9 {
967 let beat = ((elapsed_secs - seg.start_secs) * seg.bpm / 60.0).clamp(0.0, seg.beats);
968 return Some(PlaybackPosition {
969 measure_index: seg.measure_idx,
970 beat,
971 });
972 }
973 }
974 None
975}
976
977#[cfg(test)]
978mod tests {
979 use super::*;
980 use crate::model::{
981 duration::Duration,
982 pitch::{Pitch, Step},
983 score::{Note, Score},
984 };
985
986 fn opts(bpm: Option<u16>) -> PlaybackOptions {
987 PlaybackOptions {
988 bpm_override: bpm,
989 ..Default::default()
990 }
991 }
992
993 #[test]
994 fn bounded_playback_events_match_unbounded_schedule_within_limit() {
995 let score = Score::new("bounded", 120, 4, 4, 0, 1);
996 let options = opts(Some(120));
997 let unbounded = to_playback_events(&score, &options);
998 let bounded = to_playback_events_bounded(&score, &options).expect("bounded schedule");
999 assert_eq!(bounded, unbounded);
1000 }
1001
1002 #[test]
1003 fn empty_score_no_events() {
1004 let score = Score::new("T", 120, 4, 4, 0, 1);
1005 assert!(to_playback_events(&score, &opts(None)).is_empty());
1006 }
1007
1008 #[test]
1009 fn single_note_at_beat_zero() {
1010 let mut score = Score::new("T", 120, 4, 4, 0, 1);
1011 score.parts[0].staves[0].measures[0].voices[0] =
1012 vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
1013 let events = to_playback_events(&score, &opts(None));
1014 assert_eq!(events.len(), 1);
1015 assert_eq!(events[0].address.as_deref(), Some("0:0:0:0:0"));
1016 assert_eq!(
1017 events[0].source,
1018 Some(NoteAddr {
1019 part: 0,
1020 staff: 0,
1021 measure: 0,
1022 voice: 0,
1023 note: 0,
1024 })
1025 );
1026 assert!((events[0].time_beats).abs() < 1e-9);
1027 assert_eq!(events[0].pitch_midi, 60);
1028 assert_eq!(events[0].velocity, 64);
1029 assert!((events[0].duration_beats - 1.0).abs() < 1e-9);
1030 assert_eq!(events[0].part_index, 0);
1031 }
1032
1033 #[test]
1034 fn tied_notes_are_one_continuous_playback_event() {
1035 let mut score = Score::new("T", 120, 4, 4, 0, 1);
1036 let mut first = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1037 first.tie_start = true;
1038 let mut second = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1039 second.tie_end = true;
1040 score.parts[0].staves[0].measures[0].voices[0] = vec![first, second];
1041
1042 let events = to_playback_events(&score, &opts(None));
1043 assert_eq!(events.len(), 1);
1044 assert_eq!(
1045 events[0].source.as_ref().map(|address| address.note),
1046 Some(0)
1047 );
1048 assert!((events[0].time_beats).abs() < 1e-9);
1049 assert!((events[0].duration_beats - 2.0).abs() < 1e-9);
1050 assert!((events[0].duration_secs - 1.0).abs() < 1e-9);
1051 }
1052
1053 #[test]
1054 fn ties_cross_measure_boundaries_and_tempo_changes_without_retriggering() {
1055 let mut score = Score::new("T", 120, 4, 4, 0, 2);
1056 let first_measure = &mut score.parts[0].staves[0].measures[0];
1057 let mut first = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
1058 first.tie_start = true;
1059 first_measure.voices[0] = vec![first];
1060
1061 let second_measure = &mut score.parts[0].staves[0].measures[1];
1062 second_measure.tempo = Some(60);
1063 let mut second = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
1064 second.tie_end = true;
1065 second_measure.voices[0] = vec![second];
1066
1067 let events = to_playback_events(&score, &opts(None));
1068 assert_eq!(events.len(), 1);
1069 assert_eq!(
1070 events[0].source.as_ref().map(|address| address.measure),
1071 Some(0)
1072 );
1073 assert!((events[0].time_beats).abs() < 1e-9);
1074 assert!((events[0].duration_beats - 8.0).abs() < 1e-9);
1075 assert!((events[0].duration_secs - 6.0).abs() < 1e-9);
1076 }
1077
1078 #[test]
1079 fn malformed_tie_end_does_not_drop_playback_event() {
1080 let mut score = Score::new("T", 120, 4, 4, 0, 1);
1081 let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1082 note.tie_end = true;
1083 score.parts[0].staves[0].measures[0].voices[0] = vec![note];
1084 let events = to_playback_events(&score, &opts(None));
1085 assert_eq!(events.len(), 1);
1086 assert!((events[0].duration_beats - 1.0).abs() < 1e-9);
1087 }
1088
1089 #[test]
1090 fn microtonal_playback_event_keeps_exact_midi_cents() {
1091 let mut score = Score::new("T", 120, 4, 4, 0, 1);
1092 score.parts[0].staves[0].measures[0].voices[0] = vec![Note::new(
1093 Pitch::with_microtone(Step::C, 4, 0, 50),
1094 Duration::Quarter,
1095 )];
1096 let events = to_playback_events(&score, &opts(None));
1097 assert_eq!(events[0].pitch_midi, 61);
1098 assert_eq!(events[0].pitch_midi_cents, 6050);
1099 }
1100
1101 #[test]
1102 fn chord_expands_to_multiple_events() {
1103 let mut score = Score::new("T", 120, 4, 4, 0, 1);
1104 let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1105 note.pitches.push(Pitch::new(Step::E, 4));
1106 note.pitches.push(Pitch::new(Step::G, 4));
1107 score.parts[0].staves[0].measures[0].voices[0] = vec![note];
1108 let events = to_playback_events(&score, &opts(None));
1109 assert_eq!(events.len(), 3);
1110 assert!(
1111 events
1112 .iter()
1113 .all(|event| event.address.as_deref() == Some("0:0:0:0:0"))
1114 );
1115 assert!(events.iter().all(|e| e.time_beats.abs() < 1e-9));
1116 }
1117
1118 #[test]
1119 fn grace_notes_excluded() {
1120 let mut score = Score::new("T", 120, 4, 4, 0, 1);
1121 let mut grace = Note::new(Pitch::new(Step::D, 4), Duration::Eighth);
1122 grace.is_grace = true;
1123 let regular = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1124 score.parts[0].staves[0].measures[0].voices[0] = vec![grace, regular];
1125 let events = to_playback_events(&score, &opts(None));
1126 assert_eq!(events.len(), 1);
1127 assert_eq!(events[0].pitch_midi, 60);
1128 }
1129
1130 #[test]
1131 fn metronome_events_have_no_source_address() {
1132 let score = Score::new("T", 120, 4, 4, 0, 1);
1133 let options = PlaybackOptions {
1134 metronome: Some(MetronomeConfig::default()),
1135 ..Default::default()
1136 };
1137 let events = to_playback_events(&score, &options);
1138 assert!(!events.is_empty());
1139 assert!(events.iter().all(|event| event.address.is_none()));
1140 }
1141
1142 #[test]
1143 fn second_note_has_correct_time() {
1144 let mut score = Score::new("T", 120, 4, 4, 0, 1);
1145 score.parts[0].staves[0].measures[0].voices[0] = vec![
1146 Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
1147 Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
1148 ];
1149 let events = to_playback_events(&score, &opts(None));
1150 assert_eq!(events.len(), 2);
1151 assert!((events[0].time_beats).abs() < 1e-9);
1152 assert!((events[1].time_beats - 1.0).abs() < 1e-9);
1153 }
1154
1155 #[test]
1156 fn sparse_voice_keeps_measure_boundaries() {
1157 let mut score = Score::new("T", 120, 4, 4, 0, 2);
1158 let note = || Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1159 score.parts[0].staves[0].measures[0].voices[1] = vec![note()];
1160 score.parts[0].staves[0].measures[1].voices[1] = vec![note()];
1161
1162 let events = to_playback_events(&score, &opts(None));
1163 assert_eq!(events.len(), 2);
1164 assert!((events[0].time_beats).abs() < 1e-9);
1165 assert!((events[1].time_beats - 4.0).abs() < 1e-9);
1166 assert!((events[1].time_secs - 2.0).abs() < 1e-9);
1167 }
1168
1169 #[test]
1170 fn time_secs_120_bpm_quarter_note_is_half_second() {
1171 let mut score = Score::new("T", 120, 4, 4, 0, 1);
1172 score.parts[0].staves[0].measures[0].voices[0] =
1173 vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
1174 let events = to_playback_events(&score, &opts(None));
1175 assert!((events[0].time_secs).abs() < 1e-9);
1176 assert!((events[0].duration_secs - 0.5).abs() < 1e-9);
1177 }
1178
1179 #[test]
1180 fn bpm_override_changes_time_secs() {
1181 let mut score = Score::new("T", 120, 4, 4, 0, 1);
1182 score.parts[0].staves[0].measures[0].voices[0] = vec![
1183 Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
1184 Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
1185 ];
1186 let events = to_playback_events(&score, &opts(Some(60)));
1187 assert!((events[0].time_secs).abs() < 1e-9);
1188 assert!((events[1].time_secs - 1.0).abs() < 1e-9);
1189 }
1190
1191 #[test]
1192 fn transpose_semitones_shifts_midi_output() {
1193 let mut score = Score::new("T", 120, 4, 4, 0, 1);
1194 score.parts[0].staves[0].transpose_semitones = -2;
1195 score.parts[0].staves[0].measures[0].voices[0] =
1196 vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
1197 let events = to_playback_events(&score, &opts(None));
1198 assert_eq!(events.len(), 1);
1199 assert_eq!(events[0].pitch_midi, 58);
1200 }
1201
1202 #[test]
1203 fn percussion_channel_9_ignores_transpose_semitones() {
1204 let mut score = Score::new("T", 120, 4, 4, 0, 1);
1205 score.parts[0].midi_channel = 9;
1206 score.parts[0].staves[0].transpose_semitones = -2;
1207 score.parts[0].staves[0].measures[0].voices[0] =
1208 vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
1209 let events = to_playback_events(&score, &opts(None));
1210 assert_eq!(events.len(), 1);
1211 assert_eq!(events[0].pitch_midi, 60);
1212 }
1213
1214 #[test]
1215 fn staccato_halves_duration_beats() {
1216 let mut score = Score::new("T", 120, 4, 4, 0, 1);
1217 let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1218 note.articulations
1219 .push(crate::model::notation::Articulation::Staccato);
1220 score.parts[0].staves[0].measures[0].voices[0] = vec![note];
1221 let events = to_playback_events(&score, &opts(None));
1222 assert_eq!(events.len(), 1);
1223 assert!((events[0].duration_beats - 0.5).abs() < 1e-9);
1224 }
1225
1226 #[test]
1227 fn staccatissimo_also_halves_duration() {
1228 let mut score = Score::new("T", 120, 4, 4, 0, 1);
1229 let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1230 note.articulations
1231 .push(crate::model::notation::Articulation::Staccatissimo);
1232 score.parts[0].staves[0].measures[0].voices[0] = vec![note];
1233 let events = to_playback_events(&score, &opts(None));
1234 assert!((events[0].duration_beats - 0.5).abs() < 1e-9);
1235 }
1236
1237 #[test]
1238 fn staccato_does_not_shift_next_note_time() {
1239 let mut score = Score::new("T", 120, 4, 4, 0, 1);
1240 let mut n1 = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1241 n1.articulations
1242 .push(crate::model::notation::Articulation::Staccato);
1243 let n2 = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
1244 score.parts[0].staves[0].measures[0].voices[0] = vec![n1, n2];
1245 let events = to_playback_events(&score, &opts(None));
1246 assert_eq!(events.len(), 2);
1247 assert!((events[1].time_beats - 1.0).abs() < 1e-9);
1248 }
1249
1250 #[test]
1251 fn accent_boosts_velocity_clamped() {
1252 let mut score = Score::new("T", 120, 4, 4, 0, 1);
1253 let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1254 note.articulations
1255 .push(crate::model::notation::Articulation::Accent);
1256 score.parts[0].staves[0].measures[0].voices[0] = vec![note];
1257 let events = to_playback_events(&score, &opts(None));
1258 assert_eq!(events[0].velocity, 84);
1259 }
1260
1261 #[test]
1262 fn accent_clamped_at_127() {
1263 let mut score = Score::new("T", 120, 4, 4, 0, 1);
1264 let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1265 note.dynamic = Some(crate::model::notation::Dynamic::Ffff);
1266 note.articulations
1267 .push(crate::model::notation::Articulation::Accent);
1268 score.parts[0].staves[0].measures[0].voices[0] = vec![note];
1269 let events = to_playback_events(&score, &opts(None));
1270 assert_eq!(events[0].velocity, 127);
1271 }
1272
1273 #[test]
1274 fn tenuto_keeps_full_duration() {
1275 let mut score = Score::new("T", 120, 4, 4, 0, 1);
1276 let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1277 note.articulations
1278 .push(crate::model::notation::Articulation::Tenuto);
1279 score.parts[0].staves[0].measures[0].voices[0] = vec![note];
1280 let events = to_playback_events(&score, &opts(None));
1281 assert!((events[0].duration_beats - 1.0).abs() < 1e-9);
1282 }
1283
1284 #[test]
1285 fn pedal_start_sets_pedal_field() {
1286 let mut score = Score::new("T", 120, 4, 4, 0, 1);
1287 let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1288 note.pedal_start = true;
1289 score.parts[0].staves[0].measures[0].voices[0] = vec![note];
1290 let events = to_playback_events(&score, &opts(None));
1291 assert!(events[0].pedal);
1292 }
1293
1294 #[test]
1295 fn no_pedal_start_pedal_is_false() {
1296 let mut score = Score::new("T", 120, 4, 4, 0, 1);
1297 score.parts[0].staves[0].measures[0].voices[0] =
1298 vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
1299 let events = to_playback_events(&score, &opts(None));
1300 assert!(!events[0].pedal);
1301 }
1302
1303 #[test]
1304 fn set_tempo_at_measure_changes_time_secs() {
1305 let mut score = Score::new("T", 120, 4, 4, 0, 2);
1306 score.parts[0].staves[0].measures[0].voices[0] =
1307 vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1308 score.parts[0].staves[0].measures[1].tempo = Some(60);
1309 score.parts[0].staves[0].measures[1].voices[0] =
1310 vec![Note::new(Pitch::new(Step::D, 4), Duration::Whole)];
1311 let events = to_playback_events(&score, &opts(None));
1312 assert_eq!(events.len(), 2);
1313 assert!((events[0].time_secs).abs() < 1e-9);
1314 assert!((events[0].duration_secs - 2.0).abs() < 1e-9);
1315 assert!((events[1].time_secs - 2.0).abs() < 1e-9);
1316 assert!((events[1].duration_secs - 4.0).abs() < 1e-9);
1317 }
1318
1319 #[test]
1320 fn muted_part_produces_no_events() {
1321 let mut score = Score::new("T", 120, 4, 4, 0, 1);
1322 score.parts[0].staves[0].measures[0].voices[0] =
1323 vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
1324 let options = PlaybackOptions {
1325 muted_parts: vec![0],
1326 ..Default::default()
1327 };
1328 assert!(to_playback_events(&score, &options).is_empty());
1329 }
1330
1331 #[test]
1332 fn part_index_field_set_correctly() {
1333 let mut score = Score::new("T", 120, 4, 4, 0, 1);
1334 score.parts[0].staves[0].measures[0].voices[0] =
1335 vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
1336 let events = to_playback_events(&score, &opts(None));
1337 assert_eq!(events[0].part_index, 0);
1338 }
1339
1340 #[test]
1343 fn loop_region_filters_measures() {
1344 let mut score = Score::new("T", 120, 4, 4, 0, 3);
1346 for mi in 0..3 {
1347 score.parts[0].staves[0].measures[mi].voices[0] =
1348 vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1349 }
1350 let options = PlaybackOptions {
1351 loop_region: Some((1, 2)),
1352 ..Default::default()
1353 };
1354 let events = to_playback_events(&score, &options);
1355 assert_eq!(events.len(), 2);
1356 assert!((events[0].time_beats).abs() < 1e-9);
1358 }
1359
1360 #[test]
1361 fn loop_region_none_plays_all_measures() {
1362 let mut score = Score::new("T", 120, 4, 4, 0, 3);
1363 for mi in 0..3 {
1364 score.parts[0].staves[0].measures[mi].voices[0] =
1365 vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
1366 }
1367 let events = to_playback_events(&score, &opts(None));
1368 assert_eq!(events.len(), 3);
1369 }
1370
1371 #[test]
1374 fn fermata_multiplier_extends_duration() {
1375 let mut score = Score::new("T", 120, 4, 4, 0, 1);
1376 let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1377 note.articulations
1378 .push(crate::model::notation::Articulation::Fermata);
1379 score.parts[0].staves[0].measures[0].voices[0] = vec![note];
1380 let options = PlaybackOptions {
1381 fermata_multiplier: 2.0,
1382 ..Default::default()
1383 };
1384 let events = to_playback_events(&score, &options);
1385 assert_eq!(events.len(), 1);
1386 assert!((events[0].duration_beats - 2.0).abs() < 1e-9);
1387 }
1388
1389 #[test]
1390 fn fermata_default_multiplier_is_1_5() {
1391 let mut score = Score::new("T", 120, 4, 4, 0, 1);
1392 let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1393 note.articulations
1394 .push(crate::model::notation::Articulation::Fermata);
1395 score.parts[0].staves[0].measures[0].voices[0] = vec![note];
1396 let events = to_playback_events(&score, &PlaybackOptions::default());
1397 assert_eq!(events.len(), 1);
1398 assert!((events[0].duration_beats - 1.5).abs() < 1e-9);
1399 }
1400
1401 #[test]
1402 fn non_fermata_note_unaffected_by_fermata_multiplier() {
1403 let mut score = Score::new("T", 120, 4, 4, 0, 1);
1404 score.parts[0].staves[0].measures[0].voices[0] =
1405 vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
1406 let options = PlaybackOptions {
1407 fermata_multiplier: 3.0,
1408 ..Default::default()
1409 };
1410 let events = to_playback_events(&score, &options);
1411 assert!((events[0].duration_beats - 1.0).abs() < 1e-9);
1412 }
1413
1414 #[test]
1417 fn swing_triplet_first_eighth_is_long() {
1418 let mut score = Score::new("T", 120, 4, 4, 0, 1);
1420 score.parts[0].staves[0].measures[0].voices[0] = vec![
1421 Note::new(Pitch::new(Step::C, 4), Duration::Eighth),
1422 Note::new(Pitch::new(Step::D, 4), Duration::Eighth),
1423 ];
1424 let options = PlaybackOptions {
1425 swing: Some(0.67),
1426 ..Default::default()
1427 };
1428 let events = to_playback_events(&score, &options);
1429 let e_c = events.iter().find(|e| e.pitch_midi == 60).unwrap();
1431 let e_d = events.iter().find(|e| e.pitch_midi == 62).unwrap();
1432 assert!(
1433 (e_c.duration_beats - 0.67).abs() < 1e-9,
1434 "first eighth should be 0.67"
1435 );
1436 assert!(
1437 (e_d.duration_beats - 0.33).abs() < 1e-9,
1438 "second eighth should be 0.33"
1439 );
1440 assert!(
1442 (e_d.time_beats - 0.67).abs() < 1e-9,
1443 "second note start should be at 0.67"
1444 );
1445 }
1446
1447 #[test]
1448 fn swing_non_eighth_not_affected() {
1449 let mut score = Score::new("T", 120, 4, 4, 0, 1);
1450 score.parts[0].staves[0].measures[0].voices[0] =
1451 vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
1452 let options = PlaybackOptions {
1453 swing: Some(0.67),
1454 ..Default::default()
1455 };
1456 let events = to_playback_events(&score, &options);
1457 assert!(
1458 (events[0].duration_beats - 1.0).abs() < 1e-9,
1459 "quarter note unaffected"
1460 );
1461 }
1462
1463 #[test]
1464 fn swing_none_is_straight() {
1465 let mut score = Score::new("T", 120, 4, 4, 0, 1);
1466 score.parts[0].staves[0].measures[0].voices[0] =
1467 vec![Note::new(Pitch::new(Step::C, 4), Duration::Eighth)];
1468 let options = PlaybackOptions {
1469 swing: None,
1470 ..Default::default()
1471 };
1472 let events = to_playback_events(&score, &options);
1473 assert!(
1474 (events[0].duration_beats - 0.5).abs() < 1e-9,
1475 "no swing = straight eighth"
1476 );
1477 }
1478
1479 #[test]
1480 fn channel_matches_part_midi_channel() {
1481 let mut score = Score::new("T", 120, 4, 4, 0, 1);
1482 score.parts[0].midi_channel = 3;
1483 score.parts[0].staves[0].measures[0].voices[0] =
1484 vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
1485 let events = to_playback_events(&score, &opts(None));
1486 assert_eq!(events[0].channel, 3);
1487 }
1488
1489 #[test]
1490 fn swing_unit_default_is_eighth() {
1491 assert_eq!(PlaybackOptions::default().swing_unit, Duration::Eighth);
1492 }
1493
1494 #[test]
1495 fn swing_unit_sixteenth() {
1496 let mut score = Score::new("T", 120, 4, 4, 0, 1);
1497 score.parts[0].staves[0].measures[0].voices[0] = vec![
1498 Note::new(Pitch::new(Step::C, 4), Duration::Sixteenth),
1499 Note::new(Pitch::new(Step::D, 4), Duration::Sixteenth),
1500 ];
1501 let options = PlaybackOptions {
1502 swing: Some(0.67),
1503 swing_unit: Duration::Sixteenth,
1504 ..Default::default()
1505 };
1506 let events = to_playback_events(&score, &options);
1507 let e_c = events.iter().find(|e| e.pitch_midi == 60).unwrap();
1508 let e_d = events.iter().find(|e| e.pitch_midi == 62).unwrap();
1509 assert!(
1510 (e_c.duration_beats - 0.335).abs() < 1e-9,
1511 "first 16th should be 0.335"
1512 );
1513 assert!(
1514 (e_d.duration_beats - 0.165).abs() < 1e-9,
1515 "second 16th should be 0.165"
1516 );
1517 }
1518
1519 #[test]
1520 fn swing_resets_per_measure() {
1521 let mut score = Score::new("T", 120, 4, 4, 0, 2);
1523 let pair = || {
1524 vec![
1525 Note::new(Pitch::new(Step::C, 4), Duration::Eighth),
1526 Note::new(Pitch::new(Step::D, 4), Duration::Eighth),
1527 ]
1528 };
1529 score.parts[0].staves[0].measures[0].voices[0] = pair();
1530 score.parts[0].staves[0].measures[1].voices[0] = pair();
1531 let options = PlaybackOptions {
1532 swing: Some(0.67),
1533 ..Default::default()
1534 };
1535 let events = to_playback_events(&score, &options);
1536 let durations: Vec<f64> = events.iter().map(|e| e.duration_beats).collect();
1538 assert!((durations[0] - 0.67).abs() < 1e-9, "m0 first note long");
1540 assert!((durations[1] - 0.33).abs() < 1e-9, "m0 second note short");
1542 assert!(
1544 (durations[2] - 0.67).abs() < 1e-9,
1545 "m1 first note long (reset)"
1546 );
1547 assert!((durations[3] - 0.33).abs() < 1e-9, "m1 second note short");
1549 }
1550
1551 #[test]
1552 fn multi_voice_events_preserve_source_voice_addresses() {
1553 let mut score = Score::new("T", 120, 4, 4, 0, 1);
1554 score.parts[0].staves[0].measures[0].voices[0] =
1555 vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
1556 score.parts[0].staves[0].measures[0].voices[1] =
1557 vec![Note::new(Pitch::new(Step::E, 4), Duration::Quarter)];
1558
1559 let events = to_playback_events(&score, &PlaybackOptions::default());
1560 let addresses: Vec<&str> = events
1561 .iter()
1562 .filter_map(|event| event.address.as_deref())
1563 .collect();
1564 assert!(addresses.contains(&"0:0:0:0:0"));
1565 assert!(addresses.contains(&"0:0:0:1:0"));
1566 }
1567
1568 #[test]
1569 fn playback_exposes_original_musicxml_voice_number_alongside_slot_address() {
1570 let mut score = Score::new("T", 120, 4, 4, 0, 1);
1571 let measure = &mut score.parts[0].staves[0].measures[0];
1572 measure.voices[1] = vec![Note::new(Pitch::new(Step::E, 4), Duration::Quarter)];
1573 measure.source_voice_numbers[1] = Some(5);
1574
1575 let events = to_playback_events(&score, &PlaybackOptions::default());
1576 let event = events
1577 .iter()
1578 .find(|event| event.address.as_deref() == Some("0:0:0:1:0"))
1579 .expect("event from slot 1");
1580 assert_eq!(event.source.as_ref().map(|source| source.voice), Some(1));
1581 assert_eq!(event.source_voice_number, Some(5));
1582 }
1583
1584 #[test]
1587 fn playback_position_at_zero_is_measure_0_beat_0() {
1588 let score = Score::new("T", 120, 4, 4, 0, 4);
1589 let pos = compute_playback_position(&score, &PlaybackOptions::default(), 0.0).unwrap();
1590 assert_eq!(pos.measure_index, 0);
1591 assert!(pos.beat.abs() < 1e-9);
1592 }
1593
1594 #[test]
1595 fn playback_position_at_half_measure_is_beat_2() {
1596 let score = Score::new("T", 120, 4, 4, 0, 4);
1598 let pos = compute_playback_position(&score, &PlaybackOptions::default(), 0.5).unwrap();
1599 assert_eq!(pos.measure_index, 0);
1600 assert!(
1601 (pos.beat - 1.0).abs() < 1e-9,
1602 "expected beat 1.0, got {}",
1603 pos.beat
1604 );
1605 }
1606
1607 #[test]
1608 fn playback_position_beyond_score_is_none() {
1609 let score = Score::new("T", 120, 4, 4, 0, 1);
1611 assert!(compute_playback_position(&score, &PlaybackOptions::default(), 10.0).is_none());
1612 }
1613
1614 #[test]
1615 fn playback_position_tempo_change_takes_effect() {
1616 let mut score = Score::new("T", 120, 4, 4, 0, 2);
1619 score.parts[0].staves[0].measures[1].tempo = Some(60);
1620 let pos = compute_playback_position(&score, &PlaybackOptions::default(), 2.5).unwrap();
1621 assert_eq!(pos.measure_index, 1);
1622 assert!(
1623 (pos.beat - 0.5).abs() < 1e-9,
1624 "expected beat 0.5, got {}",
1625 pos.beat
1626 );
1627 }
1628
1629 #[test]
1630 fn playback_position_loop_region_starts_at_zero() {
1631 let score = Score::new("T", 120, 4, 4, 0, 4);
1633 let options = PlaybackOptions {
1634 loop_region: Some((1, 2)),
1635 ..Default::default()
1636 };
1637 let pos = compute_playback_position(&score, &options, 0.0).unwrap();
1638 assert_eq!(pos.measure_index, 1);
1639 assert!(pos.beat.abs() < 1e-9);
1640 }
1641
1642 #[test]
1645 fn metronome_injects_beat_events() {
1646 let score = Score::new("T", 120, 4, 4, 0, 1);
1648 let options = PlaybackOptions {
1649 metronome: Some(MetronomeConfig::default()),
1650 ..Default::default()
1651 };
1652 let events = to_playback_events(&score, &options);
1653 let metro_events: Vec<_> = events.iter().filter(|e| e.is_metronome).collect();
1654 assert_eq!(metro_events.len(), 4, "expected 4 metronome clicks in 4/4");
1655 }
1656
1657 #[test]
1658 fn metronome_accent_is_first_beat() {
1659 let score = Score::new("T", 120, 4, 4, 0, 1);
1660 let metro = MetronomeConfig::default();
1661 let options = PlaybackOptions {
1662 metronome: Some(metro.clone()),
1663 ..Default::default()
1664 };
1665 let events = to_playback_events(&score, &options);
1666 let mut metro_events: Vec<_> = events.iter().filter(|e| e.is_metronome).collect();
1667 metro_events.sort_by(|a, b| a.time_beats.partial_cmp(&b.time_beats).unwrap());
1668 assert_eq!(metro_events[0].pitch_midi, metro.accent_pitch);
1669 assert_eq!(metro_events[0].velocity, metro.accent_velocity);
1670 }
1671
1672 #[test]
1673 fn metronome_regular_beat_pitch() {
1674 let score = Score::new("T", 120, 4, 4, 0, 1);
1675 let metro = MetronomeConfig::default();
1676 let options = PlaybackOptions {
1677 metronome: Some(metro.clone()),
1678 ..Default::default()
1679 };
1680 let events = to_playback_events(&score, &options);
1681 let mut metro_events: Vec<_> = events.iter().filter(|e| e.is_metronome).collect();
1682 metro_events.sort_by(|a, b| a.time_beats.partial_cmp(&b.time_beats).unwrap());
1683 for ev in &metro_events[1..] {
1684 assert_eq!(ev.pitch_midi, metro.beat_pitch);
1685 assert_eq!(ev.velocity, metro.beat_velocity);
1686 }
1687 }
1688
1689 #[test]
1690 fn metronome_events_are_marked() {
1691 let score = Score::new("T", 120, 4, 4, 0, 1);
1692 let options = PlaybackOptions {
1693 metronome: Some(MetronomeConfig::default()),
1694 ..Default::default()
1695 };
1696 let events = to_playback_events(&score, &options);
1697 assert!(events.iter().any(|e| e.is_metronome));
1698 }
1699
1700 #[test]
1701 fn metronome_none_produces_no_extra_events() {
1702 let score = Score::new("T", 120, 4, 4, 0, 1);
1703 let events = to_playback_events(&score, &PlaybackOptions::default());
1704 assert!(events.iter().all(|e| !e.is_metronome));
1705 }
1706
1707 fn comparison_event(time_secs: f64, duration_secs: f64) -> PlaybackEvent {
1708 PlaybackEvent {
1709 address: Some("0:0:0:0:0".into()),
1710 source: Some(NoteAddr {
1711 part: 0,
1712 staff: 0,
1713 measure: 0,
1714 voice: 0,
1715 note: 0,
1716 }),
1717 source_voice_number: None,
1718 time_beats: 0.0,
1719 time_secs,
1720 pitch_midi: 60,
1721 pitch_midi_cents: 6000,
1722 velocity: 64,
1723 duration_beats: 1.0,
1724 duration_secs,
1725 pedal: false,
1726 part_index: 0,
1727 channel: 0,
1728 is_metronome: false,
1729 }
1730 }
1731
1732 #[test]
1733 fn playback_timing_comparison_reports_tolerance_and_identity() {
1734 let expected = [comparison_event(1.0, 0.5)];
1735 let actual = [comparison_event(1.004, 0.503)];
1736 let report = compare_playback_timing(
1737 &expected,
1738 &actual,
1739 &PlaybackTimingTolerance {
1740 start_secs: 0.005,
1741 duration_secs: 0.005,
1742 },
1743 )
1744 .expect("comparison");
1745 assert!(report.within_tolerance);
1746 assert_eq!(report.matched_events, 1);
1747 assert_eq!(
1748 report.contract_version,
1749 PLAYBACK_COMPARISON_CONTRACT_VERSION
1750 );
1751
1752 let mut typed_only = comparison_event(1.004, 0.503);
1753 typed_only.address = None;
1754 let report = compare_playback_timing(
1755 &expected,
1756 &[typed_only],
1757 &PlaybackTimingTolerance::default(),
1758 )
1759 .expect("typed source comparison");
1760 assert_eq!(report.matched_events, 1);
1761
1762 let actual = [comparison_event(1.02, 0.6)];
1763 let report =
1764 compare_playback_timing(&expected, &actual, &PlaybackTimingTolerance::default())
1765 .expect("comparison");
1766 assert!(!report.within_tolerance);
1767 assert!(
1768 report
1769 .mismatches
1770 .iter()
1771 .any(|m| matches!(m, PlaybackTimingMismatch::StartTime { index: 0, .. }))
1772 );
1773 assert!(
1774 report
1775 .mismatches
1776 .iter()
1777 .any(|m| matches!(m, PlaybackTimingMismatch::Duration { index: 0, .. }))
1778 );
1779 }
1780
1781 #[test]
1782 fn playback_timing_comparison_rejects_invalid_tolerance() {
1783 let error = compare_playback_timing(
1784 &[],
1785 &[],
1786 &PlaybackTimingTolerance {
1787 start_secs: -0.1,
1788 duration_secs: 0.0,
1789 },
1790 );
1791 assert!(matches!(
1792 error,
1793 Err(crate::Error::InvalidPlaybackComparison)
1794 ));
1795 }
1796
1797 #[test]
1798 fn tablature_performance_projection_keeps_position_and_reports_pitch_error() {
1799 let mut score = Score::new("Tab", 120, 4, 4, 0, 1);
1800 score.parts[0].staves[0].tablature = Some(super::super::notation::TablatureConfig {
1801 lines: 6,
1802 tuning_midi: vec![40, 45, 50, 55, 59, 64],
1803 capo: 0,
1804 });
1805 let notes = &mut score.parts[0].staves[0].measures[0].voices[0];
1806 *notes = vec![crate::Note::new(
1807 crate::Pitch::new(crate::Step::C, 4),
1808 crate::Duration::Quarter,
1809 )];
1810 notes[0].tab_position = Some(super::super::notation::TabPosition {
1811 string: 1,
1812 fret: 20,
1813 });
1814 notes[0].guitar_technique = Some(super::super::notation::GuitarTechnique::Bend);
1815 notes[0].guitar_bend_alter_cents = Some(150);
1816 let report = project_tablature_performance(&score, &PlaybackOptions::default())
1817 .expect("tablature projection");
1818 assert_eq!(report.events.len(), 1);
1819 assert_eq!(report.events[0].string, 1);
1820 assert_eq!(report.events[0].fret, 20);
1821 assert_eq!(
1822 report.events[0].technique,
1823 Some(super::super::notation::GuitarTechnique::Bend)
1824 );
1825 assert_eq!(report.events[0].bend_alter_cents, Some(150));
1826 assert!(report.diagnostics.is_empty());
1827
1828 score.parts[0].staves[0].measures[0].voices[0][0].tab_position =
1829 Some(super::super::notation::TabPosition {
1830 string: 1,
1831 fret: 19,
1832 });
1833 let report = project_tablature_performance(&score, &PlaybackOptions::default())
1834 .expect("tablature projection");
1835 assert_eq!(report.events[0].pitch_error_cents, 100);
1836 assert!(matches!(
1837 report.diagnostics[0],
1838 TablaturePerformanceDiagnostic::PitchMismatch {
1839 error_cents: 100,
1840 ..
1841 }
1842 ));
1843 }
1844
1845 #[test]
1846 fn tablature_performance_projection_does_not_invent_missing_positions() {
1847 let mut score = Score::new("Tab", 120, 4, 4, 0, 1);
1848 score.parts[0].staves[0].tablature = Some(super::super::notation::TablatureConfig {
1849 lines: 6,
1850 tuning_midi: vec![40, 45, 50, 55, 59, 64],
1851 capo: 0,
1852 });
1853 score.parts[0].staves[0].measures[0].voices[0].push(crate::Note::new(
1854 crate::Pitch::new(crate::Step::C, 4),
1855 crate::Duration::Quarter,
1856 ));
1857 let report = project_tablature_performance(&score, &PlaybackOptions::default())
1858 .expect("tablature projection");
1859 assert!(report.events.is_empty());
1860 assert!(matches!(
1861 report.diagnostics[0],
1862 TablaturePerformanceDiagnostic::MissingPosition { .. }
1863 ));
1864 }
1865
1866 #[test]
1867 fn tablature_performance_event_accepts_legacy_json_without_technique() {
1868 let mut score = Score::new("Tab", 120, 4, 4, 0, 1);
1869 score.parts[0].staves[0].tablature = Some(super::super::notation::TablatureConfig {
1870 lines: 6,
1871 tuning_midi: vec![40, 45, 50, 55, 59, 64],
1872 capo: 0,
1873 });
1874 let mut note = crate::Note::new(
1875 crate::Pitch::new(crate::Step::C, 4),
1876 crate::Duration::Quarter,
1877 );
1878 note.tab_position = Some(super::super::notation::TabPosition {
1879 string: 1,
1880 fret: 20,
1881 });
1882 score.parts[0].staves[0].measures[0].voices[0] = vec![note];
1883 let report = project_tablature_performance(&score, &PlaybackOptions::default())
1884 .expect("tablature projection");
1885 let mut legacy = serde_json::to_value(&report.events[0]).expect("event JSON");
1886 legacy
1887 .as_object_mut()
1888 .expect("event object")
1889 .remove("technique");
1890 let restored: TablaturePerformanceEvent =
1891 serde_json::from_value(legacy).expect("legacy event JSON");
1892 assert_eq!(restored.technique, None);
1893 }
1894
1895 #[test]
1896 fn tablature_round_trip_report_preserves_authored_positions() {
1897 let mut score = Score::new("Tab", 120, 4, 4, 0, 1);
1898 score.parts[0].staves[0].tablature = Some(super::super::notation::TablatureConfig {
1899 lines: 6,
1900 tuning_midi: vec![40, 45, 50, 55, 59, 64],
1901 capo: 2,
1902 });
1903 let mut note = crate::Note::new(
1904 crate::Pitch::new(crate::Step::C, 4),
1905 crate::Duration::Quarter,
1906 );
1907 note.tab_positions = vec![crate::TabPosition { string: 5, fret: 1 }];
1908 score.parts[0].staves[0].measures[0].voices[0] = vec![note];
1909 let report = tablature_round_trip_report(&score).expect("tab round-trip report");
1910 assert!(report.equivalent);
1911 assert_eq!(report.checked_notes, 1);
1912 assert_eq!(report.positioned_notes, 1);
1913 assert!(report.diagnostics.is_empty());
1914 }
1915}