Skip to main content

sim_lib_music_transform/exact/
register.rs

1use std::collections::BTreeMap;
2
3use sim_lib_music_core::{Pitch, Staff};
4
5use super::{MusicTransform, MusicTransformChange, finish, note_order, transform_notes};
6use crate::TransformError;
7
8/// Tie direction for equally near octave placements.
9#[derive(Copy, Clone, Debug, PartialEq, Eq)]
10pub enum RegisterTie {
11    /// Select the higher candidate.
12    Ascending,
13    /// Select the lower candidate.
14    Descending,
15}
16
17/// Inclusive semitone bounds and tie policy for register unwrapping.
18#[derive(Copy, Clone, Debug, PartialEq, Eq)]
19pub struct RegisterRange {
20    /// Lowest allowed pitch.
21    pub low: Pitch,
22    /// Highest allowed pitch.
23    pub high: Pitch,
24    /// Direction chosen for equally near candidates.
25    pub tie: RegisterTie,
26}
27
28/// Chooses octave placements that minimize adjacent motion within `range`.
29///
30/// Pitch classes, timings, voices, and identities remain fixed. The report
31/// stores both pitches for every register edit, making [`restore_register`]
32/// an exact inverse.
33pub fn unwrap_register(
34    staff: &Staff,
35    range: RegisterRange,
36) -> Result<MusicTransform<Staff>, TransformError> {
37    let low = range.low.semitone();
38    let high = range.high.semitone();
39    if low > high {
40        return Err(TransformError::InvalidTransformOutput {
41            transform: "register-unwrap",
42            reason: "low pitch must not exceed high pitch",
43        });
44    }
45    let mut voices = staff.voices.clone();
46    let mut changes = Vec::new();
47    for voice in &mut voices {
48        voice.notes.sort_by(note_order);
49        let mut previous = None;
50        for note in &mut voice.notes {
51            let original = note.note.pitch;
52            let target = previous.unwrap_or_else(|| original.semitone());
53            let candidates = register_candidates(original, low, high);
54            let Some(semitone) = nearest_candidate(&candidates, target, range.tie) else {
55                return Err(TransformError::InvalidTransformOutput {
56                    transform: "register-unwrap",
57                    reason: "register contains no octave placement for a pitch class",
58                });
59            };
60            note.note.pitch = Pitch::from_semitone(semitone);
61            previous = Some(semitone);
62            if note.note.pitch != original {
63                changes.push(MusicTransformChange::Pitch {
64                    event_id: note.event_id.clone(),
65                    before: original,
66                    after: note.note.pitch,
67                });
68            }
69        }
70    }
71    finish(voices, changes)
72}
73
74/// Restores the original pitches recorded by [`unwrap_register`].
75pub fn restore_register(
76    report: &MusicTransform<Staff>,
77) -> Result<MusicTransform<Staff>, TransformError> {
78    let originals = report
79        .changes
80        .iter()
81        .filter_map(|change| match change {
82            MusicTransformChange::Pitch {
83                event_id, before, ..
84            } => Some((event_id.clone(), *before)),
85            _ => None,
86        })
87        .collect::<BTreeMap<_, _>>();
88    transform_notes(&report.value, |mut note, changes| {
89        if let Some(original) = originals.get(&note.event_id)
90            && note.note.pitch != *original
91        {
92            let before = note.note.pitch;
93            note.note.pitch = *original;
94            changes.push(MusicTransformChange::Pitch {
95                event_id: note.event_id.clone(),
96                before,
97                after: *original,
98            });
99        }
100        note
101    })
102}
103
104fn register_candidates(pitch: Pitch, low: i32, high: i32) -> Vec<i32> {
105    let class = i32::from(pitch.class.value());
106    let mut candidate = low + (class - low).rem_euclid(12);
107    let mut output = Vec::new();
108    while candidate <= high {
109        output.push(candidate);
110        candidate += 12;
111    }
112    output
113}
114
115fn nearest_candidate(candidates: &[i32], target: i32, tie: RegisterTie) -> Option<i32> {
116    candidates.iter().copied().min_by(|left, right| {
117        let left_distance = (*left - target).abs();
118        let right_distance = (*right - target).abs();
119        left_distance.cmp(&right_distance).then_with(|| match tie {
120            RegisterTie::Ascending => right.cmp(left),
121            RegisterTie::Descending => left.cmp(right),
122        })
123    })
124}