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, TransformReport,
10    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) -> Music {
119        self.apply_report(object).music
120    }
121
122    /// Applies the remap, returning the music together with any diagnostics.
123    pub fn apply_report(&self, object: &dyn MusicObject) -> TransformReport {
124        match self {
125            Self::Chromatic(semitones) => {
126                TransformReport::clean(crate::map_notes(object, |note| {
127                    let pitch = note.pitch.transpose(*semitones);
128                    note_with_pitch(note, pitch)
129                }))
130            }
131            Self::ScaleDegree { scale, steps } => {
132                map_pitches_with_diagnostics(object, "pitch-remap", |pitch| {
133                    scale
134                        .transpose_diatonic(pitch, *steps)
135                        .map_err(|_| out_of_scale_diagnostic("pitch-remap", pitch, "scale remap"))
136                })
137            }
138            Self::PitchClass { from, to } => {
139                TransformReport::clean(crate::map_notes(object, |note| {
140                    let pitch = if note.pitch.class == *from {
141                        Pitch {
142                            class: *to,
143                            octave: note.pitch.octave,
144                        }
145                    } else {
146                        note.pitch
147                    };
148                    note_with_pitch(note, pitch)
149                }))
150            }
151            Self::DrumKey(map) => map_pitches_with_diagnostics(object, "pitch-remap", |pitch| {
152                let Some(key) = pitch.to_midi() else {
153                    return Err(TransformDiagnostic::new(
154                        TransformDiagnosticCode::MissingMidiKey,
155                        "pitch-remap",
156                        "drum-key remap needs a MIDI key",
157                    ));
158                };
159                Ok(map
160                    .get(&key)
161                    .map(|mapped| Pitch::from_midi(*mapped))
162                    .unwrap_or(pitch))
163            }),
164            Self::ChordTone { scale, degree } => {
165                map_pitches_with_diagnostics(object, "pitch-remap", |pitch| {
166                    Ok(nearest_pitch_in_chord(pitch, *scale, *degree))
167                })
168            }
169            Self::Tuning(tuning) => TransformReport::clean(crate::map_notes(object, |note| {
170                let pitch = note.pitch.transpose(tuning.semitone_delta());
171                note_with_pitch(note, pitch)
172            })),
173            Self::Vector { scale, offsets } => {
174                map_pitches_with_diagnostics(object, "pitch-remap", |pitch| {
175                    vector_remap(pitch, *scale, offsets)
176                })
177            }
178            Self::Matrix { scale, matrix } => {
179                map_pitches_with_diagnostics(object, "pitch-remap", |pitch| {
180                    matrix_remap(pitch, *scale, matrix)
181                })
182            }
183            Self::Callable(map) => TransformReport::clean(crate::map_notes(object, |note| {
184                let pitch = map.map_pitch(note.pitch);
185                note_with_pitch(note, pitch)
186            })),
187        }
188    }
189}
190
191fn vector_remap(pitch: Pitch, scale: Scale, offsets: &[i32]) -> Result<Pitch, TransformDiagnostic> {
192    if offsets.is_empty() {
193        return Err(TransformDiagnostic::new(
194            TransformDiagnosticCode::UnsupportedMapping,
195            "pitch-remap",
196            "vector remap needs at least one offset",
197        ));
198    }
199    let degree = scale
200        .degree_of(pitch.class)
201        .ok_or_else(|| out_of_scale_diagnostic("pitch-remap", pitch, "vector remap"))?;
202    let offset = offsets[(degree - 1) % offsets.len()];
203    Ok(pitch.transpose(offset))
204}
205
206fn matrix_remap(
207    pitch: Pitch,
208    scale: Scale,
209    matrix: &IntMatrix,
210) -> Result<Pitch, TransformDiagnostic> {
211    if matrix.divisor <= 0 {
212        return Err(TransformDiagnostic::new(
213            TransformDiagnosticCode::InvalidMatrix,
214            "pitch-remap",
215            "matrix divisor must be positive",
216        ));
217    }
218    let degree = scale
219        .degree_of(pitch.class)
220        .ok_or_else(|| out_of_scale_diagnostic("pitch-remap", pitch, "matrix remap"))?
221        as i32;
222    let input = [degree, i32::from(pitch.octave), 1];
223    let target_degree = dot(matrix.degree_row, input) / matrix.divisor;
224    if target_degree <= 0 {
225        return Err(TransformDiagnostic::new(
226            TransformDiagnosticCode::InvalidMatrix,
227            "pitch-remap",
228            "matrix remap produced a non-positive scale degree",
229        ));
230    }
231    let target_octave = dot(matrix.octave_row, input) / matrix.divisor;
232    let octave = i16::try_from(target_octave).map_err(|_| {
233        TransformDiagnostic::new(
234            TransformDiagnosticCode::InvalidMatrix,
235            "pitch-remap",
236            "matrix remap produced an octave outside the supported range",
237        )
238    })?;
239    Ok(Pitch {
240        class: scale.pitch_at_degree(target_degree as usize),
241        octave,
242    })
243}
244
245fn nearest_pitch_in_chord(pitch: Pitch, scale: Scale, degree: usize) -> Pitch {
246    let chord = Chord::chord_tones_in(scale, degree, pitch.octave);
247    chord
248        .pitches()
249        .into_iter()
250        .min_by_key(|candidate| {
251            (
252                (candidate.semitone() - pitch.semitone()).abs(),
253                candidate.semitone(),
254            )
255        })
256        .unwrap_or_else(|| nearest_pitch_in_scale(pitch, &scale))
257}
258
259fn out_of_scale_diagnostic(
260    transform: &'static str,
261    pitch: Pitch,
262    action: &'static str,
263) -> TransformDiagnostic {
264    TransformDiagnostic::new(
265        TransformDiagnosticCode::PitchOutOfScale,
266        transform,
267        format!("{action} cannot place pitch class {}", pitch.class.0),
268    )
269}
270
271fn dot(row: [i32; 3], input: [i32; 3]) -> i32 {
272    row.into_iter()
273        .zip(input)
274        .map(|(left, right)| left * right)
275        .sum()
276}