Skip to main content

sim_lib_music_transform/
pitch_map.rs

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/// Policy used when a [`PitchMap`] has no direct image for a source class.
10#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
11pub enum PitchMapPolicy {
12    /// Keep the input pitch and record an unmapped witness.
13    Unmapped,
14    /// Clamp to the nearest mapped source class without wrapping around the domain.
15    Clamp,
16    /// Reject the pitch as a diagnostic or direct mapping error.
17    Reject,
18    /// Nudge to the nearest mapped source class on the circular octave space.
19    Nearest,
20}
21
22/// Error returned by partial pitch map construction, application, or composition.
23#[derive(Clone, Debug, Error, PartialEq, Eq)]
24pub enum MapError {
25    /// A map image length did not match its octave-space domain.
26    #[error("pitch map image length {image_len} does not match domain length {domain_len}")]
27    ImageLengthMismatch {
28        /// Domain length.
29        domain_len: usize,
30        /// Image length.
31        image_len: usize,
32    },
33    /// Two composed maps used different octave spaces.
34    #[error("pitch map domains differ: left {left_len}, right {right_len}")]
35    DomainMismatch {
36        /// Left map domain length.
37        left_len: u16,
38        /// Right map domain length.
39        right_len: u16,
40    },
41    /// A map with a nudge policy had no mapped entries to nudge toward.
42    #[error("pitch map has no mapped entries")]
43    NoMappedEntries,
44    /// A reject policy encountered an unmapped source class.
45    #[error("pitch map rejected unmapped class {class}")]
46    Unmapped {
47        /// Folded source class.
48        class: u16,
49    },
50    /// The map can be composed as an integer map but cannot be applied to
51    /// octave-aware [`Pitch`] values.
52    #[error("pitch map domain {divisions} cannot map octave-aware Pitch values")]
53    UnsupportedPitchDomain {
54        /// Domain division count.
55        divisions: u16,
56    },
57    /// A mapped absolute value cannot be represented as a [`Pitch`] semitone.
58    #[error("pitch map target value {value} is outside the supported Pitch range")]
59    TargetOutOfRange {
60        /// Absolute target value.
61        value: i64,
62    },
63}
64
65/// Witness explaining how a [`PitchMap`] handled one input pitch.
66#[derive(Clone, Debug, PartialEq, Eq)]
67pub enum MapWitness {
68    /// The source class had a direct image.
69    Direct {
70        /// Folded source class.
71        source_class: u16,
72        /// Mapped absolute semitone.
73        target_value: i64,
74    },
75    /// The map left an unmapped source unchanged.
76    Unmapped {
77        /// Folded source class.
78        source_class: u16,
79    },
80    /// The map used an explicit policy to choose a mapped source class.
81    Nudged {
82        /// Folded source class requested by the input pitch.
83        source_class: u16,
84        /// Folded mapped class selected by the policy.
85        chosen_class: u16,
86        /// Mapped absolute semitone.
87        target_value: i64,
88        /// Policy that chose the mapped class.
89        policy: PitchMapPolicy,
90    },
91}
92
93/// Result of applying a [`PitchMap`] to one pitch.
94#[derive(Clone, Debug, PartialEq, Eq)]
95pub struct PitchMapResult {
96    /// Mapped pitch.
97    pub pitch: Pitch,
98    /// Witness for the mapping path.
99    pub witness: MapWitness,
100}
101
102/// Partial inverse row for a target value in a [`PitchMap`].
103#[derive(Clone, Debug, PartialEq, Eq)]
104pub struct MapInverseWitness {
105    /// Target image value.
106    pub target: i32,
107    /// Source classes that map directly to `target`.
108    pub sources: Vec<u16>,
109}
110
111/// Witness for one source class while composing two pitch maps.
112#[derive(Clone, Debug, PartialEq, Eq)]
113pub enum MapCompositionWitness {
114    /// Both maps had direct images for the composition path.
115    Direct {
116        /// Source class in the left map.
117        source_class: u16,
118        /// Intermediate value produced by the left map.
119        via_value: i32,
120        /// Target value produced by the right map.
121        target_value: i32,
122    },
123    /// At least one direct image was absent.
124    Undefined {
125        /// Source class in the left map.
126        source_class: u16,
127        /// Stable reason the composition is partial at this class.
128        reason: &'static str,
129    },
130}
131
132/// Result of composing two [`PitchMap`] values with per-class witnesses.
133#[derive(Clone, Debug, PartialEq, Eq)]
134pub struct PitchMapComposition {
135    /// Composed map.
136    pub map: PitchMap,
137    /// Witnesses for direct and lossy source classes.
138    pub witnesses: Vec<MapCompositionWitness>,
139}
140
141/// Partial map from folded source classes to absolute target offsets.
142#[derive(Clone, Debug, PartialEq, Eq)]
143pub struct PitchMap {
144    /// Source octave-like domain.
145    pub domain: OctaveSpace,
146    /// Per-source-class target offsets. `None` records a hole.
147    pub image: Vec<Option<i32>>,
148    /// Policy for holes during pitch application.
149    pub policy: PitchMapPolicy,
150}
151
152impl PitchMap {
153    /// Builds a pitch map and verifies that the image covers the whole domain.
154    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    /// Builds an identity map over `domain`.
174    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    /// Builds a chromatic transposition map in twelve-tone semitone space.
185    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    /// Builds a pitch-class rotation map over any octave space.
197    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    /// Builds a twelve-tone inversion map around `axis`.
209    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    /// Builds a twelve-tone pitch-class substitution map.
222    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    /// Builds a partial scale-lock map whose holes are handled by `policy`.
234    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    /// Returns `true` when at least one source class has no direct image.
249    pub fn is_partial(&self) -> bool {
250        self.image.iter().any(Option::is_none)
251    }
252
253    /// Applies the map to a pitch, returning the pitch and an explicit witness.
254    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    /// Returns grouped direct inverse witnesses for every target value.
269    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    /// Returns `true` when the direct inverse is incomplete or many-to-one.
286    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
378/// Composes `a` followed by `b`, discarding per-class witnesses.
379pub fn compose_pitch_maps(a: &PitchMap, b: &PitchMap) -> Result<PitchMap, MapError> {
380    Ok(compose_pitch_map_report(a, b)?.map)
381}
382
383/// Composes `a` followed by `b`, returning witnesses for lossy classes.
384pub 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}