Skip to main content

acorde_core/model/
arrange.rs

1//! Deterministic, pitch-based arrangement of a multi-part score onto a
2//! 2-staff accordion part (treble = right hand / melody, bass = left hand /
3//! everything else).
4//!
5//! v1 heuristic: parts are ranked by mean MIDI pitch to pick the melody;
6//! everything else is merged onto the bass staff by onset-time bucketing,
7//! keeping only the shortest contributing duration at each onset. Dropped
8//! independent countermelodies under a dense chord are the known ceiling —
9//! a proper voice-leading reduction is real music-engraving work, not a v1
10//! target. A single-part score (already a 2-staff piano-style reduction, or
11//! a lone melodic line) skips the multi-part ranking entirely.
12
13use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15
16use super::duration::Duration;
17use super::gm::instrument_range;
18use super::notation::{Barline, ChordSymbol, Clef, TupletBoundary, TupletInfo};
19use super::pitch::Pitch;
20use super::score::{Measure, Note, NoteAddr, Part, Score, Staff};
21use crate::Error;
22
23const ACCORDION_PROGRAM: u8 = 21;
24// Conservative default for the first guided chromatic-accordion preset. A later
25// instrument-specific contract can narrow or widen either hand explicitly.
26const GUIDED_RIGHT_HAND_RANGE: (i16, i16) = (48, 96);
27const GUIDED_LEFT_HAND_RANGE: (i16, i16) = (36, 84);
28/// Onset/duration quantization grid — 64th-note resolution, the finest
29/// [`Duration`] unit the model supports.
30const GRID: f64 = 64.0;
31/// Middle-C split point used only when a single part must be divided
32/// between the two staves (no second candidate part to use as the bass).
33const SPLIT_MIDI: u8 = 60;
34
35#[derive(Debug, Clone, Serialize)]
36pub struct PartCandidate {
37    pub part_index: usize,
38    pub name: String,
39    pub mean_pitch: f64,
40}
41
42#[derive(Debug, Clone, Serialize)]
43pub struct AccordionAnalysis {
44    pub candidates: Vec<PartCandidate>,
45    /// True when the top two candidates' mean pitch is within 3 semitones —
46    /// the automatic ranking is a coin flip, caller should offer a picker.
47    pub ambiguous: bool,
48}
49
50#[derive(Debug, Clone, Serialize)]
51pub struct ArrangeResult {
52    pub score: Score,
53    pub notes: Vec<String>,
54}
55
56/// First-stage, explicitly harmonized accompaniment. No chord is inferred from the melody.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "kebab-case")]
59pub enum AccordionPattern {
60    HeldBassChord,
61    BassChordPulse,
62    AlternatingBassChord,
63    Arpeggio,
64    RisingArpeggio,
65    AlbertiBass,
66    OffbeatChords,
67    SwingAccompaniment,
68    WalkingBass,
69    WaltzBassChord,
70    WaltzAlternatingBassChord,
71}
72
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74pub struct PositionedAccordionChord {
75    pub measure_index: usize,
76    /// Quarter-note beats from the beginning of the authored measure, on a 1/64-beat grid.
77    pub offset_beats: f64,
78    pub chord: ChordSymbol,
79}
80
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
82pub struct GuidedAccordionOptions {
83    pub pattern: AccordionPattern,
84    pub chords: Vec<PositionedAccordionChord>,
85}
86
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88pub struct GuidedArrangeDiagnostic {
89    pub code: String,
90    pub message: String,
91    pub measure_index: Option<usize>,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct GuidedAccordionResult {
96    /// None on any error. A caller never receives a partially generated score.
97    pub score: Option<Score>,
98    pub resolved_options: GuidedAccordionOptions,
99    pub diagnostics: Vec<GuidedArrangeDiagnostic>,
100    pub source_notes: Vec<NoteAddr>,
101    pub generated_notes: Vec<NoteAddr>,
102    pub contract_version: u16,
103}
104
105fn guided_error(
106    options: GuidedAccordionOptions,
107    code: &str,
108    message: impl Into<String>,
109    measure_index: Option<usize>,
110) -> GuidedAccordionResult {
111    GuidedAccordionResult {
112        score: None,
113        resolved_options: options,
114        diagnostics: vec![GuidedArrangeDiagnostic {
115            code: code.to_string(),
116            message: message.into(),
117            measure_index,
118        }],
119        source_notes: Vec::new(),
120        generated_notes: Vec::new(),
121        contract_version: 1,
122    }
123}
124
125fn accordion_pitch_class(name: &str) -> Option<u8> {
126    let mut characters = name.chars();
127    let base: i16 = match characters.next()? {
128        'C' => 0,
129        'D' => 2,
130        'E' => 4,
131        'F' => 5,
132        'G' => 7,
133        'A' => 9,
134        'B' => 11,
135        _ => return None,
136    };
137    let alter = match characters.next() {
138        None => 0,
139        Some('#') => 1,
140        Some('b') => -1,
141        _ => return None,
142    };
143    if characters.next().is_some() {
144        return None;
145    }
146    Some((base + alter).rem_euclid(12) as u8)
147}
148
149#[derive(Clone)]
150struct RealizedAccordionChord {
151    symbol: ChordSymbol,
152    bass_midi: u8,
153    chord_midi: Vec<u8>,
154}
155
156fn realize_accordion_chord(symbol: &ChordSymbol) -> Option<RealizedAccordionChord> {
157    if !symbol.degrees.is_empty() {
158        return None;
159    }
160    let root = accordion_pitch_class(&symbol.root)?;
161    let bass = symbol
162        .bass
163        .as_deref()
164        .map(accordion_pitch_class)
165        .unwrap_or(Some(root))?;
166    let intervals: &[u8] = match symbol.kind.as_str() {
167        "major" => &[0, 4, 7],
168        "minor" => &[0, 3, 7],
169        "diminished" => &[0, 3, 6],
170        "augmented" => &[0, 4, 8],
171        "major-seventh" => &[0, 4, 7, 11],
172        "minor-seventh" => &[0, 3, 7, 10],
173        "dominant" => &[0, 4, 7, 10],
174        _ => return None,
175    };
176    Some(RealizedAccordionChord {
177        symbol: symbol.clone(),
178        bass_midi: 36 + bass,
179        chord_midi: intervals
180            .iter()
181            .map(|interval| 60 + root + interval)
182            .collect(),
183    })
184}
185
186pub(crate) fn preset_chord_supported(symbol: &ChordSymbol, bass_mode: AccordionBassMode) -> bool {
187    realize_accordion_chord(symbol).is_some_and(|realized| {
188        bass_mode == AccordionBassMode::FreeBass || realized.chord_midi.len() <= 3
189    })
190}
191
192fn guided_note(pitches: &[u8], duration: Duration) -> Note {
193    let mut note = Note::new(Pitch::from_midi(pitches[0], false), duration);
194    note.pitches = pitches
195        .iter()
196        .map(|midi| Pitch::from_midi(*midi, false))
197        .collect();
198    note
199}
200
201fn guided_region(
202    pattern: AccordionPattern,
203    chord: &RealizedAccordionChord,
204    start_tick: u32,
205    ticks: u32,
206    next_bass_midi: Option<u8>,
207    pickup_phase_tick: u32,
208    waltz_fifth_bass: bool,
209) -> Option<Vec<Note>> {
210    let mut notes = Vec::new();
211    match pattern {
212        AccordionPattern::HeldBassChord => {
213            let mut pitches = vec![chord.bass_midi];
214            pitches.extend(&chord.chord_midi);
215            let mut remaining = ticks;
216            while remaining > 0 {
217                let duration = Duration::whole_filling_beats(f64::from(remaining) / GRID);
218                let filled = (duration.beats(0) * GRID).round() as u32;
219                if filled == 0 || filled > remaining {
220                    return None;
221                }
222                let mut note = guided_note(&pitches, duration);
223                note.tie_end = !notes.is_empty();
224                note.tie_start = remaining > filled;
225                notes.push(note);
226                remaining -= filled;
227            }
228        }
229        AccordionPattern::BassChordPulse | AccordionPattern::AlternatingBassChord => {
230            if pickup_phase_tick > 0 {
231                let phase_start = pickup_phase_tick.checked_add(start_tick)?;
232                if !phase_start.is_multiple_of(32) || !ticks.is_multiple_of(32) {
233                    return None;
234                }
235                let mut phase = phase_start;
236                let phase_end = phase.checked_add(ticks)?;
237                while phase < phase_end {
238                    let run = (phase_end - phase).min(64 - phase % 64);
239                    let duration = match run {
240                        32 => Duration::Eighth,
241                        64 => Duration::Quarter,
242                        _ => return None,
243                    };
244                    let pitches: Vec<u8> = match (phase / 64) % 4 {
245                        0 => vec![chord.bass_midi],
246                        2 if pattern == AccordionPattern::AlternatingBassChord => {
247                            vec![chord.bass_midi + 7]
248                        }
249                        2 => vec![chord.bass_midi],
250                        _ => chord.chord_midi.to_vec(),
251                    };
252                    notes.push(guided_note(&pitches, duration));
253                    phase += run;
254                }
255            } else {
256                if !ticks.is_multiple_of(64) {
257                    return None;
258                }
259                for pulse in 0..ticks / 64 {
260                    let pitches: Vec<u8> = match (start_tick / 64 + pulse) % 4 {
261                        0 => vec![chord.bass_midi],
262                        2 if pattern == AccordionPattern::AlternatingBassChord => {
263                            vec![chord.bass_midi + 7]
264                        }
265                        2 => vec![chord.bass_midi],
266                        _ => chord.chord_midi.to_vec(),
267                    };
268                    notes.push(guided_note(&pitches, Duration::Quarter));
269                }
270            }
271        }
272        AccordionPattern::WaltzBassChord | AccordionPattern::WaltzAlternatingBassChord => {
273            let phase_start = pickup_phase_tick.checked_add(start_tick)?;
274            if !phase_start.is_multiple_of(32) || !ticks.is_multiple_of(32) {
275                return None;
276            }
277            let mut phase = phase_start;
278            let phase_end = phase.checked_add(ticks)?;
279            while phase < phase_end {
280                let run = (phase_end - phase).min(64 - phase % 64);
281                let duration = match run {
282                    32 => Duration::Eighth,
283                    64 => Duration::Quarter,
284                    _ => return None,
285                };
286                let pitches = if (phase / 64).is_multiple_of(3) {
287                    let fifth =
288                        pattern == AccordionPattern::WaltzAlternatingBassChord && waltz_fifth_bass;
289                    vec![chord.bass_midi + u8::from(fifth) * 7]
290                } else {
291                    chord.chord_midi.to_vec()
292                };
293                notes.push(guided_note(&pitches, duration));
294                phase += run;
295            }
296        }
297        AccordionPattern::Arpeggio => {
298            if !ticks.is_multiple_of(32) {
299                return None;
300            }
301            for pulse in 0..ticks / 32 {
302                let midi = match (start_tick / 32 + pulse) % 4 {
303                    0 => chord.bass_midi + 12,
304                    1 | 3 => chord.chord_midi[1],
305                    _ => chord.chord_midi[2],
306                };
307                notes.push(guided_note(&[midi], Duration::Eighth));
308            }
309        }
310        AccordionPattern::RisingArpeggio | AccordionPattern::AlbertiBass => {
311            if !ticks.is_multiple_of(32) {
312                return None;
313            }
314            for pulse in 0..ticks / 32 {
315                let phase = (start_tick / 32 + pulse) % 4;
316                let midi = if pattern == AccordionPattern::RisingArpeggio {
317                    match phase {
318                        0 => chord.bass_midi + 12,
319                        1 => chord.chord_midi[1],
320                        2 => chord.chord_midi[2],
321                        _ => chord
322                            .chord_midi
323                            .get(3)
324                            .copied()
325                            .unwrap_or(chord.chord_midi[0] + 12),
326                    }
327                } else {
328                    match phase {
329                        0 => chord.bass_midi + 12,
330                        1 | 3 => chord.chord_midi[2],
331                        _ => chord.chord_midi[1],
332                    }
333                };
334                notes.push(guided_note(&[midi], Duration::Eighth));
335            }
336        }
337        AccordionPattern::OffbeatChords => {
338            if pickup_phase_tick > 0 {
339                let phase_start = pickup_phase_tick.checked_add(start_tick)?;
340                if !phase_start.is_multiple_of(32) || !ticks.is_multiple_of(32) {
341                    return None;
342                }
343                for step in 0..ticks / 32 {
344                    if (phase_start / 32 + step).is_multiple_of(2) {
345                        notes.push(Note::rest(Duration::Eighth));
346                    } else {
347                        notes.push(guided_note(&chord.chord_midi, Duration::Eighth));
348                    }
349                }
350            } else {
351                if !ticks.is_multiple_of(64) {
352                    return None;
353                }
354                for _ in 0..ticks / 64 {
355                    notes.push(Note::rest(Duration::Eighth));
356                    notes.push(guided_note(&chord.chord_midi, Duration::Eighth));
357                }
358            }
359        }
360        AccordionPattern::SwingAccompaniment => {
361            if !ticks.is_multiple_of(64) {
362                return None;
363            }
364            let triplet = Some(TupletInfo {
365                actual_notes: 3,
366                normal_notes: 2,
367            });
368            for _ in 0..ticks / 64 {
369                let mut bass = guided_note(&[chord.bass_midi], Duration::Quarter);
370                bass.tuplet = triplet.clone();
371                bass.tuplet_boundary = Some(TupletBoundary::Start);
372                let mut chord_note = guided_note(&chord.chord_midi, Duration::Eighth);
373                chord_note.tuplet = triplet.clone();
374                chord_note.tuplet_boundary = Some(TupletBoundary::Stop);
375                notes.push(bass);
376                notes.push(chord_note);
377            }
378        }
379        AccordionPattern::WalkingBass => {
380            if !ticks.is_multiple_of(64) {
381                return None;
382            }
383            let pulses = ticks / 64;
384            for pulse in 0..pulses {
385                let midi = if pulse + 1 == pulses {
386                    next_bass_midi.map_or(chord.bass_midi, |next| {
387                        if chord.bass_midi <= next {
388                            next.saturating_sub(1)
389                        } else {
390                            next.saturating_add(1)
391                        }
392                    })
393                } else {
394                    match (start_tick / 64 + pulse) % 3 {
395                        0 => chord.bass_midi,
396                        1 => chord.bass_midi + (chord.chord_midi[1] - chord.chord_midi[0]),
397                        _ => chord.bass_midi + 7,
398                    }
399                };
400                notes.push(guided_note(&[midi], Duration::Quarter));
401            }
402        }
403    }
404    if let Some(first) = notes.iter_mut().find(|note| !note.is_rest) {
405        first.chord_symbol = Some(chord.symbol.clone());
406    }
407    Some(notes)
408}
409
410/// Generate a two-staff accordion score from a monophonic melody and positioned, explicit chords.
411/// Source melody notes, bar lengths, ties, repeats and endings remain authored; invalid requests
412/// return only structured diagnostics. Generation is deterministic in musical content and timing.
413pub fn arrange_with_explicit_chords(
414    score: &Score,
415    options: GuidedAccordionOptions,
416) -> GuidedAccordionResult {
417    if score.parts.len() != 1 || score.parts[0].staves.len() != 1 {
418        return guided_error(
419            options,
420            "unsupported-source",
421            "expected one melody part and one staff",
422            None,
423        );
424    }
425    let source_staff = &score.parts[0].staves[0];
426    if source_staff.measures.is_empty() || source_staff.measures.len() > 2048 {
427        return guided_error(
428            options,
429            "unsupported-source",
430            "measure count is empty or exceeds 2048",
431            None,
432        );
433    }
434    if options.chords.is_empty() || options.chords.len() > 8192 {
435        return guided_error(
436            options,
437            "missing-chords",
438            "provide 1 to 8192 positioned chords",
439            None,
440        );
441    }
442    for (index, measure) in source_staff.measures.iter().enumerate() {
443        if measure.voices.iter().skip(1).any(|voice| !voice.is_empty())
444            || measure.voices[0]
445                .iter()
446                .any(|note| !note.is_rest && note.pitches.len() != 1)
447            || measure.multi_rest_count.is_some()
448        {
449            return guided_error(
450                options,
451                "unsupported-source",
452                "expected a monophonic melody without measure repeats",
453                Some(index),
454            );
455        }
456    }
457    let validation = super::validate::validate(score);
458    if !validation.is_valid() {
459        return guided_error(
460            options,
461            "invalid-source",
462            format!("source validation failed: {:?}", validation.errors[0]),
463            None,
464        );
465    }
466    let mut regions: Vec<Vec<(u32, RealizedAccordionChord)>> =
467        vec![Vec::new(); source_staff.measures.len()];
468    let mut length_ticks = Vec::with_capacity(source_staff.measures.len());
469    for index in 0..source_staff.measures.len() {
470        let beats = score.playback_measure_beats(index);
471        let ticks = (beats * GRID).round();
472        if !ticks.is_finite() || ticks <= 0.0 || (ticks / GRID - beats).abs() > 1e-9 {
473            return guided_error(
474                options,
475                "unsupported-rhythm",
476                "measure length must be on a 1/64-beat grid",
477                Some(index),
478            );
479        }
480        length_ticks.push(ticks as u32);
481    }
482    for placed in &options.chords {
483        let index = placed.measure_index;
484        let Some(&length) = length_ticks.get(index) else {
485            return guided_error(
486                options,
487                "chord-position",
488                "chord measure is outside the score",
489                Some(index),
490            );
491        };
492        let offset = (placed.offset_beats * GRID).round();
493        if !offset.is_finite()
494            || offset < 0.0
495            || offset >= f64::from(length)
496            || (offset / GRID - placed.offset_beats).abs() > 1e-9
497        {
498            return guided_error(
499                options,
500                "chord-position",
501                "chord offset must be inside the measure on a 1/64-beat grid",
502                Some(index),
503            );
504        }
505        let Some(chord) = realize_accordion_chord(&placed.chord) else {
506            return guided_error(
507                options.clone(),
508                "unsupported-chord",
509                format!("cannot realize chord {}", placed.chord.display_text()),
510                Some(index),
511            );
512        };
513        regions[index].push((offset as u32, chord));
514    }
515    let mut left = Staff::new(Clef::Bass);
516    let mut generated_notes = Vec::new();
517    let mut current_chord: Option<RealizedAccordionChord> = None;
518    let is_waltz = matches!(
519        options.pattern,
520        AccordionPattern::WaltzBassChord | AccordionPattern::WaltzAlternatingBassChord
521    );
522    let has_waltz_pickup = is_waltz && length_ticks.first().is_some_and(|length| *length < 3 * 64);
523    let mut running_meter = score.settings.time_signature.clone();
524    for (index, source_measure) in source_staff.measures.iter().enumerate() {
525        if let Some(meter) = source_measure.time_sig.as_ref() {
526            running_meter = meter.clone();
527        }
528        if is_waltz && (running_meter.numerator != 3 || running_meter.denominator != 4) {
529            return guided_error(
530                options,
531                "unsupported-meter",
532                "waltz accompaniment requires 3/4",
533                Some(index),
534            );
535        }
536        if is_waltz
537            && (length_ticks[index] > 3 * 64
538                || !length_ticks[index].is_multiple_of(32)
539                || (index > 0 && index + 1 < length_ticks.len() && length_ticks[index] < 3 * 64))
540        {
541            return guided_error(
542                options,
543                "unsupported-rhythm",
544                "waltz accompaniment supports a short first pickup and short final bar on half-beat boundaries",
545                Some(index),
546            );
547        }
548        // A fractional first pickup occupies the end of its nominal meter. Whole-beat
549        // pickups retain the established measure-start phase for preset compatibility.
550        let pickup_phase_tick = if is_waltz && index == 0 && has_waltz_pickup {
551            3 * 64 - length_ticks[index]
552        } else if index == 0 && !length_ticks[index].is_multiple_of(64) {
553            let meter = source_measure
554                .time_sig
555                .as_ref()
556                .unwrap_or(&score.settings.time_signature);
557            let meter_ticks = (f64::from(meter.numerator) * 4.0 / f64::from(meter.denominator)
558                * GRID)
559                .round() as u32;
560            meter_ticks.saturating_sub(length_ticks[index])
561        } else {
562            0
563        };
564        // The pickup fills the end of an implied bar but does not advance the
565        // root/fifth alternation. The first complete bar always starts on root bass.
566        let waltz_fifth_bass = index.saturating_sub(usize::from(has_waltz_pickup)) % 2 == 1;
567        regions[index].sort_by_key(|(offset, _)| *offset);
568        if regions[index]
569            .windows(2)
570            .any(|window| window[0].0 == window[1].0)
571        {
572            return guided_error(
573                options,
574                "chord-position",
575                "two chords occupy the same position",
576                Some(index),
577            );
578        }
579        if regions[index]
580            .first()
581            .is_none_or(|(offset, _)| *offset != 0)
582        {
583            let Some(chord) = current_chord.clone() else {
584                return guided_error(
585                    options,
586                    "missing-chords",
587                    "no explicit chord covers the start of this measure",
588                    Some(index),
589                );
590            };
591            regions[index].insert(0, (0, chord));
592        }
593        // A repeated spelling of the same harmony is an annotation, not a
594        // request to restart the accompaniment or its walking-bass approach.
595        regions[index].dedup_by(|later, earlier| later.1.symbol == earlier.1.symbol);
596        let mut generated = Vec::new();
597        for position in 0..regions[index].len() {
598            let (start, chord) = &regions[index][position];
599            let end = regions[index]
600                .get(position + 1)
601                .map_or(length_ticks[index], |(offset, _)| *offset);
602            let next_bass = regions[index]
603                .get(position + 1)
604                .map(|(_, next)| next.bass_midi)
605                .or_else(|| {
606                    regions.iter().skip(index + 1).find_map(|bar| {
607                        bar.iter()
608                            .min_by_key(|(offset, _)| *offset)
609                            .map(|(_, next)| next.bass_midi)
610                    })
611                });
612            let Some(mut notes) = guided_region(
613                options.pattern,
614                chord,
615                *start,
616                end - start,
617                next_bass,
618                pickup_phase_tick,
619                waltz_fifth_bass,
620            ) else {
621                return guided_error(
622                    options,
623                    "unsupported-rhythm",
624                    "pattern cannot fill the chord region exactly",
625                    Some(index),
626                );
627            };
628            for (offset, note) in notes.iter().enumerate() {
629                if !note.is_rest {
630                    generated_notes.push(NoteAddr {
631                        part: 0,
632                        staff: 1,
633                        measure: index,
634                        voice: 0,
635                        note: generated.len() + offset,
636                    });
637                }
638            }
639            generated.append(&mut notes);
640            current_chord = Some(chord.clone());
641        }
642        let mut measure = Measure::empty(
643            source_measure
644                .time_sig
645                .as_ref()
646                .map_or(score.settings.time_signature.numerator, |ts| ts.numerator),
647            source_measure
648                .time_sig
649                .as_ref()
650                .map_or(score.settings.time_signature.denominator, |ts| {
651                    ts.denominator
652                }),
653        );
654        measure.number = source_measure.number;
655        measure.time_sig = source_measure.time_sig.clone();
656        measure.key_sig = source_measure.key_sig.clone();
657        measure.tempo = source_measure.tempo;
658        measure.tempo_ramp_to = source_measure.tempo_ramp_to;
659        measure.barline_left = source_measure.barline_left.clone();
660        measure.barline_right = source_measure.barline_right.clone();
661        measure.volta = source_measure.volta.clone();
662        measure.system_break = source_measure.system_break;
663        measure.page_break = source_measure.page_break;
664        measure.section_break = source_measure.section_break;
665        measure.actual_length = source_measure.actual_length;
666        measure.voices[0] = generated;
667        left.measures.push(measure);
668    }
669    let mut output = score.clone();
670    output.id = uuid::Uuid::new_v4().to_string();
671    let mut part = Part::new("Accordion", "Acc.");
672    part.midi_program = ACCORDION_PROGRAM;
673    part.staves = vec![source_staff.clone(), left];
674    output.parts = vec![part];
675    output.part_groups.clear();
676    output.views.clear();
677    for (staff_index, staff) in output.parts[0].staves.iter().enumerate() {
678        let (min_midi, max_midi) = if staff_index == 0 {
679            GUIDED_RIGHT_HAND_RANGE
680        } else {
681            GUIDED_LEFT_HAND_RANGE
682        };
683        for (measure_index, measure) in staff.measures.iter().enumerate() {
684            for note in &measure.voices[0] {
685                if note
686                    .pitches
687                    .iter()
688                    .any(|pitch| pitch.to_midi() < min_midi || pitch.to_midi() > max_midi)
689                {
690                    return guided_error(
691                        options,
692                        "out-of-range",
693                        format!("hand {staff_index} requires MIDI range {min_midi}..={max_midi}"),
694                        Some(measure_index),
695                    );
696                }
697            }
698        }
699    }
700    let output_validation = super::validate::validate(&output);
701    if !output_validation.is_valid() {
702        return guided_error(
703            options,
704            "invalid-output",
705            format!(
706                "generated score validation failed: {:?}",
707                output_validation.errors[0]
708            ),
709            None,
710        );
711    }
712    let source_notes = source_staff
713        .measures
714        .iter()
715        .enumerate()
716        .flat_map(|(measure, bar)| {
717            bar.voices[0]
718                .iter()
719                .enumerate()
720                .filter(|(_, note)| !note.is_rest)
721                .map(move |(note, _)| NoteAddr {
722                    part: 0,
723                    staff: 0,
724                    measure,
725                    voice: 0,
726                    note,
727                })
728        })
729        .collect();
730    GuidedAccordionResult {
731        score: Some(output),
732        resolved_options: options,
733        diagnostics: Vec::new(),
734        source_notes,
735        generated_notes,
736        contract_version: 1,
737    }
738}
739
740#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
741#[serde(rename_all = "kebab-case")]
742pub enum AccordionInversion {
743    Root,
744    First,
745    Second,
746    Third,
747    Nearby,
748}
749
750#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
751#[serde(rename_all = "kebab-case")]
752pub enum AccordionMelodicVoice {
753    Unchanged,
754    ThirdBelow,
755    SixthBelow,
756    OctaveBelow,
757}
758
759#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
760#[serde(rename_all = "kebab-case")]
761pub enum AccordionSwingPolicy {
762    Straight,
763    NotatedAccompanimentTwoToOne,
764}
765
766#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
767#[serde(rename_all = "kebab-case")]
768pub enum AccordionBassMode {
769    StandardBass,
770    FreeBass,
771}
772
773#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
774pub struct LayeredAccordionOptions {
775    pub chords: Vec<PositionedAccordionChord>,
776    /// An explicitly supplied alternative progression. No melody harmonization is inferred.
777    pub alternative_chords: Option<Vec<PositionedAccordionChord>>,
778    pub inversion: Option<AccordionInversion>,
779    pub accompaniment: Option<AccordionPattern>,
780    pub melodic_voice: AccordionMelodicVoice,
781    pub swing: AccordionSwingPolicy,
782    pub bass_mode: AccordionBassMode,
783}
784
785#[derive(Debug, Clone, Serialize, Deserialize)]
786pub struct LayeredAccordionResult {
787    pub score: Option<Score>,
788    pub resolved_options: LayeredAccordionOptions,
789    pub resolved_chords: Vec<PositionedAccordionChord>,
790    pub diagnostics: Vec<GuidedArrangeDiagnostic>,
791    pub source_notes: Vec<NoteAddr>,
792    pub generated_notes: Vec<NoteAddr>,
793    /// A host should leave PlaybackOptions.swing unset for the notated 2:1 policy.
794    pub notated_swing: bool,
795    pub contract_version: u16,
796}
797
798/// One explicitly selected sustained bass pitch. Chords are only needed when
799/// an optional melodic layer uses harmony; they never change the drone pitch.
800#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
801pub struct AccordionDroneOptions {
802    pub pitch_midi: u8,
803    pub bass_mode: AccordionBassMode,
804    /// Optional harmony/added-voice layers. Accompaniment must be `None`.
805    #[serde(default)]
806    pub layers: Option<LayeredAccordionOptions>,
807}
808
809#[derive(Debug, Clone, Serialize, Deserialize)]
810pub struct AccordionDroneResult {
811    pub score: Option<Score>,
812    pub resolved_options: AccordionDroneOptions,
813    pub diagnostics: Vec<GuidedArrangeDiagnostic>,
814    pub source_notes: Vec<NoteAddr>,
815    pub generated_notes: Vec<NoteAddr>,
816    pub contract_version: u16,
817}
818
819fn drone_error(
820    options: AccordionDroneOptions,
821    code: &str,
822    message: impl Into<String>,
823    measure_index: Option<usize>,
824) -> AccordionDroneResult {
825    AccordionDroneResult {
826        score: None,
827        resolved_options: options,
828        diagnostics: vec![GuidedArrangeDiagnostic {
829            code: code.to_string(),
830            message: message.into(),
831            measure_index,
832        }],
833        source_notes: Vec::new(),
834        generated_notes: Vec::new(),
835        contract_version: 1,
836    }
837}
838
839/// Sustain one bass pitch through each authored bar, including melody rests.
840/// The drone reattacks at every bar boundary; split notes inside a bar are tied.
841/// Repeat playback follows the source barlines. No chord is inferred or required.
842pub fn arrange_with_drone(score: &Score, options: AccordionDroneOptions) -> AccordionDroneResult {
843    let range = match options.bass_mode {
844        AccordionBassMode::StandardBass => (36, 47),
845        AccordionBassMode::FreeBass => GUIDED_LEFT_HAND_RANGE,
846    };
847    if !(range.0..=range.1).contains(&i16::from(options.pitch_midi)) {
848        return drone_error(
849            options,
850            "out-of-range",
851            format!(
852                "drone pitch must be MIDI {}..={} for the selected bass mode",
853                range.0, range.1
854            ),
855            None,
856        );
857    }
858    if score.parts.len() != 1 || score.parts[0].staves.len() != 1 {
859        return drone_error(
860            options,
861            "unsupported-source",
862            "expected one melody part and one staff",
863            None,
864        );
865    }
866    let source_staff = &score.parts[0].staves[0];
867    if source_staff.measures.is_empty() || source_staff.measures.len() > 2048 {
868        return drone_error(
869            options,
870            "unsupported-source",
871            "measure count is empty or exceeds 2048",
872            None,
873        );
874    }
875    for (index, measure) in source_staff.measures.iter().enumerate() {
876        if measure.voices.iter().skip(1).any(|voice| !voice.is_empty())
877            || measure.voices[0]
878                .iter()
879                .any(|note| !note.is_rest && note.pitches.len() != 1)
880            || measure.multi_rest_count.is_some()
881        {
882            return drone_error(
883                options,
884                "unsupported-source",
885                "expected a monophonic melody without measure repeats",
886                Some(index),
887            );
888        }
889        if measure.voices[0]
890            .iter()
891            .flat_map(|note| &note.pitches)
892            .any(|pitch| {
893                !(GUIDED_RIGHT_HAND_RANGE.0..=GUIDED_RIGHT_HAND_RANGE.1).contains(&pitch.to_midi())
894            })
895        {
896            return drone_error(
897                options,
898                "out-of-range",
899                "melody exceeds the right-hand MIDI range 48..=96",
900                Some(index),
901            );
902        }
903    }
904    let validation = super::validate::validate(score);
905    if !validation.is_valid() {
906        return drone_error(
907            options,
908            "invalid-source",
909            format!("source validation failed: {:?}", validation.errors[0]),
910            None,
911        );
912    }
913    let mut generated_notes = Vec::new();
914    let (mut output, source_notes) = if let Some(layers) = &options.layers {
915        if layers.accompaniment.is_some()
916            || layers.swing != AccordionSwingPolicy::Straight
917            || layers.bass_mode != options.bass_mode
918        {
919            return drone_error(
920                options,
921                "invalid-layers",
922                "drone layers require no other accompaniment, straight timing and the same bass mode",
923                None,
924            );
925        }
926        let layered = arrange_with_layers(score, layers.clone());
927        let Some(output) = layered.score else {
928            return AccordionDroneResult {
929                score: None,
930                resolved_options: options,
931                diagnostics: layered.diagnostics,
932                source_notes: Vec::new(),
933                generated_notes: Vec::new(),
934                contract_version: 1,
935            };
936        };
937        generated_notes = layered.generated_notes;
938        (output, layered.source_notes)
939    } else {
940        let mut output = score.clone();
941        output.id = uuid::Uuid::new_v4().to_string();
942        let mut part = Part::new("Accordion", "Acc.");
943        part.midi_program = ACCORDION_PROGRAM;
944        part.staves = vec![source_staff.clone(), Staff::new(Clef::Bass)];
945        output.parts = vec![part];
946        output.part_groups.clear();
947        output.views.clear();
948        let source_notes = source_staff
949            .measures
950            .iter()
951            .enumerate()
952            .flat_map(|(measure, bar)| {
953                bar.voices[0]
954                    .iter()
955                    .enumerate()
956                    .filter(|(_, note)| !note.is_rest)
957                    .map(move |(note, _)| NoteAddr {
958                        part: 0,
959                        staff: 0,
960                        measure,
961                        voice: 0,
962                        note,
963                    })
964            })
965            .collect();
966        (output, source_notes)
967    };
968    let left = &mut output.parts[0].staves[1];
969    left.measures.clear();
970    for (index, source_measure) in source_staff.measures.iter().enumerate() {
971        let beats = score.playback_measure_beats(index);
972        let ticks = (beats * GRID).round();
973        if !ticks.is_finite() || ticks <= 0.0 || (ticks / GRID - beats).abs() > 1e-9 {
974            return drone_error(
975                options,
976                "unsupported-rhythm",
977                "measure length must be on a 1/64-beat grid",
978                Some(index),
979            );
980        }
981        let mut measure = Measure::empty(
982            source_measure
983                .time_sig
984                .as_ref()
985                .map_or(score.settings.time_signature.numerator, |ts| ts.numerator),
986            source_measure
987                .time_sig
988                .as_ref()
989                .map_or(score.settings.time_signature.denominator, |ts| {
990                    ts.denominator
991                }),
992        );
993        measure.number = source_measure.number;
994        measure.time_sig = source_measure.time_sig.clone();
995        measure.key_sig = source_measure.key_sig.clone();
996        measure.tempo = source_measure.tempo;
997        measure.tempo_ramp_to = source_measure.tempo_ramp_to;
998        measure.barline_left = source_measure.barline_left.clone();
999        measure.barline_right = source_measure.barline_right.clone();
1000        measure.volta = source_measure.volta.clone();
1001        measure.system_break = source_measure.system_break;
1002        measure.page_break = source_measure.page_break;
1003        measure.section_break = source_measure.section_break;
1004        measure.actual_length = source_measure.actual_length;
1005        measure.voices[0].clear();
1006        let mut remaining = ticks as u32;
1007        while remaining > 0 {
1008            let duration = Duration::whole_filling_beats(f64::from(remaining) / GRID);
1009            let filled = (duration.beats(0) * GRID).round() as u32;
1010            if filled == 0 || filled > remaining {
1011                return drone_error(
1012                    options,
1013                    "unsupported-rhythm",
1014                    "drone cannot fill the measure exactly",
1015                    Some(index),
1016                );
1017            }
1018            let mut note = guided_note(&[options.pitch_midi], duration);
1019            note.tie_end = !measure.voices[0].is_empty();
1020            note.tie_start = remaining > filled;
1021            generated_notes.push(NoteAddr {
1022                part: 0,
1023                staff: 1,
1024                measure: index,
1025                voice: 0,
1026                note: measure.voices[0].len(),
1027            });
1028            measure.voices[0].push(note);
1029            remaining -= filled;
1030        }
1031        left.measures.push(measure);
1032    }
1033    let validation = super::validate::validate(&output);
1034    if !validation.is_valid() {
1035        return drone_error(
1036            options,
1037            "invalid-output",
1038            format!(
1039                "generated score validation failed: {:?}",
1040                validation.errors[0]
1041            ),
1042            None,
1043        );
1044    }
1045    AccordionDroneResult {
1046        score: Some(output),
1047        resolved_options: options,
1048        diagnostics: Vec::new(),
1049        source_notes,
1050        generated_notes,
1051        contract_version: 1,
1052    }
1053}
1054
1055fn layered_error(
1056    options: LayeredAccordionOptions,
1057    code: &str,
1058    message: impl Into<String>,
1059    measure_index: Option<usize>,
1060) -> LayeredAccordionResult {
1061    LayeredAccordionResult {
1062        score: None,
1063        resolved_options: options,
1064        resolved_chords: Vec::new(),
1065        diagnostics: vec![GuidedArrangeDiagnostic {
1066            code: code.to_string(),
1067            message: message.into(),
1068            measure_index,
1069        }],
1070        source_notes: Vec::new(),
1071        generated_notes: Vec::new(),
1072        notated_swing: false,
1073        contract_version: 1,
1074    }
1075}
1076
1077fn chord_pitch_name(midi: u8) -> &'static str {
1078    [
1079        "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B",
1080    ][(midi % 12) as usize]
1081}
1082
1083fn resolve_layered_chords(
1084    chords: &mut [PositionedAccordionChord],
1085    inversion: Option<AccordionInversion>,
1086) -> Result<(), (usize, String)> {
1087    let mut previous_bass: Option<u8> = None;
1088    chords.sort_by(|a, b| {
1089        (a.measure_index, a.offset_beats)
1090            .partial_cmp(&(b.measure_index, b.offset_beats))
1091            .unwrap_or(std::cmp::Ordering::Equal)
1092    });
1093    for placed in chords {
1094        let Some(realized) = realize_accordion_chord(&placed.chord) else {
1095            return Err((
1096                placed.measure_index,
1097                format!("cannot realize chord {}", placed.chord.display_text()),
1098            ));
1099        };
1100        if let Some(policy) = inversion {
1101            let index = match policy {
1102                AccordionInversion::Root => 0,
1103                AccordionInversion::First => 1,
1104                AccordionInversion::Second => 2,
1105                AccordionInversion::Third => 3,
1106                AccordionInversion::Nearby => realized
1107                    .chord_midi
1108                    .iter()
1109                    .enumerate()
1110                    .min_by_key(|(_, midi)| {
1111                        previous_bass.map_or(0, |previous| {
1112                            (i16::from(**midi % 12 + 36) - i16::from(previous)).abs()
1113                        })
1114                    })
1115                    .map_or(0, |(index, _)| index),
1116            };
1117            let Some(&tone) = realized.chord_midi.get(index) else {
1118                return Err((
1119                    placed.measure_index,
1120                    "requested inversion has no chord tone".to_string(),
1121                ));
1122            };
1123            placed.chord.bass = Some(chord_pitch_name(tone).to_string());
1124            previous_bass = Some(36 + tone % 12);
1125        } else {
1126            previous_bass = Some(realized.bass_midi);
1127        }
1128    }
1129    Ok(())
1130}
1131
1132fn chord_at_note(
1133    chords: &[PositionedAccordionChord],
1134    measure_index: usize,
1135    offset_beats: f64,
1136) -> Option<&ChordSymbol> {
1137    chords
1138        .iter()
1139        .rfind(|placed| {
1140            placed.measure_index < measure_index
1141                || (placed.measure_index == measure_index
1142                    && placed.offset_beats <= offset_beats + 1e-9)
1143        })
1144        .map(|placed| &placed.chord)
1145}
1146
1147fn harmony_voice_pitch(
1148    source_midi: i16,
1149    chord: &ChordSymbol,
1150    voice: AccordionMelodicVoice,
1151) -> Option<i16> {
1152    if voice == AccordionMelodicVoice::OctaveBelow {
1153        return Some(source_midi - 12);
1154    }
1155    let realized = realize_accordion_chord(chord)?;
1156    let allowed: &[i16] = match voice {
1157        AccordionMelodicVoice::ThirdBelow => &[3, 4],
1158        AccordionMelodicVoice::SixthBelow => &[8, 9],
1159        _ => return None,
1160    };
1161    (GUIDED_RIGHT_HAND_RANGE.0..=GUIDED_RIGHT_HAND_RANGE.1)
1162        .rev()
1163        .filter(|candidate| {
1164            allowed.contains(&(source_midi - candidate))
1165                && realized
1166                    .chord_midi
1167                    .iter()
1168                    .any(|tone| i16::from(*tone % 12) == candidate.rem_euclid(12))
1169        })
1170        .max()
1171}
1172
1173/// Compose explicit harmony, accompaniment and an optional added melodic voice.
1174/// Each layer is opt-in; the original melody staff is copied unchanged. A fixed 2:1 swing
1175/// policy is encoded as quarter-plus-eighth triplets in the accompaniment only.
1176pub fn arrange_with_layers(
1177    score: &Score,
1178    options: LayeredAccordionOptions,
1179) -> LayeredAccordionResult {
1180    let mut resolved_chords = options
1181        .alternative_chords
1182        .clone()
1183        .unwrap_or_else(|| options.chords.clone());
1184    if resolved_chords.is_empty() {
1185        return layered_error(
1186            options,
1187            "missing-chords",
1188            "supply an explicit chord progression",
1189            None,
1190        );
1191    }
1192    if let Err((measure, message)) = resolve_layered_chords(&mut resolved_chords, options.inversion)
1193    {
1194        return layered_error(options, "unsupported-chord", message, Some(measure));
1195    }
1196    let notated_swing = options.swing == AccordionSwingPolicy::NotatedAccompanimentTwoToOne;
1197    if notated_swing && options.accompaniment.is_none() {
1198        return layered_error(
1199            options,
1200            "unsupported-swing",
1201            "notated swing needs an accompaniment layer",
1202            None,
1203        );
1204    }
1205    let pattern = if notated_swing {
1206        AccordionPattern::SwingAccompaniment
1207    } else {
1208        options
1209            .accompaniment
1210            .unwrap_or(AccordionPattern::HeldBassChord)
1211    };
1212    if options.bass_mode == AccordionBassMode::StandardBass
1213        && (resolved_chords.iter().any(|placed| {
1214            realize_accordion_chord(&placed.chord).is_some_and(|chord| chord.chord_midi.len() > 3)
1215        }) || matches!(
1216            pattern,
1217            AccordionPattern::Arpeggio
1218                | AccordionPattern::RisingArpeggio
1219                | AccordionPattern::AlbertiBass
1220                | AccordionPattern::WalkingBass
1221        ))
1222    {
1223        return layered_error(
1224            options,
1225            "unsupported-standard-bass",
1226            "the selected seventh harmony or melodic bass pattern requires free bass",
1227            None,
1228        );
1229    }
1230    let base = arrange_with_explicit_chords(
1231        score,
1232        GuidedAccordionOptions {
1233            pattern,
1234            chords: resolved_chords.clone(),
1235        },
1236    );
1237    let Some(mut output) = base.score else {
1238        return LayeredAccordionResult {
1239            score: None,
1240            resolved_options: options,
1241            resolved_chords,
1242            diagnostics: base.diagnostics,
1243            source_notes: Vec::new(),
1244            generated_notes: Vec::new(),
1245            notated_swing,
1246            contract_version: 1,
1247        };
1248    };
1249    let mut generated_notes = base.generated_notes;
1250    if options.accompaniment.is_none() {
1251        for measure in &mut output.parts[0].staves[1].measures {
1252            measure.voices[0].clear();
1253        }
1254        generated_notes.clear();
1255    }
1256    if options.melodic_voice != AccordionMelodicVoice::Unchanged {
1257        for (measure_index, measure) in output.parts[0].staves[0].measures.iter_mut().enumerate() {
1258            let mut offset = 0.0;
1259            let mut voice = Vec::new();
1260            for source_note in &measure.voices[0] {
1261                let mut added = if source_note.is_rest {
1262                    Note::rest(source_note.duration.clone())
1263                } else {
1264                    Note::new(source_note.pitches[0].clone(), source_note.duration.clone())
1265                };
1266                added.dot_count = source_note.dot_count;
1267                added.tuplet = source_note.tuplet.clone();
1268                added.tuplet_boundary = source_note.tuplet_boundary;
1269                added.beam = source_note.beam;
1270                added.articulations = source_note.articulations.clone();
1271                added.dynamic = source_note.dynamic;
1272                added.is_grace = source_note.is_grace;
1273                added.grace_slash = source_note.grace_slash;
1274                added.is_cue = source_note.is_cue;
1275                if options.melodic_voice == AccordionMelodicVoice::OctaveBelow {
1276                    added.tie_start = source_note.tie_start;
1277                    added.tie_end = source_note.tie_end;
1278                }
1279                if !source_note.is_rest {
1280                    let Some(chord) = chord_at_note(&resolved_chords, measure_index, offset) else {
1281                        return layered_error(
1282                            options,
1283                            "missing-chords",
1284                            "no chord covers a melody note",
1285                            Some(measure_index),
1286                        );
1287                    };
1288                    let source_midi = source_note.pitches[0].to_midi();
1289                    let Some(target) =
1290                        harmony_voice_pitch(source_midi, chord, options.melodic_voice)
1291                    else {
1292                        return layered_error(
1293                            options,
1294                            "incompatible-harmony",
1295                            "no chord tone fits the requested melodic interval",
1296                            Some(measure_index),
1297                        );
1298                    };
1299                    if !(GUIDED_RIGHT_HAND_RANGE.0..=GUIDED_RIGHT_HAND_RANGE.1).contains(&target) {
1300                        return layered_error(
1301                            options,
1302                            "out-of-range",
1303                            "added melodic voice exceeds the right-hand range",
1304                            Some(measure_index),
1305                        );
1306                    }
1307                    added.pitches[0] = Pitch::from_midi(target as u8, false);
1308                    generated_notes.push(NoteAddr {
1309                        part: 0,
1310                        staff: 0,
1311                        measure: measure_index,
1312                        voice: 1,
1313                        note: voice.len(),
1314                    });
1315                }
1316                offset += source_note.beats();
1317                voice.push(added);
1318            }
1319            measure.voices[1] = voice;
1320        }
1321    }
1322    let validation = super::validate::validate(&output);
1323    if !validation.is_valid() {
1324        return layered_error(
1325            options,
1326            "invalid-output",
1327            format!(
1328                "layered score validation failed: {:?}",
1329                validation.errors[0]
1330            ),
1331            None,
1332        );
1333    }
1334    LayeredAccordionResult {
1335        score: Some(output),
1336        resolved_options: options,
1337        resolved_chords,
1338        diagnostics: Vec::new(),
1339        source_notes: base.source_notes,
1340        generated_notes,
1341        notated_swing,
1342        contract_version: 1,
1343    }
1344}
1345
1346fn is_percussion_part(part: &Part) -> bool {
1347    part.midi_channel == 9 || part.staves.iter().any(|s| s.clef == Clef::Percussion)
1348}
1349
1350fn mean_pitch(part: &Part) -> Option<f64> {
1351    let (sum, count) = part
1352        .staves
1353        .iter()
1354        .flat_map(|s| s.measures.iter())
1355        .flat_map(|m| m.voices.iter())
1356        .flat_map(|v| v.iter())
1357        .filter(|n| !n.is_rest && !n.is_grace)
1358        .flat_map(|n| n.pitches.iter())
1359        .fold((0i64, 0i64), |(sum, count), p| {
1360            (sum + p.to_midi() as i64, count + 1)
1361        });
1362    if count == 0 {
1363        None
1364    } else {
1365        Some(sum as f64 / count as f64)
1366    }
1367}
1368
1369/// Rank non-percussion, non-silent parts by mean pitch (descending). The top
1370/// part is the default melody/right-hand candidate.
1371pub fn analyze_for_accordion(score: &Score) -> AccordionAnalysis {
1372    let mut candidates: Vec<PartCandidate> = score
1373        .parts
1374        .iter()
1375        .enumerate()
1376        .filter(|(_, p)| !is_percussion_part(p))
1377        .filter_map(|(i, p)| {
1378            mean_pitch(p).map(|mp| PartCandidate {
1379                part_index: i,
1380                name: p.name.clone(),
1381                mean_pitch: mp,
1382            })
1383        })
1384        .collect();
1385    candidates.sort_by(|a, b| {
1386        b.mean_pitch
1387            .partial_cmp(&a.mean_pitch)
1388            .unwrap_or(std::cmp::Ordering::Equal)
1389    });
1390    let ambiguous =
1391        candidates.len() >= 2 && (candidates[0].mean_pitch - candidates[1].mean_pitch).abs() < 3.0;
1392    AccordionAnalysis {
1393        candidates,
1394        ambiguous,
1395    }
1396}
1397
1398struct SourceEvent {
1399    onset_ticks: i64,
1400    beats: f64,
1401    pitches: Vec<Pitch>,
1402}
1403
1404fn events_from_parts(
1405    score: &Score,
1406    part_indices: &[usize],
1407    measure_idx: usize,
1408) -> Vec<SourceEvent> {
1409    let mut events = Vec::new();
1410    for &pi in part_indices {
1411        let part = &score.parts[pi];
1412        for staff in &part.staves {
1413            let Some(measure) = staff.measures.get(measure_idx) else {
1414                continue;
1415            };
1416            if measure.multi_rest_count.is_some() {
1417                continue;
1418            }
1419            for voice in &measure.voices {
1420                let mut onset = 0.0f64;
1421                for note in voice {
1422                    let b = note.beats();
1423                    if !note.is_rest && !note.is_grace && !note.pitches.is_empty() {
1424                        let pitches = note
1425                            .pitches
1426                            .iter()
1427                            .map(|p| {
1428                                let midi = (p.to_midi() + staff.transpose_semitones as i16)
1429                                    .clamp(0, 127) as u8;
1430                                Pitch::from_midi(midi, false)
1431                            })
1432                            .collect();
1433                        events.push(SourceEvent {
1434                            onset_ticks: (onset * GRID).round() as i64,
1435                            beats: b,
1436                            pitches,
1437                        });
1438                    }
1439                    onset += b;
1440                }
1441            }
1442        }
1443    }
1444    events
1445}
1446
1447/// Same walk as [`events_from_parts`] but for a single part, splitting each
1448/// note's pitches at [`SPLIT_MIDI`] instead of by which part they came from.
1449/// Used only when there is no second candidate part to merge in as the bass.
1450fn events_from_pitch_split(
1451    score: &Score,
1452    part_index: usize,
1453    measure_idx: usize,
1454    high: bool,
1455) -> Vec<SourceEvent> {
1456    let part = &score.parts[part_index];
1457    let mut events = Vec::new();
1458    for staff in &part.staves {
1459        let Some(measure) = staff.measures.get(measure_idx) else {
1460            continue;
1461        };
1462        if measure.multi_rest_count.is_some() {
1463            continue;
1464        }
1465        for voice in &measure.voices {
1466            let mut onset = 0.0f64;
1467            for note in voice {
1468                let b = note.beats();
1469                if !note.is_rest && !note.is_grace {
1470                    let pitches: Vec<Pitch> = note
1471                        .pitches
1472                        .iter()
1473                        .filter_map(|p| {
1474                            let midi = (p.to_midi() + staff.transpose_semitones as i16)
1475                                .clamp(0, 127) as u8;
1476                            let keep = if high {
1477                                midi >= SPLIT_MIDI
1478                            } else {
1479                                midi < SPLIT_MIDI
1480                            };
1481                            if keep {
1482                                Some(Pitch::from_midi(midi, false))
1483                            } else {
1484                                None
1485                            }
1486                        })
1487                        .collect();
1488                    if !pitches.is_empty() {
1489                        events.push(SourceEvent {
1490                            onset_ticks: (onset * GRID).round() as i64,
1491                            beats: b,
1492                            pitches,
1493                        });
1494                    }
1495                }
1496                onset += b;
1497            }
1498        }
1499    }
1500    events
1501}
1502
1503fn fill_rests(notes: &mut Vec<Note>, mut remaining: f64) {
1504    while remaining > 1.0 / GRID {
1505        let dur = Duration::whole_filling_beats(remaining);
1506        let filled = dur.beats(0);
1507        notes.push(Note::rest(dur));
1508        remaining -= filled;
1509    }
1510}
1511
1512/// Bucket-merge precomputed `events_per_measure` onto a single staff, one
1513/// measure at a time, gap-filling with rests via [`Duration::whole_filling_beats`]
1514/// (the same greedy decomposition [`Measure::empty`] uses).
1515fn assemble_staff(
1516    clef: Clef,
1517    template_per_measure: Vec<Option<Measure>>,
1518    events_per_measure: Vec<Vec<SourceEvent>>,
1519    default_ts: super::notation::TimeSignature,
1520) -> Staff {
1521    let mut current_ts = default_ts;
1522    let mut measures = Vec::with_capacity(template_per_measure.len());
1523
1524    for (mi, (template, events)) in template_per_measure
1525        .into_iter()
1526        .zip(events_per_measure)
1527        .enumerate()
1528    {
1529        if let Some(ts) = template.as_ref().and_then(|m| m.time_sig.as_ref()) {
1530            current_ts = ts.clone();
1531        }
1532        let total_beats = current_ts.total_beats();
1533
1534        let mut buckets: BTreeMap<i64, Vec<SourceEvent>> = BTreeMap::new();
1535        for ev in events {
1536            buckets.entry(ev.onset_ticks).or_default().push(ev);
1537        }
1538        let keys: Vec<i64> = buckets.keys().copied().collect();
1539
1540        let mut voice0: Vec<Note> = Vec::new();
1541        let mut cursor = 0.0f64;
1542        for (idx, &key) in keys.iter().enumerate() {
1543            let onset = key as f64 / GRID;
1544            if onset < cursor - 1.0 / GRID {
1545                continue;
1546            } // swallowed by a longer preceding event
1547            if onset > cursor {
1548                fill_rests(&mut voice0, onset - cursor);
1549                cursor = onset;
1550            }
1551
1552            let next_onset = keys
1553                .get(idx + 1)
1554                .map(|&k| k as f64 / GRID)
1555                .unwrap_or(total_beats);
1556            let group = &buckets[&key];
1557            let shortest = group.iter().map(|e| e.beats).fold(f64::MAX, f64::min);
1558            let cap = (next_onset - onset).max(1.0 / GRID);
1559            let sounding = shortest.min(cap).min((total_beats - onset).max(1.0 / GRID));
1560
1561            let mut pitches: Vec<Pitch> = Vec::new();
1562            let mut seen_midi: Vec<u8> = Vec::new();
1563            for ev in group {
1564                for p in &ev.pitches {
1565                    let midi = p.to_midi().clamp(0, 127) as u8;
1566                    if !seen_midi.contains(&midi) {
1567                        seen_midi.push(midi);
1568                        pitches.push(p.clone());
1569                    }
1570                }
1571            }
1572            if pitches.is_empty() {
1573                continue;
1574            }
1575
1576            let dur = Duration::whole_filling_beats(sounding);
1577            let emitted = dur.beats(0);
1578            let mut note = Note::new(pitches[0].clone(), dur);
1579            note.pitches = pitches;
1580            voice0.push(note);
1581            cursor += emitted;
1582        }
1583        if total_beats - cursor > 1.0 / GRID {
1584            fill_rests(&mut voice0, total_beats - cursor);
1585        }
1586        if voice0.is_empty() {
1587            fill_rests(&mut voice0, total_beats);
1588        }
1589
1590        let mut measure = Measure::empty(current_ts.numerator, current_ts.denominator);
1591        measure.number = mi as u32 + 1;
1592        measure.time_sig = template.as_ref().and_then(|m| m.time_sig.clone());
1593        measure.key_sig = template.as_ref().and_then(|m| m.key_sig.clone());
1594        measure.tempo = template.as_ref().and_then(|m| m.tempo);
1595        measure.barline_left = template
1596            .as_ref()
1597            .map(|m| m.barline_left.clone())
1598            .unwrap_or(Barline::Normal);
1599        measure.barline_right = template
1600            .as_ref()
1601            .map(|m| m.barline_right.clone())
1602            .unwrap_or(Barline::Normal);
1603        measure.voices[0] = voice0;
1604        measures.push(measure);
1605    }
1606
1607    Staff {
1608        clef,
1609        measures,
1610        transpose_semitones: 0,
1611        tablature: None,
1612        presentation: Default::default(),
1613    }
1614}
1615
1616fn template_measure_for_parts(score: &Score, part_indices: &[usize], mi: usize) -> Option<Measure> {
1617    part_indices
1618        .iter()
1619        .filter_map(|&pi| score.parts[pi].staves.first())
1620        .find_map(|s| s.measures.get(mi).cloned())
1621}
1622
1623/// Octave-shift `score` (expected to be the freshly-merged, single-part
1624/// accordion score) so its mean pitch sits within the practical range for
1625/// the accordion GM program. Only multiples of 12 are considered — an
1626/// arbitrary semitone shift would break the key signature.
1627fn octave_fit(score: &Score) -> (Score, i8) {
1628    let (lo, hi) = instrument_range(ACCORDION_PROGRAM);
1629    let target_mid = (lo as f64 + hi as f64) / 2.0;
1630    let Some(mp) = score.parts.first().and_then(mean_pitch) else {
1631        return (score.clone(), 0);
1632    };
1633
1634    let shift = [-24i8, -12, 0, 12, 24]
1635        .into_iter()
1636        .min_by(|&a, &b| {
1637            let da = (mp + a as f64 - target_mid).abs();
1638            let db = (mp + b as f64 - target_mid).abs();
1639            da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
1640        })
1641        .unwrap_or(0);
1642
1643    if shift == 0 {
1644        (score.clone(), 0)
1645    } else {
1646        (super::score::transpose(score, shift), shift)
1647    }
1648}
1649
1650/// Arrange `score` for accordion: merge onto two staves (treble = right
1651/// hand / melody, bass = left hand / everything else), reassign to the
1652/// Accordion GM program, and octave-fit to its practical range.
1653///
1654/// `right_hand_part_index` overrides the automatic mean-pitch ranking from
1655/// [`analyze_for_accordion`] — pass `None` to use the default (highest mean
1656/// pitch). Percussion parts are always excluded from both staves.
1657pub fn arrange_for_accordion(
1658    score: &Score,
1659    right_hand_part_index: Option<usize>,
1660) -> Result<ArrangeResult, Error> {
1661    let analysis = analyze_for_accordion(score);
1662    if analysis.candidates.is_empty() {
1663        return Err(Error::InvalidCommand(
1664            "no pitched, non-percussion part to arrange".to_string(),
1665        ));
1666    }
1667
1668    let treble_index = match right_hand_part_index {
1669        Some(i) => {
1670            if i >= score.parts.len() || is_percussion_part(&score.parts[i]) {
1671                return Err(Error::PartNotFound(i));
1672            }
1673            i
1674        }
1675        None => analysis.candidates[0].part_index,
1676    };
1677    let bass_indices: Vec<usize> = analysis
1678        .candidates
1679        .iter()
1680        .map(|c| c.part_index)
1681        .filter(|&i| i != treble_index)
1682        .collect();
1683
1684    let mut notes = Vec::new();
1685    let measure_count = analysis
1686        .candidates
1687        .iter()
1688        .map(|c| {
1689            score.parts[c.part_index]
1690                .staves
1691                .iter()
1692                .map(|s| s.measures.len())
1693                .max()
1694                .unwrap_or(0)
1695        })
1696        .max()
1697        .unwrap_or(0);
1698    let default_ts = score.settings.time_signature.clone();
1699
1700    let (treble_staff, bass_staff) = if !bass_indices.is_empty() {
1701        let treble_template: Vec<Option<Measure>> = (0..measure_count)
1702            .map(|mi| template_measure_for_parts(score, &[treble_index], mi))
1703            .collect();
1704        let treble_events: Vec<Vec<SourceEvent>> = (0..measure_count)
1705            .map(|mi| events_from_parts(score, &[treble_index], mi))
1706            .collect();
1707        let bass_template: Vec<Option<Measure>> = (0..measure_count)
1708            .map(|mi| template_measure_for_parts(score, &bass_indices, mi))
1709            .collect();
1710        let bass_events: Vec<Vec<SourceEvent>> = (0..measure_count)
1711            .map(|mi| events_from_parts(score, &bass_indices, mi))
1712            .collect();
1713        notes.push(format!(
1714            "右手(高音部): {} / 左手(低音部): {}パートを統合",
1715            analysis
1716                .candidates
1717                .iter()
1718                .find(|c| c.part_index == treble_index)
1719                .map(|c| c.name.as_str())
1720                .unwrap_or(""),
1721            bass_indices.len()
1722        ));
1723        (
1724            assemble_staff(
1725                Clef::Treble,
1726                treble_template,
1727                treble_events,
1728                default_ts.clone(),
1729            ),
1730            assemble_staff(Clef::Bass, bass_template, bass_events, default_ts),
1731        )
1732    } else {
1733        let template: Vec<Option<Measure>> = (0..measure_count)
1734            .map(|mi| template_measure_for_parts(score, &[treble_index], mi))
1735            .collect();
1736        let treble_events: Vec<Vec<SourceEvent>> = (0..measure_count)
1737            .map(|mi| events_from_pitch_split(score, treble_index, mi, true))
1738            .collect();
1739        let bass_events: Vec<Vec<SourceEvent>> = (0..measure_count)
1740            .map(|mi| events_from_pitch_split(score, treble_index, mi, false))
1741            .collect();
1742        notes.push("単一パートのため中央ハ(MIDI 60)を基準に上下2段へ分割".to_string());
1743        (
1744            assemble_staff(
1745                Clef::Treble,
1746                template.clone(),
1747                treble_events,
1748                default_ts.clone(),
1749            ),
1750            assemble_staff(Clef::Bass, template, bass_events, default_ts),
1751        )
1752    };
1753
1754    let mut accordion_part = Part::new("Accordion", "Acc.");
1755    accordion_part.midi_program = ACCORDION_PROGRAM;
1756    accordion_part.staves = vec![treble_staff, bass_staff];
1757
1758    let mut merged = Score {
1759        id: uuid::Uuid::new_v4().to_string(),
1760        schema_version: 1,
1761        metadata: score.metadata.clone(),
1762        settings: score.settings.clone(),
1763        parts: vec![accordion_part],
1764        part_groups: Vec::new(),
1765        texts: score.texts.clone(),
1766        style_overrides: score.style_overrides.clone(),
1767        object_style_overrides: score.object_style_overrides.clone(),
1768        chord_definitions: score.chord_definitions.clone(),
1769        spanners: score.spanners.clone(),
1770        views: Vec::new(),
1771    };
1772
1773    let (fitted, shift) = octave_fit(&merged);
1774    merged = fitted;
1775    if shift != 0 {
1776        notes.push(format!(
1777            "アコーディオンの実用音域に合わせて{}オクターブ移調",
1778            shift / 12
1779        ));
1780    }
1781
1782    super::score::respell_score_to_key(&mut merged);
1783
1784    if analysis.ambiguous && right_hand_part_index.is_none() {
1785        notes.push("上位2パートの平均音高が僅差のため、右手パートの選択が曖昧です".to_string());
1786    }
1787
1788    Ok(ArrangeResult {
1789        score: merged,
1790        notes,
1791    })
1792}
1793
1794#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1795#[serde(rename_all = "kebab-case")]
1796pub enum AccordionLossPolicy {
1797    RequireAll,
1798    PermitReduction,
1799}
1800
1801#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1802pub struct AccordionReductionOptions {
1803    pub right_hand_part_index: Option<usize>,
1804    /// Empty means all other pitched parts in source order.
1805    pub left_hand_part_indices: Vec<usize>,
1806    pub loss_policy: AccordionLossPolicy,
1807    pub octave_fit: bool,
1808}
1809
1810#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1811pub struct AccordionReductionDiagnostic {
1812    pub code: String,
1813    pub message: String,
1814    pub source: Option<NoteAddr>,
1815    #[serde(default, skip_serializing_if = "Option::is_none")]
1816    pub source_location: Option<String>,
1817}
1818
1819#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1820pub struct AccordionSourceMapping {
1821    pub source: NoteAddr,
1822    /// None exactly when the selected loss policy permits this source note to be omitted.
1823    pub target: Option<NoteAddr>,
1824}
1825
1826#[derive(Debug, Clone, Serialize, Deserialize)]
1827pub struct AccordionReductionResult {
1828    /// None when a requested lossless reduction or source structure cannot be represented.
1829    pub score: Option<Score>,
1830    pub resolved_options: AccordionReductionOptions,
1831    pub diagnostics: Vec<AccordionReductionDiagnostic>,
1832    pub source_map: Vec<AccordionSourceMapping>,
1833    pub transposition_semitones: i8,
1834    pub contract_version: u16,
1835}
1836
1837fn reduction_error(
1838    options: AccordionReductionOptions,
1839    code: &str,
1840    message: impl Into<String>,
1841) -> AccordionReductionResult {
1842    AccordionReductionResult {
1843        score: None,
1844        resolved_options: options,
1845        diagnostics: vec![AccordionReductionDiagnostic {
1846            code: code.to_string(),
1847            message: message.into(),
1848            source: None,
1849            source_location: None,
1850        }],
1851        source_map: Vec::new(),
1852        transposition_semitones: 0,
1853        contract_version: 1,
1854    }
1855}
1856
1857fn same_reduction_bar(a: &Measure, b: &Measure) -> bool {
1858    a.number == b.number
1859        && a.actual_length == b.actual_length
1860        && a.time_sig == b.time_sig
1861        && a.barline_left == b.barline_left
1862        && a.barline_right == b.barline_right
1863        && a.volta == b.volta
1864        && a.section_break == b.section_break
1865        && a.measure_repeat == b.measure_repeat
1866}
1867
1868fn reduction_measure_metadata(measure: &Measure) -> serde_json::Value {
1869    let mut metadata = measure.clone();
1870    metadata.voices = [vec![], vec![], vec![], vec![]];
1871    metadata.source_voice_numbers = [None; 4];
1872    serde_json::to_value(metadata).unwrap_or(serde_json::Value::Null)
1873}
1874
1875fn source_voice_addresses(score: &Score, part: usize, staff: usize, voice: usize) -> Vec<NoteAddr> {
1876    score.parts[part].staves[staff]
1877        .measures
1878        .iter()
1879        .enumerate()
1880        .flat_map(|(measure, bar)| {
1881            bar.voices[voice]
1882                .iter()
1883                .enumerate()
1884                .filter(|(_, note)| !note.is_rest)
1885                .map(move |(note, _)| NoteAddr {
1886                    part,
1887                    staff,
1888                    measure,
1889                    voice,
1890                    note,
1891                })
1892        })
1893        .collect()
1894}
1895
1896/// Reduce authored parts onto two accordion staves with explicit part selection and loss policy.
1897/// Selected voices are copied into separate score voices, so ties, tuplets and source note IDs
1898/// survive without onset bucketing or note shortening. Voice capacity and unselected notes are
1899/// either reported as source-located omissions or rejected before returning a score.
1900pub fn arrange_for_accordion_with_options(
1901    score: &Score,
1902    mut options: AccordionReductionOptions,
1903) -> AccordionReductionResult {
1904    let analysis = analyze_for_accordion(score);
1905    let Some(right) = options.right_hand_part_index.or_else(|| {
1906        analysis
1907            .candidates
1908            .first()
1909            .map(|candidate| candidate.part_index)
1910    }) else {
1911        return reduction_error(
1912            options,
1913            "no-pitched-part",
1914            "no pitched source part is available",
1915        );
1916    };
1917    if right >= score.parts.len() || is_percussion_part(&score.parts[right]) {
1918        return reduction_error(
1919            options,
1920            "invalid-selection",
1921            "right-hand part is invalid or percussion",
1922        );
1923    }
1924    options.right_hand_part_index = Some(right);
1925    if options.left_hand_part_indices.is_empty() {
1926        options.left_hand_part_indices = analysis
1927            .candidates
1928            .iter()
1929            .map(|candidate| candidate.part_index)
1930            .filter(|index| *index != right)
1931            .collect();
1932    }
1933    let left_indices = &options.left_hand_part_indices;
1934    if left_indices.is_empty()
1935        || left_indices.iter().any(|index| {
1936            *index >= score.parts.len()
1937                || *index == right
1938                || is_percussion_part(&score.parts[*index])
1939        })
1940        || {
1941            let mut seen = std::collections::BTreeSet::new();
1942            left_indices.iter().any(|index| !seen.insert(index))
1943        }
1944    {
1945        return reduction_error(
1946            options,
1947            "invalid-selection",
1948            "left-hand selection must contain distinct pitched parts other than the right hand",
1949        );
1950    }
1951    let right_staff = match score.parts[right].staves.first() {
1952        Some(staff) if !staff.measures.is_empty() => staff,
1953        _ => {
1954            return reduction_error(
1955                options,
1956                "unsupported-source",
1957                "right-hand part has no staff or measures",
1958            );
1959        }
1960    };
1961    for &part_index in left_indices {
1962        let Some(staff) = score.parts[part_index].staves.first() else {
1963            return reduction_error(
1964                options,
1965                "unsupported-source",
1966                format!("left-hand part {part_index} has no staff"),
1967            );
1968        };
1969        if staff.measures.len() != right_staff.measures.len()
1970            || staff
1971                .measures
1972                .iter()
1973                .zip(&right_staff.measures)
1974                .any(|(a, b)| !same_reduction_bar(a, b))
1975        {
1976            return reduction_error(
1977                options,
1978                "misaligned-bars",
1979                format!(
1980                    "part {part_index} has different bar numbering, lengths, repeats or endings"
1981                ),
1982            );
1983        }
1984    }
1985    let validation = super::validate::validate(score);
1986    if !validation.is_valid() {
1987        return reduction_error(
1988            options,
1989            "invalid-source",
1990            format!("source validation failed: {:?}", validation.errors[0]),
1991        );
1992    }
1993    if !score.spanners.is_empty() || !score.object_style_overrides.is_empty() {
1994        return reduction_error(
1995            options,
1996            "unsupported-notation",
1997            "source spanners or object style overrides require address remapping",
1998        );
1999    }
2000    let mut diagnostics = Vec::new();
2001    let primary_left = &score.parts[left_indices[0]].staves[0];
2002    for &part_index in left_indices.iter().skip(1) {
2003        for (measure_index, (primary, additional)) in primary_left
2004            .measures
2005            .iter()
2006            .zip(&score.parts[part_index].staves[0].measures)
2007            .enumerate()
2008        {
2009            if reduction_measure_metadata(primary) != reduction_measure_metadata(additional) {
2010                diagnostics.push(AccordionReductionDiagnostic {
2011                    code: "unsupported-notation".to_string(),
2012                    message: format!("part {part_index}, measure {measure_index} has distinct measure metadata that one left staff cannot retain"),
2013                    source: None,
2014                    source_location: Some(format!("/score/part/{}/staff/1/measure/{}", part_index + 1, measure_index + 1)),
2015                });
2016            }
2017        }
2018    }
2019    if !score.views.is_empty() || !score.part_groups.is_empty() {
2020        diagnostics.push(AccordionReductionDiagnostic {
2021            code: "unsupported-score-view".to_string(),
2022            message: "source views or part groups cannot be carried into one accordion part"
2023                .to_string(),
2024            source: None,
2025            source_location: Some("/score/views".to_string()),
2026        });
2027    }
2028    let mut source_map = Vec::new();
2029    let mut included = std::collections::BTreeSet::new();
2030    let right_copy = right_staff.clone();
2031    for voice in 0..4 {
2032        for address in source_voice_addresses(score, right, 0, voice) {
2033            source_map.push(AccordionSourceMapping {
2034                target: Some(NoteAddr {
2035                    part: 0,
2036                    staff: 0,
2037                    ..address.clone()
2038                }),
2039                source: address,
2040            });
2041        }
2042    }
2043    included.insert((right, 0));
2044    let mut left_copy = score.parts[left_indices[0]].staves[0].clone();
2045    left_copy.clef = Clef::Bass;
2046    for measure in &mut left_copy.measures {
2047        measure.voices = [vec![], vec![], vec![], vec![]];
2048        measure.source_voice_numbers = [None; 4];
2049    }
2050    let mut next_voice = 0;
2051    for &part_index in left_indices {
2052        let staff = &score.parts[part_index].staves[0];
2053        included.insert((part_index, 0));
2054        for voice in 0..4 {
2055            if staff
2056                .measures
2057                .iter()
2058                .all(|measure| measure.voices[voice].is_empty())
2059            {
2060                continue;
2061            }
2062            if next_voice < 4 {
2063                for (measure_index, measure) in staff.measures.iter().enumerate() {
2064                    left_copy.measures[measure_index].voices[next_voice] =
2065                        measure.voices[voice].clone();
2066                    for (note_index, note) in measure.voices[voice].iter().enumerate() {
2067                        if note.is_rest {
2068                            continue;
2069                        }
2070                        let address = NoteAddr {
2071                            part: part_index,
2072                            staff: 0,
2073                            measure: measure_index,
2074                            voice,
2075                            note: note_index,
2076                        };
2077                        source_map.push(AccordionSourceMapping {
2078                            target: Some(NoteAddr {
2079                                part: 0,
2080                                staff: 1,
2081                                measure: measure_index,
2082                                voice: next_voice,
2083                                note: note_index,
2084                            }),
2085                            source: address,
2086                        });
2087                    }
2088                }
2089                next_voice += 1;
2090            } else {
2091                for address in source_voice_addresses(score, part_index, 0, voice) {
2092                    diagnostics.push(AccordionReductionDiagnostic {
2093                        code: "voice-capacity".to_string(),
2094                        message: "left hand has more than four source voices".to_string(),
2095                        source: Some(address.clone()),
2096                        source_location: None,
2097                    });
2098                    source_map.push(AccordionSourceMapping {
2099                        source: address,
2100                        target: None,
2101                    });
2102                }
2103            }
2104        }
2105    }
2106    for (part_index, part) in score.parts.iter().enumerate() {
2107        for staff_index in 0..part.staves.len() {
2108            if included.contains(&(part_index, staff_index)) {
2109                continue;
2110            }
2111            for voice in 0..4 {
2112                for address in source_voice_addresses(score, part_index, staff_index, voice) {
2113                    diagnostics.push(AccordionReductionDiagnostic {
2114                        code: "unselected-source".to_string(),
2115                        message: "source note is outside the selected hands".to_string(),
2116                        source: Some(address.clone()),
2117                        source_location: None,
2118                    });
2119                    source_map.push(AccordionSourceMapping {
2120                        source: address,
2121                        target: None,
2122                    });
2123                }
2124            }
2125        }
2126    }
2127    source_map.sort_by_key(|mapping| {
2128        (
2129            mapping.source.part,
2130            mapping.source.staff,
2131            mapping.source.measure,
2132            mapping.source.voice,
2133            mapping.source.note,
2134        )
2135    });
2136    if options.loss_policy == AccordionLossPolicy::RequireAll && !diagnostics.is_empty() {
2137        return AccordionReductionResult {
2138            score: None,
2139            resolved_options: options,
2140            diagnostics,
2141            source_map,
2142            transposition_semitones: 0,
2143            contract_version: 1,
2144        };
2145    }
2146    let mut output = score.clone();
2147    output.id = uuid::Uuid::new_v4().to_string();
2148    let mut accordion = Part::new("Accordion", "Acc.");
2149    accordion.midi_program = ACCORDION_PROGRAM;
2150    accordion.staves = vec![right_copy, left_copy];
2151    output.parts = vec![accordion];
2152    output.part_groups.clear();
2153    output.views.clear();
2154    let (output, shift) = if options.octave_fit {
2155        octave_fit(&output)
2156    } else {
2157        (output, 0)
2158    };
2159    if shift != 0 {
2160        diagnostics.push(AccordionReductionDiagnostic {
2161            code: "octave-shift".to_string(),
2162            message: format!("both hands transposed by {shift} semitones"),
2163            source: None,
2164            source_location: None,
2165        });
2166    }
2167    let output_validation = super::validate::validate(&output);
2168    if !output_validation.is_valid() {
2169        return reduction_error(
2170            options,
2171            "invalid-output",
2172            format!(
2173                "reduced score validation failed: {:?}",
2174                output_validation.errors[0]
2175            ),
2176        );
2177    }
2178    AccordionReductionResult {
2179        score: Some(output),
2180        resolved_options: options,
2181        diagnostics,
2182        source_map,
2183        transposition_semitones: shift,
2184        contract_version: 1,
2185    }
2186}
2187
2188#[cfg(test)]
2189mod tests {
2190    use super::*;
2191    use crate::model::{pitch::Step, score::Score, validate::validate};
2192
2193    fn note(step: Step, octave: i8, duration: Duration) -> Note {
2194        Note::new(Pitch::new(step, octave), duration)
2195    }
2196
2197    fn guided_fixture() -> Score {
2198        let mut score = Score::new("melody", 120, 4, 4, 0, 2);
2199        let first = &mut score.parts[0].staves[0].measures[0];
2200        first.number = 0;
2201        first.actual_length = super::super::score::MeasureLength::from_beats(1.0);
2202        first.voices[0] = vec![note(Step::C, 5, Duration::Quarter)];
2203        let second = &mut score.parts[0].staves[0].measures[1];
2204        second.voices[0] = vec![note(Step::D, 5, Duration::Whole)];
2205        second.barline_right = Barline::RepeatEnd;
2206        score
2207    }
2208
2209    fn guided_options(pattern: AccordionPattern) -> GuidedAccordionOptions {
2210        let chord = |root: &str| {
2211            serde_json::from_value(serde_json::json!({"root": root, "kind": "major", "bass": null}))
2212                .unwrap()
2213        };
2214        GuidedAccordionOptions {
2215            pattern,
2216            chords: vec![
2217                PositionedAccordionChord {
2218                    measure_index: 0,
2219                    offset_beats: 0.0,
2220                    chord: chord("C"),
2221                },
2222                PositionedAccordionChord {
2223                    measure_index: 1,
2224                    offset_beats: 0.0,
2225                    chord: chord("G"),
2226                },
2227            ],
2228        }
2229    }
2230
2231    #[test]
2232    fn guided_patterns_preserve_source_and_pickup_repeat_timing() {
2233        let score = guided_fixture();
2234        let before = serde_json::to_value(&score).unwrap();
2235        let original_events: Vec<_> = crate::to_playback_events(&score, &Default::default())
2236            .into_iter()
2237            .map(|event| (event.pitch_midi, event.time_beats, event.duration_beats))
2238            .collect();
2239        let mut bass_patterns = Vec::new();
2240        for pattern in [
2241            AccordionPattern::HeldBassChord,
2242            AccordionPattern::AlternatingBassChord,
2243            AccordionPattern::Arpeggio,
2244        ] {
2245            let result = arrange_with_explicit_chords(&score, guided_options(pattern));
2246            assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2247            let arranged = result.score.expect("valid arrangement");
2248            assert_eq!(arranged.parts.len(), 1);
2249            assert_eq!(arranged.parts[0].staves.len(), 2);
2250            assert_eq!(arranged.parts[0].staves[0].measures[0].number, 0);
2251            assert_eq!(
2252                arranged.parts[0].staves[1].measures[0].actual_length,
2253                score.parts[0].staves[0].measures[0].actual_length
2254            );
2255            assert_eq!(
2256                arranged.parts[0].staves[1].measures[1].barline_right,
2257                Barline::RepeatEnd
2258            );
2259            assert_eq!(
2260                arranged.parts[0].staves[0].measures[0].voices[0][0].id,
2261                score.parts[0].staves[0].measures[0].voices[0][0].id
2262            );
2263            let melody_events: Vec<_> = crate::to_playback_events(&arranged, &Default::default())
2264                .into_iter()
2265                .filter(|event| {
2266                    event
2267                        .source
2268                        .as_ref()
2269                        .is_some_and(|address| address.staff == 0)
2270                })
2271                .map(|event| (event.pitch_midi, event.time_beats, event.duration_beats))
2272                .collect();
2273            assert_eq!(melody_events, original_events);
2274            bass_patterns.push(
2275                arranged.parts[0].staves[1].measures[1].voices[0]
2276                    .iter()
2277                    .map(|note| {
2278                        (
2279                            note.pitches.iter().map(Pitch::to_midi).collect::<Vec<_>>(),
2280                            note.beats(),
2281                        )
2282                    })
2283                    .collect::<Vec<_>>(),
2284            );
2285        }
2286        assert_ne!(bass_patterns[0], bass_patterns[1]);
2287        assert_ne!(bass_patterns[1], bass_patterns[2]);
2288        assert_eq!(serde_json::to_value(&score).unwrap(), before);
2289    }
2290
2291    #[test]
2292    fn repeated_harmony_does_not_restart_pulse_arpeggio_or_walking_patterns() {
2293        let mut score = Score::new("same harmony", 60, 4, 4, 0, 1);
2294        score.parts[0].staves[0].measures[0].voices[0] = vec![note(Step::C, 5, Duration::Whole)];
2295        for pattern in [
2296            AccordionPattern::AlternatingBassChord,
2297            AccordionPattern::Arpeggio,
2298            AccordionPattern::RisingArpeggio,
2299            AccordionPattern::AlbertiBass,
2300            AccordionPattern::WalkingBass,
2301        ] {
2302            let mut single = guided_options(pattern);
2303            single.chords.truncate(1);
2304            let mut repeated = single.clone();
2305            let mut redundant = single.chords[0].clone();
2306            redundant.offset_beats = 2.0;
2307            repeated.chords.push(redundant);
2308            let baseline = arrange_with_explicit_chords(&score, single);
2309            let actual = arrange_with_explicit_chords(&score, repeated.clone());
2310            assert!(
2311                baseline.diagnostics.is_empty(),
2312                "{pattern:?}: {:?}",
2313                baseline.diagnostics
2314            );
2315            assert!(
2316                actual.diagnostics.is_empty(),
2317                "{pattern:?}: {:?}",
2318                actual.diagnostics
2319            );
2320            assert_eq!(actual.resolved_options, repeated);
2321            let left = |score: &Score| {
2322                score.parts[0].staves[1].measures[0].voices[0]
2323                    .iter()
2324                    .map(|note| {
2325                        (
2326                            note.pitches.iter().map(Pitch::to_midi).collect::<Vec<_>>(),
2327                            note.beats(),
2328                        )
2329                    })
2330                    .collect::<Vec<_>>()
2331            };
2332            assert_eq!(
2333                left(baseline.score.as_ref().unwrap()),
2334                left(actual.score.as_ref().unwrap()),
2335                "{pattern:?}"
2336            );
2337        }
2338    }
2339
2340    #[test]
2341    fn changed_harmony_keeps_measure_phase_across_pickup_and_three_four() {
2342        let mut score = Score::new("pickup phase", 60, 3, 4, 0, 2);
2343        score.parts[0].staves[0].measures[0].actual_length =
2344            super::super::score::MeasureLength::from_beats(1.0);
2345        score.parts[0].staves[0].measures[0].voices[0] = vec![note(Step::C, 5, Duration::Quarter)];
2346        let mut dotted = note(Step::G, 5, Duration::Half);
2347        dotted.dot_count = 1;
2348        score.parts[0].staves[0].measures[1].voices[0] = vec![dotted];
2349        let mut options = guided_options(AccordionPattern::AlternatingBassChord);
2350        options.chords[1].offset_beats = 1.0;
2351        let result = arrange_with_explicit_chords(&score, options);
2352        assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2353        let arranged = result.score.unwrap();
2354        let midi = |bar: usize| {
2355            arranged.parts[0].staves[1].measures[bar].voices[0]
2356                .iter()
2357                .map(|note| note.pitches.iter().map(Pitch::to_midi).collect::<Vec<_>>())
2358                .collect::<Vec<_>>()
2359        };
2360        assert_eq!(midi(0), vec![vec![36]]);
2361        // The inherited C starts the new bar. G begins on its second beat,
2362        // where the measure-anchored pattern calls for the chord, then D bass.
2363        assert_eq!(midi(1), vec![vec![36], vec![67, 71, 74], vec![50]]);
2364        assert_eq!(
2365            arranged.parts[0].staves[0].measures[0].actual_length,
2366            score.parts[0].staves[0].measures[0].actual_length
2367        );
2368    }
2369
2370    #[test]
2371    fn four_four_arpeggios_keep_phase_after_weak_beat_chord_change() {
2372        let mut score = Score::new("arpeggio change", 60, 4, 4, 0, 1);
2373        score.parts[0].staves[0].measures[0].voices[0] = vec![note(Step::C, 5, Duration::Whole)];
2374        for (pattern, expected) in [
2375            (AccordionPattern::Arpeggio, 74),
2376            (AccordionPattern::RisingArpeggio, 74),
2377            (AccordionPattern::AlbertiBass, 71),
2378        ] {
2379            let mut options = guided_options(pattern);
2380            options.chords[1].measure_index = 0;
2381            options.chords[1].offset_beats = 1.0;
2382            let result = arrange_with_explicit_chords(&score, options);
2383            assert!(
2384                result.diagnostics.is_empty(),
2385                "{pattern:?}: {:?}",
2386                result.diagnostics
2387            );
2388            let arranged = result.score.unwrap();
2389            let left = &arranged.parts[0].staves[1].measures[0].voices[0];
2390            assert_eq!(left.len(), 8);
2391            assert_eq!(left[2].pitches[0].to_midi(), expected, "{pattern:?}");
2392        }
2393    }
2394
2395    #[test]
2396    fn layered_and_preset_paths_use_the_same_continuous_pattern() {
2397        let mut score = Score::new("layered pulse", 60, 4, 4, 0, 1);
2398        score.parts[0].staves[0].measures[0].voices[0] = vec![note(Step::C, 5, Duration::Whole)];
2399        let mut guided = guided_options(AccordionPattern::AlternatingBassChord);
2400        guided.chords.truncate(1);
2401        let mut repeated = guided.chords[0].clone();
2402        repeated.offset_beats = 2.0;
2403        guided.chords.push(repeated);
2404        let direct = arrange_with_explicit_chords(&score, guided.clone());
2405        let layered = arrange_with_layers(
2406            &score,
2407            LayeredAccordionOptions {
2408                chords: guided.chords.clone(),
2409                alternative_chords: None,
2410                inversion: None,
2411                accompaniment: Some(AccordionPattern::AlternatingBassChord),
2412                melodic_voice: AccordionMelodicVoice::Unchanged,
2413                swing: AccordionSwingPolicy::Straight,
2414                bass_mode: AccordionBassMode::FreeBass,
2415            },
2416        );
2417        let preset = crate::arrange_accordion_preset(
2418            &score,
2419            crate::AccordionPresetSelection {
2420                chords: guided.chords,
2421                alternative_chords: None,
2422                inversion: None,
2423                bass_mode: AccordionBassMode::FreeBass,
2424            },
2425            "alternating-bass",
2426        );
2427        for arranged in [
2428            direct.score.unwrap(),
2429            layered.score.unwrap(),
2430            preset.score.unwrap(),
2431        ] {
2432            let left = &arranged.parts[0].staves[1].measures[0].voices[0];
2433            assert_eq!(left[2].pitches[0].to_midi(), 43);
2434            assert_eq!(left.len(), 4);
2435        }
2436    }
2437
2438    #[test]
2439    fn guided_arrangement_rejects_uncovered_unsupported_and_out_of_range_input() {
2440        let score = guided_fixture();
2441        let mut options = guided_options(AccordionPattern::Arpeggio);
2442        options.chords.remove(0);
2443        let result = arrange_with_explicit_chords(&score, options);
2444        assert!(result.score.is_none());
2445        assert_eq!(result.diagnostics[0].code, "missing-chords");
2446
2447        let mut options = guided_options(AccordionPattern::AlternatingBassChord);
2448        options.chords[1].offset_beats = 0.5;
2449        let result = arrange_with_explicit_chords(&score, options);
2450        assert!(result.score.is_none());
2451        assert_eq!(result.diagnostics[0].code, "unsupported-rhythm");
2452
2453        let mut high = guided_fixture();
2454        high.parts[0].staves[0].measures[0].voices[0][0].pitches[0] = Pitch::new(Step::C, 9);
2455        let result =
2456            arrange_with_explicit_chords(&high, guided_options(AccordionPattern::HeldBassChord));
2457        assert!(result.score.is_none());
2458        assert_eq!(result.diagnostics[0].code, "out-of-range");
2459    }
2460
2461    #[test]
2462    fn guided_arrangement_carries_explicit_harmony_into_internal_short_bar() {
2463        let mut score = Score::new("short cadence", 120, 4, 4, 0, 2);
2464        score.parts[0].staves[0].measures[0].voices[0] = vec![note(Step::C, 5, Duration::Whole)];
2465        let cadence = &mut score.parts[0].staves[0].measures[1];
2466        cadence.actual_length = super::super::score::MeasureLength::from_beats(3.0);
2467        let mut dotted = note(Step::D, 5, Duration::Half);
2468        dotted.dot_count = 1;
2469        cadence.voices[0] = vec![dotted];
2470        let cadence_length = cadence.actual_length;
2471        let mut options = guided_options(AccordionPattern::AlternatingBassChord);
2472        options.chords.pop();
2473        let first = arrange_with_explicit_chords(&score, options.clone());
2474        let second = arrange_with_explicit_chords(&score, options);
2475        assert!(first.diagnostics.is_empty(), "{:?}", first.diagnostics);
2476        let arranged = first.score.unwrap();
2477        assert_eq!(
2478            arranged.parts[0].staves[1].measures[1].actual_length,
2479            cadence_length
2480        );
2481        let left = |score: &Score| {
2482            crate::to_playback_events(score, &Default::default())
2483                .into_iter()
2484                .filter(|event| {
2485                    event
2486                        .source
2487                        .as_ref()
2488                        .is_some_and(|address| address.staff == 1)
2489                })
2490                .map(|event| (event.pitch_midi, event.time_beats, event.duration_beats))
2491                .collect::<Vec<_>>()
2492        };
2493        assert_eq!(left(&arranged), left(second.score.as_ref().unwrap()));
2494        let mut starts = left(&arranged)
2495            .iter()
2496            .map(|event| event.1)
2497            .collect::<Vec<_>>();
2498        starts.dedup();
2499        assert_eq!(starts, [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
2500    }
2501
2502    fn layered_options(pattern: AccordionPattern) -> LayeredAccordionOptions {
2503        let guided = guided_options(pattern);
2504        LayeredAccordionOptions {
2505            chords: guided.chords,
2506            alternative_chords: None,
2507            inversion: None,
2508            accompaniment: Some(pattern),
2509            melodic_voice: AccordionMelodicVoice::Unchanged,
2510            swing: AccordionSwingPolicy::Straight,
2511            bass_mode: AccordionBassMode::FreeBass,
2512        }
2513    }
2514
2515    #[test]
2516    fn extended_accompaniments_are_distinct_and_walking_approaches_next_bass() {
2517        let score = guided_fixture();
2518        let mut signatures = std::collections::BTreeSet::new();
2519        for pattern in [
2520            AccordionPattern::HeldBassChord,
2521            AccordionPattern::BassChordPulse,
2522            AccordionPattern::AlternatingBassChord,
2523            AccordionPattern::Arpeggio,
2524            AccordionPattern::RisingArpeggio,
2525            AccordionPattern::AlbertiBass,
2526            AccordionPattern::OffbeatChords,
2527            AccordionPattern::WalkingBass,
2528        ] {
2529            let result = arrange_with_layers(&score, layered_options(pattern));
2530            assert!(
2531                result.diagnostics.is_empty(),
2532                "{pattern:?}: {:?}",
2533                result.diagnostics
2534            );
2535            let arranged = result.score.unwrap();
2536            let signature = arranged.parts[0].staves[1].measures[1].voices[0]
2537                .iter()
2538                .map(|note| {
2539                    (
2540                        note.is_rest,
2541                        note.pitches.iter().map(Pitch::to_midi).collect::<Vec<_>>(),
2542                        note.beats().to_bits(),
2543                    )
2544                })
2545                .collect::<Vec<_>>();
2546            assert!(
2547                signatures.insert(format!("{signature:?}")),
2548                "duplicate pattern: {pattern:?}"
2549            );
2550            if pattern == AccordionPattern::WalkingBass {
2551                let pickup = &arranged.parts[0].staves[1].measures[0].voices[0][0];
2552                assert_eq!(
2553                    pickup.pitches[0].to_midi(),
2554                    42,
2555                    "approaches G bass (43) from below"
2556                );
2557            }
2558        }
2559    }
2560
2561    #[test]
2562    fn explicit_seventh_harmony_and_inversion_change_only_generated_notes() {
2563        let score = guided_fixture();
2564        let original = serde_json::to_value(&score).unwrap();
2565        let mut options = layered_options(AccordionPattern::HeldBassChord);
2566        options.chords[0].chord.kind = "major-seventh".to_string();
2567        options.chords[1].chord.kind = "dominant".to_string();
2568        let result = arrange_with_layers(&score, options.clone());
2569        assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2570        let arranged = result.score.unwrap();
2571        assert_eq!(
2572            serde_json::to_value(&arranged.parts[0].staves[0]).unwrap(),
2573            serde_json::to_value(&score.parts[0].staves[0]).unwrap(),
2574            "the authored melody staff must be identical in the JSON contract"
2575        );
2576        assert!(
2577            arranged.parts[0].staves[1].measures[0].voices[0][0]
2578                .pitches
2579                .iter()
2580                .any(|pitch| pitch.to_midi() == 71)
2581        );
2582        assert!(
2583            arranged.parts[0].staves[1].measures[1].voices[0][0]
2584                .pitches
2585                .iter()
2586                .any(|pitch| pitch.to_midi() == 77)
2587        );
2588        assert_eq!(
2589            arranged.parts[0].staves[0].measures[0].voices[0][0].id,
2590            score.parts[0].staves[0].measures[0].voices[0][0].id
2591        );
2592
2593        options.alternative_chords = Some(guided_options(AccordionPattern::HeldBassChord).chords);
2594        options.alternative_chords.as_mut().unwrap()[0].chord.root = "F".to_string();
2595        options.inversion = Some(AccordionInversion::First);
2596        let alternative = arrange_with_layers(&score, options);
2597        assert!(
2598            alternative.diagnostics.is_empty(),
2599            "{:?}",
2600            alternative.diagnostics
2601        );
2602        assert_eq!(
2603            alternative.resolved_chords[0].chord.bass.as_deref(),
2604            Some("A")
2605        );
2606        assert_eq!(
2607            alternative.score.unwrap().parts[0].staves[1].measures[0].voices[0][0].pitches[0]
2608                .to_midi(),
2609            45
2610        );
2611        assert_eq!(serde_json::to_value(&score).unwrap(), original);
2612    }
2613
2614    #[test]
2615    fn supplied_jazz_sevenths_have_exact_pitch_classes_and_symbols() {
2616        let mut score = Score::new("sevenths", 120, 4, 4, 0, 1);
2617        score.parts[0].staves[0].measures[0].voices[0] = vec![note(Step::C, 5, Duration::Whole)];
2618        for (root, kind, expected) in [
2619            ("C", "major-seventh", [0, 4, 7, 11]),
2620            ("F", "major-seventh", [5, 9, 0, 4]),
2621            ("A", "minor-seventh", [9, 0, 4, 7]),
2622            ("D", "minor-seventh", [2, 5, 9, 0]),
2623            ("E", "minor-seventh", [4, 7, 11, 2]),
2624            ("G", "dominant", [7, 11, 2, 5]),
2625            ("E", "dominant", [4, 8, 11, 2]),
2626        ] {
2627            let mut options = layered_options(AccordionPattern::HeldBassChord);
2628            options.chords.truncate(1);
2629            options.chords[0].chord.root = root.to_string();
2630            options.chords[0].chord.kind = kind.to_string();
2631            let result = arrange_with_layers(&score, options);
2632            assert!(
2633                result.diagnostics.is_empty(),
2634                "{root} {kind}: {:?}",
2635                result.diagnostics
2636            );
2637            let arranged = result.score.unwrap();
2638            let chord = &arranged.parts[0].staves[1].measures[0].voices[0][0];
2639            assert_eq!(
2640                chord
2641                    .pitches
2642                    .iter()
2643                    .skip(1)
2644                    .map(|pitch| pitch.to_midi() % 12)
2645                    .collect::<Vec<_>>(),
2646                expected.map(|value| value as i16)
2647            );
2648            assert_eq!(
2649                chord
2650                    .chord_symbol
2651                    .as_ref()
2652                    .map(|symbol| (symbol.root.as_str(), symbol.kind.as_str())),
2653                Some((root, kind))
2654            );
2655        }
2656    }
2657
2658    #[test]
2659    fn notated_swing_is_exact_two_to_one_without_measure_drift() {
2660        let score = guided_fixture();
2661        let mut options = layered_options(AccordionPattern::BassChordPulse);
2662        options.swing = AccordionSwingPolicy::NotatedAccompanimentTwoToOne;
2663        let result = arrange_with_layers(&score, options);
2664        assert!(result.notated_swing);
2665        assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2666        let arranged = result.score.unwrap();
2667        let notes = &arranged.parts[0].staves[1].measures[1].voices[0];
2668        assert_eq!(notes.len(), 8);
2669        assert_eq!(
2670            crate::tuplet_group_ranges(notes),
2671            [(0, 1), (2, 3), (4, 5), (6, 7)]
2672        );
2673        for pair in notes.chunks(2) {
2674            assert_eq!(pair.len(), 2);
2675            assert!((pair[0].beats() / pair[1].beats() - 2.0).abs() < 1e-9);
2676            assert_eq!(
2677                pair[0]
2678                    .tuplet
2679                    .as_ref()
2680                    .map(|tuplet| (tuplet.actual_notes, tuplet.normal_notes)),
2681                Some((3, 2))
2682            );
2683        }
2684        assert!((notes.iter().map(Note::beats).sum::<f64>() - 4.0).abs() < 1e-9);
2685        let starts = crate::to_playback_events(&arranged, &Default::default())
2686            .into_iter()
2687            .filter(|event| {
2688                event
2689                    .source
2690                    .as_ref()
2691                    .is_some_and(|address| address.staff == 1 && address.measure == 1)
2692            })
2693            .map(|event| event.time_beats)
2694            .collect::<Vec<_>>();
2695        assert!(
2696            starts
2697                .iter()
2698                .any(|time| (*time - (1.0 + 2.0 / 3.0)).abs() < 1e-9)
2699        );
2700    }
2701
2702    #[test]
2703    fn melodic_voice_layer_is_independent_of_accompaniment() {
2704        let mut score = Score::new("layers", 120, 4, 4, 0, 1);
2705        score.parts[0].staves[0].measures[0].voices[0] = vec![note(Step::G, 4, Duration::Whole)];
2706        let mut options = layered_options(AccordionPattern::BassChordPulse);
2707        options.chords.truncate(1);
2708        options.melodic_voice = AccordionMelodicVoice::ThirdBelow;
2709        let with_voice = arrange_with_layers(&score, options.clone());
2710        assert!(
2711            with_voice.diagnostics.is_empty(),
2712            "{:?}",
2713            with_voice.diagnostics
2714        );
2715        let arranged = with_voice.score.unwrap();
2716        assert_eq!(
2717            arranged.parts[0].staves[0].measures[0].voices[1][0].pitches[0].to_midi(),
2718            64
2719        );
2720        options.melodic_voice = AccordionMelodicVoice::Unchanged;
2721        let without_voice = arrange_with_layers(&score, options.clone()).score.unwrap();
2722        let bass = |score: &Score| {
2723            score.parts[0].staves[1].measures[0].voices[0]
2724                .iter()
2725                .map(|note| {
2726                    (
2727                        note.pitches.iter().map(Pitch::to_midi).collect::<Vec<_>>(),
2728                        note.beats(),
2729                    )
2730                })
2731                .collect::<Vec<_>>()
2732        };
2733        assert_eq!(bass(&arranged), bass(&without_voice));
2734        options.accompaniment = None;
2735        options.melodic_voice = AccordionMelodicVoice::ThirdBelow;
2736        let without_bass = arrange_with_layers(&score, options);
2737        assert!(
2738            without_bass.score.as_ref().unwrap().parts[0].staves[1].measures[0].voices[0]
2739                .is_empty()
2740        );
2741        assert_eq!(
2742            without_bass.score.unwrap().parts[0].staves[0].measures[0].voices[1][0].pitches[0]
2743                .to_midi(),
2744            64
2745        );
2746
2747        score.parts[0].staves[0].measures[0].voices[0][0].pitches[0] = Pitch::new(Step::C, 5);
2748        for (voice, expected) in [
2749            (AccordionMelodicVoice::SixthBelow, 64),
2750            (AccordionMelodicVoice::OctaveBelow, 60),
2751        ] {
2752            let mut options = layered_options(AccordionPattern::HeldBassChord);
2753            options.chords.truncate(1);
2754            options.melodic_voice = voice;
2755            let result = arrange_with_layers(&score, options);
2756            assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2757            assert_eq!(
2758                result.score.unwrap().parts[0].staves[0].measures[0].voices[1][0].pitches[0]
2759                    .to_midi(),
2760                expected
2761            );
2762        }
2763    }
2764
2765    #[test]
2766    fn standard_bass_rejects_seventh_realization_without_partial_score() {
2767        let score = guided_fixture();
2768        let mut options = layered_options(AccordionPattern::HeldBassChord);
2769        options.bass_mode = AccordionBassMode::StandardBass;
2770        options.chords[0].chord.kind = "major-seventh".to_string();
2771        let result = arrange_with_layers(&score, options);
2772        assert!(result.score.is_none());
2773        assert_eq!(result.diagnostics[0].code, "unsupported-standard-bass");
2774    }
2775
2776    #[test]
2777    fn walking_bass_keeps_pickups_rests_and_supported_meters_aligned() {
2778        for (numerator, duration, dots) in [
2779            (2, Duration::Quarter, 0),
2780            (3, Duration::Half, 0),
2781            (4, Duration::Half, 1),
2782        ] {
2783            let mut score = Score::new("meter", 120, numerator, 4, 0, 2);
2784            score.parts[0].staves[0].measures[0].actual_length =
2785                super::super::score::MeasureLength::from_beats(1.0);
2786            score.parts[0].staves[0].measures[0].voices[0] =
2787                vec![note(Step::C, 5, Duration::Quarter)];
2788            let mut final_note = note(Step::G, 4, duration);
2789            final_note.dot_count = dots;
2790            score.parts[0].staves[0].measures[1].voices[0] =
2791                vec![Note::rest(Duration::Quarter), final_note];
2792            let result =
2793                arrange_with_layers(&score, layered_options(AccordionPattern::WalkingBass));
2794            assert!(
2795                result.diagnostics.is_empty(),
2796                "{numerator}/4: {:?}",
2797                result.diagnostics
2798            );
2799            let arranged = result.score.unwrap();
2800            assert!(
2801                (arranged.parts[0].staves[1].measures[0].voices[0]
2802                    .iter()
2803                    .map(Note::beats)
2804                    .sum::<f64>()
2805                    - 1.0)
2806                    .abs()
2807                    < 1e-9
2808            );
2809            assert!(
2810                (arranged.parts[0].staves[1].measures[1].voices[0]
2811                    .iter()
2812                    .map(Note::beats)
2813                    .sum::<f64>()
2814                    - f64::from(numerator))
2815                .abs()
2816                    < 1e-9
2817            );
2818            let melody = crate::to_playback_events(&arranged, &Default::default())
2819                .into_iter()
2820                .filter(|event| {
2821                    event
2822                        .source
2823                        .as_ref()
2824                        .is_some_and(|address| address.staff == 0)
2825                })
2826                .map(|event| event.time_beats)
2827                .collect::<Vec<_>>();
2828            assert_eq!(melody, [0.0, 2.0]);
2829        }
2830    }
2831
2832    #[test]
2833    fn layered_score_preserves_accidental_pickup_meter_change_and_short_final_bar() {
2834        let mut score = Score::new("mixed bars", 120, 4, 4, 0, 3);
2835        let first = &mut score.parts[0].staves[0].measures[0];
2836        first.number = 0;
2837        first.actual_length = super::super::score::MeasureLength::from_beats(1.0);
2838        first.voices[0] = vec![Note::new(
2839            Pitch::with_alter(Step::C, 5, 1),
2840            Duration::Quarter,
2841        )];
2842        let middle = &mut score.parts[0].staves[0].measures[1];
2843        middle.time_sig = Some(super::super::notation::TimeSignature {
2844            numerator: 3,
2845            denominator: 4,
2846        });
2847        middle.voices[0] = vec![
2848            Note::rest(Duration::Quarter),
2849            note(Step::G, 4, Duration::Half),
2850        ];
2851        let last = &mut score.parts[0].staves[0].measures[2];
2852        last.time_sig = Some(super::super::notation::TimeSignature {
2853            numerator: 4,
2854            denominator: 4,
2855        });
2856        last.actual_length = super::super::score::MeasureLength::from_beats(2.0);
2857        last.voices[0] = vec![note(Step::F, 4, Duration::Half)];
2858
2859        let input_json = serde_json::to_value(&score).unwrap();
2860        let input_staff = serde_json::to_value(&score.parts[0].staves[0]).unwrap();
2861        let mut options = layered_options(AccordionPattern::WalkingBass);
2862        options.chords.push(PositionedAccordionChord {
2863            measure_index: 2,
2864            offset_beats: 0.0,
2865            chord: serde_json::from_value(
2866                serde_json::json!({"root":"F","kind":"major","bass":null}),
2867            )
2868            .unwrap(),
2869        });
2870        let result = arrange_with_layers(&score, options);
2871        assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2872        let arranged = result.score.unwrap();
2873        assert_eq!(
2874            serde_json::to_value(&arranged.parts[0].staves[0]).unwrap(),
2875            input_staff
2876        );
2877        assert_eq!(serde_json::to_value(&score).unwrap(), input_json);
2878        let left = &arranged.parts[0].staves[1].measures;
2879        assert_eq!(
2880            left.iter()
2881                .map(|bar| bar.voices[0].iter().map(Note::beats).sum::<f64>())
2882                .collect::<Vec<_>>(),
2883            [1.0, 3.0, 2.0]
2884        );
2885        assert_eq!(
2886            left[1].time_sig.as_ref().map(|meter| meter.numerator),
2887            Some(3)
2888        );
2889        assert_eq!(
2890            left[2].actual_length,
2891            super::super::score::MeasureLength::from_beats(2.0)
2892        );
2893        let melody_events = |source: &Score| {
2894            crate::to_playback_events(source, &Default::default())
2895                .into_iter()
2896                .filter(|event| {
2897                    event
2898                        .source
2899                        .as_ref()
2900                        .is_some_and(|address| address.staff == 0)
2901                })
2902                .map(|event| (event.pitch_midi, event.time_beats, event.duration_beats))
2903                .collect::<Vec<_>>()
2904        };
2905        assert_eq!(melody_events(&arranged), melody_events(&score));
2906    }
2907
2908    fn reduction_fixture() -> Score {
2909        let mut score = guided_fixture();
2910        let mut bass = score.parts[0].clone();
2911        bass.name = "bass".to_string();
2912        bass.staves[0].clef = Clef::Bass;
2913        bass.staves[0].measures[0].voices[0] = vec![note(Step::C, 3, Duration::Quarter)];
2914        bass.staves[0].measures[1].voices[0] = vec![note(Step::G, 3, Duration::Whole)];
2915        let mut middle = bass.clone();
2916        middle.name = "countermelody".to_string();
2917        middle.staves[0].measures[0].voices[0] = vec![note(Step::E, 4, Duration::Quarter)];
2918        middle.staves[0].measures[1].voices[0] = vec![note(Step::F, 4, Duration::Whole)];
2919        score.parts.push(bass);
2920        score.parts.push(middle);
2921        score
2922    }
2923
2924    fn reduction_options(policy: AccordionLossPolicy) -> AccordionReductionOptions {
2925        AccordionReductionOptions {
2926            right_hand_part_index: Some(0),
2927            left_hand_part_indices: vec![1, 2],
2928            loss_policy: policy,
2929            octave_fit: false,
2930        }
2931    }
2932
2933    #[test]
2934    fn explicit_reduction_preserves_three_parts_and_source_timing() {
2935        let score = reduction_fixture();
2936        let before = serde_json::to_value(&score).unwrap();
2937        let result = arrange_for_accordion_with_options(
2938            &score,
2939            reduction_options(AccordionLossPolicy::RequireAll),
2940        );
2941        assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
2942        let reduced = result.score.unwrap();
2943        assert_eq!(reduced.parts[0].staves.len(), 2);
2944        assert_eq!(
2945            reduced.parts[0].staves[1].measures[0].actual_length,
2946            score.parts[1].staves[0].measures[0].actual_length
2947        );
2948        assert_eq!(
2949            reduced.parts[0].staves[1].measures[1].barline_right,
2950            Barline::RepeatEnd
2951        );
2952        assert_eq!(result.source_map.len(), 6);
2953        assert!(
2954            result
2955                .source_map
2956                .iter()
2957                .all(|mapping| mapping.target.is_some())
2958        );
2959        for mapping in &result.source_map {
2960            let source_note = &score.parts[mapping.source.part].staves[mapping.source.staff]
2961                .measures[mapping.source.measure]
2962                .voices[mapping.source.voice][mapping.source.note];
2963            let target = mapping.target.as_ref().unwrap();
2964            let copied = &reduced.parts[target.part].staves[target.staff].measures[target.measure]
2965                .voices[target.voice][target.note];
2966            assert_eq!(copied.id, source_note.id);
2967            assert_eq!(copied.beats(), source_note.beats());
2968            assert_eq!(copied.pitches, source_note.pitches);
2969        }
2970        let original_melody = crate::to_playback_events(&score, &Default::default())
2971            .into_iter()
2972            .filter(|event| event.source.as_ref().is_some_and(|addr| addr.part == 0))
2973            .map(|event| (event.pitch_midi, event.time_beats))
2974            .collect::<Vec<_>>();
2975        let reduced_melody = crate::to_playback_events(&reduced, &Default::default())
2976            .into_iter()
2977            .filter(|event| event.source.as_ref().is_some_and(|addr| addr.staff == 0))
2978            .map(|event| (event.pitch_midi, event.time_beats))
2979            .collect::<Vec<_>>();
2980        assert_eq!(original_melody, reduced_melody);
2981        assert_eq!(serde_json::to_value(&score).unwrap(), before);
2982    }
2983
2984    #[test]
2985    fn lossless_reduction_rejects_voice_overflow_and_permitted_reduction_reports_each_note() {
2986        let mut score = reduction_fixture();
2987        for offset in 0..3 {
2988            let mut extra = score.parts[1].clone();
2989            extra.name = format!("extra {offset}");
2990            extra.staves[0].measures[0].voices[0][0].pitches[0] = Pitch::new(Step::D, 3 + offset);
2991            score.parts.push(extra);
2992        }
2993        let mut options = reduction_options(AccordionLossPolicy::RequireAll);
2994        options.left_hand_part_indices = vec![1, 2, 3, 4, 5];
2995        let rejected = arrange_for_accordion_with_options(&score, options.clone());
2996        assert!(rejected.score.is_none());
2997        assert_eq!(rejected.diagnostics.len(), 2);
2998        assert!(rejected.diagnostics.iter().all(|diagnostic| {
2999            diagnostic.code == "voice-capacity"
3000                && diagnostic
3001                    .source
3002                    .as_ref()
3003                    .is_some_and(|source| source.part == 5)
3004        }));
3005        options.loss_policy = AccordionLossPolicy::PermitReduction;
3006        let reduced = arrange_for_accordion_with_options(&score, options);
3007        assert!(reduced.score.is_some());
3008        assert_eq!(
3009            reduced
3010                .source_map
3011                .iter()
3012                .filter(|mapping| mapping.target.is_none())
3013                .count(),
3014            2
3015        );
3016        assert!(
3017            reduced
3018                .source_map
3019                .iter()
3020                .filter(|mapping| mapping.target.is_none())
3021                .all(|mapping| mapping.source.part == 5)
3022        );
3023    }
3024
3025    #[test]
3026    fn reduction_reports_unselected_notes_and_optional_octave_shift() {
3027        let mut score = reduction_fixture();
3028        let mut options = reduction_options(AccordionLossPolicy::RequireAll);
3029        options.left_hand_part_indices = vec![1];
3030        let rejected = arrange_for_accordion_with_options(&score, options.clone());
3031        assert!(rejected.score.is_none());
3032        assert_eq!(rejected.diagnostics.len(), 2);
3033        assert!(rejected.diagnostics.iter().all(|diagnostic| {
3034            diagnostic.code == "unselected-source"
3035                && diagnostic
3036                    .source
3037                    .as_ref()
3038                    .is_some_and(|source| source.part == 2)
3039        }));
3040
3041        options.loss_policy = AccordionLossPolicy::PermitReduction;
3042        let reduced = arrange_for_accordion_with_options(&score, options.clone());
3043        assert!(reduced.score.is_some());
3044        assert_eq!(
3045            reduced
3046                .source_map
3047                .iter()
3048                .filter(|mapping| mapping.target.is_none())
3049                .count(),
3050            2
3051        );
3052
3053        for part in &mut score.parts {
3054            for measure in &mut part.staves[0].measures {
3055                for note in &mut measure.voices[0] {
3056                    if !note.is_rest {
3057                        note.pitches[0] = Pitch::new(Step::C, 7);
3058                    }
3059                }
3060            }
3061        }
3062        options.left_hand_part_indices = vec![1, 2];
3063        options.loss_policy = AccordionLossPolicy::RequireAll;
3064        options.octave_fit = true;
3065        let fitted = arrange_for_accordion_with_options(&score, options);
3066        assert!(fitted.score.is_some());
3067        assert_ne!(fitted.transposition_semitones, 0);
3068        assert!(
3069            fitted
3070                .diagnostics
3071                .iter()
3072                .any(|diagnostic| diagnostic.code == "octave-shift")
3073        );
3074    }
3075
3076    #[test]
3077    fn reduction_reports_measure_annotations_that_cannot_merge() {
3078        let mut score = reduction_fixture();
3079        score.parts[2].staves[0].measures[1].tempo_text = Some("rubato".to_string());
3080        let options = reduction_options(AccordionLossPolicy::RequireAll);
3081        let rejected = arrange_for_accordion_with_options(&score, options.clone());
3082        assert!(rejected.score.is_none());
3083        assert!(rejected.diagnostics.iter().any(|diagnostic| diagnostic.code
3084            == "unsupported-notation"
3085            && diagnostic.source_location.as_deref() == Some("/score/part/3/staff/1/measure/2")));
3086        let mut permitted = options;
3087        permitted.loss_policy = AccordionLossPolicy::PermitReduction;
3088        let result = arrange_for_accordion_with_options(&score, permitted);
3089        assert!(result.score.is_some());
3090        assert!(
3091            result
3092                .diagnostics
3093                .iter()
3094                .any(|diagnostic| diagnostic.code == "unsupported-notation")
3095        );
3096    }
3097
3098    #[test]
3099    fn analyze_ranks_by_mean_pitch_descending() {
3100        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3101        score.parts[0].name = "Low".to_string();
3102        score.parts[0].staves[0].measures[0].voices[0] = vec![note(Step::C, 3, Duration::Whole)];
3103
3104        let mut high = score.parts[0].clone();
3105        high.name = "High".to_string();
3106        high.staves[0].measures[0].voices[0] = vec![note(Step::C, 5, Duration::Whole)];
3107        score.parts.push(high);
3108
3109        let analysis = analyze_for_accordion(&score);
3110        assert_eq!(analysis.candidates.len(), 2);
3111        assert_eq!(analysis.candidates[0].name, "High");
3112        assert_eq!(analysis.candidates[1].name, "Low");
3113    }
3114
3115    #[test]
3116    fn analyze_excludes_percussion_channel() {
3117        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3118        score.parts[0].midi_channel = 9;
3119        score.parts[0].staves[0].measures[0].voices[0] = vec![note(Step::C, 4, Duration::Whole)];
3120        assert!(analyze_for_accordion(&score).candidates.is_empty());
3121    }
3122
3123    #[test]
3124    fn analyze_excludes_silent_part() {
3125        // Score::new's default measures are rest-filled, not truly empty — mean_pitch
3126        // must filter is_rest notes out, not just check for an empty voice.
3127        let score = Score::new("T", 120, 4, 4, 0, 1);
3128        assert!(analyze_for_accordion(&score).candidates.is_empty());
3129    }
3130
3131    #[test]
3132    fn arrange_no_candidates_errors() {
3133        let score = Score::new("T", 120, 4, 4, 0, 1);
3134        assert!(arrange_for_accordion(&score, None).is_err());
3135    }
3136
3137    #[test]
3138    fn arrange_rejects_out_of_range_part_index() {
3139        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3140        score.parts[0].staves[0].measures[0].voices[0] = vec![note(Step::C, 4, Duration::Whole)];
3141        let result = arrange_for_accordion(&score, Some(99));
3142        assert!(matches!(result, Err(Error::PartNotFound(99))));
3143    }
3144
3145    #[test]
3146    fn arrange_single_part_splits_chord_and_sets_accordion_program() {
3147        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3148        let mut chord = note(Step::C, 5, Duration::Whole);
3149        chord.pitches.push(Pitch::new(Step::C, 3));
3150        score.parts[0].staves[0].measures[0].voices[0] = vec![chord];
3151
3152        let result = arrange_for_accordion(&score, None).unwrap();
3153        assert_eq!(result.score.parts.len(), 1);
3154        assert_eq!(result.score.parts[0].midi_program, ACCORDION_PROGRAM);
3155        assert_eq!(result.score.parts[0].staves.len(), 2);
3156        assert_eq!(result.score.parts[0].staves[0].clef, Clef::Treble);
3157        assert_eq!(result.score.parts[0].staves[1].clef, Clef::Bass);
3158    }
3159
3160    #[test]
3161    fn arrange_two_parts_puts_higher_mean_pitch_on_treble() {
3162        let mut score = Score::new("T", 120, 4, 4, 0, 2);
3163        score.parts[0].staves[0].measures[0].voices[0] = vec![note(Step::C, 3, Duration::Whole)];
3164        score.parts[0].staves[0].measures[1].voices[0] = vec![note(Step::C, 3, Duration::Whole)];
3165
3166        let mut melody = score.parts[0].clone();
3167        melody.name = "Melody".to_string();
3168        melody.staves[0].measures[0].voices[0] = vec![note(Step::C, 5, Duration::Whole)];
3169        melody.staves[0].measures[1].voices[0] = vec![note(Step::C, 5, Duration::Whole)];
3170        score.parts.push(melody);
3171
3172        let result = arrange_for_accordion(&score, None).unwrap();
3173        assert_eq!(result.score.parts[0].midi_program, ACCORDION_PROGRAM);
3174        let treble_note = &result.score.parts[0].staves[0].measures[0].voices[0][0];
3175        assert!(!treble_note.is_rest);
3176        assert_eq!(treble_note.pitches[0].octave, 5);
3177    }
3178
3179    #[test]
3180    fn arrange_right_hand_override_picks_requested_part() {
3181        let mut score = Score::new("T", 120, 4, 4, 0, 1);
3182        score.parts[0].name = "Low".to_string();
3183        score.parts[0].staves[0].measures[0].voices[0] = vec![note(Step::C, 3, Duration::Whole)];
3184
3185        let mut high = score.parts[0].clone();
3186        high.name = "High".to_string();
3187        high.staves[0].measures[0].voices[0] = vec![note(Step::C, 5, Duration::Whole)];
3188        score.parts.push(high);
3189
3190        // Force the LOWER part (index 0) onto the treble staff, against the default ranking.
3191        let result = arrange_for_accordion(&score, Some(0)).unwrap();
3192        let treble_note = &result.score.parts[0].staves[0].measures[0].voices[0][0];
3193        assert!(!treble_note.is_rest);
3194        assert_eq!(treble_note.pitches[0].octave, 3);
3195    }
3196
3197    #[test]
3198    fn arranged_measures_pass_beat_count_validation() {
3199        // Deliberately mismatched rhythmic density (2 halves vs 4 quarters vs a
3200        // whole note) across the two source parts — exercises the onset
3201        // bucketing + gap-fill path most likely to leave a measure underfull.
3202        let mut score = Score::new("T", 120, 4, 4, 0, 2);
3203        score.parts[0].staves[0].measures[0].voices[0] = vec![
3204            note(Step::C, 3, Duration::Half),
3205            note(Step::E, 3, Duration::Half),
3206        ];
3207        score.parts[0].staves[0].measures[1].voices[0] = vec![note(Step::G, 3, Duration::Whole)];
3208
3209        let mut melody = score.parts[0].clone();
3210        melody.staves[0].measures[0].voices[0] = vec![
3211            note(Step::C, 5, Duration::Quarter),
3212            note(Step::D, 5, Duration::Quarter),
3213            note(Step::E, 5, Duration::Quarter),
3214            note(Step::F, 5, Duration::Quarter),
3215        ];
3216        melody.staves[0].measures[1].voices[0] = vec![note(Step::G, 5, Duration::Whole)];
3217        score.parts.push(melody);
3218
3219        let result = arrange_for_accordion(&score, None).unwrap();
3220        let report = validate(&result.score);
3221        assert!(
3222            report.errors.is_empty(),
3223            "expected no beat-count errors, got {:?}",
3224            report.errors
3225        );
3226    }
3227}