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