Skip to main content

acorde_core/model/
melodic_insert.rs

1//! Conservative, explicitly keyed melodic insertions with source provenance.
2use super::{
3    duration::Duration,
4    notation::{BeamState, KeySignature},
5    pitch::{Pitch, Step},
6    score::{Note, NoteAddr, Score},
7    validate::validate,
8};
9use serde::{Deserialize, Serialize};
10use std::collections::HashSet;
11use uuid::Uuid;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "kebab-case")]
15pub enum MelodicInsertionKind {
16    Passing,
17    UpperNeighbor,
18    LowerNeighbor,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "kebab-case")]
23pub enum MelodicInsertionSource {
24    ShortenNote,
25    FillRest,
26}
27
28/// Explicit inputs; the measure range is zero-based and end-exclusive.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct MelodicInsertionOptions {
31    pub part: usize,
32    pub staff: usize,
33    pub voice: usize,
34    pub start_measure: usize,
35    pub end_measure: usize,
36    pub key: KeySignature,
37    pub kind: MelodicInsertionKind,
38    pub source: MelodicInsertionSource,
39    pub max_insertions: usize,
40    pub min_midi: u8,
41    pub max_midi: u8,
42    pub max_leap_semitones: u8,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct MelodicSourceMapping {
47    pub source: NoteAddr,
48    pub output: NoteAddr,
49}
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct MelodicInsertion {
52    pub source_anchor: NoteAddr,
53    pub output_anchor: NoteAddr,
54    pub inserted: NoteAddr,
55    pub original_duration: Duration,
56    pub original_dot_count: u8,
57    pub pitch: Pitch,
58}
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct MelodicInsertionDiagnostic {
61    pub code: String,
62    pub measure: Option<usize>,
63    pub message: String,
64}
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct MelodicInsertionResult {
67    pub score: Option<Score>,
68    pub options: MelodicInsertionOptions,
69    pub source_mapping: Vec<MelodicSourceMapping>,
70    pub insertions: Vec<MelodicInsertion>,
71    pub diagnostics: Vec<MelodicInsertionDiagnostic>,
72}
73
74fn diagnostic(code: &str, measure: Option<usize>, message: &str) -> MelodicInsertionDiagnostic {
75    MelodicInsertionDiagnostic {
76        code: code.into(),
77        measure,
78        message: message.into(),
79    }
80}
81fn plain(note: &Note) -> bool {
82    !note.is_unpitched
83        && !note.is_grace
84        && !note.is_cue
85        && !note.tie_start
86        && !note.tie_end
87        && note.pitch_tie_starts.is_empty()
88        && note.pitch_tie_ends.is_empty()
89        && note.tuplet.is_none()
90        && note.tuplet_boundary.is_none()
91        && note.beam == BeamState::None
92        && note.articulations.is_empty()
93        && note.tremolo_pair.is_none()
94        && note.hairpin_start.is_none()
95        && !note.hairpin_end
96        && note.ottava_start.is_none()
97        && !note.ottava_end
98        && !note.pedal_start
99        && !note.pedal_end
100        && note.cross_staff.is_none()
101        && !note.slur_start
102        && !note.slur_end
103        && !note.glissando_start
104        && !note.glissando_end
105        && !note.trill_line_start
106        && !note.trill_line_end
107}
108fn step_index(step: &Step) -> i16 {
109    match step {
110        Step::C => 0,
111        Step::D => 1,
112        Step::E => 2,
113        Step::F => 3,
114        Step::G => 4,
115        Step::A => 5,
116        Step::B => 6,
117    }
118}
119fn diatonic_position(p: &Pitch) -> i16 {
120    i16::from(p.octave) * 7 + step_index(&p.step)
121}
122fn pitch_at(position: i16, key: &KeySignature) -> Option<Pitch> {
123    let octave = i8::try_from(position.div_euclid(7)).ok()?;
124    let step = match position.rem_euclid(7) {
125        0 => Step::C,
126        1 => Step::D,
127        2 => Step::E,
128        3 => Step::F,
129        4 => Step::G,
130        5 => Step::A,
131        _ => Step::B,
132    };
133    let alter = key.alter_for_step(&step);
134    Some(Pitch::with_alter(step, octave, alter))
135}
136fn candidate(left: &Pitch, right: &Pitch, options: &MelodicInsertionOptions) -> Option<Pitch> {
137    if left.microtone_cents != 0
138        || right.microtone_cents != 0
139        || !options.key.contains_pitch(left)
140        || !options.key.contains_pitch(right)
141    {
142        return None;
143    }
144    let (a, b) = (diatonic_position(left), diatonic_position(right));
145    let position = match options.kind {
146        MelodicInsertionKind::Passing if (b - a).abs() == 2 => a + (b - a).signum(),
147        MelodicInsertionKind::UpperNeighbor if a == b && left.to_midi() == right.to_midi() => a + 1,
148        MelodicInsertionKind::LowerNeighbor if a == b && left.to_midi() == right.to_midi() => a - 1,
149        _ => return None,
150    };
151    let pitch = pitch_at(position, &options.key)?;
152    let midi = pitch.to_midi();
153    if !(i16::from(options.min_midi)..=i16::from(options.max_midi)).contains(&midi)
154        || (midi - left.to_midi()).abs() > i16::from(options.max_leap_semitones)
155        || (midi - right.to_midi()).abs() > i16::from(options.max_leap_semitones)
156    {
157        return None;
158    }
159    Some(pitch)
160}
161fn generated_id(
162    score: &Score,
163    anchor: &Note,
164    measure: usize,
165    index: usize,
166    kind: MelodicInsertionKind,
167    source: MelodicInsertionSource,
168) -> String {
169    let input = format!(
170        "melodic-insertion-v1:{}:{}:{measure}:{index}:{kind:?}:{source:?}",
171        score.id, anchor.id
172    );
173    let hash = |seed: u64| {
174        input
175            .bytes()
176            .fold(seed, |h, b| (h ^ u64::from(b)).wrapping_mul(0x100000001b3))
177    };
178    Uuid::from_u128(
179        (u128::from(hash(0xcbf29ce484222325)) << 64) | u128::from(hash(0x84222325cbf29ce4)),
180    )
181    .to_string()
182}
183
184/// Produce a new score without mutating the source. Only simple, monophonic spans are edited;
185/// ineligible bars are left unchanged and diagnosed. Keep the input score for undo.
186pub fn insert_melodic_notes(
187    score: &Score,
188    options: MelodicInsertionOptions,
189) -> MelodicInsertionResult {
190    let mut result = MelodicInsertionResult {
191        score: None,
192        options: options.clone(),
193        source_mapping: Vec::new(),
194        insertions: Vec::new(),
195        diagnostics: Vec::new(),
196    };
197    if !validate(score).is_valid()
198        || !(-7..=7).contains(&options.key.fifths)
199        || !matches!(options.key.mode.as_str(), "major" | "minor")
200        || options.min_midi > options.max_midi
201        || options.max_midi > 127
202        || options.max_leap_semitones == 0
203        || options.max_leap_semitones > 12
204        || options.max_insertions > 128
205        || options.start_measure >= options.end_measure
206        || options.end_measure - options.start_measure > 128
207    {
208        result.diagnostics.push(diagnostic(
209            "invalid-options",
210            None,
211            "invalid score, range, key, pitch or density limit",
212        ));
213        return result;
214    }
215    let Some(staff) = score
216        .parts
217        .get(options.part)
218        .and_then(|p| p.staves.get(options.staff))
219    else {
220        result.diagnostics.push(diagnostic(
221            "invalid-address",
222            None,
223            "part or staff does not exist",
224        ));
225        return result;
226    };
227    if options.end_measure > staff.measures.len() || options.voice >= 4 {
228        result.diagnostics.push(diagnostic(
229            "invalid-address",
230            None,
231            "measure or voice does not exist",
232        ));
233        return result;
234    }
235    let has_harmony_ranges = score
236        .parts
237        .iter()
238        .flat_map(|p| &p.staves)
239        .flat_map(|s| &s.measures)
240        .flat_map(|m| &m.voices)
241        .flatten()
242        .any(|n| {
243            n.chord_symbol
244                .as_ref()
245                .is_some_and(|h| h.range_end.is_some())
246        });
247    if !score.spanners.is_empty()
248        || !score.object_style_overrides.is_empty()
249        || !score.views.is_empty()
250        || has_harmony_ranges
251    {
252        result.diagnostics.push(diagnostic(
253            "unsupported-references",
254            None,
255            "typed spans, harmony ranges, object overrides and score views require address remapping",
256        ));
257        return result;
258    }
259    let mut used: HashSet<String> = score
260        .parts
261        .iter()
262        .flat_map(|p| &p.staves)
263        .flat_map(|s| &s.measures)
264        .flat_map(|m| &m.voices)
265        .flatten()
266        .map(|n| n.id.clone())
267        .collect();
268    let mut output = score.clone();
269    let mut key = score.settings.key_signature.clone();
270    for mi in 0..options.end_measure {
271        let measure = &staff.measures[mi];
272        if let Some(k) = &measure.key_sig {
273            key = k.clone();
274        }
275        if mi < options.start_measure {
276            continue;
277        }
278        let voice = &measure.voices[options.voice];
279        let incompatible = key != options.key
280            || measure
281                .voices
282                .iter()
283                .enumerate()
284                .any(|(i, v)| i != options.voice && v.iter().any(|n| !n.is_rest))
285            || voice.iter().any(|n| !plain(n));
286        if incompatible {
287            result.diagnostics.push(diagnostic(
288                "unsupported-measure",
289                Some(mi),
290                "key differs or the measure contains polyphony, ties, tuplets, articulations, beams or spans",
291            ));
292        }
293        let mut next_voice = Vec::with_capacity(voice.len() + 1);
294        let mut changed = false;
295        for (i, note) in voice.iter().enumerate() {
296            let addr = |note| NoteAddr {
297                part: options.part,
298                staff: options.staff,
299                measure: mi,
300                voice: options.voice,
301                note,
302            };
303            let output_index = next_voice.len();
304            result.source_mapping.push(MelodicSourceMapping {
305                source: addr(i),
306                output: addr(output_index),
307            });
308            let mut anchor = note.clone();
309            let split = if note.dot_count == 0 {
310                match note.duration {
311                    Duration::Half => Some((Duration::Quarter, Duration::Eighth)),
312                    Duration::Quarter => Some((Duration::Eighth, Duration::Sixteenth)),
313                    _ => None,
314                }
315            } else {
316                None
317            };
318            let endpoints = match options.source {
319                MelodicInsertionSource::ShortenNote if !note.is_rest && note.pitches.len() == 1 => {
320                    voice
321                        .get(i + 1)
322                        .filter(|n| !n.is_rest && n.pitches.len() == 1)
323                        .map(|n| (&note.pitches[0], &n.pitches[0]))
324                }
325                MelodicInsertionSource::FillRest if note.is_rest && i > 0 => voice
326                    .get(i - 1)
327                    .zip(voice.get(i + 1))
328                    .filter(|(a, b)| {
329                        !a.is_rest && !b.is_rest && a.pitches.len() == 1 && b.pitches.len() == 1
330                    })
331                    .map(|(a, b)| (&a.pitches[0], &b.pitches[0])),
332                _ => None,
333            };
334            let attempted = !incompatible
335                && result.insertions.len() < options.max_insertions
336                && endpoints.is_some();
337            let candidate = if attempted {
338                endpoints.and_then(|(a, b)| candidate(a, b, &options))
339            } else {
340                None
341            };
342            if attempted && (split.is_none() || candidate.is_none()) {
343                result.diagnostics.push(diagnostic(
344                    "ineligible-candidate",
345                    Some(mi),
346                    "the interval, pitch limits or written duration cannot be edited safely",
347                ));
348            }
349            if let (Some((short, added)), Some(pitch)) = (split, candidate) {
350                let id = generated_id(score, note, mi, i, options.kind, options.source);
351                if !used.insert(id.clone()) {
352                    result.diagnostics.push(diagnostic(
353                        "id-conflict",
354                        Some(mi),
355                        "generated note identity conflicts with an existing note",
356                    ));
357                } else {
358                    anchor.duration = short;
359                    anchor.dot_count = 1;
360                    let mut inserted = Note::new(pitch.clone(), added);
361                    inserted.id = id;
362                    result.insertions.push(MelodicInsertion {
363                        source_anchor: addr(i),
364                        output_anchor: addr(output_index),
365                        inserted: addr(output_index + 1),
366                        original_duration: note.duration.clone(),
367                        original_dot_count: note.dot_count,
368                        pitch,
369                    });
370                    next_voice.push(anchor);
371                    next_voice.push(inserted);
372                    changed = true;
373                    continue;
374                }
375            }
376            next_voice.push(anchor);
377        }
378        if !changed && !incompatible {
379            result.diagnostics.push(diagnostic(
380                "no-candidate",
381                Some(mi),
382                "no eligible insertion at the selected density and musical constraints",
383            ));
384        }
385        output.parts[options.part].staves[options.staff].measures[mi].voices[options.voice] =
386            next_voice;
387    }
388    if !validate(&output).is_valid() {
389        result.diagnostics.push(diagnostic(
390            "invalid-output",
391            None,
392            "generated score failed validation",
393        ));
394        result.source_mapping.clear();
395        result.insertions.clear();
396        return result;
397    }
398    result.score = Some(output);
399    result
400}