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