use sim_lib_pitch_core::{OctaveSpace, Pitch, TieDirection, folded_distance, split_floor};
use thiserror::Error;
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub enum PitchMapPolicy {
Unmapped,
Clamp,
Reject,
Nearest,
}
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub(crate) enum MapError {
#[error("pitch map image length {image_len} does not match domain length {domain_len}")]
ImageLengthMismatch {
domain_len: usize,
image_len: usize,
},
#[error("pitch map has no mapped entries")]
NoMappedEntries,
#[error("pitch map rejected unmapped class {class}")]
Unmapped {
class: u16,
},
#[error("pitch map domain {divisions} cannot map octave-aware Pitch values")]
UnsupportedPitchDomain {
divisions: u16,
},
#[error("pitch map target value {value} is outside the supported Pitch range")]
TargetOutOfRange {
value: i64,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MapWitness {
Direct {
source_class: u16,
target_value: i64,
},
Unmapped {
source_class: u16,
},
Nudged {
source_class: u16,
chosen_class: u16,
target_value: i64,
policy: PitchMapPolicy,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct PitchMapResult {
pub pitch: Pitch,
pub witness: MapWitness,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct PitchMap {
pub domain: OctaveSpace,
pub image: Vec<Option<i32>>,
pub policy: PitchMapPolicy,
}
impl PitchMap {
pub(crate) fn new(
domain: OctaveSpace,
image: Vec<Option<i32>>,
policy: PitchMapPolicy,
) -> Result<Self, MapError> {
let domain_len = usize::from(domain.len());
if image.len() != domain_len {
return Err(MapError::ImageLengthMismatch {
domain_len,
image_len: image.len(),
});
}
Ok(Self {
domain,
image,
policy,
})
}
pub(crate) fn map_pitch(&self, pitch: Pitch) -> Result<PitchMapResult, MapError> {
if self.domain != OctaveSpace::twelve_tone() {
return Err(MapError::UnsupportedPitchDomain {
divisions: self.domain.len(),
});
}
let (value, witness) = self.map_value(i64::from(pitch.semitone()))?;
let semitone = i32::try_from(value).map_err(|_| MapError::TargetOutOfRange { value })?;
Ok(PitchMapResult {
pitch: Pitch::from_semitone(semitone),
witness,
})
}
fn map_value(&self, value: i64) -> Result<(i64, MapWitness), MapError> {
let divisions = i64::from(self.domain.len());
let (octaves, folded) = split_floor(value, self.domain);
let source_class = folded;
let Some(mapped) = self.image[usize::from(source_class)] else {
return self.map_hole(octaves, source_class);
};
let target_value = octaves * divisions + i64::from(mapped);
Ok((
target_value,
MapWitness::Direct {
source_class,
target_value,
},
))
}
fn map_hole(&self, octaves: i64, source_class: u16) -> Result<(i64, MapWitness), MapError> {
match self.policy {
PitchMapPolicy::Unmapped => {
let target_value = octaves * i64::from(self.domain.len()) + i64::from(source_class);
Ok((target_value, MapWitness::Unmapped { source_class }))
}
PitchMapPolicy::Reject => Err(MapError::Unmapped {
class: source_class,
}),
PitchMapPolicy::Clamp | PitchMapPolicy::Nearest => {
let chosen = self.choose_mapped_class(source_class)?;
let mapped = self.image[usize::from(chosen)].expect("chosen mapped class");
let target_value = octaves * i64::from(self.domain.len()) + i64::from(mapped);
Ok((
target_value,
MapWitness::Nudged {
source_class,
chosen_class: chosen,
target_value,
policy: self.policy,
},
))
}
}
}
fn choose_mapped_class(&self, source_class: u16) -> Result<u16, MapError> {
let mapped = self
.image
.iter()
.enumerate()
.filter_map(|(index, value)| value.map(|_| u16::try_from(index).expect("class index")))
.collect::<Vec<_>>();
if mapped.is_empty() {
return Err(MapError::NoMappedEntries);
}
match self.policy {
PitchMapPolicy::Clamp => mapped
.iter()
.copied()
.min_by_key(|candidate| {
let delta = i32::from(*candidate) - i32::from(source_class);
(delta.abs(), delta.is_negative())
})
.ok_or(MapError::NoMappedEntries),
PitchMapPolicy::Nearest => mapped
.iter()
.copied()
.min_by_key(|candidate| {
(
folded_distance(
i64::from(*candidate),
i64::from(source_class),
self.domain,
TieDirection::Descending,
),
candidate.cmp(&source_class).is_gt(),
)
})
.ok_or(MapError::NoMappedEntries),
PitchMapPolicy::Unmapped | PitchMapPolicy::Reject => unreachable!("handled above"),
}
}
}