Skip to main content

sim_lib_music_transform/
remap.rs

1use std::collections::BTreeMap;
2
3use sim_lib_music_core::{Music, MusicObject};
4use sim_lib_pitch_chord::Chord;
5use sim_lib_pitch_core::{Pitch, PitchClass};
6use sim_lib_pitch_scale::Scale;
7
8use crate::pitch_map::{MapError, PitchMap, PitchMapPolicy};
9use crate::{
10    CallablePitchMap, TransformDiagnostic, TransformDiagnosticCode, TransformError,
11    TransformReport, map_pitches_with_diagnostics, nearest_pitch_in_scale, note_with_pitch,
12};
13
14/// Microtonal tuning offset expressed in cents.
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct TuningRemap {
17    /// Offset in cents (100 cents to a semitone).
18    pub cents: i32,
19}
20
21impl TuningRemap {
22    /// Builds a tuning remap from an offset in cents.
23    pub fn new(cents: i32) -> Self {
24        Self { cents }
25    }
26
27    /// Rounds the cents offset to the nearest whole semitone.
28    ///
29    /// # Examples
30    ///
31    /// ```
32    /// use sim_lib_music_transform::TuningRemap;
33    ///
34    /// assert_eq!(TuningRemap::new(150).semitone_delta(), 2);
35    /// assert_eq!(TuningRemap::new(40).semitone_delta(), 0);
36    /// ```
37    pub fn semitone_delta(&self) -> i32 {
38        (f64::from(self.cents) / 100.0).round() as i32
39    }
40
41    /// Returns the rounded tuning offset as a first-class pitch map.
42    pub fn pitch_map(&self) -> PitchMap {
43        PitchMap::chromatic_delta(self.semitone_delta())
44    }
45}
46
47/// Integer 2x3 affine matrix mapping `(degree, octave, 1)` to a new pitch.
48#[derive(Clone, Debug, PartialEq, Eq)]
49pub struct IntMatrix {
50    /// Coefficients producing the target scale degree.
51    pub degree_row: [i32; 3],
52    /// Coefficients producing the target octave.
53    pub octave_row: [i32; 3],
54    /// Shared divisor applied to both rows' results.
55    pub divisor: i32,
56}
57
58impl IntMatrix {
59    /// Builds a matrix from its degree row, octave row, and divisor.
60    pub fn new(degree_row: [i32; 3], octave_row: [i32; 3], divisor: i32) -> Self {
61        Self {
62            degree_row,
63            octave_row,
64            divisor,
65        }
66    }
67
68    /// Returns the identity matrix, which leaves degree and octave unchanged.
69    pub fn identity() -> Self {
70        Self::new([1, 0, 0], [0, 1, 0], 1)
71    }
72}
73
74/// A pitch remapping strategy applied note by note across material.
75#[derive(Clone, Debug, PartialEq, Eq)]
76pub enum PitchRemap {
77    /// Shift every pitch by a fixed number of semitones.
78    Chromatic(i32),
79    /// Transpose by scale degrees within `scale`.
80    ScaleDegree {
81        /// Scale that defines the diatonic steps.
82        scale: Scale,
83        /// Number of scale degrees to move.
84        steps: i32,
85    },
86    /// Replace one pitch class with another, keeping the octave.
87    PitchClass {
88        /// Pitch class to match.
89        from: PitchClass,
90        /// Pitch class to substitute.
91        to: PitchClass,
92    },
93    /// Remap MIDI drum keys via an explicit key-to-key table.
94    DrumKey(BTreeMap<u8, u8>),
95    /// Snap each pitch to the nearest tone of a chord built on `scale`.
96    ChordTone {
97        /// Scale the chord is drawn from.
98        scale: Scale,
99        /// Chord degree within the scale.
100        degree: usize,
101    },
102    /// Apply a microtonal tuning offset.
103    Tuning(TuningRemap),
104    /// Offset each pitch by a per-degree vector of semitone offsets.
105    Vector {
106        /// Scale used to find each pitch's degree.
107        scale: Scale,
108        /// Per-degree semitone offsets, indexed cyclically.
109        offsets: Vec<i32>,
110    },
111    /// Apply an integer affine [`IntMatrix`] over `(degree, octave)`.
112    Matrix {
113        /// Scale used to resolve degrees.
114        scale: Scale,
115        /// Transformation matrix.
116        matrix: IntMatrix,
117    },
118    /// Apply a named [`CallablePitchMap`].
119    Callable(CallablePitchMap),
120    /// Apply a first-class partial pitch map.
121    Map(PitchMap),
122}
123
124impl PitchRemap {
125    /// Applies the remap and returns just the music, discarding diagnostics.
126    pub fn apply(&self, object: &dyn MusicObject) -> Result<Music, TransformError> {
127        Ok(self.apply_report(object)?.music)
128    }
129
130    /// Applies the remap, returning the music together with any diagnostics.
131    pub fn apply_report(
132        &self,
133        object: &dyn MusicObject,
134    ) -> Result<TransformReport, TransformError> {
135        match self {
136            Self::Chromatic(semitones) => {
137                Ok(TransformReport::clean(crate::map_notes(object, |note| {
138                    let pitch = note.pitch.transpose(*semitones);
139                    note_with_pitch(note, pitch)
140                })?))
141            }
142            Self::ScaleDegree { scale, steps } => {
143                map_pitches_with_diagnostics(object, "pitch-remap", |pitch| {
144                    scale
145                        .transpose_diatonic(pitch, *steps)
146                        .map_err(|_| out_of_scale_diagnostic("pitch-remap", pitch, "scale remap"))
147                })
148            }
149            Self::PitchClass { from, to } => {
150                Ok(TransformReport::clean(crate::map_notes(object, |note| {
151                    let pitch = if note.pitch.class == *from {
152                        Pitch {
153                            class: *to,
154                            octave: note.pitch.octave,
155                        }
156                    } else {
157                        note.pitch
158                    };
159                    note_with_pitch(note, pitch)
160                })?))
161            }
162            Self::DrumKey(map) => map_pitches_with_diagnostics(object, "pitch-remap", |pitch| {
163                let Some(key) = pitch.to_midi() else {
164                    return Err(TransformDiagnostic::new(
165                        TransformDiagnosticCode::MissingMidiKey,
166                        "pitch-remap",
167                        "drum-key remap needs a MIDI key",
168                    ));
169                };
170                Ok(map
171                    .get(&key)
172                    .map(|mapped| Pitch::from_midi(*mapped))
173                    .unwrap_or(pitch))
174            }),
175            Self::ChordTone { scale, degree } => {
176                map_pitches_with_diagnostics(object, "pitch-remap", |pitch| {
177                    nearest_pitch_in_chord(pitch, *scale, *degree)
178                })
179            }
180            Self::Tuning(tuning) => Ok(TransformReport::clean(crate::map_notes(object, |note| {
181                let pitch = note.pitch.transpose(tuning.semitone_delta());
182                note_with_pitch(note, pitch)
183            })?)),
184            Self::Vector { scale, offsets } => {
185                map_pitches_with_diagnostics(object, "pitch-remap", |pitch| {
186                    vector_remap(pitch, *scale, offsets)
187                })
188            }
189            Self::Matrix { scale, matrix } => {
190                map_pitches_with_diagnostics(object, "pitch-remap", |pitch| {
191                    matrix_remap(pitch, *scale, matrix)
192                })
193            }
194            Self::Callable(map) => Ok(TransformReport::clean(crate::map_notes(object, |note| {
195                let pitch = map.map_pitch(note.pitch);
196                note_with_pitch(note, pitch)
197            })?)),
198            Self::Map(map) => map_pitches_with_diagnostics(object, "pitch-remap", |pitch| {
199                map.map_pitch(pitch)
200                    .map(|result| result.pitch)
201                    .map_err(|error| map_error_diagnostic(error, "pitch-remap"))
202            }),
203        }
204    }
205
206    /// Returns this remap as a composable pitch map when it is static and
207    /// expressible in the integer pitch-map domain.
208    pub fn as_pitch_map(&self) -> Option<PitchMap> {
209        match self {
210            Self::Chromatic(semitones) => Some(PitchMap::chromatic_delta(*semitones)),
211            Self::PitchClass { from, to } => Some(PitchMap::pitch_class_substitution(
212                *from,
213                *to,
214                PitchMapPolicy::Reject,
215            )),
216            Self::Tuning(tuning) => Some(tuning.pitch_map()),
217            Self::Map(map) => Some(map.clone()),
218            Self::ScaleDegree { .. }
219            | Self::DrumKey(_)
220            | Self::ChordTone { .. }
221            | Self::Vector { .. }
222            | Self::Matrix { .. }
223            | Self::Callable(_) => None,
224        }
225    }
226}
227
228fn map_error_diagnostic(error: MapError, transform: &'static str) -> TransformDiagnostic {
229    TransformDiagnostic::new(
230        TransformDiagnosticCode::UnsupportedMapping,
231        transform,
232        error.to_string(),
233    )
234}
235
236fn vector_remap(pitch: Pitch, scale: Scale, offsets: &[i32]) -> Result<Pitch, TransformDiagnostic> {
237    if offsets.is_empty() {
238        return Err(TransformDiagnostic::new(
239            TransformDiagnosticCode::UnsupportedMapping,
240            "pitch-remap",
241            "vector remap needs at least one offset",
242        ));
243    }
244    let degree = scale
245        .degree_of(pitch.class)
246        .ok_or_else(|| out_of_scale_diagnostic("pitch-remap", pitch, "vector remap"))?;
247    let offset = offsets[(degree - 1) % offsets.len()];
248    Ok(pitch.transpose(offset))
249}
250
251fn matrix_remap(
252    pitch: Pitch,
253    scale: Scale,
254    matrix: &IntMatrix,
255) -> Result<Pitch, TransformDiagnostic> {
256    if matrix.divisor <= 0 {
257        return Err(TransformDiagnostic::new(
258            TransformDiagnosticCode::InvalidMatrix,
259            "pitch-remap",
260            "matrix divisor must be positive",
261        ));
262    }
263    let degree = scale
264        .degree_of(pitch.class)
265        .ok_or_else(|| out_of_scale_diagnostic("pitch-remap", pitch, "matrix remap"))?
266        as i32;
267    let input = [degree, i32::from(pitch.octave), 1];
268    let target_degree = dot(matrix.degree_row, input) / matrix.divisor;
269    if target_degree <= 0 {
270        return Err(TransformDiagnostic::new(
271            TransformDiagnosticCode::InvalidMatrix,
272            "pitch-remap",
273            "matrix remap produced a non-positive scale degree",
274        ));
275    }
276    let target_octave = dot(matrix.octave_row, input) / matrix.divisor;
277    let octave = i16::try_from(target_octave).map_err(|_| {
278        TransformDiagnostic::new(
279            TransformDiagnosticCode::InvalidMatrix,
280            "pitch-remap",
281            "matrix remap produced an octave outside the supported range",
282        )
283    })?;
284    let target_degree = usize::try_from(target_degree).map_err(|_| {
285        TransformDiagnostic::new(
286            TransformDiagnosticCode::InvalidMatrix,
287            "pitch-remap",
288            "matrix remap produced a scale degree outside the supported range",
289        )
290    })?;
291    let class = scale.pitch_at_degree(target_degree).map_err(|_| {
292        TransformDiagnostic::new(
293            TransformDiagnosticCode::InvalidMatrix,
294            "pitch-remap",
295            "matrix remap produced a non-positive scale degree",
296        )
297    })?;
298    Ok(Pitch { class, octave })
299}
300
301fn nearest_pitch_in_chord(
302    pitch: Pitch,
303    scale: Scale,
304    degree: usize,
305) -> Result<Pitch, TransformDiagnostic> {
306    let chord = Chord::chord_tones_in(scale, degree, pitch.octave).map_err(|_| {
307        TransformDiagnostic::new(
308            TransformDiagnosticCode::UnsupportedMapping,
309            "pitch-remap",
310            "chord-tone remap needs a one-based scale degree",
311        )
312    })?;
313    Ok(chord
314        .pitches()
315        .into_iter()
316        .min_by_key(|candidate| {
317            (
318                (candidate.semitone() - pitch.semitone()).abs(),
319                candidate.semitone(),
320            )
321        })
322        .unwrap_or_else(|| nearest_pitch_in_scale(pitch, &scale)))
323}
324
325fn out_of_scale_diagnostic(
326    transform: &'static str,
327    pitch: Pitch,
328    action: &'static str,
329) -> TransformDiagnostic {
330    TransformDiagnostic::new(
331        TransformDiagnosticCode::PitchOutOfScale,
332        transform,
333        format!("{action} cannot place pitch class {}", pitch.class.value()),
334    )
335}
336
337fn dot(row: [i32; 3], input: [i32; 3]) -> i32 {
338    row.into_iter()
339        .zip(input)
340        .map(|(left, right)| left * right)
341        .sum()
342}