use super::duration::Duration;
use super::notation::{Articulation, GuitarTechnique, TabPosition};
use super::repeat::measure_sequence;
use super::score::{NoteAddr, Score};
use serde::{Deserialize, Serialize};
fn default_fermata_multiplier() -> f64 {
1.5
}
fn default_swing_unit() -> Duration {
Duration::Eighth
}
fn default_metronome_channel() -> u8 {
9
}
fn default_accent_pitch() -> u8 {
76
}
fn default_beat_pitch() -> u8 {
77
}
fn default_accent_velocity() -> u8 {
100
}
fn default_beat_velocity() -> u8 {
70
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetronomeConfig {
#[serde(default = "default_metronome_channel")]
pub channel: u8,
#[serde(default = "default_accent_pitch")]
pub accent_pitch: u8,
#[serde(default = "default_beat_pitch")]
pub beat_pitch: u8,
#[serde(default = "default_accent_velocity")]
pub accent_velocity: u8,
#[serde(default = "default_beat_velocity")]
pub beat_velocity: u8,
}
impl Default for MetronomeConfig {
fn default() -> Self {
Self {
channel: 9,
accent_pitch: 76,
beat_pitch: 77,
accent_velocity: 100,
beat_velocity: 70,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlaybackOptions {
pub bpm_override: Option<u16>,
pub muted_parts: Vec<usize>,
#[serde(default)]
pub loop_region: Option<(usize, usize)>,
#[serde(default = "default_fermata_multiplier")]
pub fermata_multiplier: f64,
#[serde(default)]
pub swing: Option<f64>,
#[serde(default = "default_swing_unit")]
pub swing_unit: Duration,
#[serde(default)]
pub metronome: Option<MetronomeConfig>,
}
impl Default for PlaybackOptions {
fn default() -> Self {
Self {
bpm_override: None,
muted_parts: Vec::new(),
loop_region: None,
fermata_multiplier: 1.5,
swing: None,
swing_unit: Duration::Eighth,
metronome: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PlaybackEvent {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub address: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<NoteAddr>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_voice_number: Option<u32>,
pub time_beats: f64,
pub time_secs: f64,
pub pitch_midi: u8,
#[serde(default)]
pub pitch_midi_cents: i32,
pub velocity: u8,
pub duration_beats: f64,
pub duration_secs: f64,
pub pedal: bool,
pub part_index: usize,
pub channel: u8,
#[serde(default)]
pub is_metronome: bool,
}
pub const PLAYBACK_COMPARISON_CONTRACT_VERSION: u16 = 1;
pub const MAX_PLAYBACK_COMPARISON_EVENTS: usize = 1_000_000;
const MAX_PLAYBACK_MISMATCHES: usize = 256;
pub const MAX_TAB_PERFORMANCE_EVENTS: usize = 1_000_000;
const MAX_TAB_PERFORMANCE_DIAGNOSTICS: usize = 1_024;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PlaybackTimingTolerance {
pub start_secs: f64,
pub duration_secs: f64,
}
impl Default for PlaybackTimingTolerance {
fn default() -> Self {
Self {
start_secs: 0.005,
duration_secs: 0.005,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum PlaybackTimingMismatch {
EventCount { expected: usize, actual: usize },
EventIdentity { index: usize },
StartTime { index: usize, error_secs: f64 },
Duration { index: usize, error_secs: f64 },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PlaybackTimingReport {
pub contract_version: u16,
pub expected_events: usize,
pub actual_events: usize,
pub matched_events: usize,
pub max_start_error_secs: f64,
pub max_duration_error_secs: f64,
pub within_tolerance: bool,
pub mismatches: Vec<PlaybackTimingMismatch>,
}
pub fn compare_playback_timing(
expected: &[PlaybackEvent],
actual: &[PlaybackEvent],
tolerance: &PlaybackTimingTolerance,
) -> Result<PlaybackTimingReport, crate::Error> {
if !tolerance.start_secs.is_finite()
|| tolerance.start_secs < 0.0
|| !tolerance.duration_secs.is_finite()
|| tolerance.duration_secs < 0.0
{
return Err(crate::Error::InvalidPlaybackComparison);
}
if expected.len() > MAX_PLAYBACK_COMPARISON_EVENTS {
return Err(crate::Error::PlaybackComparisonTooLarge(expected.len()));
}
if actual.len() > MAX_PLAYBACK_COMPARISON_EVENTS {
return Err(crate::Error::PlaybackComparisonTooLarge(actual.len()));
}
let mut mismatches = Vec::new();
if expected.len() != actual.len() {
mismatches.push(PlaybackTimingMismatch::EventCount {
expected: expected.len(),
actual: actual.len(),
});
}
let mut matched_events = 0;
let mut max_start_error_secs: f64 = 0.0;
let mut max_duration_error_secs: f64 = 0.0;
for (index, (expected_event, actual_event)) in expected.iter().zip(actual).enumerate() {
let source_identity_matches = match (&expected_event.source, &actual_event.source) {
(Some(expected), Some(actual)) => expected == actual,
_ => expected_event.address == actual_event.address,
};
let identity_matches = source_identity_matches
&& expected_event.pitch_midi_cents == actual_event.pitch_midi_cents
&& expected_event.velocity == actual_event.velocity
&& expected_event.part_index == actual_event.part_index
&& expected_event.channel == actual_event.channel
&& expected_event.is_metronome == actual_event.is_metronome;
let start_error_secs = (expected_event.time_secs - actual_event.time_secs).abs();
let duration_error_secs = (expected_event.duration_secs - actual_event.duration_secs).abs();
if start_error_secs.is_finite() {
max_start_error_secs = max_start_error_secs.max(start_error_secs);
}
if duration_error_secs.is_finite() {
max_duration_error_secs = max_duration_error_secs.max(duration_error_secs);
}
let start_matches =
start_error_secs.is_finite() && start_error_secs <= tolerance.start_secs;
let duration_matches =
duration_error_secs.is_finite() && duration_error_secs <= tolerance.duration_secs;
if identity_matches && start_matches && duration_matches {
matched_events += 1;
continue;
}
if mismatches.len() < MAX_PLAYBACK_MISMATCHES {
if !identity_matches {
mismatches.push(PlaybackTimingMismatch::EventIdentity { index });
}
if !start_matches && mismatches.len() < MAX_PLAYBACK_MISMATCHES {
mismatches.push(PlaybackTimingMismatch::StartTime {
index,
error_secs: start_error_secs,
});
}
if !duration_matches && mismatches.len() < MAX_PLAYBACK_MISMATCHES {
mismatches.push(PlaybackTimingMismatch::Duration {
index,
error_secs: duration_error_secs,
});
}
}
}
Ok(PlaybackTimingReport {
contract_version: PLAYBACK_COMPARISON_CONTRACT_VERSION,
expected_events: expected.len(),
actual_events: actual.len(),
matched_events,
max_start_error_secs,
max_duration_error_secs,
within_tolerance: mismatches.is_empty(),
mismatches,
})
}
pub const TAB_PERFORMANCE_CONTRACT_VERSION: u16 = 3;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TablaturePerformanceEvent {
pub playback: PlaybackEvent,
pub string: u8,
pub fret: u8,
#[serde(default)]
pub technique: Option<GuitarTechnique>,
#[serde(default)]
pub bend_alter_cents: Option<i16>,
pub expected_pitch_midi_cents: i32,
pub pitch_error_cents: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum TablaturePerformanceDiagnostic {
NoTablatureStaff {
address: String,
},
MissingPosition {
address: String,
pitch_index: usize,
},
StringOutOfRange {
address: String,
string: u8,
lines: u8,
},
TuningUnavailable {
address: String,
string: u8,
},
PitchMismatch {
address: String,
pitch_index: usize,
error_cents: i32,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TablaturePerformanceReport {
pub contract_version: u16,
pub events: Vec<TablaturePerformanceEvent>,
pub diagnostics: Vec<TablaturePerformanceDiagnostic>,
}
pub const TAB_ROUND_TRIP_CONTRACT_VERSION: u16 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TablatureRoundTripReport {
pub contract_version: u16,
pub checked_notes: usize,
pub positioned_notes: usize,
pub equivalent: bool,
pub diagnostics: Vec<TablatureRoundTripDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum TablatureRoundTripDiagnostic {
StructureMismatch {
address: String,
},
TablatureConfigMismatch {
address: String,
},
PositionMismatch {
address: String,
pitch_index: usize,
expected: Option<TabPosition>,
actual: Option<TabPosition>,
},
}
pub fn tablature_round_trip_report(
score: &Score,
) -> Result<TablatureRoundTripReport, crate::Error> {
let encoded = serde_json::to_string(score)
.map_err(|error| crate::Error::TabRoundTripSerialization(error.to_string()))?;
let restored: Score = serde_json::from_str(&encoded)
.map_err(|error| crate::Error::TabRoundTripSerialization(error.to_string()))?;
let mut checked_notes = 0;
let mut positioned_notes = 0;
let mut diagnostics = Vec::new();
for (part_index, part) in score.parts.iter().enumerate() {
let Some(restored_part) = restored.parts.get(part_index) else {
diagnostics.push(TablatureRoundTripDiagnostic::StructureMismatch {
address: format!("{part_index}"),
});
continue;
};
for (staff_index, staff) in part.staves.iter().enumerate() {
let address = format!("{part_index}:{staff_index}");
let Some(restored_staff) = restored_part.staves.get(staff_index) else {
diagnostics.push(TablatureRoundTripDiagnostic::StructureMismatch { address });
continue;
};
if staff.tablature != restored_staff.tablature {
diagnostics.push(TablatureRoundTripDiagnostic::TablatureConfigMismatch {
address: address.clone(),
});
}
for (measure_index, measure) in staff.measures.iter().enumerate() {
let Some(restored_measure) = restored_staff.measures.get(measure_index) else {
diagnostics.push(TablatureRoundTripDiagnostic::StructureMismatch {
address: format!("{address}:{measure_index}"),
});
continue;
};
for (voice_index, voice) in measure.voices.iter().enumerate() {
let Some(restored_voice) = restored_measure.voices.get(voice_index) else {
diagnostics.push(TablatureRoundTripDiagnostic::StructureMismatch {
address: format!("{address}:{measure_index}:{voice_index}"),
});
continue;
};
for (note_index, note) in voice.iter().enumerate() {
checked_notes += 1;
if !note.tab_positions.is_empty() || note.tab_position.is_some() {
positioned_notes += 1;
}
let note_address =
format!("{address}:{measure_index}:{voice_index}:{note_index}");
let Some(restored_note) = restored_voice.get(note_index) else {
diagnostics.push(TablatureRoundTripDiagnostic::StructureMismatch {
address: note_address,
});
continue;
};
let pitch_count = note.pitches.len().max(note.tab_positions.len()).max(1);
for pitch_index in 0..pitch_count {
let expected = note
.tab_positions
.get(pitch_index)
.cloned()
.or_else(|| note.tab_position.clone());
let actual = restored_note
.tab_positions
.get(pitch_index)
.cloned()
.or_else(|| restored_note.tab_position.clone());
if expected != actual {
diagnostics.push(TablatureRoundTripDiagnostic::PositionMismatch {
address: note_address.clone(),
pitch_index,
expected,
actual,
});
}
}
}
}
}
}
}
Ok(TablatureRoundTripReport {
contract_version: TAB_ROUND_TRIP_CONTRACT_VERSION,
checked_notes,
positioned_notes,
equivalent: diagnostics.is_empty(),
diagnostics,
})
}
pub fn project_tablature_performance(
score: &Score,
options: &PlaybackOptions,
) -> Result<TablaturePerformanceReport, crate::Error> {
let playback = to_playback_events(score, options);
if playback.len() > MAX_TAB_PERFORMANCE_EVENTS {
return Err(crate::Error::TabPerformanceTooLarge(playback.len()));
}
let mut events = Vec::new();
let mut diagnostics = Vec::new();
for event in playback {
let Some(address) = event.address.as_deref() else {
continue;
};
let Some((part_index, staff_index, measure_index, voice_index, note_index)) =
parse_playback_address(address)
else {
continue;
};
let Some(staff) = score
.parts
.get(part_index)
.and_then(|part| part.staves.get(staff_index))
else {
continue;
};
let Some(note) = staff
.measures
.get(measure_index)
.and_then(|measure| measure.voices.get(voice_index))
.and_then(|voice| voice.get(note_index))
else {
continue;
};
let Some(tab) = staff.tablature.as_ref() else {
push_tab_diagnostic(
&mut diagnostics,
TablaturePerformanceDiagnostic::NoTablatureStaff {
address: address.into(),
},
);
continue;
};
let transpose_cents = if event.channel == 9 {
0
} else {
i32::from(staff.transpose_semitones) * 100
};
let written_pitch_cents = event.pitch_midi_cents - transpose_cents;
let pitch_index = note
.pitches
.iter()
.position(|pitch| pitch.to_midi_cents() == written_pitch_cents)
.unwrap_or(0);
let position = note
.tab_positions
.get(pitch_index)
.or(note.tab_position.as_ref());
let Some(position) = position else {
push_tab_diagnostic(
&mut diagnostics,
TablaturePerformanceDiagnostic::MissingPosition {
address: address.into(),
pitch_index,
},
);
continue;
};
if position.string == 0 || position.string > tab.lines {
push_tab_diagnostic(
&mut diagnostics,
TablaturePerformanceDiagnostic::StringOutOfRange {
address: address.into(),
string: position.string,
lines: tab.lines,
},
);
continue;
}
let Some(tuning) = tab.tuning_midi.get(usize::from(position.string - 1)) else {
push_tab_diagnostic(
&mut diagnostics,
TablaturePerformanceDiagnostic::TuningUnavailable {
address: address.into(),
string: position.string,
},
);
continue;
};
let expected_pitch_midi_cents =
tuning.saturating_add(i16::from(position.fret) + i16::from(tab.capo)) as i32 * 100;
let pitch_error_cents = event.pitch_midi_cents - expected_pitch_midi_cents;
if pitch_error_cents != 0 {
push_tab_diagnostic(
&mut diagnostics,
TablaturePerformanceDiagnostic::PitchMismatch {
address: address.into(),
pitch_index,
error_cents: pitch_error_cents,
},
);
}
events.push(TablaturePerformanceEvent {
playback: event,
string: position.string,
fret: position.fret,
technique: note.guitar_technique.clone(),
bend_alter_cents: note.guitar_bend_alter_cents,
expected_pitch_midi_cents,
pitch_error_cents,
});
}
Ok(TablaturePerformanceReport {
contract_version: TAB_PERFORMANCE_CONTRACT_VERSION,
events,
diagnostics,
})
}
fn parse_playback_address(address: &str) -> Option<(usize, usize, usize, usize, usize)> {
let mut fields = address.split(':').map(|field| field.parse::<usize>().ok());
Some((
fields.next()??,
fields.next()??,
fields.next()??,
fields.next()??,
fields.next()??,
))
}
fn push_tab_diagnostic(
diagnostics: &mut Vec<TablaturePerformanceDiagnostic>,
diagnostic: TablaturePerformanceDiagnostic,
) {
if diagnostics.len() < MAX_TAB_PERFORMANCE_DIAGNOSTICS {
diagnostics.push(diagnostic);
}
}
pub fn to_playback_events(score: &Score, options: &PlaybackOptions) -> Vec<PlaybackEvent> {
let bpm = options
.bpm_override
.unwrap_or(score.settings.tempo_bpm)
.max(1) as f64;
let full_seq = measure_sequence(score);
let seq: Vec<usize> = if let Some((lo, hi)) = options.loop_region {
full_seq
.into_iter()
.filter(|&idx| idx >= lo && idx <= hi)
.collect()
} else {
full_seq
};
let mut events: Vec<PlaybackEvent> = Vec::new();
for (part_index, part) in score.parts.iter().enumerate() {
if options.muted_parts.contains(&part_index) {
continue;
}
for (staff_index, staff) in part.staves.iter().enumerate() {
for voice_idx in 0..4usize {
let mut time_beats = 0.0f64;
let mut time_secs_cursor = 0.0f64;
let mut current_bpm = bpm;
for &idx in &seq {
let measure = match staff.measures.get(idx) {
Some(m) => m,
None => continue,
};
if let Some(b) = measure.tempo {
current_bpm = b.max(1) as f64;
}
let measure_beats = measure
.time_sig
.as_ref()
.unwrap_or(&score.settings.time_signature)
.total_beats();
let measure_start_beats = time_beats;
let measure_start_secs = time_secs_cursor;
let mut local_beats = 0.0f64;
let mut local_secs = 0.0f64;
let mut swing_first = true;
for (note_index, note) in measure.voices[voice_idx].iter().enumerate() {
if note.is_grace {
continue;
}
let dur = match options.swing {
Some(ratio)
if note.tuplet.is_none()
&& note.dot_count == 0
&& note.duration == options.swing_unit =>
{
let pair = note.beats() * 2.0;
let d = if swing_first {
ratio * pair
} else {
(1.0 - ratio) * pair
};
swing_first = !swing_first;
d
}
Some(_) => {
swing_first = true;
note.beats()
}
None => note.beats(),
};
if !note.is_rest {
let mut velocity = note
.dynamic
.as_ref()
.map(|d| d.to_velocity())
.unwrap_or(64u8);
let mut sounding_dur = dur;
for art in ¬e.articulations {
match art {
Articulation::Staccato | Articulation::Staccatissimo => {
sounding_dur *= 0.5;
}
Articulation::Accent | Articulation::Marcato => {
velocity = velocity.saturating_add(20).min(127);
}
Articulation::Fermata => {
sounding_dur *= options.fermata_multiplier;
}
_ => {}
}
}
let pedal = note.pedal_start;
let transpose = if part.midi_channel == 9 {
0i8
} else {
staff.transpose_semitones
};
for pitch in ¬e.pitches {
let midi = (pitch.to_midi() + transpose as i16).clamp(0, 127) as u8;
events.push(PlaybackEvent {
address: Some(format!(
"{part_index}:{staff_index}:{idx}:{voice_idx}:{note_index}"
)),
source: Some(NoteAddr {
part: part_index,
staff: staff_index,
measure: idx,
voice: voice_idx,
note: note_index,
}),
source_voice_number: measure.source_voice_numbers[voice_idx],
time_beats: measure_start_beats + local_beats,
time_secs: measure_start_secs + local_secs,
pitch_midi: midi,
pitch_midi_cents: pitch.to_midi_cents()
+ transpose as i32 * 100,
velocity,
duration_beats: sounding_dur,
duration_secs: sounding_dur / current_bpm * 60.0,
pedal,
part_index,
channel: part.midi_channel,
is_metronome: false,
});
}
}
local_beats += dur;
local_secs += dur / current_bpm * 60.0;
}
time_beats = measure_start_beats + measure_beats;
time_secs_cursor = measure_start_secs + measure_beats / current_bpm * 60.0;
}
}
}
}
if let Some(ref metro) = options.metronome {
let mut cursor_secs = 0.0f64;
let mut cursor_beats = 0.0f64;
let mut metro_bpm = bpm;
for &idx in &seq {
let first_staff = score.parts.first().and_then(|p| p.staves.first());
if let Some(t) = first_staff
.and_then(|s| s.measures.get(idx))
.and_then(|m| m.tempo)
{
metro_bpm = t.max(1) as f64;
}
let ts = first_staff
.and_then(|s| s.measures.get(idx))
.and_then(|m| m.time_sig.as_ref())
.unwrap_or(&score.settings.time_signature);
let beat_unit = ts.beat_unit_beats();
let num_beats = (ts.total_beats() / beat_unit).round() as u32;
for b in 0..num_beats {
let is_accent = b == 0;
let beat_offset_secs = b as f64 * beat_unit / metro_bpm * 60.0;
events.push(PlaybackEvent {
address: None,
source: None,
source_voice_number: None,
time_beats: cursor_beats + b as f64 * beat_unit,
time_secs: cursor_secs + beat_offset_secs,
pitch_midi: if is_accent {
metro.accent_pitch
} else {
metro.beat_pitch
},
pitch_midi_cents: i32::from(if is_accent {
metro.accent_pitch
} else {
metro.beat_pitch
}) * 100,
velocity: if is_accent {
metro.accent_velocity
} else {
metro.beat_velocity
},
duration_beats: beat_unit * 0.1,
duration_secs: beat_unit * 0.1 / metro_bpm * 60.0,
pedal: false,
part_index: usize::MAX,
channel: metro.channel,
is_metronome: true,
});
}
let measure_beats = ts.total_beats();
cursor_secs += measure_beats / metro_bpm * 60.0;
cursor_beats += measure_beats;
}
}
events = merge_tied_events(score, events);
events.sort_by(|a, b| {
a.time_beats
.partial_cmp(&b.time_beats)
.unwrap_or(std::cmp::Ordering::Equal)
});
events
}
fn merge_tied_events(score: &Score, events: Vec<PlaybackEvent>) -> Vec<PlaybackEvent> {
use std::collections::HashMap;
let mut merged = Vec::with_capacity(events.len());
let mut pending: HashMap<(usize, usize, usize, i32), usize> = HashMap::new();
for event in events {
let Some(source) = event.source.as_ref() else {
merged.push(event);
continue;
};
let tied_note = score
.parts
.get(source.part)
.and_then(|part| part.staves.get(source.staff))
.and_then(|staff| staff.measures.get(source.measure))
.and_then(|measure| measure.voices.get(source.voice))
.and_then(|voice| voice.get(source.note));
let Some(note) = tied_note else {
merged.push(event);
continue;
};
let key = (
source.part,
source.staff,
source.voice,
event.pitch_midi_cents,
);
if note.tie_end
&& pending.get(&key).is_some_and(|&index| {
merged.get(index).is_some_and(|previous| {
(previous.time_beats + previous.duration_beats - event.time_beats).abs() < 1e-9
})
})
{
let index = pending[&key];
let previous = &mut merged[index];
previous.duration_beats += event.duration_beats;
previous.duration_secs += event.duration_secs;
if !note.tie_start {
pending.remove(&key);
}
continue;
}
let index = merged.len();
merged.push(event);
if note.tie_start {
pending.insert(key, index);
} else {
pending.remove(&key);
}
}
merged
}
pub fn to_playback_events_bounded(
score: &Score,
options: &PlaybackOptions,
) -> Result<Vec<PlaybackEvent>, crate::Error> {
let events = to_playback_events(score, options);
if events.len() > MAX_PLAYBACK_COMPARISON_EVENTS {
return Err(crate::Error::PlaybackComparisonTooLarge(events.len()));
}
Ok(events)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlaybackPosition {
pub measure_index: usize,
pub beat: f64,
}
struct MeasureSegment {
measure_idx: usize,
start_secs: f64,
duration_secs: f64,
beats: f64,
bpm: f64,
}
fn build_measure_segments(score: &Score, options: &PlaybackOptions) -> Vec<MeasureSegment> {
let init_bpm = options
.bpm_override
.unwrap_or(score.settings.tempo_bpm)
.max(1) as f64;
let full_seq = measure_sequence(score);
let seq: Vec<usize> = if let Some((lo, hi)) = options.loop_region {
full_seq
.into_iter()
.filter(|&i| i >= lo && i <= hi)
.collect()
} else {
full_seq
};
let mut segments = Vec::with_capacity(seq.len());
let mut cursor_secs = 0.0f64;
let mut current_bpm = init_bpm;
for idx in seq {
let first_measure = score
.parts
.first()
.and_then(|p| p.staves.first())
.and_then(|s| s.measures.get(idx));
if let Some(t) = first_measure.and_then(|m| m.tempo) {
current_bpm = t.max(1) as f64;
}
let ts = first_measure
.and_then(|m| m.time_sig.as_ref())
.unwrap_or(&score.settings.time_signature);
let beats = ts.total_beats();
let duration_secs = beats / current_bpm * 60.0;
segments.push(MeasureSegment {
measure_idx: idx,
start_secs: cursor_secs,
duration_secs,
beats,
bpm: current_bpm,
});
cursor_secs += duration_secs;
}
segments
}
pub fn compute_playback_position(
score: &Score,
options: &PlaybackOptions,
elapsed_secs: f64,
) -> Option<PlaybackPosition> {
if elapsed_secs < 0.0 {
return None;
}
let segments = build_measure_segments(score, options);
for seg in &segments {
if elapsed_secs < seg.start_secs + seg.duration_secs + 1e-9 {
let beat = ((elapsed_secs - seg.start_secs) * seg.bpm / 60.0).clamp(0.0, seg.beats);
return Some(PlaybackPosition {
measure_index: seg.measure_idx,
beat,
});
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{
duration::Duration,
pitch::{Pitch, Step},
score::{Note, Score},
};
fn opts(bpm: Option<u16>) -> PlaybackOptions {
PlaybackOptions {
bpm_override: bpm,
..Default::default()
}
}
#[test]
fn bounded_playback_events_match_unbounded_schedule_within_limit() {
let score = Score::new("bounded", 120, 4, 4, 0, 1);
let options = opts(Some(120));
let unbounded = to_playback_events(&score, &options);
let bounded = to_playback_events_bounded(&score, &options).expect("bounded schedule");
assert_eq!(bounded, unbounded);
}
#[test]
fn empty_score_no_events() {
let score = Score::new("T", 120, 4, 4, 0, 1);
assert!(to_playback_events(&score, &opts(None)).is_empty());
}
#[test]
fn single_note_at_beat_zero() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
score.parts[0].staves[0].measures[0].voices[0] =
vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
let events = to_playback_events(&score, &opts(None));
assert_eq!(events.len(), 1);
assert_eq!(events[0].address.as_deref(), Some("0:0:0:0:0"));
assert_eq!(
events[0].source,
Some(NoteAddr {
part: 0,
staff: 0,
measure: 0,
voice: 0,
note: 0,
})
);
assert!((events[0].time_beats).abs() < 1e-9);
assert_eq!(events[0].pitch_midi, 60);
assert_eq!(events[0].velocity, 64);
assert!((events[0].duration_beats - 1.0).abs() < 1e-9);
assert_eq!(events[0].part_index, 0);
}
#[test]
fn tied_notes_are_one_continuous_playback_event() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
let mut first = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
first.tie_start = true;
let mut second = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
second.tie_end = true;
score.parts[0].staves[0].measures[0].voices[0] = vec![first, second];
let events = to_playback_events(&score, &opts(None));
assert_eq!(events.len(), 1);
assert_eq!(
events[0].source.as_ref().map(|address| address.note),
Some(0)
);
assert!((events[0].time_beats).abs() < 1e-9);
assert!((events[0].duration_beats - 2.0).abs() < 1e-9);
assert!((events[0].duration_secs - 1.0).abs() < 1e-9);
}
#[test]
fn ties_cross_measure_boundaries_and_tempo_changes_without_retriggering() {
let mut score = Score::new("T", 120, 4, 4, 0, 2);
let first_measure = &mut score.parts[0].staves[0].measures[0];
let mut first = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
first.tie_start = true;
first_measure.voices[0] = vec![first];
let second_measure = &mut score.parts[0].staves[0].measures[1];
second_measure.tempo = Some(60);
let mut second = Note::new(Pitch::new(Step::C, 4), Duration::Whole);
second.tie_end = true;
second_measure.voices[0] = vec![second];
let events = to_playback_events(&score, &opts(None));
assert_eq!(events.len(), 1);
assert_eq!(
events[0].source.as_ref().map(|address| address.measure),
Some(0)
);
assert!((events[0].time_beats).abs() < 1e-9);
assert!((events[0].duration_beats - 8.0).abs() < 1e-9);
assert!((events[0].duration_secs - 6.0).abs() < 1e-9);
}
#[test]
fn malformed_tie_end_does_not_drop_playback_event() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
note.tie_end = true;
score.parts[0].staves[0].measures[0].voices[0] = vec![note];
let events = to_playback_events(&score, &opts(None));
assert_eq!(events.len(), 1);
assert!((events[0].duration_beats - 1.0).abs() < 1e-9);
}
#[test]
fn microtonal_playback_event_keeps_exact_midi_cents() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
score.parts[0].staves[0].measures[0].voices[0] = vec![Note::new(
Pitch::with_microtone(Step::C, 4, 0, 50),
Duration::Quarter,
)];
let events = to_playback_events(&score, &opts(None));
assert_eq!(events[0].pitch_midi, 61);
assert_eq!(events[0].pitch_midi_cents, 6050);
}
#[test]
fn chord_expands_to_multiple_events() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
note.pitches.push(Pitch::new(Step::E, 4));
note.pitches.push(Pitch::new(Step::G, 4));
score.parts[0].staves[0].measures[0].voices[0] = vec![note];
let events = to_playback_events(&score, &opts(None));
assert_eq!(events.len(), 3);
assert!(
events
.iter()
.all(|event| event.address.as_deref() == Some("0:0:0:0:0"))
);
assert!(events.iter().all(|e| e.time_beats.abs() < 1e-9));
}
#[test]
fn grace_notes_excluded() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
let mut grace = Note::new(Pitch::new(Step::D, 4), Duration::Eighth);
grace.is_grace = true;
let regular = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
score.parts[0].staves[0].measures[0].voices[0] = vec![grace, regular];
let events = to_playback_events(&score, &opts(None));
assert_eq!(events.len(), 1);
assert_eq!(events[0].pitch_midi, 60);
}
#[test]
fn metronome_events_have_no_source_address() {
let score = Score::new("T", 120, 4, 4, 0, 1);
let options = PlaybackOptions {
metronome: Some(MetronomeConfig::default()),
..Default::default()
};
let events = to_playback_events(&score, &options);
assert!(!events.is_empty());
assert!(events.iter().all(|event| event.address.is_none()));
}
#[test]
fn second_note_has_correct_time() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
score.parts[0].staves[0].measures[0].voices[0] = vec![
Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
];
let events = to_playback_events(&score, &opts(None));
assert_eq!(events.len(), 2);
assert!((events[0].time_beats).abs() < 1e-9);
assert!((events[1].time_beats - 1.0).abs() < 1e-9);
}
#[test]
fn sparse_voice_keeps_measure_boundaries() {
let mut score = Score::new("T", 120, 4, 4, 0, 2);
let note = || Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
score.parts[0].staves[0].measures[0].voices[1] = vec![note()];
score.parts[0].staves[0].measures[1].voices[1] = vec![note()];
let events = to_playback_events(&score, &opts(None));
assert_eq!(events.len(), 2);
assert!((events[0].time_beats).abs() < 1e-9);
assert!((events[1].time_beats - 4.0).abs() < 1e-9);
assert!((events[1].time_secs - 2.0).abs() < 1e-9);
}
#[test]
fn time_secs_120_bpm_quarter_note_is_half_second() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
score.parts[0].staves[0].measures[0].voices[0] =
vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
let events = to_playback_events(&score, &opts(None));
assert!((events[0].time_secs).abs() < 1e-9);
assert!((events[0].duration_secs - 0.5).abs() < 1e-9);
}
#[test]
fn bpm_override_changes_time_secs() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
score.parts[0].staves[0].measures[0].voices[0] = vec![
Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
Note::new(Pitch::new(Step::D, 4), Duration::Quarter),
];
let events = to_playback_events(&score, &opts(Some(60)));
assert!((events[0].time_secs).abs() < 1e-9);
assert!((events[1].time_secs - 1.0).abs() < 1e-9);
}
#[test]
fn transpose_semitones_shifts_midi_output() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
score.parts[0].staves[0].transpose_semitones = -2;
score.parts[0].staves[0].measures[0].voices[0] =
vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
let events = to_playback_events(&score, &opts(None));
assert_eq!(events.len(), 1);
assert_eq!(events[0].pitch_midi, 58);
}
#[test]
fn percussion_channel_9_ignores_transpose_semitones() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
score.parts[0].midi_channel = 9;
score.parts[0].staves[0].transpose_semitones = -2;
score.parts[0].staves[0].measures[0].voices[0] =
vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
let events = to_playback_events(&score, &opts(None));
assert_eq!(events.len(), 1);
assert_eq!(events[0].pitch_midi, 60);
}
#[test]
fn staccato_halves_duration_beats() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
note.articulations
.push(crate::model::notation::Articulation::Staccato);
score.parts[0].staves[0].measures[0].voices[0] = vec![note];
let events = to_playback_events(&score, &opts(None));
assert_eq!(events.len(), 1);
assert!((events[0].duration_beats - 0.5).abs() < 1e-9);
}
#[test]
fn staccatissimo_also_halves_duration() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
note.articulations
.push(crate::model::notation::Articulation::Staccatissimo);
score.parts[0].staves[0].measures[0].voices[0] = vec![note];
let events = to_playback_events(&score, &opts(None));
assert!((events[0].duration_beats - 0.5).abs() < 1e-9);
}
#[test]
fn staccato_does_not_shift_next_note_time() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
let mut n1 = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
n1.articulations
.push(crate::model::notation::Articulation::Staccato);
let n2 = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
score.parts[0].staves[0].measures[0].voices[0] = vec![n1, n2];
let events = to_playback_events(&score, &opts(None));
assert_eq!(events.len(), 2);
assert!((events[1].time_beats - 1.0).abs() < 1e-9);
}
#[test]
fn accent_boosts_velocity_clamped() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
note.articulations
.push(crate::model::notation::Articulation::Accent);
score.parts[0].staves[0].measures[0].voices[0] = vec![note];
let events = to_playback_events(&score, &opts(None));
assert_eq!(events[0].velocity, 84);
}
#[test]
fn accent_clamped_at_127() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
note.dynamic = Some(crate::model::notation::Dynamic::Ffff);
note.articulations
.push(crate::model::notation::Articulation::Accent);
score.parts[0].staves[0].measures[0].voices[0] = vec![note];
let events = to_playback_events(&score, &opts(None));
assert_eq!(events[0].velocity, 127);
}
#[test]
fn tenuto_keeps_full_duration() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
note.articulations
.push(crate::model::notation::Articulation::Tenuto);
score.parts[0].staves[0].measures[0].voices[0] = vec![note];
let events = to_playback_events(&score, &opts(None));
assert!((events[0].duration_beats - 1.0).abs() < 1e-9);
}
#[test]
fn pedal_start_sets_pedal_field() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
note.pedal_start = true;
score.parts[0].staves[0].measures[0].voices[0] = vec![note];
let events = to_playback_events(&score, &opts(None));
assert!(events[0].pedal);
}
#[test]
fn no_pedal_start_pedal_is_false() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
score.parts[0].staves[0].measures[0].voices[0] =
vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
let events = to_playback_events(&score, &opts(None));
assert!(!events[0].pedal);
}
#[test]
fn set_tempo_at_measure_changes_time_secs() {
let mut score = Score::new("T", 120, 4, 4, 0, 2);
score.parts[0].staves[0].measures[0].voices[0] =
vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
score.parts[0].staves[0].measures[1].tempo = Some(60);
score.parts[0].staves[0].measures[1].voices[0] =
vec![Note::new(Pitch::new(Step::D, 4), Duration::Whole)];
let events = to_playback_events(&score, &opts(None));
assert_eq!(events.len(), 2);
assert!((events[0].time_secs).abs() < 1e-9);
assert!((events[0].duration_secs - 2.0).abs() < 1e-9);
assert!((events[1].time_secs - 2.0).abs() < 1e-9);
assert!((events[1].duration_secs - 4.0).abs() < 1e-9);
}
#[test]
fn muted_part_produces_no_events() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
score.parts[0].staves[0].measures[0].voices[0] =
vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
let options = PlaybackOptions {
muted_parts: vec![0],
..Default::default()
};
assert!(to_playback_events(&score, &options).is_empty());
}
#[test]
fn part_index_field_set_correctly() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
score.parts[0].staves[0].measures[0].voices[0] =
vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
let events = to_playback_events(&score, &opts(None));
assert_eq!(events[0].part_index, 0);
}
#[test]
fn loop_region_filters_measures() {
let mut score = Score::new("T", 120, 4, 4, 0, 3);
for mi in 0..3 {
score.parts[0].staves[0].measures[mi].voices[0] =
vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
}
let options = PlaybackOptions {
loop_region: Some((1, 2)),
..Default::default()
};
let events = to_playback_events(&score, &options);
assert_eq!(events.len(), 2);
assert!((events[0].time_beats).abs() < 1e-9);
}
#[test]
fn loop_region_none_plays_all_measures() {
let mut score = Score::new("T", 120, 4, 4, 0, 3);
for mi in 0..3 {
score.parts[0].staves[0].measures[mi].voices[0] =
vec![Note::new(Pitch::new(Step::C, 4), Duration::Whole)];
}
let events = to_playback_events(&score, &opts(None));
assert_eq!(events.len(), 3);
}
#[test]
fn fermata_multiplier_extends_duration() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
note.articulations
.push(crate::model::notation::Articulation::Fermata);
score.parts[0].staves[0].measures[0].voices[0] = vec![note];
let options = PlaybackOptions {
fermata_multiplier: 2.0,
..Default::default()
};
let events = to_playback_events(&score, &options);
assert_eq!(events.len(), 1);
assert!((events[0].duration_beats - 2.0).abs() < 1e-9);
}
#[test]
fn fermata_default_multiplier_is_1_5() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
let mut note = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
note.articulations
.push(crate::model::notation::Articulation::Fermata);
score.parts[0].staves[0].measures[0].voices[0] = vec![note];
let events = to_playback_events(&score, &PlaybackOptions::default());
assert_eq!(events.len(), 1);
assert!((events[0].duration_beats - 1.5).abs() < 1e-9);
}
#[test]
fn non_fermata_note_unaffected_by_fermata_multiplier() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
score.parts[0].staves[0].measures[0].voices[0] =
vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
let options = PlaybackOptions {
fermata_multiplier: 3.0,
..Default::default()
};
let events = to_playback_events(&score, &options);
assert!((events[0].duration_beats - 1.0).abs() < 1e-9);
}
#[test]
fn swing_triplet_first_eighth_is_long() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
score.parts[0].staves[0].measures[0].voices[0] = vec![
Note::new(Pitch::new(Step::C, 4), Duration::Eighth),
Note::new(Pitch::new(Step::D, 4), Duration::Eighth),
];
let options = PlaybackOptions {
swing: Some(0.67),
..Default::default()
};
let events = to_playback_events(&score, &options);
let e_c = events.iter().find(|e| e.pitch_midi == 60).unwrap();
let e_d = events.iter().find(|e| e.pitch_midi == 62).unwrap();
assert!(
(e_c.duration_beats - 0.67).abs() < 1e-9,
"first eighth should be 0.67"
);
assert!(
(e_d.duration_beats - 0.33).abs() < 1e-9,
"second eighth should be 0.33"
);
assert!(
(e_d.time_beats - 0.67).abs() < 1e-9,
"second note start should be at 0.67"
);
}
#[test]
fn swing_non_eighth_not_affected() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
score.parts[0].staves[0].measures[0].voices[0] =
vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
let options = PlaybackOptions {
swing: Some(0.67),
..Default::default()
};
let events = to_playback_events(&score, &options);
assert!(
(events[0].duration_beats - 1.0).abs() < 1e-9,
"quarter note unaffected"
);
}
#[test]
fn swing_none_is_straight() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
score.parts[0].staves[0].measures[0].voices[0] =
vec![Note::new(Pitch::new(Step::C, 4), Duration::Eighth)];
let options = PlaybackOptions {
swing: None,
..Default::default()
};
let events = to_playback_events(&score, &options);
assert!(
(events[0].duration_beats - 0.5).abs() < 1e-9,
"no swing = straight eighth"
);
}
#[test]
fn channel_matches_part_midi_channel() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
score.parts[0].midi_channel = 3;
score.parts[0].staves[0].measures[0].voices[0] =
vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
let events = to_playback_events(&score, &opts(None));
assert_eq!(events[0].channel, 3);
}
#[test]
fn swing_unit_default_is_eighth() {
assert_eq!(PlaybackOptions::default().swing_unit, Duration::Eighth);
}
#[test]
fn swing_unit_sixteenth() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
score.parts[0].staves[0].measures[0].voices[0] = vec![
Note::new(Pitch::new(Step::C, 4), Duration::Sixteenth),
Note::new(Pitch::new(Step::D, 4), Duration::Sixteenth),
];
let options = PlaybackOptions {
swing: Some(0.67),
swing_unit: Duration::Sixteenth,
..Default::default()
};
let events = to_playback_events(&score, &options);
let e_c = events.iter().find(|e| e.pitch_midi == 60).unwrap();
let e_d = events.iter().find(|e| e.pitch_midi == 62).unwrap();
assert!(
(e_c.duration_beats - 0.335).abs() < 1e-9,
"first 16th should be 0.335"
);
assert!(
(e_d.duration_beats - 0.165).abs() < 1e-9,
"second 16th should be 0.165"
);
}
#[test]
fn swing_resets_per_measure() {
let mut score = Score::new("T", 120, 4, 4, 0, 2);
let pair = || {
vec![
Note::new(Pitch::new(Step::C, 4), Duration::Eighth),
Note::new(Pitch::new(Step::D, 4), Duration::Eighth),
]
};
score.parts[0].staves[0].measures[0].voices[0] = pair();
score.parts[0].staves[0].measures[1].voices[0] = pair();
let options = PlaybackOptions {
swing: Some(0.67),
..Default::default()
};
let events = to_playback_events(&score, &options);
let durations: Vec<f64> = events.iter().map(|e| e.duration_beats).collect();
assert!((durations[0] - 0.67).abs() < 1e-9, "m0 first note long");
assert!((durations[1] - 0.33).abs() < 1e-9, "m0 second note short");
assert!(
(durations[2] - 0.67).abs() < 1e-9,
"m1 first note long (reset)"
);
assert!((durations[3] - 0.33).abs() < 1e-9, "m1 second note short");
}
#[test]
fn multi_voice_events_preserve_source_voice_addresses() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
score.parts[0].staves[0].measures[0].voices[0] =
vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
score.parts[0].staves[0].measures[0].voices[1] =
vec![Note::new(Pitch::new(Step::E, 4), Duration::Quarter)];
let events = to_playback_events(&score, &PlaybackOptions::default());
let addresses: Vec<&str> = events
.iter()
.filter_map(|event| event.address.as_deref())
.collect();
assert!(addresses.contains(&"0:0:0:0:0"));
assert!(addresses.contains(&"0:0:0:1:0"));
}
#[test]
fn playback_exposes_original_musicxml_voice_number_alongside_slot_address() {
let mut score = Score::new("T", 120, 4, 4, 0, 1);
let measure = &mut score.parts[0].staves[0].measures[0];
measure.voices[1] = vec![Note::new(Pitch::new(Step::E, 4), Duration::Quarter)];
measure.source_voice_numbers[1] = Some(5);
let events = to_playback_events(&score, &PlaybackOptions::default());
let event = events
.iter()
.find(|event| event.address.as_deref() == Some("0:0:0:1:0"))
.expect("event from slot 1");
assert_eq!(event.source.as_ref().map(|source| source.voice), Some(1));
assert_eq!(event.source_voice_number, Some(5));
}
#[test]
fn playback_position_at_zero_is_measure_0_beat_0() {
let score = Score::new("T", 120, 4, 4, 0, 4);
let pos = compute_playback_position(&score, &PlaybackOptions::default(), 0.0).unwrap();
assert_eq!(pos.measure_index, 0);
assert!(pos.beat.abs() < 1e-9);
}
#[test]
fn playback_position_at_half_measure_is_beat_2() {
let score = Score::new("T", 120, 4, 4, 0, 4);
let pos = compute_playback_position(&score, &PlaybackOptions::default(), 0.5).unwrap();
assert_eq!(pos.measure_index, 0);
assert!(
(pos.beat - 1.0).abs() < 1e-9,
"expected beat 1.0, got {}",
pos.beat
);
}
#[test]
fn playback_position_beyond_score_is_none() {
let score = Score::new("T", 120, 4, 4, 0, 1);
assert!(compute_playback_position(&score, &PlaybackOptions::default(), 10.0).is_none());
}
#[test]
fn playback_position_tempo_change_takes_effect() {
let mut score = Score::new("T", 120, 4, 4, 0, 2);
score.parts[0].staves[0].measures[1].tempo = Some(60);
let pos = compute_playback_position(&score, &PlaybackOptions::default(), 2.5).unwrap();
assert_eq!(pos.measure_index, 1);
assert!(
(pos.beat - 0.5).abs() < 1e-9,
"expected beat 0.5, got {}",
pos.beat
);
}
#[test]
fn playback_position_loop_region_starts_at_zero() {
let score = Score::new("T", 120, 4, 4, 0, 4);
let options = PlaybackOptions {
loop_region: Some((1, 2)),
..Default::default()
};
let pos = compute_playback_position(&score, &options, 0.0).unwrap();
assert_eq!(pos.measure_index, 1);
assert!(pos.beat.abs() < 1e-9);
}
#[test]
fn metronome_injects_beat_events() {
let score = Score::new("T", 120, 4, 4, 0, 1);
let options = PlaybackOptions {
metronome: Some(MetronomeConfig::default()),
..Default::default()
};
let events = to_playback_events(&score, &options);
let metro_events: Vec<_> = events.iter().filter(|e| e.is_metronome).collect();
assert_eq!(metro_events.len(), 4, "expected 4 metronome clicks in 4/4");
}
#[test]
fn metronome_accent_is_first_beat() {
let score = Score::new("T", 120, 4, 4, 0, 1);
let metro = MetronomeConfig::default();
let options = PlaybackOptions {
metronome: Some(metro.clone()),
..Default::default()
};
let events = to_playback_events(&score, &options);
let mut metro_events: Vec<_> = events.iter().filter(|e| e.is_metronome).collect();
metro_events.sort_by(|a, b| a.time_beats.partial_cmp(&b.time_beats).unwrap());
assert_eq!(metro_events[0].pitch_midi, metro.accent_pitch);
assert_eq!(metro_events[0].velocity, metro.accent_velocity);
}
#[test]
fn metronome_regular_beat_pitch() {
let score = Score::new("T", 120, 4, 4, 0, 1);
let metro = MetronomeConfig::default();
let options = PlaybackOptions {
metronome: Some(metro.clone()),
..Default::default()
};
let events = to_playback_events(&score, &options);
let mut metro_events: Vec<_> = events.iter().filter(|e| e.is_metronome).collect();
metro_events.sort_by(|a, b| a.time_beats.partial_cmp(&b.time_beats).unwrap());
for ev in &metro_events[1..] {
assert_eq!(ev.pitch_midi, metro.beat_pitch);
assert_eq!(ev.velocity, metro.beat_velocity);
}
}
#[test]
fn metronome_events_are_marked() {
let score = Score::new("T", 120, 4, 4, 0, 1);
let options = PlaybackOptions {
metronome: Some(MetronomeConfig::default()),
..Default::default()
};
let events = to_playback_events(&score, &options);
assert!(events.iter().any(|e| e.is_metronome));
}
#[test]
fn metronome_none_produces_no_extra_events() {
let score = Score::new("T", 120, 4, 4, 0, 1);
let events = to_playback_events(&score, &PlaybackOptions::default());
assert!(events.iter().all(|e| !e.is_metronome));
}
fn comparison_event(time_secs: f64, duration_secs: f64) -> PlaybackEvent {
PlaybackEvent {
address: Some("0:0:0:0:0".into()),
source: Some(NoteAddr {
part: 0,
staff: 0,
measure: 0,
voice: 0,
note: 0,
}),
source_voice_number: None,
time_beats: 0.0,
time_secs,
pitch_midi: 60,
pitch_midi_cents: 6000,
velocity: 64,
duration_beats: 1.0,
duration_secs,
pedal: false,
part_index: 0,
channel: 0,
is_metronome: false,
}
}
#[test]
fn playback_timing_comparison_reports_tolerance_and_identity() {
let expected = [comparison_event(1.0, 0.5)];
let actual = [comparison_event(1.004, 0.503)];
let report = compare_playback_timing(
&expected,
&actual,
&PlaybackTimingTolerance {
start_secs: 0.005,
duration_secs: 0.005,
},
)
.expect("comparison");
assert!(report.within_tolerance);
assert_eq!(report.matched_events, 1);
assert_eq!(
report.contract_version,
PLAYBACK_COMPARISON_CONTRACT_VERSION
);
let mut typed_only = comparison_event(1.004, 0.503);
typed_only.address = None;
let report = compare_playback_timing(
&expected,
&[typed_only],
&PlaybackTimingTolerance::default(),
)
.expect("typed source comparison");
assert_eq!(report.matched_events, 1);
let actual = [comparison_event(1.02, 0.6)];
let report =
compare_playback_timing(&expected, &actual, &PlaybackTimingTolerance::default())
.expect("comparison");
assert!(!report.within_tolerance);
assert!(
report
.mismatches
.iter()
.any(|m| matches!(m, PlaybackTimingMismatch::StartTime { index: 0, .. }))
);
assert!(
report
.mismatches
.iter()
.any(|m| matches!(m, PlaybackTimingMismatch::Duration { index: 0, .. }))
);
}
#[test]
fn playback_timing_comparison_rejects_invalid_tolerance() {
let error = compare_playback_timing(
&[],
&[],
&PlaybackTimingTolerance {
start_secs: -0.1,
duration_secs: 0.0,
},
);
assert!(matches!(
error,
Err(crate::Error::InvalidPlaybackComparison)
));
}
#[test]
fn tablature_performance_projection_keeps_position_and_reports_pitch_error() {
let mut score = Score::new("Tab", 120, 4, 4, 0, 1);
score.parts[0].staves[0].tablature = Some(super::super::notation::TablatureConfig {
lines: 6,
tuning_midi: vec![40, 45, 50, 55, 59, 64],
capo: 0,
});
let notes = &mut score.parts[0].staves[0].measures[0].voices[0];
*notes = vec![crate::Note::new(
crate::Pitch::new(crate::Step::C, 4),
crate::Duration::Quarter,
)];
notes[0].tab_position = Some(super::super::notation::TabPosition {
string: 1,
fret: 20,
});
notes[0].guitar_technique = Some(super::super::notation::GuitarTechnique::Bend);
notes[0].guitar_bend_alter_cents = Some(150);
let report = project_tablature_performance(&score, &PlaybackOptions::default())
.expect("tablature projection");
assert_eq!(report.events.len(), 1);
assert_eq!(report.events[0].string, 1);
assert_eq!(report.events[0].fret, 20);
assert_eq!(
report.events[0].technique,
Some(super::super::notation::GuitarTechnique::Bend)
);
assert_eq!(report.events[0].bend_alter_cents, Some(150));
assert!(report.diagnostics.is_empty());
score.parts[0].staves[0].measures[0].voices[0][0].tab_position =
Some(super::super::notation::TabPosition {
string: 1,
fret: 19,
});
let report = project_tablature_performance(&score, &PlaybackOptions::default())
.expect("tablature projection");
assert_eq!(report.events[0].pitch_error_cents, 100);
assert!(matches!(
report.diagnostics[0],
TablaturePerformanceDiagnostic::PitchMismatch {
error_cents: 100,
..
}
));
}
#[test]
fn tablature_performance_projection_does_not_invent_missing_positions() {
let mut score = Score::new("Tab", 120, 4, 4, 0, 1);
score.parts[0].staves[0].tablature = Some(super::super::notation::TablatureConfig {
lines: 6,
tuning_midi: vec![40, 45, 50, 55, 59, 64],
capo: 0,
});
score.parts[0].staves[0].measures[0].voices[0].push(crate::Note::new(
crate::Pitch::new(crate::Step::C, 4),
crate::Duration::Quarter,
));
let report = project_tablature_performance(&score, &PlaybackOptions::default())
.expect("tablature projection");
assert!(report.events.is_empty());
assert!(matches!(
report.diagnostics[0],
TablaturePerformanceDiagnostic::MissingPosition { .. }
));
}
#[test]
fn tablature_performance_event_accepts_legacy_json_without_technique() {
let mut score = Score::new("Tab", 120, 4, 4, 0, 1);
score.parts[0].staves[0].tablature = Some(super::super::notation::TablatureConfig {
lines: 6,
tuning_midi: vec![40, 45, 50, 55, 59, 64],
capo: 0,
});
let mut note = crate::Note::new(
crate::Pitch::new(crate::Step::C, 4),
crate::Duration::Quarter,
);
note.tab_position = Some(super::super::notation::TabPosition {
string: 1,
fret: 20,
});
score.parts[0].staves[0].measures[0].voices[0] = vec![note];
let report = project_tablature_performance(&score, &PlaybackOptions::default())
.expect("tablature projection");
let mut legacy = serde_json::to_value(&report.events[0]).expect("event JSON");
legacy
.as_object_mut()
.expect("event object")
.remove("technique");
let restored: TablaturePerformanceEvent =
serde_json::from_value(legacy).expect("legacy event JSON");
assert_eq!(restored.technique, None);
}
#[test]
fn tablature_round_trip_report_preserves_authored_positions() {
let mut score = Score::new("Tab", 120, 4, 4, 0, 1);
score.parts[0].staves[0].tablature = Some(super::super::notation::TablatureConfig {
lines: 6,
tuning_midi: vec![40, 45, 50, 55, 59, 64],
capo: 2,
});
let mut note = crate::Note::new(
crate::Pitch::new(crate::Step::C, 4),
crate::Duration::Quarter,
);
note.tab_positions = vec![crate::TabPosition { string: 5, fret: 1 }];
score.parts[0].staves[0].measures[0].voices[0] = vec![note];
let report = tablature_round_trip_report(&score).expect("tab round-trip report");
assert!(report.equivalent);
assert_eq!(report.checked_notes, 1);
assert_eq!(report.positioned_notes, 1);
assert!(report.diagnostics.is_empty());
}
}