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        tablature: None,
318    }
319}
320
321fn template_measure_for_parts(score: &Score, part_indices: &[usize], mi: usize) -> Option<Measure> {
322    part_indices
323        .iter()
324        .filter_map(|&pi| score.parts[pi].staves.first())
325        .find_map(|s| s.measures.get(mi).cloned())
326}
327
328/// Octave-shift `score` (expected to be the freshly-merged, single-part
329/// accordion score) so its mean pitch sits within the practical range for
330/// the accordion GM program. Only multiples of 12 are considered — an
331/// arbitrary semitone shift would break the key signature.
332fn octave_fit(score: &Score) -> (Score, i8) {
333    let (lo, hi) = instrument_range(ACCORDION_PROGRAM);
334    let target_mid = (lo as f64 + hi as f64) / 2.0;
335    let Some(mp) = score.parts.first().and_then(mean_pitch) else {
336        return (score.clone(), 0);
337    };
338
339    let shift = [-24i8, -12, 0, 12, 24]
340        .into_iter()
341        .min_by(|&a, &b| {
342            let da = (mp + a as f64 - target_mid).abs();
343            let db = (mp + b as f64 - target_mid).abs();
344            da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
345        })
346        .unwrap_or(0);
347
348    if shift == 0 {
349        (score.clone(), 0)
350    } else {
351        (super::score::transpose(score, shift), shift)
352    }
353}
354
355/// Arrange `score` for accordion: merge onto two staves (treble = right
356/// hand / melody, bass = left hand / everything else), reassign to the
357/// Accordion GM program, and octave-fit to its practical range.
358///
359/// `right_hand_part_index` overrides the automatic mean-pitch ranking from
360/// [`analyze_for_accordion`] — pass `None` to use the default (highest mean
361/// pitch). Percussion parts are always excluded from both staves.
362pub fn arrange_for_accordion(
363    score: &Score,
364    right_hand_part_index: Option<usize>,
365) -> Result<ArrangeResult, Error> {
366    let analysis = analyze_for_accordion(score);
367    if analysis.candidates.is_empty() {
368        return Err(Error::InvalidCommand(
369            "no pitched, non-percussion part to arrange".to_string(),
370        ));
371    }
372
373    let treble_index = match right_hand_part_index {
374        Some(i) => {
375            if i >= score.parts.len() || is_percussion_part(&score.parts[i]) {
376                return Err(Error::PartNotFound(i));
377            }
378            i
379        }
380        None => analysis.candidates[0].part_index,
381    };
382    let bass_indices: Vec<usize> = analysis
383        .candidates
384        .iter()
385        .map(|c| c.part_index)
386        .filter(|&i| i != treble_index)
387        .collect();
388
389    let mut notes = Vec::new();
390    let measure_count = analysis
391        .candidates
392        .iter()
393        .map(|c| {
394            score.parts[c.part_index]
395                .staves
396                .iter()
397                .map(|s| s.measures.len())
398                .max()
399                .unwrap_or(0)
400        })
401        .max()
402        .unwrap_or(0);
403    let default_ts = score.settings.time_signature.clone();
404
405    let (treble_staff, bass_staff) = if !bass_indices.is_empty() {
406        let treble_template: Vec<Option<Measure>> = (0..measure_count)
407            .map(|mi| template_measure_for_parts(score, &[treble_index], mi))
408            .collect();
409        let treble_events: Vec<Vec<SourceEvent>> = (0..measure_count)
410            .map(|mi| events_from_parts(score, &[treble_index], mi))
411            .collect();
412        let bass_template: Vec<Option<Measure>> = (0..measure_count)
413            .map(|mi| template_measure_for_parts(score, &bass_indices, mi))
414            .collect();
415        let bass_events: Vec<Vec<SourceEvent>> = (0..measure_count)
416            .map(|mi| events_from_parts(score, &bass_indices, mi))
417            .collect();
418        notes.push(format!(
419            "右手(高音部): {} / 左手(低音部): {}パートを統合",
420            analysis
421                .candidates
422                .iter()
423                .find(|c| c.part_index == treble_index)
424                .map(|c| c.name.as_str())
425                .unwrap_or(""),
426            bass_indices.len()
427        ));
428        (
429            assemble_staff(
430                Clef::Treble,
431                treble_template,
432                treble_events,
433                default_ts.clone(),
434            ),
435            assemble_staff(Clef::Bass, bass_template, bass_events, default_ts),
436        )
437    } else {
438        let template: Vec<Option<Measure>> = (0..measure_count)
439            .map(|mi| template_measure_for_parts(score, &[treble_index], mi))
440            .collect();
441        let treble_events: Vec<Vec<SourceEvent>> = (0..measure_count)
442            .map(|mi| events_from_pitch_split(score, treble_index, mi, true))
443            .collect();
444        let bass_events: Vec<Vec<SourceEvent>> = (0..measure_count)
445            .map(|mi| events_from_pitch_split(score, treble_index, mi, false))
446            .collect();
447        notes.push("単一パートのため中央ハ(MIDI 60)を基準に上下2段へ分割".to_string());
448        (
449            assemble_staff(
450                Clef::Treble,
451                template.clone(),
452                treble_events,
453                default_ts.clone(),
454            ),
455            assemble_staff(Clef::Bass, template, bass_events, default_ts),
456        )
457    };
458
459    let mut accordion_part = Part::new("Accordion", "Acc.");
460    accordion_part.midi_program = ACCORDION_PROGRAM;
461    accordion_part.staves = vec![treble_staff, bass_staff];
462
463    let mut merged = Score {
464        id: uuid::Uuid::new_v4().to_string(),
465        schema_version: 1,
466        metadata: score.metadata.clone(),
467        settings: score.settings.clone(),
468        parts: vec![accordion_part],
469        part_groups: Vec::new(),
470        texts: score.texts.clone(),
471        chord_definitions: score.chord_definitions.clone(),
472    };
473
474    let (fitted, shift) = octave_fit(&merged);
475    merged = fitted;
476    if shift != 0 {
477        notes.push(format!(
478            "アコーディオンの実用音域に合わせて{}オクターブ移調",
479            shift / 12
480        ));
481    }
482
483    super::score::respell_score_to_key(&mut merged);
484
485    if analysis.ambiguous && right_hand_part_index.is_none() {
486        notes.push("上位2パートの平均音高が僅差のため、右手パートの選択が曖昧です".to_string());
487    }
488
489    Ok(ArrangeResult {
490        score: merged,
491        notes,
492    })
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use crate::model::{pitch::Step, score::Score, validate::validate};
499
500    fn note(step: Step, octave: i8, duration: Duration) -> Note {
501        Note::new(Pitch::new(step, octave), duration)
502    }
503
504    #[test]
505    fn analyze_ranks_by_mean_pitch_descending() {
506        let mut score = Score::new("T", 120, 4, 4, 0, 1);
507        score.parts[0].name = "Low".to_string();
508        score.parts[0].staves[0].measures[0].voices[0] = vec![note(Step::C, 3, Duration::Whole)];
509
510        let mut high = score.parts[0].clone();
511        high.name = "High".to_string();
512        high.staves[0].measures[0].voices[0] = vec![note(Step::C, 5, Duration::Whole)];
513        score.parts.push(high);
514
515        let analysis = analyze_for_accordion(&score);
516        assert_eq!(analysis.candidates.len(), 2);
517        assert_eq!(analysis.candidates[0].name, "High");
518        assert_eq!(analysis.candidates[1].name, "Low");
519    }
520
521    #[test]
522    fn analyze_excludes_percussion_channel() {
523        let mut score = Score::new("T", 120, 4, 4, 0, 1);
524        score.parts[0].midi_channel = 9;
525        score.parts[0].staves[0].measures[0].voices[0] = vec![note(Step::C, 4, Duration::Whole)];
526        assert!(analyze_for_accordion(&score).candidates.is_empty());
527    }
528
529    #[test]
530    fn analyze_excludes_silent_part() {
531        // Score::new's default measures are rest-filled, not truly empty — mean_pitch
532        // must filter is_rest notes out, not just check for an empty voice.
533        let score = Score::new("T", 120, 4, 4, 0, 1);
534        assert!(analyze_for_accordion(&score).candidates.is_empty());
535    }
536
537    #[test]
538    fn arrange_no_candidates_errors() {
539        let score = Score::new("T", 120, 4, 4, 0, 1);
540        assert!(arrange_for_accordion(&score, None).is_err());
541    }
542
543    #[test]
544    fn arrange_rejects_out_of_range_part_index() {
545        let mut score = Score::new("T", 120, 4, 4, 0, 1);
546        score.parts[0].staves[0].measures[0].voices[0] = vec![note(Step::C, 4, Duration::Whole)];
547        let result = arrange_for_accordion(&score, Some(99));
548        assert!(matches!(result, Err(Error::PartNotFound(99))));
549    }
550
551    #[test]
552    fn arrange_single_part_splits_chord_and_sets_accordion_program() {
553        let mut score = Score::new("T", 120, 4, 4, 0, 1);
554        let mut chord = note(Step::C, 5, Duration::Whole);
555        chord.pitches.push(Pitch::new(Step::C, 3));
556        score.parts[0].staves[0].measures[0].voices[0] = vec![chord];
557
558        let result = arrange_for_accordion(&score, None).unwrap();
559        assert_eq!(result.score.parts.len(), 1);
560        assert_eq!(result.score.parts[0].midi_program, ACCORDION_PROGRAM);
561        assert_eq!(result.score.parts[0].staves.len(), 2);
562        assert_eq!(result.score.parts[0].staves[0].clef, Clef::Treble);
563        assert_eq!(result.score.parts[0].staves[1].clef, Clef::Bass);
564    }
565
566    #[test]
567    fn arrange_two_parts_puts_higher_mean_pitch_on_treble() {
568        let mut score = Score::new("T", 120, 4, 4, 0, 2);
569        score.parts[0].staves[0].measures[0].voices[0] = vec![note(Step::C, 3, Duration::Whole)];
570        score.parts[0].staves[0].measures[1].voices[0] = vec![note(Step::C, 3, Duration::Whole)];
571
572        let mut melody = score.parts[0].clone();
573        melody.name = "Melody".to_string();
574        melody.staves[0].measures[0].voices[0] = vec![note(Step::C, 5, Duration::Whole)];
575        melody.staves[0].measures[1].voices[0] = vec![note(Step::C, 5, Duration::Whole)];
576        score.parts.push(melody);
577
578        let result = arrange_for_accordion(&score, None).unwrap();
579        assert_eq!(result.score.parts[0].midi_program, ACCORDION_PROGRAM);
580        let treble_note = &result.score.parts[0].staves[0].measures[0].voices[0][0];
581        assert!(!treble_note.is_rest);
582        assert_eq!(treble_note.pitches[0].octave, 5);
583    }
584
585    #[test]
586    fn arrange_right_hand_override_picks_requested_part() {
587        let mut score = Score::new("T", 120, 4, 4, 0, 1);
588        score.parts[0].name = "Low".to_string();
589        score.parts[0].staves[0].measures[0].voices[0] = vec![note(Step::C, 3, Duration::Whole)];
590
591        let mut high = score.parts[0].clone();
592        high.name = "High".to_string();
593        high.staves[0].measures[0].voices[0] = vec![note(Step::C, 5, Duration::Whole)];
594        score.parts.push(high);
595
596        // Force the LOWER part (index 0) onto the treble staff, against the default ranking.
597        let result = arrange_for_accordion(&score, Some(0)).unwrap();
598        let treble_note = &result.score.parts[0].staves[0].measures[0].voices[0][0];
599        assert!(!treble_note.is_rest);
600        assert_eq!(treble_note.pitches[0].octave, 3);
601    }
602
603    #[test]
604    fn arranged_measures_pass_beat_count_validation() {
605        // Deliberately mismatched rhythmic density (2 halves vs 4 quarters vs a
606        // whole note) across the two source parts — exercises the onset
607        // bucketing + gap-fill path most likely to leave a measure underfull.
608        let mut score = Score::new("T", 120, 4, 4, 0, 2);
609        score.parts[0].staves[0].measures[0].voices[0] = vec![
610            note(Step::C, 3, Duration::Half),
611            note(Step::E, 3, Duration::Half),
612        ];
613        score.parts[0].staves[0].measures[1].voices[0] = vec![note(Step::G, 3, Duration::Whole)];
614
615        let mut melody = score.parts[0].clone();
616        melody.staves[0].measures[0].voices[0] = vec![
617            note(Step::C, 5, Duration::Quarter),
618            note(Step::D, 5, Duration::Quarter),
619            note(Step::E, 5, Duration::Quarter),
620            note(Step::F, 5, Duration::Quarter),
621        ];
622        melody.staves[0].measures[1].voices[0] = vec![note(Step::G, 5, Duration::Whole)];
623        score.parts.push(melody);
624
625        let result = arrange_for_accordion(&score, None).unwrap();
626        let report = validate(&result.score);
627        assert!(
628            report.errors.is_empty(),
629            "expected no beat-count errors, got {:?}",
630            report.errors
631        );
632    }
633}