Skip to main content

acorde_core/model/
arrange.rs

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