Skip to main content

sim_lib_music_transform/exact/
additive.rs

1use std::collections::BTreeSet;
2
3use sim_lib_music_core::{Staff, StaffNote, StaffVoice};
4
5use super::{MusicTransform, MusicTransformChange, finish};
6use crate::TransformError;
7
8/// Material introduced by a strictly additive staff transform.
9///
10/// New voices carry their complete initial contents. `notes` contains notes
11/// appended to an existing voice or to a voice introduced by this patch.
12/// Applying the patch never changes existing voice metadata or note payloads.
13#[derive(Clone, Debug, Default, PartialEq, Eq)]
14pub struct AdditiveStaffPatch {
15    /// Complete voices introduced by the patch.
16    pub voices: Vec<StaffVoice>,
17    /// Notes introduced into named voices.
18    pub notes: Vec<StaffNote>,
19}
20
21/// Applies new voices and notes while retaining every source value verbatim.
22pub fn apply_additive_staff_patch(
23    source: &Staff,
24    patch: &AdditiveStaffPatch,
25) -> Result<MusicTransform<Staff>, TransformError> {
26    let source_ids = source.object_ids().into_iter().collect::<BTreeSet<_>>();
27    let mut voices = source.voices.clone();
28    let mut changes = Vec::new();
29
30    for voice in &patch.voices {
31        if source_ids.contains(&voice.id) || voices.iter().any(|item| item.id == voice.id) {
32            return invalid("an added voice identity already exists");
33        }
34        changes.push(MusicTransformChange::AddedVoice {
35            voice_id: voice.id.clone(),
36        });
37        for note in &voice.notes {
38            changes.push(added_note_change(note));
39        }
40        voices.push(voice.clone());
41    }
42
43    for note in &patch.notes {
44        let Some(voice) = voices.iter_mut().find(|voice| voice.id == note.voice_id) else {
45            return invalid("an added note names a missing voice");
46        };
47        changes.push(added_note_change(note));
48        voice.notes.push(note.clone());
49    }
50
51    let transformed = finish(voices, changes)?;
52    ensure_source_unchanged(source, &transformed.value)?;
53    Ok(transformed)
54}
55
56/// Removes exactly the material named by an additive patch.
57///
58/// Removal fails closed if an introduced note or voice has been changed,
59/// removed, or replaced since application. This prevents the inverse from
60/// deleting material merely because it reuses an identity.
61pub fn remove_additive_staff_patch(
62    completed: &Staff,
63    patch: &AdditiveStaffPatch,
64) -> Result<MusicTransform<Staff>, TransformError> {
65    let mut voices = completed.voices.clone();
66    let mut changes = Vec::new();
67
68    for note in patch.notes.iter().rev() {
69        let Some(voice) = voices.iter_mut().find(|voice| voice.id == note.voice_id) else {
70            return invalid("the completed staff is missing an added note voice");
71        };
72        let Some(index) = voice.notes.iter().position(|candidate| candidate == note) else {
73            return invalid("an added note is missing or has changed");
74        };
75        voice.notes.remove(index);
76        changes.push(MusicTransformChange::Removed {
77            note_id: note.note_id.clone(),
78            event_id: note.event_id.clone(),
79            reason: "reverse additive staff patch",
80        });
81    }
82
83    for added in patch.voices.iter().rev() {
84        let Some(index) = voices.iter().position(|voice| voice.id == added.id) else {
85            return invalid("an added voice is missing");
86        };
87        if voices[index] != *added {
88            return invalid("an added voice has changed");
89        }
90        for note in &added.notes {
91            changes.push(MusicTransformChange::Removed {
92                note_id: note.note_id.clone(),
93                event_id: note.event_id.clone(),
94                reason: "reverse additive staff patch",
95            });
96        }
97        changes.push(MusicTransformChange::RemovedVoice {
98            voice_id: added.id.clone(),
99        });
100        voices.remove(index);
101    }
102
103    finish(voices, changes)
104}
105
106fn added_note_change(note: &StaffNote) -> MusicTransformChange {
107    MusicTransformChange::AddedNote {
108        voice_id: note.voice_id.clone(),
109        note_id: note.note_id.clone(),
110        event_id: note.event_id.clone(),
111    }
112}
113
114fn ensure_source_unchanged(source: &Staff, completed: &Staff) -> Result<(), TransformError> {
115    for source_voice in &source.voices {
116        let Some(completed_voice) = completed
117            .voices
118            .iter()
119            .find(|voice| voice.id == source_voice.id)
120        else {
121            return invalid("an additive transform removed a source voice");
122        };
123        if completed_voice.name != source_voice.name
124            || completed_voice.duration != source_voice.duration
125            || !source_voice
126                .notes
127                .iter()
128                .all(|note| completed_voice.notes.contains(note))
129        {
130            return invalid("an additive transform changed source material");
131        }
132    }
133    Ok(())
134}
135
136fn invalid<T>(reason: &'static str) -> Result<T, TransformError> {
137    Err(TransformError::InvalidTransformOutput {
138        transform: "additive-staff-patch",
139        reason,
140    })
141}