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