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#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct TuningRemap {
16 pub cents: i32,
18}
19
20impl TuningRemap {
21 pub fn new(cents: i32) -> Self {
23 Self { cents }
24 }
25
26 pub fn semitone_delta(&self) -> i32 {
37 (f64::from(self.cents) / 100.0).round() as i32
38 }
39}
40
41#[derive(Clone, Debug, PartialEq, Eq)]
43pub struct IntMatrix {
44 pub degree_row: [i32; 3],
46 pub octave_row: [i32; 3],
48 pub divisor: i32,
50}
51
52impl IntMatrix {
53 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 pub fn identity() -> Self {
64 Self::new([1, 0, 0], [0, 1, 0], 1)
65 }
66}
67
68#[derive(Clone, Debug, PartialEq, Eq)]
70pub enum PitchRemap {
71 Chromatic(i32),
73 ScaleDegree {
75 scale: Scale,
77 steps: i32,
79 },
80 PitchClass {
82 from: PitchClass,
84 to: PitchClass,
86 },
87 DrumKey(BTreeMap<u8, u8>),
89 ChordTone {
91 scale: Scale,
93 degree: usize,
95 },
96 Tuning(TuningRemap),
98 Vector {
100 scale: Scale,
102 offsets: Vec<i32>,
104 },
105 Matrix {
107 scale: Scale,
109 matrix: IntMatrix,
111 },
112 Callable(CallablePitchMap),
114}
115
116impl PitchRemap {
117 pub fn apply(&self, object: &dyn MusicObject) -> Music {
119 self.apply_report(object).music
120 }
121
122 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}