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#[derive(Clone, Debug, PartialEq, Eq)]
16pub enum PitchDelta {
17 Semitones(i32),
19 Octaves(i16),
21 ScaleDegrees {
23 scale: Scale,
25 steps: i32,
27 },
28 FrequencyRatio(Ratio<i64>),
30 Custom(CallablePitchMap),
32}
33
34#[derive(Clone, Debug, PartialEq, Eq)]
36pub struct TransposeTransform {
37 pub by: PitchDelta,
39}
40
41impl TransposeTransform {
42 pub fn new(by: PitchDelta) -> Self {
44 Self { by }
45 }
46
47 pub fn apply(&self, object: &dyn MusicObject) -> Result<Music, TransformError> {
49 Ok(self.apply_report(object)?.music)
50 }
51
52 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#[derive(Clone, Debug, PartialEq, Eq)]
106pub struct CustomPitchAxis {
107 pub name: String,
109 pub axis: Pitch,
111}
112
113impl CustomPitchAxis {
114 pub fn new(name: impl Into<String>, axis: Pitch) -> Self {
116 Self {
117 name: name.into(),
118 axis,
119 }
120 }
121}
122
123#[derive(Clone, Debug, PartialEq, Eq)]
125pub enum PitchAxis {
126 Pitch(Pitch),
128 PitchClass(PitchClass),
130 ScaleDegree {
132 scale: Scale,
134 degree: usize,
136 octave: i16,
138 },
139 ChordRoot(Chord),
141 Frequency(Pitch),
143 Custom(CustomPitchAxis),
145}
146
147#[derive(Clone, Debug, PartialEq, Eq)]
149pub struct InvertTransform {
150 pub axis: PitchAxis,
152}
153
154impl InvertTransform {
155 pub fn new(axis: PitchAxis) -> Self {
157 Self { axis }
158 }
159
160 pub fn apply(&self, object: &dyn MusicObject) -> Result<Music, TransformError> {
162 Ok(self.apply_report(object)?.music)
163 }
164
165 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#[derive(Clone, Debug, PartialEq, Eq)]
198pub struct RetrogradeTransform {
199 pub mode: RetrogradeMode,
201}
202
203impl RetrogradeTransform {
204 pub fn new(mode: RetrogradeMode) -> Self {
206 Self { mode }
207 }
208
209 pub fn apply(&self, object: &dyn MusicObject) -> Result<Music, TransformError> {
211 retrograde_with_mode(object, self.mode)
212 }
213
214 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#[derive(Clone, Debug, PartialEq, Eq)]
233pub struct TimeMapPoint {
234 pub input: Time,
236 pub output: Time,
238}
239
240impl TimeMapPoint {
241 pub fn new(input: Time, output: Time) -> Self {
243 Self { input, output }
244 }
245}
246
247#[derive(Clone, Debug, PartialEq, Eq)]
249pub struct WarpMarker {
250 pub source: Time,
252 pub target: Time,
254}
255
256impl WarpMarker {
257 pub fn new(source: Time, target: Time) -> Self {
259 Self { source, target }
260 }
261}
262
263#[derive(Clone, Debug, PartialEq, Eq)]
265pub enum StretchPolicy {
266 TempoRatio(Time),
268 TimeRatio(Time),
270 FitToDuration(Time),
272 TimeMap(Vec<TimeMapPoint>),
274 WarpMarkers(Vec<WarpMarker>),
276}
277
278impl StretchPolicy {
279 pub fn apply(&self, object: &dyn MusicObject) -> Result<Music, TransformError> {
281 Ok(self.apply_report(object)?.music)
282 }
283
284 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#[derive(Clone, Debug, PartialEq, Eq)]
324pub enum TransformStep {
325 Transpose(TransposeTransform),
327 Invert(InvertTransform),
329 Retrograde(RetrogradeTransform),
331 Stretch(StretchPolicy),
333 Remap(PitchRemap),
335}
336
337impl TransformStep {
338 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
355pub struct TransformChain {
356 pub steps: Vec<TransformStep>,
358}
359
360impl TransformChain {
361 pub fn new(steps: Vec<TransformStep>) -> Self {
363 Self { steps }
364 }
365
366 pub fn apply(&self, object: &dyn MusicObject) -> Result<Music, TransformError> {
368 Ok(self.apply_report(object)?.music)
369 }
370
371 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(¤t)?;
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}