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#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct TuningRemap {
17 pub cents: i32,
19}
20
21impl TuningRemap {
22 pub fn new(cents: i32) -> Self {
24 Self { cents }
25 }
26
27 pub fn semitone_delta(&self) -> i32 {
38 (f64::from(self.cents) / 100.0).round() as i32
39 }
40
41 pub fn pitch_map(&self) -> PitchMap {
43 PitchMap::chromatic_delta(self.semitone_delta())
44 }
45}
46
47#[derive(Clone, Debug, PartialEq, Eq)]
49pub struct IntMatrix {
50 pub degree_row: [i32; 3],
52 pub octave_row: [i32; 3],
54 pub divisor: i32,
56}
57
58impl IntMatrix {
59 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 pub fn identity() -> Self {
70 Self::new([1, 0, 0], [0, 1, 0], 1)
71 }
72}
73
74#[derive(Clone, Debug, PartialEq, Eq)]
76pub enum PitchRemap {
77 Chromatic(i32),
79 ScaleDegree {
81 scale: Scale,
83 steps: i32,
85 },
86 PitchClass {
88 from: PitchClass,
90 to: PitchClass,
92 },
93 DrumKey(BTreeMap<u8, u8>),
95 ChordTone {
97 scale: Scale,
99 degree: usize,
101 },
102 Tuning(TuningRemap),
104 Vector {
106 scale: Scale,
108 offsets: Vec<i32>,
110 },
111 Matrix {
113 scale: Scale,
115 matrix: IntMatrix,
117 },
118 Callable(CallablePitchMap),
120 Map(PitchMap),
122}
123
124impl PitchRemap {
125 pub fn apply(&self, object: &dyn MusicObject) -> Result<Music, TransformError> {
127 Ok(self.apply_report(object)?.music)
128 }
129
130 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 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}