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#[derive(Clone, Debug, PartialEq, Eq)]
15pub enum PitchDelta {
16 Semitones(i32),
18 Octaves(i16),
20 ScaleDegrees {
22 scale: Scale,
24 steps: i32,
26 },
27 FrequencyRatio(Ratio<i64>),
29 Custom(CallablePitchMap),
31}
32
33#[derive(Clone, Debug, PartialEq, Eq)]
35pub struct TransposeTransform {
36 pub by: PitchDelta,
38}
39
40impl TransposeTransform {
41 pub fn new(by: PitchDelta) -> Self {
43 Self { by }
44 }
45
46 pub fn apply(&self, object: &dyn MusicObject) -> Music {
48 self.apply_report(object).music
49 }
50
51 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#[derive(Clone, Debug, PartialEq, Eq)]
100pub struct CustomPitchAxis {
101 pub name: String,
103 pub axis: Pitch,
105}
106
107impl CustomPitchAxis {
108 pub fn new(name: impl Into<String>, axis: Pitch) -> Self {
110 Self {
111 name: name.into(),
112 axis,
113 }
114 }
115}
116
117#[derive(Clone, Debug, PartialEq, Eq)]
119pub enum PitchAxis {
120 Pitch(Pitch),
122 PitchClass(PitchClass),
124 ScaleDegree {
126 scale: Scale,
128 degree: usize,
130 octave: i16,
132 },
133 ChordRoot(Chord),
135 Frequency(Pitch),
137 Custom(CustomPitchAxis),
139}
140
141#[derive(Clone, Debug, PartialEq, Eq)]
143pub struct InvertTransform {
144 pub axis: PitchAxis,
146}
147
148impl InvertTransform {
149 pub fn new(axis: PitchAxis) -> Self {
151 Self { axis }
152 }
153
154 pub fn apply(&self, object: &dyn MusicObject) -> Music {
156 self.apply_report(object).music
157 }
158
159 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#[derive(Clone, Debug, PartialEq, Eq)]
189pub struct RetrogradeTransform {
190 pub mode: RetrogradeMode,
192}
193
194impl RetrogradeTransform {
195 pub fn new(mode: RetrogradeMode) -> Self {
197 Self { mode }
198 }
199
200 pub fn apply(&self, object: &dyn MusicObject) -> Music {
202 retrograde_with_mode(object, self.mode)
203 }
204
205 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#[derive(Clone, Debug, PartialEq, Eq)]
221pub struct TimeMapPoint {
222 pub input: Time,
224 pub output: Time,
226}
227
228impl TimeMapPoint {
229 pub fn new(input: Time, output: Time) -> Self {
231 Self { input, output }
232 }
233}
234
235#[derive(Clone, Debug, PartialEq, Eq)]
237pub struct WarpMarker {
238 pub source: Time,
240 pub target: Time,
242}
243
244impl WarpMarker {
245 pub fn new(source: Time, target: Time) -> Self {
247 Self { source, target }
248 }
249}
250
251#[derive(Clone, Debug, PartialEq, Eq)]
253pub enum StretchPolicy {
254 TempoRatio(Time),
256 TimeRatio(Time),
258 FitToDuration(Time),
260 TimeMap(Vec<TimeMapPoint>),
262 WarpMarkers(Vec<WarpMarker>),
264}
265
266impl StretchPolicy {
267 pub fn apply(&self, object: &dyn MusicObject) -> Music {
269 self.apply_report(object).music
270 }
271
272 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#[derive(Clone, Debug, PartialEq, Eq)]
309pub enum TransformStep {
310 Transpose(TransposeTransform),
312 Invert(InvertTransform),
314 Retrograde(RetrogradeTransform),
316 Stretch(StretchPolicy),
318 Remap(PitchRemap),
320}
321
322impl TransformStep {
323 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
337pub struct TransformChain {
338 pub steps: Vec<TransformStep>,
340}
341
342impl TransformChain {
343 pub fn new(steps: Vec<TransformStep>) -> Self {
345 Self { steps }
346 }
347
348 pub fn apply(&self, object: &dyn MusicObject) -> Music {
350 self.apply_report(object).music
351 }
352
353 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(¤t);
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}