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