Skip to main content

sim_lib_music_serial/
pitch_map.rs

1//! Local pitch-map support retained inside serial so transform depends one-way on serial.
2
3use sim_lib_pitch_core::{OctaveSpace, Pitch, TieDirection, folded_distance, split_floor};
4use thiserror::Error;
5
6/// Policy used when a [`PitchMap`] has no direct image for a source class.
7#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
8pub enum PitchMapPolicy {
9    /// Keep the input pitch and record an unmapped witness.
10    Unmapped,
11    /// Clamp to the nearest mapped source class without wrapping around the domain.
12    Clamp,
13    /// Reject the pitch as a diagnostic or direct mapping error.
14    Reject,
15    /// Nudge to the nearest mapped source class on the circular octave space.
16    Nearest,
17}
18
19/// Error returned by partial pitch map construction or application.
20#[derive(Clone, Debug, Error, PartialEq, Eq)]
21pub(crate) enum MapError {
22    /// A map image length did not match its octave-space domain.
23    #[error("pitch map image length {image_len} does not match domain length {domain_len}")]
24    ImageLengthMismatch {
25        /// Domain length.
26        domain_len: usize,
27        /// Image length.
28        image_len: usize,
29    },
30    /// A map with a nudge policy had no mapped entries to nudge toward.
31    #[error("pitch map has no mapped entries")]
32    NoMappedEntries,
33    /// A reject policy encountered an unmapped source class.
34    #[error("pitch map rejected unmapped class {class}")]
35    Unmapped {
36        /// Folded source class.
37        class: u16,
38    },
39    /// The map cannot be applied to octave-aware pitches.
40    #[error("pitch map domain {divisions} cannot map octave-aware Pitch values")]
41    UnsupportedPitchDomain {
42        /// Domain division count.
43        divisions: u16,
44    },
45    /// A mapped absolute value cannot be represented as a [`Pitch`] semitone.
46    #[error("pitch map target value {value} is outside the supported Pitch range")]
47    TargetOutOfRange {
48        /// Absolute target value.
49        value: i64,
50    },
51}
52
53/// Witness explaining how the internal pitch map handled one input pitch.
54#[derive(Clone, Debug, PartialEq, Eq)]
55pub enum MapWitness {
56    /// The source class had a direct image.
57    Direct {
58        /// Folded source class.
59        source_class: u16,
60        /// Mapped absolute semitone.
61        target_value: i64,
62    },
63    /// The map left an unmapped source unchanged.
64    Unmapped {
65        /// Folded source class.
66        source_class: u16,
67    },
68    /// The map used an explicit policy to choose a mapped source class.
69    Nudged {
70        /// Folded source class requested by the input pitch.
71        source_class: u16,
72        /// Folded mapped class selected by the policy.
73        chosen_class: u16,
74        /// Mapped absolute semitone.
75        target_value: i64,
76        /// Policy that chose the mapped class.
77        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/// Partial map from folded source classes to absolute target offsets.
88#[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}