Skip to main content

acorde_core/model/
melodic_insert.rs

1//! Conservative, explicitly keyed melodic insertions with source provenance.
2use super::{
3    chord_plan::{CHORD_PLAN_CONTRACT_VERSION, ChordPlan, ChordPlanEntry},
4    duration::Duration,
5    notation::{BeamState, KeySignature},
6    pitch::{Pitch, Step},
7    score::{Note, NoteAddr, ObjectStyleTarget, Score},
8    validate::validate,
9};
10use serde::{Deserialize, Serialize};
11use std::collections::HashSet;
12use uuid::Uuid;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "kebab-case")]
16pub enum MelodicInsertionKind {
17    Passing,
18    UpperNeighbor,
19    LowerNeighbor,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "kebab-case")]
24pub enum MelodicInsertionSource {
25    ShortenNote,
26    FillRest,
27}
28
29/// Explicit inputs; the measure range is zero-based and end-exclusive.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct MelodicInsertionOptions {
32    pub part: usize,
33    pub staff: usize,
34    pub voice: usize,
35    pub start_measure: usize,
36    pub end_measure: usize,
37    pub key: KeySignature,
38    pub kind: MelodicInsertionKind,
39    pub source: MelodicInsertionSource,
40    pub max_insertions: usize,
41    pub min_midi: u8,
42    pub max_midi: u8,
43    pub max_leap_semitones: u8,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct MelodicSourceMapping {
48    pub source: NoteAddr,
49    pub output: NoteAddr,
50}
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct MelodicInsertion {
53    pub source_anchor: NoteAddr,
54    pub output_anchor: NoteAddr,
55    pub inserted: NoteAddr,
56    pub original_duration: Duration,
57    pub original_dot_count: u8,
58    pub pitch: Pitch,
59}
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct MelodicInsertionDiagnostic {
62    pub code: String,
63    pub measure: Option<usize>,
64    pub message: String,
65}
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct MelodicInsertionResult {
68    pub score: Option<Score>,
69    pub options: MelodicInsertionOptions,
70    pub source_mapping: Vec<MelodicSourceMapping>,
71    pub insertions: Vec<MelodicInsertion>,
72    pub diagnostics: Vec<MelodicInsertionDiagnostic>,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(rename_all = "kebab-case")]
77pub enum MelodicInsertionOutcome {
78    Invalid,
79    NoChange,
80    Partial,
81    Applied,
82}
83
84impl MelodicInsertionResult {
85    pub fn outcome(&self) -> MelodicInsertionOutcome {
86        if self.score.is_none() {
87            MelodicInsertionOutcome::Invalid
88        } else if self.insertions.is_empty() {
89            MelodicInsertionOutcome::NoChange
90        } else if self.diagnostics.is_empty() {
91            MelodicInsertionOutcome::Applied
92        } else {
93            MelodicInsertionOutcome::Partial
94        }
95    }
96}
97
98/// An explicit local key, effective from `start_measure` until the next entry.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct MelodicLocalKey {
101    pub start_measure: usize,
102    pub key: KeySignature,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct MelodicInsertionSelection {
107    pub source_anchor: NoteAddr,
108    pub pitch: Pitch,
109}
110
111/// Optional musical context. Empty fields retain the conservative legacy behavior.
112#[derive(Debug, Clone, Default, Serialize, Deserialize)]
113#[serde(default)]
114pub struct MelodicInsertionContext {
115    pub chord_plan: Option<ChordPlan>,
116    pub local_keys: Vec<MelodicLocalKey>,
117    pub phrase_end_measures: Vec<usize>,
118    pub selections: Vec<MelodicInsertionSelection>,
119    pub max_insertions_per_measure: Option<usize>,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct MelodicInsertionCandidate {
124    pub source_anchor: NoteAddr,
125    pub beat: f64,
126    pub pitch: Pitch,
127    pub kind: MelodicInsertionKind,
128    pub score: i32,
129    pub reason_codes: Vec<String>,
130    pub constraint_violations: Vec<String>,
131    pub selected: bool,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct MelodicInsertionContextResult {
136    pub insertion: MelodicInsertionResult,
137    pub candidates: Vec<MelodicInsertionCandidate>,
138    pub outcome: MelodicInsertionOutcome,
139}
140
141fn diagnostic(code: &str, measure: Option<usize>, message: &str) -> MelodicInsertionDiagnostic {
142    MelodicInsertionDiagnostic {
143        code: code.into(),
144        measure,
145        message: message.into(),
146    }
147}
148fn safe_anchor(note: &Note) -> bool {
149    !note.is_unpitched
150        && !note.is_grace
151        && !note.is_cue
152        && !note.tie_start
153        && !note.tie_end
154        && note.pitch_tie_starts.is_empty()
155        && note.pitch_tie_ends.is_empty()
156        && !note.pitch_incoming_partial_ties.iter().any(|tied| *tied)
157        && !note.pitch_tie_sources.iter().any(Option::is_some)
158        && note.tuplet.is_none()
159        && note.tuplet_boundary.is_none()
160        && note.beam == BeamState::None
161        && !note
162            .articulations
163            .contains(&super::notation::Articulation::Fermata)
164        && note.tremolo_pair.is_none()
165        && note.cross_staff.is_none()
166        && note.guitar_technique.is_none()
167        && !note.hidden
168}
169
170fn remap_address(address: &mut NoteAddr, mapping: &[MelodicSourceMapping]) {
171    if let Some(found) = mapping.iter().find(|entry| entry.source == *address) {
172        *address = found.output.clone();
173    }
174}
175
176fn remap_references(score: &mut Score, mapping: &[MelodicSourceMapping]) {
177    for spanner in &mut score.spanners {
178        remap_address(&mut spanner.start, mapping);
179        remap_address(&mut spanner.end, mapping);
180    }
181    for style in &mut score.object_style_overrides {
182        if let ObjectStyleTarget::Note { address } = &mut style.target {
183            remap_address(address, mapping);
184        }
185    }
186    for note in score
187        .parts
188        .iter_mut()
189        .flat_map(|part| &mut part.staves)
190        .flat_map(|staff| &mut staff.measures)
191        .flat_map(|measure| &mut measure.voices)
192        .flatten()
193    {
194        if let Some(end) = note
195            .chord_symbol
196            .as_mut()
197            .and_then(|harmony| harmony.range_end.as_mut())
198        {
199            remap_address(end, mapping);
200        }
201    }
202}
203fn step_index(step: &Step) -> i16 {
204    match step {
205        Step::C => 0,
206        Step::D => 1,
207        Step::E => 2,
208        Step::F => 3,
209        Step::G => 4,
210        Step::A => 5,
211        Step::B => 6,
212    }
213}
214fn diatonic_position(p: &Pitch) -> i16 {
215    i16::from(p.octave) * 7 + step_index(&p.step)
216}
217fn pitch_at(position: i16, key: &KeySignature) -> Option<Pitch> {
218    let octave = i8::try_from(position.div_euclid(7)).ok()?;
219    let step = match position.rem_euclid(7) {
220        0 => Step::C,
221        1 => Step::D,
222        2 => Step::E,
223        3 => Step::F,
224        4 => Step::G,
225        5 => Step::A,
226        _ => Step::B,
227    };
228    let alter = key.alter_for_step(&step);
229    Some(Pitch::with_alter(step, octave, alter))
230}
231fn candidate(
232    left: &Pitch,
233    right: &Pitch,
234    key: &KeySignature,
235    options: &MelodicInsertionOptions,
236) -> Option<Pitch> {
237    if left.microtone_cents != 0
238        || right.microtone_cents != 0
239        || !key.contains_pitch(left)
240        || !key.contains_pitch(right)
241    {
242        return None;
243    }
244    let (a, b) = (diatonic_position(left), diatonic_position(right));
245    let position = match options.kind {
246        MelodicInsertionKind::Passing if (b - a).abs() == 2 => a + (b - a).signum(),
247        MelodicInsertionKind::UpperNeighbor if a == b && left.to_midi() == right.to_midi() => a + 1,
248        MelodicInsertionKind::LowerNeighbor if a == b && left.to_midi() == right.to_midi() => a - 1,
249        _ => return None,
250    };
251    let pitch = pitch_at(position, key)?;
252    Some(pitch)
253}
254
255fn constraint_violations(
256    pitch: &Pitch,
257    left: &Pitch,
258    right: &Pitch,
259    options: &MelodicInsertionOptions,
260) -> Vec<String> {
261    let midi = pitch.to_midi();
262    let mut violations = Vec::new();
263    if !(i16::from(options.min_midi)..=i16::from(options.max_midi)).contains(&midi) {
264        violations.push("pitch-range".into());
265    }
266    if (midi - left.to_midi()).abs() > i16::from(options.max_leap_semitones)
267        || (midi - right.to_midi()).abs() > i16::from(options.max_leap_semitones)
268    {
269        violations.push("leap-limit".into());
270    }
271    violations
272}
273
274fn chord_tones(entry: &ChordPlanEntry) -> Option<Vec<i16>> {
275    let root = entry.chord.root.as_bytes();
276    let base = match root.first()?.to_ascii_uppercase() {
277        b'C' => 0,
278        b'D' => 2,
279        b'E' => 4,
280        b'F' => 5,
281        b'G' => 7,
282        b'A' => 9,
283        b'B' => 11,
284        _ => return None,
285    };
286    let alter = match root.get(1..) {
287        Some(b"") => 0,
288        Some(b"#") => 1,
289        Some(b"b") => -1,
290        _ => return None,
291    };
292    let intervals: &[i16] = match entry.chord.kind.as_str() {
293        "major" => &[0, 4, 7],
294        "minor" => &[0, 3, 7],
295        "diminished" => &[0, 3, 6],
296        "augmented" => &[0, 4, 8],
297        "dominant" | "dominant-seventh" => &[0, 4, 7, 10],
298        "major-seventh" => &[0, 4, 7, 11],
299        "minor-seventh" => &[0, 3, 7, 10],
300        "half-diminished" => &[0, 3, 6, 10],
301        "diminished-seventh" => &[0, 3, 6, 9],
302        _ => return None,
303    };
304    Some(
305        intervals
306            .iter()
307            .map(|interval| (base + alter + *interval).rem_euclid(12))
308            .collect(),
309    )
310}
311
312fn harmony_at(plan: &ChordPlan, measure: usize, beat: f64) -> Option<&ChordPlanEntry> {
313    plan.entries
314        .iter()
315        .filter(|entry| {
316            entry.position.offset.denominator != 0
317                && (entry.position.measure_index < measure
318                    || (entry.position.measure_index == measure
319                        && entry.position.offset.beats() <= beat + 1e-8))
320        })
321        .max_by(|a, b| {
322            a.position
323                .measure_index
324                .cmp(&b.position.measure_index)
325                .then_with(|| {
326                    a.position
327                        .offset
328                        .beats()
329                        .total_cmp(&b.position.offset.beats())
330                })
331        })
332}
333
334struct CandidateEvaluation<'a> {
335    base: Pitch,
336    left: &'a Pitch,
337    right: &'a Pitch,
338    beat: f64,
339    source: NoteAddr,
340    options: &'a MelodicInsertionOptions,
341    harmony: Option<&'a ChordPlanEntry>,
342    resolves: bool,
343    phrase_end: bool,
344}
345
346fn ranked_candidates(evaluation: CandidateEvaluation<'_>) -> Vec<MelodicInsertionCandidate> {
347    let CandidateEvaluation {
348        base,
349        left,
350        right,
351        beat,
352        source,
353        options,
354        harmony,
355        resolves,
356        phrase_end,
357    } = evaluation;
358    let chord_pitches = harmony.and_then(chord_tones);
359    let mut candidates = Vec::new();
360    for delta in if harmony.is_some() {
361        [-1, 0, 1].as_slice()
362    } else {
363        [0].as_slice()
364    } {
365        let mut pitch = base.clone();
366        pitch.alter += delta;
367        let midi = pitch.to_midi();
368        let violations = constraint_violations(&pitch, left, right, options);
369        let chord_tone = chord_pitches
370            .as_ref()
371            .is_some_and(|tones| tones.contains(&midi.rem_euclid(12)));
372        let weak_beat = (beat - beat.round()).abs() > 1e-8;
373        let mut score = if chord_tone {
374            10
375        } else if harmony.is_none() {
376            5
377        } else if weak_beat && resolves {
378            6
379        } else {
380            -5
381        };
382        let mut reason_codes = vec![
383            if chord_tone {
384                "chord-tone"
385            } else if harmony.is_none() {
386                "diatonic"
387            } else if weak_beat && resolves {
388                "resolved-weak-beat-nonchord-tone"
389            } else {
390                "unresolved-or-strong-beat-dissonance"
391            }
392            .to_string(),
393        ];
394        if *delta != 0 {
395            score -= 2;
396            reason_codes.push("chromatic-alteration".into());
397        }
398        if phrase_end && !chord_tone && harmony.is_some() {
399            score -= 8;
400            reason_codes.push("phrase-ending-nonchord-tone".into());
401        }
402        if !violations.is_empty() {
403            score = -100;
404        }
405        candidates.push(MelodicInsertionCandidate {
406            source_anchor: source.clone(),
407            beat,
408            pitch,
409            kind: options.kind,
410            score,
411            reason_codes,
412            constraint_violations: violations,
413            selected: false,
414        });
415    }
416    candidates.sort_by(|a, b| {
417        b.score
418            .cmp(&a.score)
419            .then_with(|| {
420                (a.pitch.alter - base.alter)
421                    .abs()
422                    .cmp(&(b.pitch.alter - base.alter).abs())
423            })
424            .then_with(|| a.pitch.to_midi().cmp(&b.pitch.to_midi()))
425    });
426    candidates
427}
428fn generated_id(
429    score: &Score,
430    anchor: &Note,
431    measure: usize,
432    index: usize,
433    kind: MelodicInsertionKind,
434    source: MelodicInsertionSource,
435) -> String {
436    let input = format!(
437        "melodic-insertion-v1:{}:{}:{measure}:{index}:{kind:?}:{source:?}",
438        score.id, anchor.id
439    );
440    let hash = |seed: u64| {
441        input
442            .bytes()
443            .fold(seed, |h, b| (h ^ u64::from(b)).wrapping_mul(0x100000001b3))
444    };
445    Uuid::from_u128(
446        (u128::from(hash(0xcbf29ce484222325)) << 64) | u128::from(hash(0x84222325cbf29ce4)),
447    )
448    .to_string()
449}
450
451/// Produce a new score without mutating the source. Only simple, monophonic spans are edited;
452/// ineligible bars are left unchanged and diagnosed. Keep the input score for undo.
453pub fn insert_melodic_notes(
454    score: &Score,
455    options: MelodicInsertionOptions,
456) -> MelodicInsertionResult {
457    insert_melodic_notes_impl(score, options, None).insertion
458}
459
460/// Evaluate explicitly positioned harmony and local keys, returning preview candidates and
461/// a deterministic edited copy. A selected candidate is reapplied through `context.selections`.
462pub fn insert_melodic_notes_with_context(
463    score: &Score,
464    options: MelodicInsertionOptions,
465    context: &MelodicInsertionContext,
466) -> MelodicInsertionContextResult {
467    insert_melodic_notes_impl(score, options, Some(context))
468}
469
470fn insert_melodic_notes_impl(
471    score: &Score,
472    options: MelodicInsertionOptions,
473    context: Option<&MelodicInsertionContext>,
474) -> MelodicInsertionContextResult {
475    let mut result = MelodicInsertionResult {
476        score: None,
477        options: options.clone(),
478        source_mapping: Vec::new(),
479        insertions: Vec::new(),
480        diagnostics: Vec::new(),
481    };
482    let mut candidates = Vec::new();
483    macro_rules! done {
484        () => {
485            return MelodicInsertionContextResult {
486                outcome: result.outcome(),
487                insertion: result,
488                candidates,
489            }
490        };
491    }
492    if !validate(score).is_valid()
493        || !(-7..=7).contains(&options.key.fifths)
494        || !matches!(options.key.mode.as_str(), "major" | "minor")
495        || options.min_midi > options.max_midi
496        || options.max_midi > 127
497        || options.max_leap_semitones == 0
498        || options.max_leap_semitones > 12
499        || options.max_insertions > 128
500        || options.start_measure >= options.end_measure
501        || options.end_measure - options.start_measure > 128
502    {
503        result.diagnostics.push(diagnostic(
504            "invalid-options",
505            None,
506            "invalid score, range, key, pitch or density limit",
507        ));
508        done!();
509    }
510    let Some(staff) = score
511        .parts
512        .get(options.part)
513        .and_then(|p| p.staves.get(options.staff))
514    else {
515        result.diagnostics.push(diagnostic(
516            "invalid-address",
517            None,
518            "part or staff does not exist",
519        ));
520        done!();
521    };
522    if options.end_measure > staff.measures.len() || options.voice >= 4 {
523        result.diagnostics.push(diagnostic(
524            "invalid-address",
525            None,
526            "measure or voice does not exist",
527        ));
528        done!();
529    }
530    if let Some(context) = context {
531        let valid_keys = context.local_keys.iter().all(|entry| {
532            entry.start_measure < options.end_measure
533                && (-7..=7).contains(&entry.key.fifths)
534                && matches!(entry.key.mode.as_str(), "major" | "minor")
535        }) && context
536            .local_keys
537            .windows(2)
538            .all(|pair| pair[0].start_measure < pair[1].start_measure);
539        let valid_plan = context.chord_plan.as_ref().is_none_or(|plan| {
540            plan.contract_version == CHORD_PLAN_CONTRACT_VERSION
541                && !plan.entries.is_empty()
542                && plan.entries.len() <= 512
543                && plan.entries.iter().all(|entry| {
544                    entry.position.offset.denominator != 0
545                        && entry.offset_beats.is_finite()
546                        && entry.offset_beats <= 32.0
547                        && (entry.offset_beats - entry.position.offset.beats()).abs() < 1e-8
548                })
549                && plan.entries.windows(2).all(|pair| {
550                    pair[0].position.measure_index < pair[1].position.measure_index
551                        || (pair[0].position.measure_index == pair[1].position.measure_index
552                            && pair[0].position.offset.beats() < pair[1].position.offset.beats())
553                })
554        });
555        let valid_selections = context.selections.len() <= 128
556            && context
557                .selections
558                .iter()
559                .enumerate()
560                .all(|(index, selection)| {
561                    !context.selections[..index]
562                        .iter()
563                        .any(|previous| previous.source_anchor == selection.source_anchor)
564                });
565        if !valid_keys
566            || !valid_plan
567            || !valid_selections
568            || context
569                .max_insertions_per_measure
570                .is_some_and(|limit| limit > 16)
571        {
572            result.diagnostics.push(diagnostic(
573                "invalid-context",
574                None,
575                "invalid local key, chord plan or selection limit",
576            ));
577            done!();
578        }
579    }
580    let mut used: HashSet<String> = score
581        .parts
582        .iter()
583        .flat_map(|p| &p.staves)
584        .flat_map(|s| &s.measures)
585        .flat_map(|m| &m.voices)
586        .flatten()
587        .map(|n| n.id.clone())
588        .collect();
589    let mut output = score.clone();
590    let mut key = score.settings.key_signature.clone();
591    for mi in 0..options.end_measure {
592        let measure = &staff.measures[mi];
593        if let Some(k) = &measure.key_sig {
594            key = k.clone();
595        }
596        if mi < options.start_measure {
597            continue;
598        }
599        let voice = &measure.voices[options.voice];
600        let measure_start_insertions = result.insertions.len();
601        let explicit_local_key = context.and_then(|ctx| {
602            ctx.local_keys
603                .iter()
604                .rev()
605                .find(|entry| entry.start_measure <= mi)
606        });
607        let local_key = explicit_local_key.map_or(&key, |entry| &entry.key);
608        let incompatible = (explicit_local_key.is_none() && key != options.key)
609            || measure
610                .voices
611                .iter()
612                .enumerate()
613                .any(|(i, v)| i != options.voice && v.iter().any(|n| !n.is_rest));
614        if incompatible {
615            result.diagnostics.push(diagnostic(
616                "unsupported-measure",
617                Some(mi),
618                &format!(
619                    "part {} staff {} measure {} voice {}: key differs or the measure contains polyphony",
620                    options.part, options.staff, mi, options.voice
621                ),
622            ));
623        }
624        let mut next_voice = Vec::with_capacity(voice.len() + 1);
625        let mut changed = false;
626        for (i, note) in voice.iter().enumerate() {
627            let addr = |note| NoteAddr {
628                part: options.part,
629                staff: options.staff,
630                measure: mi,
631                voice: options.voice,
632                note,
633            };
634            let output_index = next_voice.len();
635            result.source_mapping.push(MelodicSourceMapping {
636                source: addr(i),
637                output: addr(output_index),
638            });
639            let mut anchor = note.clone();
640            let split = if note.dot_count == 0 {
641                match note.duration {
642                    Duration::Half => Some((Duration::Quarter, Duration::Eighth)),
643                    Duration::Quarter => Some((Duration::Eighth, Duration::Sixteenth)),
644                    _ => None,
645                }
646            } else {
647                None
648            };
649            let endpoints = match options.source {
650                MelodicInsertionSource::ShortenNote if !note.is_rest && note.pitches.len() == 1 => {
651                    voice
652                        .get(i + 1)
653                        .filter(|n| {
654                            !n.is_rest
655                                && !n.is_grace
656                                && !n.is_cue
657                                && !n.is_unpitched
658                                && n.pitches.len() == 1
659                        })
660                        .map(|n| (&note.pitches[0], &n.pitches[0]))
661                }
662                MelodicInsertionSource::FillRest if note.is_rest && i > 0 => voice
663                    .get(i - 1)
664                    .zip(voice.get(i + 1))
665                    .filter(|(a, b)| {
666                        !a.is_rest
667                            && !b.is_rest
668                            && !a.is_grace
669                            && !b.is_grace
670                            && !a.is_cue
671                            && !b.is_cue
672                            && !a.is_unpitched
673                            && !b.is_unpitched
674                            && a.pitches.len() == 1
675                            && b.pitches.len() == 1
676                    })
677                    .map(|(a, b)| (&a.pitches[0], &b.pitches[0])),
678                _ => None,
679            };
680            let attempted = !incompatible
681                && result.insertions.len() < options.max_insertions
682                && context.is_none_or(|ctx| {
683                    ctx.max_insertions_per_measure.is_none_or(|limit| {
684                        result.insertions.len() - measure_start_insertions < limit
685                    })
686                })
687                && endpoints.is_some();
688            let selected_pitch = if attempted && split.is_some() {
689                endpoints.filter(|_| safe_anchor(note)).and_then(|(a, b)| {
690                    let base = candidate(a, b, local_key, &options)?;
691                    let Some(context) = context else {
692                        return constraint_violations(&base, a, b, &options)
693                            .is_empty()
694                            .then_some(base);
695                    };
696                    let beat = voice[..i].iter().map(Note::beats).sum::<f64>()
697                        + split.as_ref().map_or(0.0, |(short, _)| {
698                            let mut shortened = note.clone();
699                            shortened.duration = short.clone();
700                            shortened.dot_count = 1;
701                            shortened.beats()
702                        });
703                    let harmony = context.chord_plan.as_ref().and_then(|plan| harmony_at(plan, mi, beat));
704                    if context.chord_plan.is_some() && harmony.is_none() {
705                        result.diagnostics.push(diagnostic("uncovered-harmony", Some(mi),
706                            &format!("part {} staff {} measure {} voice {} note {}: no explicit chord at insertion beat", options.part, options.staff, mi, options.voice, i)));
707                        return None;
708                    }
709                    if harmony.is_some_and(|entry| chord_tones(entry).is_none()) {
710                        result.diagnostics.push(diagnostic("unsupported-harmony", Some(mi),
711                            &format!("part {} staff {} measure {} voice {} note {}: unsupported chord root or kind", options.part, options.staff, mi, options.voice, i)));
712                        return None;
713                    }
714                    let right_beat = voice[..=i].iter().map(Note::beats).sum::<f64>();
715                    let resolves = context.chord_plan.as_ref()
716                        .and_then(|plan| harmony_at(plan, mi, right_beat))
717                        .and_then(chord_tones)
718                        .is_some_and(|tones| tones.contains(&b.to_midi().rem_euclid(12)));
719                    let mut ranked = ranked_candidates(CandidateEvaluation {
720                        base, left: a, right: b, beat, source: addr(i), options: &options,
721                        harmony, resolves,
722                        phrase_end: context.phrase_end_measures.contains(&mi) && i + 1 == voice.len() - 1,
723                    });
724                    let selected = if let Some(choice) = context.selections.iter().find(|choice| choice.source_anchor == addr(i)) {
725                        ranked.iter().position(|item| item.pitch == choice.pitch && item.score >= 0
726                            && item.constraint_violations.is_empty())
727                    } else {
728                        ranked.iter().position(|item| item.score >= 0
729                            && item.constraint_violations.is_empty())
730                    };
731                    let pitch = selected.map(|index| {
732                        ranked[index].selected = true;
733                        ranked[index].pitch.clone()
734                    });
735                    if pitch.is_none() {
736                        result.diagnostics.push(diagnostic("no-safe-candidate", Some(mi),
737                            &format!("part {} staff {} measure {} voice {} note {}: no allowed pitch satisfies the musical context", options.part, options.staff, mi, options.voice, i)));
738                    }
739                    candidates.extend(ranked);
740                    pitch
741                })
742            } else {
743                None
744            };
745            if attempted && (split.is_none() || selected_pitch.is_none()) {
746                result.diagnostics.push(diagnostic(
747                    "ineligible-candidate",
748                    Some(mi),
749                    &format!(
750                        "part {} staff {} measure {} voice {} note {}: interval, pitch limits, references or written duration cannot be edited safely",
751                        options.part, options.staff, mi, options.voice, i
752                    ),
753                ));
754            }
755            if let (Some((short, added)), Some(pitch)) = (split, selected_pitch) {
756                let id = generated_id(score, note, mi, i, options.kind, options.source);
757                if !used.insert(id.clone()) {
758                    result.diagnostics.push(diagnostic(
759                        "id-conflict",
760                        Some(mi),
761                        "generated note identity conflicts with an existing note",
762                    ));
763                } else {
764                    anchor.duration = short;
765                    anchor.dot_count = 1;
766                    let mut inserted = Note::new(pitch.clone(), added);
767                    inserted.id = id;
768                    result.insertions.push(MelodicInsertion {
769                        source_anchor: addr(i),
770                        output_anchor: addr(output_index),
771                        inserted: addr(output_index + 1),
772                        original_duration: note.duration.clone(),
773                        original_dot_count: note.dot_count,
774                        pitch,
775                    });
776                    next_voice.push(anchor);
777                    next_voice.push(inserted);
778                    changed = true;
779                    continue;
780                }
781            }
782            next_voice.push(anchor);
783        }
784        if !changed && !incompatible {
785            result.diagnostics.push(diagnostic(
786                "no-candidate",
787                Some(mi),
788                "no eligible insertion at the selected density and musical constraints",
789            ));
790        }
791        output.parts[options.part].staves[options.staff].measures[mi].voices[options.voice] =
792            next_voice;
793    }
794    remap_references(&mut output, &result.source_mapping);
795    if !validate(&output).is_valid() {
796        result.diagnostics.push(diagnostic(
797            "invalid-output",
798            None,
799            "generated score failed validation",
800        ));
801        result.source_mapping.clear();
802        result.insertions.clear();
803        done!();
804    }
805    result.score = Some(output);
806    MelodicInsertionContextResult {
807        outcome: result.outcome(),
808        insertion: result,
809        candidates,
810    }
811}