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::Serialize;
14use std::collections::BTreeMap;
15
16use super::duration::Duration;
17use super::gm::instrument_range;
18use super::notation::{Barline, Clef};
19use super::pitch::Pitch;
20use super::score::{Measure, Note, Part, Score, Staff};
21use crate::Error;
22
23const ACCORDION_PROGRAM: u8 = 21;
24/// Onset/duration quantization grid — 64th-note resolution, the finest
25/// [`Duration`] unit the model supports.
26const GRID: f64 = 64.0;
27/// Middle-C split point used only when a single part must be divided
28/// between the two staves (no second candidate part to use as the bass).
29const SPLIT_MIDI: u8 = 60;
30
31#[derive(Debug, Clone, Serialize)]
32pub struct PartCandidate {
33    pub part_index: usize,
34    pub name: String,
35    pub mean_pitch: f64,
36}
37
38#[derive(Debug, Clone, Serialize)]
39pub struct AccordionAnalysis {
40    pub candidates: Vec<PartCandidate>,
41    /// True when the top two candidates' mean pitch is within 3 semitones —
42    /// the automatic ranking is a coin flip, caller should offer a picker.
43    pub ambiguous: bool,
44}
45
46#[derive(Debug, Clone, Serialize)]
47pub struct ArrangeResult {
48    pub score: Score,
49    pub notes: Vec<String>,
50}
51
52fn is_percussion_part(part: &Part) -> bool {
53    part.midi_channel == 9 || part.staves.iter().any(|s| s.clef == Clef::Percussion)
54}
55
56fn mean_pitch(part: &Part) -> Option<f64> {
57    let (sum, count) = part
58        .staves
59        .iter()
60        .flat_map(|s| s.measures.iter())
61        .flat_map(|m| m.voices.iter())
62        .flat_map(|v| v.iter())
63        .filter(|n| !n.is_rest && !n.is_grace)
64        .flat_map(|n| n.pitches.iter())
65        .fold((0i64, 0i64), |(sum, count), p| {
66            (sum + p.to_midi() as i64, count + 1)
67        });
68    if count == 0 {
69        None
70    } else {
71        Some(sum as f64 / count as f64)
72    }
73}
74
75/// Rank non-percussion, non-silent parts by mean pitch (descending). The top
76/// part is the default melody/right-hand candidate.
77pub fn analyze_for_accordion(score: &Score) -> AccordionAnalysis {
78    let mut candidates: Vec<PartCandidate> = score
79        .parts
80        .iter()
81        .enumerate()
82        .filter(|(_, p)| !is_percussion_part(p))
83        .filter_map(|(i, p)| {
84            mean_pitch(p).map(|mp| PartCandidate {
85                part_index: i,
86                name: p.name.clone(),
87                mean_pitch: mp,
88            })
89        })
90        .collect();
91    candidates.sort_by(|a, b| {
92        b.mean_pitch
93            .partial_cmp(&a.mean_pitch)
94            .unwrap_or(std::cmp::Ordering::Equal)
95    });
96    let ambiguous =
97        candidates.len() >= 2 && (candidates[0].mean_pitch - candidates[1].mean_pitch).abs() < 3.0;
98    AccordionAnalysis {
99        candidates,
100        ambiguous,
101    }
102}
103
104struct SourceEvent {
105    onset_ticks: i64,
106    beats: f64,
107    pitches: Vec<Pitch>,
108}
109
110fn events_from_parts(
111    score: &Score,
112    part_indices: &[usize],
113    measure_idx: usize,
114) -> Vec<SourceEvent> {
115    let mut events = Vec::new();
116    for &pi in part_indices {
117        let part = &score.parts[pi];
118        for staff in &part.staves {
119            let Some(measure) = staff.measures.get(measure_idx) else {
120                continue;
121            };
122            if measure.multi_rest_count.is_some() {
123                continue;
124            }
125            for voice in &measure.voices {
126                let mut onset = 0.0f64;
127                for note in voice {
128                    let b = note.beats();
129                    if !note.is_rest && !note.is_grace && !note.pitches.is_empty() {
130                        let pitches = note
131                            .pitches
132                            .iter()
133                            .map(|p| {
134                                let midi = (p.to_midi() + staff.transpose_semitones as i16)
135                                    .clamp(0, 127) as u8;
136                                Pitch::from_midi(midi, false)
137                            })
138                            .collect();
139                        events.push(SourceEvent {
140                            onset_ticks: (onset * GRID).round() as i64,
141                            beats: b,
142                            pitches,
143                        });
144                    }
145                    onset += b;
146                }
147            }
148        }
149    }
150    events
151}
152
153/// Same walk as [`events_from_parts`] but for a single part, splitting each
154/// note's pitches at [`SPLIT_MIDI`] instead of by which part they came from.
155/// Used only when there is no second candidate part to merge in as the bass.
156fn events_from_pitch_split(
157    score: &Score,
158    part_index: usize,
159    measure_idx: usize,
160    high: bool,
161) -> Vec<SourceEvent> {
162    let part = &score.parts[part_index];
163    let mut events = Vec::new();
164    for staff in &part.staves {
165        let Some(measure) = staff.measures.get(measure_idx) else {
166            continue;
167        };
168        if measure.multi_rest_count.is_some() {
169            continue;
170        }
171        for voice in &measure.voices {
172            let mut onset = 0.0f64;
173            for note in voice {
174                let b = note.beats();
175                if !note.is_rest && !note.is_grace {
176                    let pitches: Vec<Pitch> = note
177                        .pitches
178                        .iter()
179                        .filter_map(|p| {
180                            let midi = (p.to_midi() + staff.transpose_semitones as i16)
181                                .clamp(0, 127) as u8;
182                            let keep = if high {
183                                midi >= SPLIT_MIDI
184                            } else {
185                                midi < SPLIT_MIDI
186                            };
187                            if keep {
188                                Some(Pitch::from_midi(midi, false))
189                            } else {
190                                None
191                            }
192                        })
193                        .collect();
194                    if !pitches.is_empty() {
195                        events.push(SourceEvent {
196                            onset_ticks: (onset * GRID).round() as i64,
197                            beats: b,
198                            pitches,
199                        });
200                    }
201                }
202                onset += b;
203            }
204        }
205    }
206    events
207}
208
209fn fill_rests(notes: &mut Vec<Note>, mut remaining: f64) {
210    while remaining > 1.0 / GRID {
211        let dur = Duration::whole_filling_beats(remaining);
212        let filled = dur.beats(0);
213        notes.push(Note::rest(dur));
214        remaining -= filled;
215    }
216}
217
218/// Bucket-merge precomputed `events_per_measure` onto a single staff, one
219/// measure at a time, gap-filling with rests via [`Duration::whole_filling_beats`]
220/// (the same greedy decomposition [`Measure::empty`] uses).
221fn assemble_staff(
222    clef: Clef,
223    template_per_measure: Vec<Option<Measure>>,
224    events_per_measure: Vec<Vec<SourceEvent>>,
225    default_ts: super::notation::TimeSignature,
226) -> Staff {
227    let mut current_ts = default_ts;
228    let mut measures = Vec::with_capacity(template_per_measure.len());
229
230    for (mi, (template, events)) in template_per_measure
231        .into_iter()
232        .zip(events_per_measure)
233        .enumerate()
234    {
235        if let Some(ts) = template.as_ref().and_then(|m| m.time_sig.as_ref()) {
236            current_ts = ts.clone();
237        }
238        let total_beats = current_ts.total_beats();
239
240        let mut buckets: BTreeMap<i64, Vec<SourceEvent>> = BTreeMap::new();
241        for ev in events {
242            buckets.entry(ev.onset_ticks).or_default().push(ev);
243        }
244        let keys: Vec<i64> = buckets.keys().copied().collect();
245
246        let mut voice0: Vec<Note> = Vec::new();
247        let mut cursor = 0.0f64;
248        for (idx, &key) in keys.iter().enumerate() {
249            let onset = key as f64 / GRID;
250            if onset < cursor - 1.0 / GRID {
251                continue;
252            } // swallowed by a longer preceding event
253            if onset > cursor {
254                fill_rests(&mut voice0, onset - cursor);
255                cursor = onset;
256            }
257
258            let next_onset = keys
259                .get(idx + 1)
260                .map(|&k| k as f64 / GRID)
261                .unwrap_or(total_beats);
262            let group = &buckets[&key];
263            let shortest = group.iter().map(|e| e.beats).fold(f64::MAX, f64::min);
264            let cap = (next_onset - onset).max(1.0 / GRID);
265            let sounding = shortest.min(cap).min((total_beats - onset).max(1.0 / GRID));
266
267            let mut pitches: Vec<Pitch> = Vec::new();
268            let mut seen_midi: Vec<u8> = Vec::new();
269            for ev in group {
270                for p in &ev.pitches {
271                    let midi = p.to_midi().clamp(0, 127) as u8;
272                    if !seen_midi.contains(&midi) {
273                        seen_midi.push(midi);
274                        pitches.push(p.clone());
275                    }
276                }
277            }
278            if pitches.is_empty() {
279                continue;
280            }
281
282            let dur = Duration::whole_filling_beats(sounding);
283            let emitted = dur.beats(0);
284            let mut note = Note::new(pitches[0].clone(), dur);
285            note.pitches = pitches;
286            voice0.push(note);
287            cursor += emitted;
288        }
289        if total_beats - cursor > 1.0 / GRID {
290            fill_rests(&mut voice0, total_beats - cursor);
291        }
292        if voice0.is_empty() {
293            fill_rests(&mut voice0, total_beats);
294        }
295
296        let mut measure = Measure::empty(current_ts.numerator, current_ts.denominator);
297        measure.number = mi as u32 + 1;
298        measure.time_sig = template.as_ref().and_then(|m| m.time_sig.clone());
299        measure.key_sig = template.as_ref().and_then(|m| m.key_sig.clone());
300        measure.tempo = template.as_ref().and_then(|m| m.tempo);
301        measure.barline_left = template
302            .as_ref()
303            .map(|m| m.barline_left.clone())
304            .unwrap_or(Barline::Normal);
305        measure.barline_right = template
306            .as_ref()
307            .map(|m| m.barline_right.clone())
308            .unwrap_or(Barline::Normal);
309        measure.voices[0] = voice0;
310        measures.push(measure);
311    }
312
313    Staff {
314        clef,
315        measures,
316        transpose_semitones: 0,
317    }
318}
319
320fn template_measure_for_parts(score: &Score, part_indices: &[usize], mi: usize) -> Option<Measure> {
321    part_indices
322        .iter()
323        .filter_map(|&pi| score.parts[pi].staves.first())
324        .find_map(|s| s.measures.get(mi).cloned())
325}
326
327/// Octave-shift `score` (expected to be the freshly-merged, single-part
328/// accordion score) so its mean pitch sits within the practical range for
329/// the accordion GM program. Only multiples of 12 are considered — an
330/// arbitrary semitone shift would break the key signature.
331fn octave_fit(score: &Score) -> (Score, i8) {
332    let (lo, hi) = instrument_range(ACCORDION_PROGRAM);
333    let target_mid = (lo as f64 + hi as f64) / 2.0;
334    let Some(mp) = score.parts.first().and_then(mean_pitch) else {
335        return (score.clone(), 0);
336    };
337
338    let shift = [-24i8, -12, 0, 12, 24]
339        .into_iter()
340        .min_by(|&a, &b| {
341            let da = (mp + a as f64 - target_mid).abs();
342            let db = (mp + b as f64 - target_mid).abs();
343            da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
344        })
345        .unwrap_or(0);
346
347    if shift == 0 {
348        (score.clone(), 0)
349    } else {
350        (super::score::transpose(score, shift), shift)
351    }
352}
353
354/// Arrange `score` for accordion: merge onto two staves (treble = right
355/// hand / melody, bass = left hand / everything else), reassign to the
356/// Accordion GM program, and octave-fit to its practical range.
357///
358/// `right_hand_part_index` overrides the automatic mean-pitch ranking from
359/// [`analyze_for_accordion`] — pass `None` to use the default (highest mean
360/// pitch). Percussion parts are always excluded from both staves.
361pub fn arrange_for_accordion(
362    score: &Score,
363    right_hand_part_index: Option<usize>,
364) -> Result<ArrangeResult, Error> {
365    let analysis = analyze_for_accordion(score);
366    if analysis.candidates.is_empty() {
367        return Err(Error::InvalidCommand(
368            "no pitched, non-percussion part to arrange".to_string(),
369        ));
370    }
371
372    let treble_index = match right_hand_part_index {
373        Some(i) => {
374            if i >= score.parts.len() || is_percussion_part(&score.parts[i]) {
375                return Err(Error::PartNotFound(i));
376            }
377            i
378        }
379        None => analysis.candidates[0].part_index,
380    };
381    let bass_indices: Vec<usize> = analysis
382        .candidates
383        .iter()
384        .map(|c| c.part_index)
385        .filter(|&i| i != treble_index)
386        .collect();
387
388    let mut notes = Vec::new();
389    let measure_count = analysis
390        .candidates
391        .iter()
392        .map(|c| {
393            score.parts[c.part_index]
394                .staves
395                .iter()
396                .map(|s| s.measures.len())
397                .max()
398                .unwrap_or(0)
399        })
400        .max()
401        .unwrap_or(0);
402    let default_ts = score.settings.time_signature.clone();
403
404    let (treble_staff, bass_staff) = if !bass_indices.is_empty() {
405        let treble_template: Vec<Option<Measure>> = (0..measure_count)
406            .map(|mi| template_measure_for_parts(score, &[treble_index], mi))
407            .collect();
408        let treble_events: Vec<Vec<SourceEvent>> = (0..measure_count)
409            .map(|mi| events_from_parts(score, &[treble_index], mi))
410            .collect();
411        let bass_template: Vec<Option<Measure>> = (0..measure_count)
412            .map(|mi| template_measure_for_parts(score, &bass_indices, mi))
413            .collect();
414        let bass_events: Vec<Vec<SourceEvent>> = (0..measure_count)
415            .map(|mi| events_from_parts(score, &bass_indices, mi))
416            .collect();
417        notes.push(format!(
418            "右手(高音部): {} / 左手(低音部): {}パートを統合",
419            analysis
420                .candidates
421                .iter()
422                .find(|c| c.part_index == treble_index)
423                .map(|c| c.name.as_str())
424                .unwrap_or(""),
425            bass_indices.len()
426        ));
427        (
428            assemble_staff(
429                Clef::Treble,
430                treble_template,
431                treble_events,
432                default_ts.clone(),
433            ),
434            assemble_staff(Clef::Bass, bass_template, bass_events, default_ts),
435        )
436    } else {
437        let template: Vec<Option<Measure>> = (0..measure_count)
438            .map(|mi| template_measure_for_parts(score, &[treble_index], mi))
439            .collect();
440        let treble_events: Vec<Vec<SourceEvent>> = (0..measure_count)
441            .map(|mi| events_from_pitch_split(score, treble_index, mi, true))
442            .collect();
443        let bass_events: Vec<Vec<SourceEvent>> = (0..measure_count)
444            .map(|mi| events_from_pitch_split(score, treble_index, mi, false))
445            .collect();
446        notes.push("単一パートのため中央ハ(MIDI 60)を基準に上下2段へ分割".to_string());
447        (
448            assemble_staff(
449                Clef::Treble,
450                template.clone(),
451                treble_events,
452                default_ts.clone(),
453            ),
454            assemble_staff(Clef::Bass, template, bass_events, default_ts),
455        )
456    };
457
458    let mut accordion_part = Part::new("Accordion", "Acc.");
459    accordion_part.midi_program = ACCORDION_PROGRAM;
460    accordion_part.staves = vec![treble_staff, bass_staff];
461
462    let mut merged = Score {
463        id: uuid::Uuid::new_v4().to_string(),
464        schema_version: 1,
465        metadata: score.metadata.clone(),
466        settings: score.settings.clone(),
467        parts: vec![accordion_part],
468        part_groups: Vec::new(),
469    };
470
471    let (fitted, shift) = octave_fit(&merged);
472    merged = fitted;
473    if shift != 0 {
474        notes.push(format!(
475            "アコーディオンの実用音域に合わせて{}オクターブ移調",
476            shift / 12
477        ));
478    }
479
480    super::score::respell_score_to_key(&mut merged);
481
482    if analysis.ambiguous && right_hand_part_index.is_none() {
483        notes.push("上位2パートの平均音高が僅差のため、右手パートの選択が曖昧です".to_string());
484    }
485
486    Ok(ArrangeResult {
487        score: merged,
488        notes,
489    })
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495    use crate::model::{pitch::Step, score::Score, validate::validate};
496
497    fn note(step: Step, octave: i8, duration: Duration) -> Note {
498        Note::new(Pitch::new(step, octave), duration)
499    }
500
501    #[test]
502    fn analyze_ranks_by_mean_pitch_descending() {
503        let mut score = Score::new("T", 120, 4, 4, 0, 1);
504        score.parts[0].name = "Low".to_string();
505        score.parts[0].staves[0].measures[0].voices[0] = vec![note(Step::C, 3, Duration::Whole)];
506
507        let mut high = score.parts[0].clone();
508        high.name = "High".to_string();
509        high.staves[0].measures[0].voices[0] = vec![note(Step::C, 5, Duration::Whole)];
510        score.parts.push(high);
511
512        let analysis = analyze_for_accordion(&score);
513        assert_eq!(analysis.candidates.len(), 2);
514        assert_eq!(analysis.candidates[0].name, "High");
515        assert_eq!(analysis.candidates[1].name, "Low");
516    }
517
518    #[test]
519    fn analyze_excludes_percussion_channel() {
520        let mut score = Score::new("T", 120, 4, 4, 0, 1);
521        score.parts[0].midi_channel = 9;
522        score.parts[0].staves[0].measures[0].voices[0] = vec![note(Step::C, 4, Duration::Whole)];
523        assert!(analyze_for_accordion(&score).candidates.is_empty());
524    }
525
526    #[test]
527    fn analyze_excludes_silent_part() {
528        // Score::new's default measures are rest-filled, not truly empty — mean_pitch
529        // must filter is_rest notes out, not just check for an empty voice.
530        let score = Score::new("T", 120, 4, 4, 0, 1);
531        assert!(analyze_for_accordion(&score).candidates.is_empty());
532    }
533
534    #[test]
535    fn arrange_no_candidates_errors() {
536        let score = Score::new("T", 120, 4, 4, 0, 1);
537        assert!(arrange_for_accordion(&score, None).is_err());
538    }
539
540    #[test]
541    fn arrange_rejects_out_of_range_part_index() {
542        let mut score = Score::new("T", 120, 4, 4, 0, 1);
543        score.parts[0].staves[0].measures[0].voices[0] = vec![note(Step::C, 4, Duration::Whole)];
544        let result = arrange_for_accordion(&score, Some(99));
545        assert!(matches!(result, Err(Error::PartNotFound(99))));
546    }
547
548    #[test]
549    fn arrange_single_part_splits_chord_and_sets_accordion_program() {
550        let mut score = Score::new("T", 120, 4, 4, 0, 1);
551        let mut chord = note(Step::C, 5, Duration::Whole);
552        chord.pitches.push(Pitch::new(Step::C, 3));
553        score.parts[0].staves[0].measures[0].voices[0] = vec![chord];
554
555        let result = arrange_for_accordion(&score, None).unwrap();
556        assert_eq!(result.score.parts.len(), 1);
557        assert_eq!(result.score.parts[0].midi_program, ACCORDION_PROGRAM);
558        assert_eq!(result.score.parts[0].staves.len(), 2);
559        assert_eq!(result.score.parts[0].staves[0].clef, Clef::Treble);
560        assert_eq!(result.score.parts[0].staves[1].clef, Clef::Bass);
561    }
562
563    #[test]
564    fn arrange_two_parts_puts_higher_mean_pitch_on_treble() {
565        let mut score = Score::new("T", 120, 4, 4, 0, 2);
566        score.parts[0].staves[0].measures[0].voices[0] = vec![note(Step::C, 3, Duration::Whole)];
567        score.parts[0].staves[0].measures[1].voices[0] = vec![note(Step::C, 3, Duration::Whole)];
568
569        let mut melody = score.parts[0].clone();
570        melody.name = "Melody".to_string();
571        melody.staves[0].measures[0].voices[0] = vec![note(Step::C, 5, Duration::Whole)];
572        melody.staves[0].measures[1].voices[0] = vec![note(Step::C, 5, Duration::Whole)];
573        score.parts.push(melody);
574
575        let result = arrange_for_accordion(&score, None).unwrap();
576        assert_eq!(result.score.parts[0].midi_program, ACCORDION_PROGRAM);
577        let treble_note = &result.score.parts[0].staves[0].measures[0].voices[0][0];
578        assert!(!treble_note.is_rest);
579        assert_eq!(treble_note.pitches[0].octave, 5);
580    }
581
582    #[test]
583    fn arrange_right_hand_override_picks_requested_part() {
584        let mut score = Score::new("T", 120, 4, 4, 0, 1);
585        score.parts[0].name = "Low".to_string();
586        score.parts[0].staves[0].measures[0].voices[0] = vec![note(Step::C, 3, Duration::Whole)];
587
588        let mut high = score.parts[0].clone();
589        high.name = "High".to_string();
590        high.staves[0].measures[0].voices[0] = vec![note(Step::C, 5, Duration::Whole)];
591        score.parts.push(high);
592
593        // Force the LOWER part (index 0) onto the treble staff, against the default ranking.
594        let result = arrange_for_accordion(&score, Some(0)).unwrap();
595        let treble_note = &result.score.parts[0].staves[0].measures[0].voices[0][0];
596        assert!(!treble_note.is_rest);
597        assert_eq!(treble_note.pitches[0].octave, 3);
598    }
599
600    #[test]
601    fn arranged_measures_pass_beat_count_validation() {
602        // Deliberately mismatched rhythmic density (2 halves vs 4 quarters vs a
603        // whole note) across the two source parts — exercises the onset
604        // bucketing + gap-fill path most likely to leave a measure underfull.
605        let mut score = Score::new("T", 120, 4, 4, 0, 2);
606        score.parts[0].staves[0].measures[0].voices[0] = vec![
607            note(Step::C, 3, Duration::Half),
608            note(Step::E, 3, Duration::Half),
609        ];
610        score.parts[0].staves[0].measures[1].voices[0] = vec![note(Step::G, 3, Duration::Whole)];
611
612        let mut melody = score.parts[0].clone();
613        melody.staves[0].measures[0].voices[0] = vec![
614            note(Step::C, 5, Duration::Quarter),
615            note(Step::D, 5, Duration::Quarter),
616            note(Step::E, 5, Duration::Quarter),
617            note(Step::F, 5, Duration::Quarter),
618        ];
619        melody.staves[0].measures[1].voices[0] = vec![note(Step::G, 5, Duration::Whole)];
620        score.parts.push(melody);
621
622        let result = arrange_for_accordion(&score, None).unwrap();
623        let report = validate(&result.score);
624        assert!(
625            report.errors.is_empty(),
626            "expected no beat-count errors, got {:?}",
627            report.errors
628        );
629    }
630}