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