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#[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) -> Result<Music, TransformError> {
119 Ok(self.apply_report(object)?.music)
120 }
121
122 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}