1use sim_lib_pitch_core::{OctaveSpace, Pitch, TieDirection, folded_distance, split_floor};
4use thiserror::Error;
5
6#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
8pub enum PitchMapPolicy {
9 Unmapped,
11 Clamp,
13 Reject,
15 Nearest,
17}
18
19#[derive(Clone, Debug, Error, PartialEq, Eq)]
21pub(crate) enum MapError {
22 #[error("pitch map image length {image_len} does not match domain length {domain_len}")]
24 ImageLengthMismatch {
25 domain_len: usize,
27 image_len: usize,
29 },
30 #[error("pitch map has no mapped entries")]
32 NoMappedEntries,
33 #[error("pitch map rejected unmapped class {class}")]
35 Unmapped {
36 class: u16,
38 },
39 #[error("pitch map domain {divisions} cannot map octave-aware Pitch values")]
41 UnsupportedPitchDomain {
42 divisions: u16,
44 },
45 #[error("pitch map target value {value} is outside the supported Pitch range")]
47 TargetOutOfRange {
48 value: i64,
50 },
51}
52
53#[derive(Clone, Debug, PartialEq, Eq)]
55pub enum MapWitness {
56 Direct {
58 source_class: u16,
60 target_value: i64,
62 },
63 Unmapped {
65 source_class: u16,
67 },
68 Nudged {
70 source_class: u16,
72 chosen_class: u16,
74 target_value: i64,
76 policy: PitchMapPolicy,
78 },
79}
80
81#[derive(Clone, Debug, PartialEq, Eq)]
82pub(crate) struct PitchMapResult {
83 pub pitch: Pitch,
84 pub witness: MapWitness,
85}
86
87#[derive(Clone, Debug, PartialEq, Eq)]
89pub(crate) struct PitchMap {
90 pub domain: OctaveSpace,
91 pub image: Vec<Option<i32>>,
92 pub policy: PitchMapPolicy,
93}
94
95impl PitchMap {
96 pub(crate) fn new(
97 domain: OctaveSpace,
98 image: Vec<Option<i32>>,
99 policy: PitchMapPolicy,
100 ) -> Result<Self, MapError> {
101 let domain_len = usize::from(domain.len());
102 if image.len() != domain_len {
103 return Err(MapError::ImageLengthMismatch {
104 domain_len,
105 image_len: image.len(),
106 });
107 }
108 Ok(Self {
109 domain,
110 image,
111 policy,
112 })
113 }
114
115 pub(crate) fn map_pitch(&self, pitch: Pitch) -> Result<PitchMapResult, MapError> {
116 if self.domain != OctaveSpace::twelve_tone() {
117 return Err(MapError::UnsupportedPitchDomain {
118 divisions: self.domain.len(),
119 });
120 }
121 let (value, witness) = self.map_value(i64::from(pitch.semitone()))?;
122 let semitone = i32::try_from(value).map_err(|_| MapError::TargetOutOfRange { value })?;
123 Ok(PitchMapResult {
124 pitch: Pitch::from_semitone(semitone),
125 witness,
126 })
127 }
128
129 fn map_value(&self, value: i64) -> Result<(i64, MapWitness), MapError> {
130 let divisions = i64::from(self.domain.len());
131 let (octaves, folded) = split_floor(value, self.domain);
132 let source_class = folded;
133 let Some(mapped) = self.image[usize::from(source_class)] else {
134 return self.map_hole(octaves, source_class);
135 };
136 let target_value = octaves * divisions + i64::from(mapped);
137 Ok((
138 target_value,
139 MapWitness::Direct {
140 source_class,
141 target_value,
142 },
143 ))
144 }
145
146 fn map_hole(&self, octaves: i64, source_class: u16) -> Result<(i64, MapWitness), MapError> {
147 match self.policy {
148 PitchMapPolicy::Unmapped => {
149 let target_value = octaves * i64::from(self.domain.len()) + i64::from(source_class);
150 Ok((target_value, MapWitness::Unmapped { source_class }))
151 }
152 PitchMapPolicy::Reject => Err(MapError::Unmapped {
153 class: source_class,
154 }),
155 PitchMapPolicy::Clamp | PitchMapPolicy::Nearest => {
156 let chosen = self.choose_mapped_class(source_class)?;
157 let mapped = self.image[usize::from(chosen)].expect("chosen mapped class");
158 let target_value = octaves * i64::from(self.domain.len()) + i64::from(mapped);
159 Ok((
160 target_value,
161 MapWitness::Nudged {
162 source_class,
163 chosen_class: chosen,
164 target_value,
165 policy: self.policy,
166 },
167 ))
168 }
169 }
170 }
171
172 fn choose_mapped_class(&self, source_class: u16) -> Result<u16, MapError> {
173 let mapped = self
174 .image
175 .iter()
176 .enumerate()
177 .filter_map(|(index, value)| value.map(|_| u16::try_from(index).expect("class index")))
178 .collect::<Vec<_>>();
179 if mapped.is_empty() {
180 return Err(MapError::NoMappedEntries);
181 }
182 match self.policy {
183 PitchMapPolicy::Clamp => mapped
184 .iter()
185 .copied()
186 .min_by_key(|candidate| {
187 let delta = i32::from(*candidate) - i32::from(source_class);
188 (delta.abs(), delta.is_negative())
189 })
190 .ok_or(MapError::NoMappedEntries),
191 PitchMapPolicy::Nearest => mapped
192 .iter()
193 .copied()
194 .min_by_key(|candidate| {
195 (
196 folded_distance(
197 i64::from(*candidate),
198 i64::from(source_class),
199 self.domain,
200 TieDirection::Descending,
201 ),
202 candidate.cmp(&source_class).is_gt(),
203 )
204 })
205 .ok_or(MapError::NoMappedEntries),
206 PitchMapPolicy::Unmapped | PitchMapPolicy::Reject => unreachable!("handled above"),
207 }
208 }
209}