Skip to main content

sim_lib_music_transform/
arranger.rs

1use num_rational::Ratio;
2
3use sim_lib_music_core::{Music, MusicObject, Note, Time, TimedNote};
4use sim_lib_pitch_chord::Chord;
5use sim_lib_pitch_core::{Pitch, PitchClass};
6use sim_lib_pitch_scale::Scale;
7
8use crate::{
9    CallablePitchMap, PitchRemap, RetrogradeMode, TransformDiagnostic, TransformDiagnosticCode,
10    TransformReport, canonical_roll, map_notes, retrograde_with_mode, to_piano_roll,
11};
12
13/// Amount and kind of pitch displacement for a transpose transform.
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub enum PitchDelta {
16    /// Move by a fixed number of semitones.
17    Semitones(i32),
18    /// Move by whole octaves.
19    Octaves(i16),
20    /// Move by scale degrees within `scale`.
21    ScaleDegrees {
22        /// Scale that defines the diatonic steps.
23        scale: Scale,
24        /// Number of scale degrees to move.
25        steps: i32,
26    },
27    /// Move by the interval nearest to a frequency ratio.
28    FrequencyRatio(Ratio<i64>),
29    /// Move via a named [`CallablePitchMap`].
30    Custom(CallablePitchMap),
31}
32
33/// Transpose transform that shifts pitch by a [`PitchDelta`].
34#[derive(Clone, Debug, PartialEq, Eq)]
35pub struct TransposeTransform {
36    /// Displacement applied to every note.
37    pub by: PitchDelta,
38}
39
40impl TransposeTransform {
41    /// Builds a transpose transform from a pitch delta.
42    pub fn new(by: PitchDelta) -> Self {
43        Self { by }
44    }
45
46    /// Applies the transpose and returns just the music.
47    pub fn apply(&self, object: &dyn MusicObject) -> Music {
48        self.apply_report(object).music
49    }
50
51    /// Applies the transpose, returning the music and any diagnostics.
52    pub fn apply_report(&self, object: &dyn MusicObject) -> TransformReport {
53        match &self.by {
54            PitchDelta::Semitones(semitones) => TransformReport::clean(map_notes(object, |note| {
55                let pitch = note.pitch.transpose(*semitones);
56                note_with_pitch(note, pitch)
57            })),
58            PitchDelta::Octaves(octaves) => {
59                let semitones = i32::from(*octaves) * 12;
60                TransformReport::clean(map_notes(object, |note| {
61                    let pitch = note.pitch.transpose(semitones);
62                    note_with_pitch(note, pitch)
63                }))
64            }
65            PitchDelta::ScaleDegrees { scale, steps } => {
66                map_pitches_with_diagnostics(object, "transpose", |pitch| {
67                    scale.transpose_diatonic(pitch, *steps).map_err(|_| {
68                        TransformDiagnostic::new(
69                            TransformDiagnosticCode::PitchOutOfScale,
70                            "transpose",
71                            format!("pitch class {} is not in the scale", pitch.class.0),
72                        )
73                    })
74                })
75            }
76            PitchDelta::FrequencyRatio(ratio) => match ratio_to_semitones(ratio) {
77                Some(semitones) => TransformReport::clean(map_notes(object, |note| {
78                    let pitch = note.pitch.transpose(semitones);
79                    note_with_pitch(note, pitch)
80                })),
81                None => TransformReport::with_diagnostic(
82                    Music::PianoRoll(to_piano_roll(object)),
83                    TransformDiagnostic::new(
84                        TransformDiagnosticCode::InvalidRatio,
85                        "transpose",
86                        "frequency ratio must be positive",
87                    ),
88                ),
89            },
90            PitchDelta::Custom(map) => TransformReport::clean(map_notes(object, |note| {
91                let pitch = map.map_pitch(note.pitch);
92                note_with_pitch(note, pitch)
93            })),
94        }
95    }
96}
97
98/// Named inversion axis carrying an explicit pitch.
99#[derive(Clone, Debug, PartialEq, Eq)]
100pub struct CustomPitchAxis {
101    /// Display name of the axis.
102    pub name: String,
103    /// Pitch the inversion mirrors about.
104    pub axis: Pitch,
105}
106
107impl CustomPitchAxis {
108    /// Builds a named axis from a pitch.
109    pub fn new(name: impl Into<String>, axis: Pitch) -> Self {
110        Self {
111            name: name.into(),
112            axis,
113        }
114    }
115}
116
117/// Specification of the axis an [`InvertTransform`] mirrors about.
118#[derive(Clone, Debug, PartialEq, Eq)]
119pub enum PitchAxis {
120    /// Mirror about a concrete pitch.
121    Pitch(Pitch),
122    /// Mirror pitch classes about a pitch class, keeping octaves.
123    PitchClass(PitchClass),
124    /// Mirror about a scale degree resolved at a given octave.
125    ScaleDegree {
126        /// Scale the degree belongs to.
127        scale: Scale,
128        /// One-based scale degree.
129        degree: usize,
130        /// Octave the resolved axis pitch sits in.
131        octave: i16,
132    },
133    /// Mirror about the root note of a chord.
134    ChordRoot(Chord),
135    /// Mirror about a pitch interpreted as a frequency center.
136    Frequency(Pitch),
137    /// Mirror about a named [`CustomPitchAxis`].
138    Custom(CustomPitchAxis),
139}
140
141/// Inversion transform that mirrors pitch about a [`PitchAxis`].
142#[derive(Clone, Debug, PartialEq, Eq)]
143pub struct InvertTransform {
144    /// Axis every note is mirrored about.
145    pub axis: PitchAxis,
146}
147
148impl InvertTransform {
149    /// Builds an inversion transform from an axis.
150    pub fn new(axis: PitchAxis) -> Self {
151        Self { axis }
152    }
153
154    /// Applies the inversion and returns just the music.
155    pub fn apply(&self, object: &dyn MusicObject) -> Music {
156        self.apply_report(object).music
157    }
158
159    /// Applies the inversion, returning the music and any diagnostics.
160    pub fn apply_report(&self, object: &dyn MusicObject) -> TransformReport {
161        match &self.axis {
162            PitchAxis::PitchClass(axis) => TransformReport::clean(map_notes(object, |note| {
163                let pitch = Pitch {
164                    class: note.pitch.class.invert(*axis),
165                    octave: note.pitch.octave,
166                };
167                note_with_pitch(note, pitch)
168            })),
169            axis => match resolve_axis(axis) {
170                Some(resolved) => TransformReport::clean(map_notes(object, |note| {
171                    let pitch = note.pitch.invert(resolved);
172                    note_with_pitch(note, pitch)
173                })),
174                None => TransformReport::with_diagnostic(
175                    Music::PianoRoll(to_piano_roll(object)),
176                    TransformDiagnostic::new(
177                        TransformDiagnosticCode::InvalidAxis,
178                        "invert",
179                        "inversion axis cannot be resolved",
180                    ),
181                ),
182            },
183        }
184    }
185}
186
187/// Retrograde transform that reverses material under a [`RetrogradeMode`].
188#[derive(Clone, Debug, PartialEq, Eq)]
189pub struct RetrogradeTransform {
190    /// Mode controlling how reversed notes are placed.
191    pub mode: RetrogradeMode,
192}
193
194impl RetrogradeTransform {
195    /// Builds a retrograde transform with the given mode.
196    pub fn new(mode: RetrogradeMode) -> Self {
197        Self { mode }
198    }
199
200    /// Reverses the material and returns the music.
201    pub fn apply(&self, object: &dyn MusicObject) -> Music {
202        retrograde_with_mode(object, self.mode)
203    }
204
205    /// Reverses the material, returning a clean report (no diagnostics).
206    pub fn apply_report(&self, object: &dyn MusicObject) -> TransformReport {
207        TransformReport::clean(self.apply(object))
208    }
209}
210
211impl Default for RetrogradeTransform {
212    fn default() -> Self {
213        Self {
214            mode: RetrogradeMode::Cutout,
215        }
216    }
217}
218
219/// One `(input -> output)` breakpoint in a piecewise-linear time map.
220#[derive(Clone, Debug, PartialEq, Eq)]
221pub struct TimeMapPoint {
222    /// Source time of the breakpoint.
223    pub input: Time,
224    /// Mapped destination time of the breakpoint.
225    pub output: Time,
226}
227
228impl TimeMapPoint {
229    /// Builds a breakpoint from input and output times.
230    pub fn new(input: Time, output: Time) -> Self {
231        Self { input, output }
232    }
233}
234
235/// A warp anchor pairing a source time with its target time.
236#[derive(Clone, Debug, PartialEq, Eq)]
237pub struct WarpMarker {
238    /// Time in the source material.
239    pub source: Time,
240    /// Time it should land on after warping.
241    pub target: Time,
242}
243
244impl WarpMarker {
245    /// Builds a warp marker from source and target times.
246    pub fn new(source: Time, target: Time) -> Self {
247        Self { source, target }
248    }
249}
250
251/// Strategy for stretching or warping material along the time axis.
252#[derive(Clone, Debug, PartialEq, Eq)]
253pub enum StretchPolicy {
254    /// Scale time inversely to a tempo ratio (faster tempo, shorter time).
255    TempoRatio(Time),
256    /// Scale time directly by a time ratio.
257    TimeRatio(Time),
258    /// Scale time so the material fills a target duration.
259    FitToDuration(Time),
260    /// Warp time through an explicit piecewise-linear map.
261    TimeMap(Vec<TimeMapPoint>),
262    /// Warp time through a set of [`WarpMarker`] anchors.
263    WarpMarkers(Vec<WarpMarker>),
264}
265
266impl StretchPolicy {
267    /// Applies the stretch and returns just the music.
268    pub fn apply(&self, object: &dyn MusicObject) -> Music {
269        self.apply_report(object).music
270    }
271
272    /// Applies the stretch, returning the music and any diagnostics.
273    pub fn apply_report(&self, object: &dyn MusicObject) -> TransformReport {
274        match self {
275            Self::TempoRatio(ratio) => match positive_ratio(*ratio) {
276                Some(factor) => stretch_by_factor(object, factor.recip()),
277                None => invalid_ratio_report(object, "stretch", "tempo ratio must be positive"),
278            },
279            Self::TimeRatio(ratio) => match positive_ratio(*ratio) {
280                Some(factor) => stretch_by_factor(object, factor),
281                None => invalid_ratio_report(object, "stretch", "time ratio must be positive"),
282            },
283            Self::FitToDuration(target) => {
284                let current = object.duration();
285                if current <= Time::from_integer(0) || *target <= Time::from_integer(0) {
286                    invalid_ratio_report(
287                        object,
288                        "stretch",
289                        "source and target duration must be positive",
290                    )
291                } else {
292                    stretch_by_factor(object, *target / current)
293                }
294            }
295            Self::TimeMap(points) => stretch_with_time_map(object, points),
296            Self::WarpMarkers(markers) => {
297                let points = markers
298                    .iter()
299                    .map(|marker| TimeMapPoint::new(marker.source, marker.target))
300                    .collect::<Vec<_>>();
301                stretch_with_time_map(object, &points)
302            }
303        }
304    }
305}
306
307/// A single step in a [`TransformChain`], wrapping one transform kind.
308#[derive(Clone, Debug, PartialEq, Eq)]
309pub enum TransformStep {
310    /// Transpose step.
311    Transpose(TransposeTransform),
312    /// Inversion step.
313    Invert(InvertTransform),
314    /// Retrograde step.
315    Retrograde(RetrogradeTransform),
316    /// Time stretch step.
317    Stretch(StretchPolicy),
318    /// Pitch remap step.
319    Remap(PitchRemap),
320}
321
322impl TransformStep {
323    /// Applies this step, returning the music and any diagnostics.
324    pub fn apply_report(&self, object: &dyn MusicObject) -> TransformReport {
325        match self {
326            Self::Transpose(transform) => transform.apply_report(object),
327            Self::Invert(transform) => transform.apply_report(object),
328            Self::Retrograde(transform) => transform.apply_report(object),
329            Self::Stretch(policy) => policy.apply_report(object),
330            Self::Remap(remap) => remap.apply_report(object),
331        }
332    }
333}
334
335/// An ordered pipeline of [`TransformStep`] values applied in sequence.
336#[derive(Clone, Debug, Default, PartialEq, Eq)]
337pub struct TransformChain {
338    /// Steps applied left to right.
339    pub steps: Vec<TransformStep>,
340}
341
342impl TransformChain {
343    /// Builds a chain from an ordered list of steps.
344    pub fn new(steps: Vec<TransformStep>) -> Self {
345        Self { steps }
346    }
347
348    /// Applies the whole chain and returns just the music.
349    pub fn apply(&self, object: &dyn MusicObject) -> Music {
350        self.apply_report(object).music
351    }
352
353    /// Applies the whole chain, accumulating diagnostics from every step.
354    pub fn apply_report(&self, object: &dyn MusicObject) -> TransformReport {
355        let mut current = Music::PianoRoll(to_piano_roll(object));
356        let mut diagnostics = Vec::new();
357        for step in &self.steps {
358            let report = step.apply_report(&current);
359            current = report.music;
360            diagnostics.extend(report.diagnostics);
361        }
362        TransformReport {
363            music: current,
364            diagnostics,
365        }
366    }
367}
368
369pub(crate) fn map_pitches_with_diagnostics(
370    object: &dyn MusicObject,
371    transform: &'static str,
372    mut map: impl FnMut(Pitch) -> Result<Pitch, TransformDiagnostic>,
373) -> TransformReport {
374    let roll = to_piano_roll(object);
375    let mut diagnostics = Vec::new();
376    let items = roll
377        .items
378        .into_iter()
379        .map(|mut item| {
380            match map(item.note.pitch) {
381                Ok(pitch) => item.note.pitch = pitch,
382                Err(mut diagnostic) => {
383                    diagnostic.transform = transform;
384                    diagnostics.push(diagnostic);
385                }
386            }
387            item
388        })
389        .collect();
390    TransformReport {
391        music: Music::PianoRoll(canonical_roll(items)),
392        diagnostics,
393    }
394}
395
396pub(crate) fn note_with_pitch(note: Note, pitch: Pitch) -> Note {
397    Note { pitch, ..note }
398}
399
400fn resolve_axis(axis: &PitchAxis) -> Option<Pitch> {
401    match axis {
402        PitchAxis::Pitch(pitch) | PitchAxis::Frequency(pitch) => Some(*pitch),
403        PitchAxis::PitchClass(_) => None,
404        PitchAxis::ScaleDegree {
405            scale,
406            degree,
407            octave,
408        } => (*degree > 0).then(|| Pitch {
409            class: scale.pitch_at_degree(*degree),
410            octave: *octave,
411        }),
412        PitchAxis::ChordRoot(chord) => chord.notes.first().copied(),
413        PitchAxis::Custom(axis) => Some(axis.axis),
414    }
415}
416
417fn ratio_to_semitones(ratio: &Ratio<i64>) -> Option<i32> {
418    if *ratio <= Ratio::from_integer(0) {
419        return None;
420    }
421    let value = *ratio.numer() as f64 / *ratio.denom() as f64;
422    Some((value.log2() * 12.0).round() as i32)
423}
424
425fn positive_ratio(ratio: Time) -> Option<Time> {
426    (ratio > Time::from_integer(0)).then_some(ratio)
427}
428
429fn invalid_ratio_report(
430    object: &dyn MusicObject,
431    transform: &'static str,
432    message: &'static str,
433) -> TransformReport {
434    TransformReport::with_diagnostic(
435        Music::PianoRoll(to_piano_roll(object)),
436        TransformDiagnostic::new(TransformDiagnosticCode::InvalidRatio, transform, message),
437    )
438}
439
440fn stretch_by_factor(object: &dyn MusicObject, factor: Time) -> TransformReport {
441    TransformReport::clean(Music::PianoRoll(canonical_roll(
442        to_piano_roll(object)
443            .items
444            .into_iter()
445            .map(|mut item| {
446                item.onset *= factor;
447                item.note.duration *= factor;
448                item
449            })
450            .collect(),
451    )))
452}
453
454fn stretch_with_time_map(object: &dyn MusicObject, points: &[TimeMapPoint]) -> TransformReport {
455    match validate_time_map(points) {
456        Ok(()) => {
457            let roll = to_piano_roll(object);
458            let mut diagnostics = Vec::new();
459            let items = roll
460                .items
461                .into_iter()
462                .map(|item| remap_timed_note(item, points, &mut diagnostics))
463                .collect();
464            TransformReport {
465                music: Music::PianoRoll(canonical_roll(items)),
466                diagnostics,
467            }
468        }
469        Err(diagnostic) => {
470            TransformReport::with_diagnostic(Music::PianoRoll(to_piano_roll(object)), diagnostic)
471        }
472    }
473}
474
475fn remap_timed_note(
476    mut item: TimedNote,
477    points: &[TimeMapPoint],
478    diagnostics: &mut Vec<TransformDiagnostic>,
479) -> TimedNote {
480    let start = map_time(item.onset, points);
481    let end = map_time(item.onset + item.note.duration, points);
482    let duration = end - start;
483    item.onset = start;
484    if duration >= Time::from_integer(0) {
485        item.note.duration = duration;
486    } else {
487        diagnostics.push(TransformDiagnostic::new(
488            TransformDiagnosticCode::NonPositiveDuration,
489            "stretch",
490            "time map produced a negative duration",
491        ));
492    }
493    item
494}
495
496fn validate_time_map(points: &[TimeMapPoint]) -> Result<(), TransformDiagnostic> {
497    if points.len() < 2 {
498        return Err(TransformDiagnostic::new(
499            TransformDiagnosticCode::InvalidTimeMap,
500            "stretch",
501            "time map needs at least two points",
502        ));
503    }
504    for window in points.windows(2) {
505        if window[0].input >= window[1].input || window[0].output > window[1].output {
506            return Err(TransformDiagnostic::new(
507                TransformDiagnosticCode::InvalidTimeMap,
508                "stretch",
509                "time map points must increase by input and not reverse output",
510            ));
511        }
512    }
513    Ok(())
514}
515
516fn map_time(time: Time, points: &[TimeMapPoint]) -> Time {
517    let (left, right) = segment_for(time, points);
518    let input_span = right.input - left.input;
519    let output_span = right.output - left.output;
520    left.output + (time - left.input) * output_span / input_span
521}
522
523fn segment_for(time: Time, points: &[TimeMapPoint]) -> (&TimeMapPoint, &TimeMapPoint) {
524    for window in points.windows(2) {
525        if time <= window[1].input {
526            return (&window[0], &window[1]);
527        }
528    }
529    let last = points.len() - 1;
530    (&points[last - 1], &points[last])
531}