Skip to main content

sim_lib_music_transform/
model.rs

1use std::collections::BTreeMap;
2
3use num_rational::Ratio;
4use thiserror::Error;
5
6use sim_lib_music_core::{
7    Articulation, AtomRef, Channel, Melody, MelodyItem, Music, MusicError, MusicObject, Note,
8    PianoRoll, Rest, Time, TimedNote,
9};
10use sim_lib_pitch_core::{Pitch, PitchClass};
11use sim_lib_pitch_scale::{Key, Mode, PitchScaleError, Scale};
12
13/// Error returned by transforms that reject invalid scaling factors.
14#[derive(Debug, Error, Clone, PartialEq, Eq)]
15pub enum TransformError {
16    /// The supplied time-scaling factor was zero or negative.
17    #[error("transform factor must be positive")]
18    InvalidFactor,
19    /// A music object or transform result violated model invariants.
20    #[error(transparent)]
21    InvalidMusic(#[from] MusicError),
22    /// A transform returned an output shape that the caller cannot use.
23    #[error("{transform} transform returned invalid output: {reason}")]
24    InvalidTransformOutput {
25        /// Name of the transform that produced the invalid output.
26        transform: &'static str,
27        /// Stable explanation of the invalid condition.
28        reason: &'static str,
29    },
30}
31
32/// Strategy for placing notes when reversing material in time.
33#[derive(Copy, Clone, Debug, PartialEq, Eq)]
34pub enum RetrogradeMode {
35    /// Mirror each note's span about the total duration.
36    Cutout,
37    /// Keep the original onset grid and reverse only the note order.
38    PinnedNoteOn,
39}
40
41/// Named diatonic function (mode) used to remap scale degrees onto pitches.
42#[derive(Clone, Debug, PartialEq, Eq)]
43pub enum FunctionMap {
44    /// Major (Ionian) function.
45    Major,
46    /// Natural minor (Aeolian) function.
47    MinorNatural,
48    /// Harmonic minor function.
49    MinorHarmonic,
50    /// Ascending melodic minor function.
51    MinorMelodicAsc,
52    /// Dorian mode function.
53    Dorian,
54    /// Phrygian mode function.
55    Phrygian,
56    /// Lydian mode function.
57    Lydian,
58    /// Mixolydian mode function.
59    Mixolydian,
60    /// Locrian mode function.
61    Locrian,
62    /// User-supplied scale used directly as the function.
63    Custom(Scale),
64}
65
66impl FunctionMap {
67    /// Returns the stable wire/display name for this function.
68    pub fn name(&self) -> &'static str {
69        match self {
70            Self::Major => "Major",
71            Self::MinorNatural => "MinorNatural",
72            Self::MinorHarmonic => "MinorHarmonic",
73            Self::MinorMelodicAsc => "MinorMelodicAsc",
74            Self::Dorian => "Dorian",
75            Self::Phrygian => "Phrygian",
76            Self::Lydian => "Lydian",
77            Self::Mixolydian => "Mixolydian",
78            Self::Locrian => "Locrian",
79            Self::Custom(_) => "Custom",
80        }
81    }
82
83    /// Builds the concrete `Scale` this function produces for the given key.
84    pub fn scale_for_key(&self, key: &Key) -> Scale {
85        match self {
86            Self::Major => Scale::new(key.tonic, Mode::Major),
87            Self::MinorNatural => Scale::new(key.tonic, Mode::MinorNatural),
88            Self::MinorHarmonic => Scale::new(key.tonic, Mode::MinorHarmonic),
89            Self::MinorMelodicAsc => Scale::new(key.tonic, Mode::MinorMelodic),
90            Self::Dorian => Scale::new(key.tonic, Mode::Dorian),
91            Self::Phrygian => Scale::new(key.tonic, Mode::Phrygian),
92            Self::Lydian => Scale::new(key.tonic, Mode::Lydian),
93            Self::Mixolydian => Scale::new(key.tonic, Mode::Mixolydian),
94            Self::Locrian => Scale::new(key.tonic, Mode::Locrian),
95            Self::Custom(scale) => *scale,
96        }
97    }
98
99    /// Resolves a scale degree to a concrete pitch at the given octave.
100    pub fn degree_to_pitch(
101        &self,
102        degree: usize,
103        key: &Key,
104        octave: i16,
105    ) -> Result<Pitch, PitchScaleError> {
106        Ok(Pitch {
107            class: self.scale_for_key(key).pitch_at_degree(degree)?,
108            octave,
109        })
110    }
111}
112
113/// Lookup table of [`FunctionMap`] values addressed by name.
114#[derive(Clone, Debug, Default)]
115pub struct FunctionMapRegistry {
116    maps: BTreeMap<String, FunctionMap>,
117}
118
119impl FunctionMapRegistry {
120    /// Creates a registry preloaded with the built-in diatonic functions.
121    pub fn new_with_builtins() -> Self {
122        let mut registry = Self::default();
123        for map in [
124            FunctionMap::Major,
125            FunctionMap::MinorNatural,
126            FunctionMap::MinorHarmonic,
127            FunctionMap::MinorMelodicAsc,
128            FunctionMap::Dorian,
129            FunctionMap::Phrygian,
130            FunctionMap::Lydian,
131            FunctionMap::Mixolydian,
132            FunctionMap::Locrian,
133        ] {
134            registry.register(map);
135        }
136        registry
137    }
138
139    /// Inserts a function under its own name, replacing any prior entry.
140    pub fn register(&mut self, map: FunctionMap) {
141        self.maps.insert(map.name().to_owned(), map);
142    }
143
144    /// Looks up a function by name.
145    pub fn get(&self, name: &str) -> Option<&FunctionMap> {
146        self.maps.get(name)
147    }
148
149    /// Returns the names of all registered functions.
150    pub fn names(&self) -> Vec<&str> {
151        self.maps.keys().map(String::as_str).collect()
152    }
153}
154
155/// Lengthens every onset and duration by `factor` (time augmentation).
156pub fn augment(object: &dyn MusicObject, factor: Time) -> Result<Music, TransformError> {
157    scale_time(object, factor)
158}
159
160/// Shortens every onset and duration by `factor` (time diminution).
161pub fn diminish(object: &dyn MusicObject, factor: Time) -> Result<Music, TransformError> {
162    if factor <= Time::from_integer(0) {
163        return Err(TransformError::InvalidFactor);
164    }
165    scale_time(object, factor.recip())
166}
167
168/// Reverses the material in time using the default [`RetrogradeMode::Cutout`].
169pub fn retrograde(object: &dyn MusicObject) -> Result<Music, TransformError> {
170    retrograde_with_mode(object, RetrogradeMode::Cutout)
171}
172
173/// Reverses the material in time using the given [`RetrogradeMode`].
174pub fn retrograde_with_mode(
175    object: &dyn MusicObject,
176    mode: RetrogradeMode,
177) -> Result<Music, TransformError> {
178    let roll = to_piano_roll(object)?;
179    let total = object.duration();
180    let items = match mode {
181        RetrogradeMode::Cutout => roll
182            .items
183            .into_iter()
184            .map(|item| TimedNote {
185                onset: total - item.onset - item.note.duration,
186                note: item.note,
187            })
188            .collect(),
189        RetrogradeMode::PinnedNoteOn => {
190            let mut onsets: Vec<Time> = roll.items.iter().map(|item| item.onset).collect();
191            onsets.sort();
192            let notes: Vec<Note> = roll.items.into_iter().rev().map(|item| item.note).collect();
193            onsets
194                .into_iter()
195                .zip(notes)
196                .map(|(onset, note)| TimedNote { onset, note })
197                .collect()
198        }
199    };
200    Ok(Music::PianoRoll(canonical_roll(items)?))
201}
202
203/// Mirrors note onsets about the total duration, then rebases to start at zero.
204pub fn time_invert(object: &dyn MusicObject) -> Result<Music, TransformError> {
205    let roll = to_piano_roll(object)?;
206    if roll.items.is_empty() {
207        return Ok(Music::PianoRoll(roll));
208    }
209    let total = object.duration();
210    let mut items: Vec<TimedNote> = roll
211        .items
212        .into_iter()
213        .map(|item| TimedNote {
214            onset: total - item.onset,
215            note: item.note,
216        })
217        .collect();
218    let min_onset = items
219        .iter()
220        .map(|item| item.onset)
221        .min()
222        .unwrap_or_else(|| Time::from_integer(0));
223    for item in &mut items {
224        item.onset -= min_onset;
225    }
226    Ok(Music::PianoRoll(canonical_roll(items)?))
227}
228
229/// Repeats the material `n` times back to back along the time axis.
230pub fn loop_n(object: &dyn MusicObject, n: usize) -> Result<Music, TransformError> {
231    let roll = to_piano_roll(object)?;
232    let span = object.duration();
233    let items = (0..n)
234        .flat_map(|index| {
235            let offset = span * Time::from_integer(index as i64);
236            roll.items.iter().cloned().map(move |mut item| {
237                item.onset += offset;
238                item
239            })
240        })
241        .collect();
242    Ok(Music::PianoRoll(canonical_roll(items)?))
243}
244
245/// Extracts the `[start, end)` time window, clipping note spans to the bounds.
246pub fn slice(object: &dyn MusicObject, start: Time, end: Time) -> Result<Music, TransformError> {
247    let roll = to_piano_roll(object)?;
248    let items = roll
249        .items
250        .into_iter()
251        .filter_map(|item| {
252            let item_start = item.onset;
253            let item_end = item.onset + item.note.duration;
254            let clipped_start = item_start.max(start);
255            let clipped_end = item_end.min(end);
256            (clipped_start < clipped_end).then(|| TimedNote {
257                onset: clipped_start - start,
258                note: Note {
259                    duration: clipped_end - clipped_start,
260                    ..item.note
261                },
262            })
263        })
264        .collect();
265    Ok(Music::PianoRoll(canonical_roll(items)?))
266}
267
268/// Transposes every note chromatically by the given number of semitones.
269pub fn transpose(object: &dyn MusicObject, semitones: i32) -> Result<Music, TransformError> {
270    map_notes(object, |note| Note {
271        pitch: note.pitch.transpose(semitones),
272        ..note
273    })
274}
275
276/// Transposes every note by `steps` scale degrees within the given scale.
277///
278/// Notes outside the scale are left unchanged.
279pub fn transpose_diatonic(
280    object: &dyn MusicObject,
281    scale: &Scale,
282    steps: i32,
283) -> Result<Music, TransformError> {
284    map_notes(object, |note| Note {
285        pitch: scale
286            .transpose_diatonic(note.pitch, steps)
287            .unwrap_or(note.pitch),
288        ..note
289    })
290}
291
292/// Inverts every note's pitch about the given axis pitch.
293pub fn pitch_invert(object: &dyn MusicObject, axis: Pitch) -> Result<Music, TransformError> {
294    map_notes(object, |note| Note {
295        pitch: note.pitch.invert(axis),
296        ..note
297    })
298}
299
300/// Applies [`pitch_invert`] then [`retrograde`] (retrograde inversion).
301pub fn retrograde_invert(object: &dyn MusicObject, axis: Pitch) -> Result<Music, TransformError> {
302    let inverted = pitch_invert(object, axis)?;
303    retrograde(&inverted)
304}
305
306/// Shifts every note by the given number of whole octaves.
307pub fn shift_octave(object: &dyn MusicObject, octaves: i16) -> Result<Music, TransformError> {
308    map_notes(object, |note| Note {
309        pitch: note.pitch.transpose(i32::from(octaves) * 12),
310        ..note
311    })
312}
313
314/// Snaps every note to the nearest pitch belonging to the given scale.
315pub fn chord_tones_in(object: &dyn MusicObject, scale: &Scale) -> Result<Music, TransformError> {
316    map_notes(object, |note| Note {
317        pitch: nearest_pitch_in_scale(note.pitch, scale),
318        ..note
319    })
320}
321
322/// Remaps each in-key note to the same scale degree under another function.
323///
324/// Notes whose pitch class is not a degree of `key` are passed through.
325pub fn map_to_function(
326    object: &dyn MusicObject,
327    key: &Key,
328    fmap: &FunctionMap,
329) -> Result<Music, TransformError> {
330    let source_scale = Scale::new(key.tonic, key.mode);
331    map_notes(object, |note| {
332        match source_scale.degree_of(note.pitch.class) {
333            Some(degree) => Note {
334                pitch: fmap
335                    .degree_to_pitch(degree, key, note.pitch.octave)
336                    .unwrap_or(note.pitch),
337                ..note
338            },
339            None => note,
340        }
341    })
342}
343
344fn scale_time(object: &dyn MusicObject, factor: Time) -> Result<Music, TransformError> {
345    if factor <= Time::from_integer(0) {
346        return Err(TransformError::InvalidFactor);
347    }
348    map_roll(object, |mut item| {
349        item.onset *= factor;
350        item.note.duration *= factor;
351        item
352    })
353}
354
355pub(crate) fn map_notes(
356    object: &dyn MusicObject,
357    f: impl Fn(Note) -> Note,
358) -> Result<Music, TransformError> {
359    map_roll(object, |mut item| {
360        item.note = f(item.note);
361        item
362    })
363}
364
365pub(crate) fn map_roll(
366    object: &dyn MusicObject,
367    f: impl Fn(TimedNote) -> TimedNote,
368) -> Result<Music, TransformError> {
369    let roll = to_piano_roll(object)?;
370    let items = roll.items.into_iter().map(f).collect();
371    Ok(Music::PianoRoll(canonical_roll(items)?))
372}
373
374pub(crate) fn to_piano_roll(object: &dyn MusicObject) -> Result<PianoRoll, TransformError> {
375    let mut atoms = Vec::new();
376    object.voices(Time::from_integer(0), &mut atoms);
377    let items = atoms
378        .into_iter()
379        .filter_map(|atom| match atom.atom {
380            AtomRef::Note(note) => Some(TimedNote {
381                onset: atom.onset,
382                note,
383            }),
384            AtomRef::Rest(_) | AtomRef::Phantom(_) => None,
385        })
386        .collect();
387    canonical_roll(items)
388}
389
390pub(crate) fn canonical_roll(items: Vec<TimedNote>) -> Result<PianoRoll, TransformError> {
391    Ok(PianoRoll::new(items)?)
392}
393
394pub(crate) fn nearest_pitch_in_scale(pitch: Pitch, scale: &Scale) -> Pitch {
395    if scale.degree_of(pitch.class).is_some() {
396        return pitch;
397    }
398    let candidates = scale
399        .pitch_classes()
400        .into_iter()
401        .flat_map(|class| {
402            [
403                Pitch {
404                    class,
405                    octave: pitch.octave - 1,
406                },
407                Pitch {
408                    class,
409                    octave: pitch.octave,
410                },
411                Pitch {
412                    class,
413                    octave: pitch.octave + 1,
414                },
415            ]
416        })
417        .collect::<Vec<_>>();
418    candidates
419        .into_iter()
420        .min_by_key(|candidate| {
421            (
422                (candidate.semitone() - pitch.semitone()).abs(),
423                candidate.semitone(),
424            )
425        })
426        .unwrap_or(pitch)
427}
428
429/// Builds a single-voice `Melody` from `(MIDI key, duration)` pairs.
430///
431/// Each note uses velocity 100, channel 0, and normal articulation. Intended
432/// as a test and example helper.
433pub fn simple_melody(items: &[(u8, Time)]) -> Melody {
434    Melody::new(
435        items
436            .iter()
437            .map(|(midi, duration)| {
438                MelodyItem::Note(
439                    Note::new(
440                        *duration,
441                        Pitch::from_midi(*midi),
442                        100,
443                        Channel::new(0).expect("channel"),
444                        Articulation::Normal,
445                    )
446                    .expect("note"),
447                )
448            })
449            .collect(),
450    )
451    .expect("melody")
452}
453
454/// Builds a `Rest` spanning the given duration.
455pub fn silence(duration: Time) -> Rest {
456    Rest::new(duration).expect("rest")
457}
458
459/// Returns the `Time` value for one quarter note (1/4).
460pub fn quarter() -> Time {
461    Ratio::new(1, 4)
462}
463
464/// Returns the canonical name of a pitch class.
465pub fn pitch_class_name(class: PitchClass) -> &'static str {
466    class.canonical_name()
467}