1use std::collections::BTreeMap;
2
3use sim_lib_pitch_core::{
4 OctaveSpace, Pitch, PitchClass, TieDirection, folded_distance, split_floor,
5};
6use sim_lib_pitch_scale::Scale;
7use thiserror::Error;
8
9#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
11pub enum PitchMapPolicy {
12 Unmapped,
14 Clamp,
16 Reject,
18 Nearest,
20}
21
22#[derive(Clone, Debug, Error, PartialEq, Eq)]
24pub enum MapError {
25 #[error("pitch map image length {image_len} does not match domain length {domain_len}")]
27 ImageLengthMismatch {
28 domain_len: usize,
30 image_len: usize,
32 },
33 #[error("pitch map domains differ: left {left_len}, right {right_len}")]
35 DomainMismatch {
36 left_len: u16,
38 right_len: u16,
40 },
41 #[error("pitch map has no mapped entries")]
43 NoMappedEntries,
44 #[error("pitch map rejected unmapped class {class}")]
46 Unmapped {
47 class: u16,
49 },
50 #[error("pitch map domain {divisions} cannot map octave-aware Pitch values")]
53 UnsupportedPitchDomain {
54 divisions: u16,
56 },
57 #[error("pitch map target value {value} is outside the supported Pitch range")]
59 TargetOutOfRange {
60 value: i64,
62 },
63}
64
65#[derive(Clone, Debug, PartialEq, Eq)]
67pub enum MapWitness {
68 Direct {
70 source_class: u16,
72 target_value: i64,
74 },
75 Unmapped {
77 source_class: u16,
79 },
80 Nudged {
82 source_class: u16,
84 chosen_class: u16,
86 target_value: i64,
88 policy: PitchMapPolicy,
90 },
91}
92
93#[derive(Clone, Debug, PartialEq, Eq)]
95pub struct PitchMapResult {
96 pub pitch: Pitch,
98 pub witness: MapWitness,
100}
101
102#[derive(Clone, Debug, PartialEq, Eq)]
104pub struct MapInverseWitness {
105 pub target: i32,
107 pub sources: Vec<u16>,
109}
110
111#[derive(Clone, Debug, PartialEq, Eq)]
113pub enum MapCompositionWitness {
114 Direct {
116 source_class: u16,
118 via_value: i32,
120 target_value: i32,
122 },
123 Undefined {
125 source_class: u16,
127 reason: &'static str,
129 },
130}
131
132#[derive(Clone, Debug, PartialEq, Eq)]
134pub struct PitchMapComposition {
135 pub map: PitchMap,
137 pub witnesses: Vec<MapCompositionWitness>,
139}
140
141#[derive(Clone, Debug, PartialEq, Eq)]
143pub struct PitchMap {
144 pub domain: OctaveSpace,
146 pub image: Vec<Option<i32>>,
148 pub policy: PitchMapPolicy,
150}
151
152impl PitchMap {
153 pub fn new(
155 domain: OctaveSpace,
156 image: Vec<Option<i32>>,
157 policy: PitchMapPolicy,
158 ) -> Result<Self, MapError> {
159 let domain_len = usize::from(domain.len());
160 if image.len() != domain_len {
161 return Err(MapError::ImageLengthMismatch {
162 domain_len,
163 image_len: image.len(),
164 });
165 }
166 Ok(Self {
167 domain,
168 image,
169 policy,
170 })
171 }
172
173 pub fn identity(domain: OctaveSpace, policy: PitchMapPolicy) -> Self {
175 Self {
176 domain,
177 image: (0..domain.len())
178 .map(|value| Some(i32::from(value)))
179 .collect(),
180 policy,
181 }
182 }
183
184 pub fn chromatic_delta(semitones: i32) -> Self {
186 let domain = OctaveSpace::twelve_tone();
187 Self {
188 domain,
189 image: (0..domain.len())
190 .map(|value| Some(i32::from(value) + semitones))
191 .collect(),
192 policy: PitchMapPolicy::Reject,
193 }
194 }
195
196 pub fn rotation(domain: OctaveSpace, steps: i32, policy: PitchMapPolicy) -> Self {
198 let len = i32::from(domain.len());
199 Self {
200 domain,
201 image: (0..domain.len())
202 .map(|value| Some((i32::from(value) + steps).rem_euclid(len)))
203 .collect(),
204 policy,
205 }
206 }
207
208 pub fn inversion(axis: PitchClass) -> Self {
210 let axis = i32::from(axis.value());
211 let domain = OctaveSpace::twelve_tone();
212 Self {
213 domain,
214 image: (0..domain.len())
215 .map(|value| Some((2 * axis - i32::from(value)).rem_euclid(12)))
216 .collect(),
217 policy: PitchMapPolicy::Reject,
218 }
219 }
220
221 pub fn pitch_class_substitution(
223 from: PitchClass,
224 to: PitchClass,
225 policy: PitchMapPolicy,
226 ) -> Self {
227 let domain = OctaveSpace::twelve_tone();
228 let mut map = Self::identity(domain, policy);
229 map.image[usize::from(from.value())] = Some(i32::from(to.value()));
230 map
231 }
232
233 pub fn from_scale(scale: Scale, policy: PitchMapPolicy) -> Self {
235 let domain = OctaveSpace::twelve_tone();
236 let mut image = vec![None; usize::from(domain.len())];
237 for class in scale.pitch_classes() {
238 let value = class.value();
239 image[usize::from(value)] = Some(i32::from(value));
240 }
241 Self {
242 domain,
243 image,
244 policy,
245 }
246 }
247
248 pub fn is_partial(&self) -> bool {
250 self.image.iter().any(Option::is_none)
251 }
252
253 pub fn map_pitch(&self, pitch: Pitch) -> Result<PitchMapResult, MapError> {
255 if self.domain != OctaveSpace::twelve_tone() {
256 return Err(MapError::UnsupportedPitchDomain {
257 divisions: self.domain.len(),
258 });
259 }
260 let (value, witness) = self.map_value(i64::from(pitch.semitone()))?;
261 let value_i32 = i32::try_from(value).map_err(|_| MapError::TargetOutOfRange { value })?;
262 Ok(PitchMapResult {
263 pitch: Pitch::from_semitone(value_i32),
264 witness,
265 })
266 }
267
268 pub fn inverse_witnesses(&self) -> Vec<MapInverseWitness> {
270 let mut groups: BTreeMap<i32, Vec<u16>> = BTreeMap::new();
271 for (source, target) in self.image.iter().enumerate() {
272 if let Some(target) = target {
273 groups
274 .entry(*target)
275 .or_default()
276 .push(u16::try_from(source).expect("source class fits u16"));
277 }
278 }
279 groups
280 .into_iter()
281 .map(|(target, sources)| MapInverseWitness { target, sources })
282 .collect()
283 }
284
285 pub fn has_partial_inverse(&self) -> bool {
287 self.is_partial()
288 || self
289 .inverse_witnesses()
290 .iter()
291 .any(|witness| witness.sources.len() != 1)
292 }
293
294 fn map_value(&self, value: i64) -> Result<(i64, MapWitness), MapError> {
295 let (octave, source_class) = split_floor(value, self.domain);
296 let source_index = usize::from(source_class);
297 if let Some(target) = self.image[source_index] {
298 let target_value = target_value(octave, self.domain, target);
299 return Ok((
300 target_value,
301 MapWitness::Direct {
302 source_class,
303 target_value,
304 },
305 ));
306 }
307
308 match self.policy {
309 PitchMapPolicy::Unmapped => Ok((value, MapWitness::Unmapped { source_class })),
310 PitchMapPolicy::Reject => Err(MapError::Unmapped {
311 class: source_class,
312 }),
313 PitchMapPolicy::Clamp | PitchMapPolicy::Nearest => {
314 let (chosen_value, chosen_class, target) =
315 self.choose_mapped_class(value, source_class)?;
316 let target_value = target_value(
317 chosen_value.div_euclid(i64::from(self.domain.len())),
318 self.domain,
319 target,
320 );
321 Ok((
322 target_value,
323 MapWitness::Nudged {
324 source_class,
325 chosen_class,
326 target_value,
327 policy: self.policy,
328 },
329 ))
330 }
331 }
332 }
333
334 fn choose_mapped_class(
335 &self,
336 value: i64,
337 source_class: u16,
338 ) -> Result<(i64, u16, i32), MapError> {
339 let candidates = self.mapped_candidates();
340 if candidates.is_empty() {
341 return Err(MapError::NoMappedEntries);
342 }
343 let len = i64::from(self.domain.len());
344 let source_octave = value.div_euclid(len);
345 match self.policy {
346 PitchMapPolicy::Clamp => {
347 let chosen = clamp_candidate(&candidates, source_class);
348 Ok((
349 source_octave * len + i64::from(chosen.0),
350 chosen.0,
351 chosen.1,
352 ))
353 }
354 PitchMapPolicy::Nearest => {
355 let chosen = nearest_candidate(&candidates, source_class, self.domain);
356 Ok((value + i64::from(chosen.2), chosen.0, chosen.1))
357 }
358 PitchMapPolicy::Unmapped | PitchMapPolicy::Reject => unreachable!(),
359 }
360 }
361
362 fn mapped_candidates(&self) -> Vec<(u16, i32)> {
363 self.image
364 .iter()
365 .enumerate()
366 .filter_map(|(source, target)| {
367 target.map(|target| {
368 (
369 u16::try_from(source).expect("source class fits u16"),
370 target,
371 )
372 })
373 })
374 .collect()
375 }
376}
377
378pub fn compose_pitch_maps(a: &PitchMap, b: &PitchMap) -> Result<PitchMap, MapError> {
380 Ok(compose_pitch_map_report(a, b)?.map)
381}
382
383pub fn compose_pitch_map_report(
385 a: &PitchMap,
386 b: &PitchMap,
387) -> Result<PitchMapComposition, MapError> {
388 if a.domain != b.domain {
389 return Err(MapError::DomainMismatch {
390 left_len: a.domain.len(),
391 right_len: b.domain.len(),
392 });
393 }
394 let mut image = Vec::with_capacity(a.image.len());
395 let mut witnesses = Vec::with_capacity(a.image.len());
396 for (source, first) in a.image.iter().enumerate() {
397 let source_class = u16::try_from(source).expect("source class fits u16");
398 let Some(via_value) = first else {
399 image.push(None);
400 witnesses.push(MapCompositionWitness::Undefined {
401 source_class,
402 reason: "left map has no image",
403 });
404 continue;
405 };
406 let (via_octave, via_class) = split_floor(i64::from(*via_value), a.domain);
407 let Some(second) = b.image[usize::from(via_class)] else {
408 image.push(None);
409 witnesses.push(MapCompositionWitness::Undefined {
410 source_class,
411 reason: "right map has no image",
412 });
413 continue;
414 };
415 let target_value = target_value(via_octave, a.domain, second);
416 let target_i32 = i32::try_from(target_value).map_err(|_| MapError::TargetOutOfRange {
417 value: target_value,
418 })?;
419 image.push(Some(target_i32));
420 witnesses.push(MapCompositionWitness::Direct {
421 source_class,
422 via_value: *via_value,
423 target_value: target_i32,
424 });
425 }
426 Ok(PitchMapComposition {
427 map: PitchMap {
428 domain: a.domain,
429 image,
430 policy: b.policy,
431 },
432 witnesses,
433 })
434}
435
436fn target_value(octave: i64, domain: OctaveSpace, target: i32) -> i64 {
437 octave * i64::from(domain.len()) + i64::from(target)
438}
439
440fn clamp_candidate(candidates: &[(u16, i32)], source_class: u16) -> (u16, i32) {
441 if source_class <= candidates[0].0 {
442 return candidates[0];
443 }
444 if source_class >= candidates[candidates.len() - 1].0 {
445 return candidates[candidates.len() - 1];
446 }
447 *candidates
448 .iter()
449 .min_by_key(|(candidate, _)| {
450 let distance = candidate.abs_diff(source_class);
451 let upward = u8::from(*candidate > source_class);
452 (distance, upward, *candidate)
453 })
454 .expect("candidates are non-empty")
455}
456
457fn nearest_candidate(
458 candidates: &[(u16, i32)],
459 source_class: u16,
460 domain: OctaveSpace,
461) -> (u16, i32, i32) {
462 candidates
463 .iter()
464 .map(|(candidate, target)| {
465 let distance = folded_distance(
466 i64::from(source_class),
467 i64::from(*candidate),
468 domain,
469 TieDirection::Ascending,
470 );
471 (*candidate, *target, distance)
472 })
473 .min_by_key(|(candidate, _, distance)| {
474 let upward = u8::from(*distance < 0);
475 (distance.abs(), upward, *candidate)
476 })
477 .expect("candidates are non-empty")
478}