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| (sum + p.to_midi() as i64, count + 1));
66    if count == 0 { None } else { Some(sum as f64 / count as f64) }
67}
68
69/// Rank non-percussion, non-silent parts by mean pitch (descending). The top
70/// part is the default melody/right-hand candidate.
71pub fn analyze_for_accordion(score: &Score) -> AccordionAnalysis {
72    let mut candidates: Vec<PartCandidate> = score
73        .parts
74        .iter()
75        .enumerate()
76        .filter(|(_, p)| !is_percussion_part(p))
77        .filter_map(|(i, p)| mean_pitch(p).map(|mp| PartCandidate { part_index: i, name: p.name.clone(), mean_pitch: mp }))
78        .collect();
79    candidates.sort_by(|a, b| b.mean_pitch.partial_cmp(&a.mean_pitch).unwrap_or(std::cmp::Ordering::Equal));
80    let ambiguous = candidates.len() >= 2 && (candidates[0].mean_pitch - candidates[1].mean_pitch).abs() < 3.0;
81    AccordionAnalysis { candidates, ambiguous }
82}
83
84struct SourceEvent {
85    onset_ticks: i64,
86    beats: f64,
87    pitches: Vec<Pitch>,
88}
89
90fn events_from_parts(score: &Score, part_indices: &[usize], measure_idx: usize) -> Vec<SourceEvent> {
91    let mut events = Vec::new();
92    for &pi in part_indices {
93        let part = &score.parts[pi];
94        for staff in &part.staves {
95            let Some(measure) = staff.measures.get(measure_idx) else { continue };
96            if measure.multi_rest_count.is_some() { continue }
97            for voice in &measure.voices {
98                let mut onset = 0.0f64;
99                for note in voice {
100                    let b = note.beats();
101                    if !note.is_rest && !note.is_grace && !note.pitches.is_empty() {
102                        let pitches = note
103                            .pitches
104                            .iter()
105                            .map(|p| {
106                                let midi = (p.to_midi() + staff.transpose_semitones as i16).clamp(0, 127) as u8;
107                                Pitch::from_midi(midi, false)
108                            })
109                            .collect();
110                        events.push(SourceEvent { onset_ticks: (onset * GRID).round() as i64, beats: b, pitches });
111                    }
112                    onset += b;
113                }
114            }
115        }
116    }
117    events
118}
119
120/// Same walk as [`events_from_parts`] but for a single part, splitting each
121/// note's pitches at [`SPLIT_MIDI`] instead of by which part they came from.
122/// Used only when there is no second candidate part to merge in as the bass.
123fn events_from_pitch_split(score: &Score, part_index: usize, measure_idx: usize, high: bool) -> Vec<SourceEvent> {
124    let part = &score.parts[part_index];
125    let mut events = Vec::new();
126    for staff in &part.staves {
127        let Some(measure) = staff.measures.get(measure_idx) else { continue };
128        if measure.multi_rest_count.is_some() { continue }
129        for voice in &measure.voices {
130            let mut onset = 0.0f64;
131            for note in voice {
132                let b = note.beats();
133                if !note.is_rest && !note.is_grace {
134                    let pitches: Vec<Pitch> = note
135                        .pitches
136                        .iter()
137                        .filter_map(|p| {
138                            let midi = (p.to_midi() + staff.transpose_semitones as i16).clamp(0, 127) as u8;
139                            let keep = if high { midi >= SPLIT_MIDI } else { midi < SPLIT_MIDI };
140                            if keep { Some(Pitch::from_midi(midi, false)) } else { None }
141                        })
142                        .collect();
143                    if !pitches.is_empty() {
144                        events.push(SourceEvent { onset_ticks: (onset * GRID).round() as i64, beats: b, pitches });
145                    }
146                }
147                onset += b;
148            }
149        }
150    }
151    events
152}
153
154fn fill_rests(notes: &mut Vec<Note>, mut remaining: f64) {
155    while remaining > 1.0 / GRID {
156        let dur = Duration::whole_filling_beats(remaining);
157        let filled = dur.beats(0);
158        notes.push(Note::rest(dur));
159        remaining -= filled;
160    }
161}
162
163/// Bucket-merge precomputed `events_per_measure` onto a single staff, one
164/// measure at a time, gap-filling with rests via [`Duration::whole_filling_beats`]
165/// (the same greedy decomposition [`Measure::empty`] uses).
166fn assemble_staff(clef: Clef, template_per_measure: Vec<Option<Measure>>, events_per_measure: Vec<Vec<SourceEvent>>, default_ts: super::notation::TimeSignature) -> Staff {
167    let mut current_ts = default_ts;
168    let mut measures = Vec::with_capacity(template_per_measure.len());
169
170    for (mi, (template, events)) in template_per_measure.into_iter().zip(events_per_measure).enumerate() {
171        if let Some(ts) = template.as_ref().and_then(|m| m.time_sig.as_ref()) {
172            current_ts = ts.clone();
173        }
174        let total_beats = current_ts.total_beats();
175
176        let mut buckets: BTreeMap<i64, Vec<SourceEvent>> = BTreeMap::new();
177        for ev in events {
178            buckets.entry(ev.onset_ticks).or_default().push(ev);
179        }
180        let keys: Vec<i64> = buckets.keys().copied().collect();
181
182        let mut voice0: Vec<Note> = Vec::new();
183        let mut cursor = 0.0f64;
184        for (idx, &key) in keys.iter().enumerate() {
185            let onset = key as f64 / GRID;
186            if onset < cursor - 1.0 / GRID { continue } // swallowed by a longer preceding event
187            if onset > cursor {
188                fill_rests(&mut voice0, onset - cursor);
189                cursor = onset;
190            }
191
192            let next_onset = keys.get(idx + 1).map(|&k| k as f64 / GRID).unwrap_or(total_beats);
193            let group = &buckets[&key];
194            let shortest = group.iter().map(|e| e.beats).fold(f64::MAX, f64::min);
195            let cap = (next_onset - onset).max(1.0 / GRID);
196            let sounding = shortest.min(cap).min((total_beats - onset).max(1.0 / GRID));
197
198            let mut pitches: Vec<Pitch> = Vec::new();
199            let mut seen_midi: Vec<u8> = Vec::new();
200            for ev in group {
201                for p in &ev.pitches {
202                    let midi = p.to_midi().clamp(0, 127) as u8;
203                    if !seen_midi.contains(&midi) {
204                        seen_midi.push(midi);
205                        pitches.push(p.clone());
206                    }
207                }
208            }
209            if pitches.is_empty() { continue }
210
211            let dur = Duration::whole_filling_beats(sounding);
212            let emitted = dur.beats(0);
213            let mut note = Note::new(pitches[0].clone(), dur);
214            note.pitches = pitches;
215            voice0.push(note);
216            cursor += emitted;
217        }
218        if total_beats - cursor > 1.0 / GRID {
219            fill_rests(&mut voice0, total_beats - cursor);
220        }
221        if voice0.is_empty() {
222            fill_rests(&mut voice0, total_beats);
223        }
224
225        let mut measure = Measure::empty(current_ts.numerator, current_ts.denominator);
226        measure.number = mi as u32 + 1;
227        measure.time_sig = template.as_ref().and_then(|m| m.time_sig.clone());
228        measure.key_sig = template.as_ref().and_then(|m| m.key_sig.clone());
229        measure.tempo = template.as_ref().and_then(|m| m.tempo);
230        measure.barline_left = template.as_ref().map(|m| m.barline_left.clone()).unwrap_or(Barline::Normal);
231        measure.barline_right = template.as_ref().map(|m| m.barline_right.clone()).unwrap_or(Barline::Normal);
232        measure.voices[0] = voice0;
233        measures.push(measure);
234    }
235
236    Staff { clef, measures, transpose_semitones: 0 }
237}
238
239fn template_measure_for_parts(score: &Score, part_indices: &[usize], mi: usize) -> Option<Measure> {
240    part_indices.iter().filter_map(|&pi| score.parts[pi].staves.first()).find_map(|s| s.measures.get(mi).cloned())
241}
242
243/// Octave-shift `score` (expected to be the freshly-merged, single-part
244/// accordion score) so its mean pitch sits within the practical range for
245/// the accordion GM program. Only multiples of 12 are considered — an
246/// arbitrary semitone shift would break the key signature.
247fn octave_fit(score: &Score) -> (Score, i8) {
248    let (lo, hi) = instrument_range(ACCORDION_PROGRAM);
249    let target_mid = (lo as f64 + hi as f64) / 2.0;
250    let Some(mp) = score.parts.first().and_then(mean_pitch) else { return (score.clone(), 0) };
251
252    let shift = [-24i8, -12, 0, 12, 24]
253        .into_iter()
254        .min_by(|&a, &b| {
255            let da = (mp + a as f64 - target_mid).abs();
256            let db = (mp + b as f64 - target_mid).abs();
257            da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
258        })
259        .unwrap_or(0);
260
261    if shift == 0 { (score.clone(), 0) } else { (super::score::transpose(score, shift), shift) }
262}
263
264/// Arrange `score` for accordion: merge onto two staves (treble = right
265/// hand / melody, bass = left hand / everything else), reassign to the
266/// Accordion GM program, and octave-fit to its practical range.
267///
268/// `right_hand_part_index` overrides the automatic mean-pitch ranking from
269/// [`analyze_for_accordion`] — pass `None` to use the default (highest mean
270/// pitch). Percussion parts are always excluded from both staves.
271pub fn arrange_for_accordion(score: &Score, right_hand_part_index: Option<usize>) -> Result<ArrangeResult, Error> {
272    let analysis = analyze_for_accordion(score);
273    if analysis.candidates.is_empty() {
274        return Err(Error::InvalidCommand("no pitched, non-percussion part to arrange".to_string()));
275    }
276
277    let treble_index = match right_hand_part_index {
278        Some(i) => {
279            if i >= score.parts.len() || is_percussion_part(&score.parts[i]) {
280                return Err(Error::PartNotFound(i));
281            }
282            i
283        }
284        None => analysis.candidates[0].part_index,
285    };
286    let bass_indices: Vec<usize> = analysis.candidates.iter().map(|c| c.part_index).filter(|&i| i != treble_index).collect();
287
288    let mut notes = Vec::new();
289    let measure_count = analysis
290        .candidates
291        .iter()
292        .map(|c| score.parts[c.part_index].staves.iter().map(|s| s.measures.len()).max().unwrap_or(0))
293        .max()
294        .unwrap_or(0);
295    let default_ts = score.settings.time_signature.clone();
296
297    let (treble_staff, bass_staff) = if !bass_indices.is_empty() {
298        let treble_template: Vec<Option<Measure>> = (0..measure_count).map(|mi| template_measure_for_parts(score, &[treble_index], mi)).collect();
299        let treble_events: Vec<Vec<SourceEvent>> = (0..measure_count).map(|mi| events_from_parts(score, &[treble_index], mi)).collect();
300        let bass_template: Vec<Option<Measure>> = (0..measure_count).map(|mi| template_measure_for_parts(score, &bass_indices, mi)).collect();
301        let bass_events: Vec<Vec<SourceEvent>> = (0..measure_count).map(|mi| events_from_parts(score, &bass_indices, mi)).collect();
302        notes.push(format!(
303            "右手(高音部): {} / 左手(低音部): {}パートを統合",
304            analysis.candidates.iter().find(|c| c.part_index == treble_index).map(|c| c.name.as_str()).unwrap_or(""),
305            bass_indices.len()
306        ));
307        (
308            assemble_staff(Clef::Treble, treble_template, treble_events, default_ts.clone()),
309            assemble_staff(Clef::Bass, bass_template, bass_events, default_ts),
310        )
311    } else {
312        let template: Vec<Option<Measure>> = (0..measure_count).map(|mi| template_measure_for_parts(score, &[treble_index], mi)).collect();
313        let treble_events: Vec<Vec<SourceEvent>> = (0..measure_count).map(|mi| events_from_pitch_split(score, treble_index, mi, true)).collect();
314        let bass_events: Vec<Vec<SourceEvent>> = (0..measure_count).map(|mi| events_from_pitch_split(score, treble_index, mi, false)).collect();
315        notes.push("単一パートのため中央ハ(MIDI 60)を基準に上下2段へ分割".to_string());
316        (
317            assemble_staff(Clef::Treble, template.clone(), treble_events, default_ts.clone()),
318            assemble_staff(Clef::Bass, template, bass_events, default_ts),
319        )
320    };
321
322    let mut accordion_part = Part::new("Accordion", "Acc.");
323    accordion_part.midi_program = ACCORDION_PROGRAM;
324    accordion_part.staves = vec![treble_staff, bass_staff];
325
326    let mut merged = Score {
327        id: uuid::Uuid::new_v4().to_string(),
328        schema_version: 1,
329        metadata: score.metadata.clone(),
330        settings: score.settings.clone(),
331        parts: vec![accordion_part],
332        part_groups: Vec::new(),
333    };
334
335    let (fitted, shift) = octave_fit(&merged);
336    merged = fitted;
337    if shift != 0 {
338        notes.push(format!("アコーディオンの実用音域に合わせて{}オクターブ移調", shift / 12));
339    }
340
341    super::score::respell_score_to_key(&mut merged);
342
343    if analysis.ambiguous && right_hand_part_index.is_none() {
344        notes.push("上位2パートの平均音高が僅差のため、右手パートの選択が曖昧です".to_string());
345    }
346
347    Ok(ArrangeResult { score: merged, notes })
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use crate::model::{pitch::Step, score::Score, validate::validate};
354
355    fn note(step: Step, octave: i8, duration: Duration) -> Note {
356        Note::new(Pitch::new(step, octave), duration)
357    }
358
359    #[test]
360    fn analyze_ranks_by_mean_pitch_descending() {
361        let mut score = Score::new("T", 120, 4, 4, 0, 1);
362        score.parts[0].name = "Low".to_string();
363        score.parts[0].staves[0].measures[0].voices[0] = vec![note(Step::C, 3, Duration::Whole)];
364
365        let mut high = score.parts[0].clone();
366        high.name = "High".to_string();
367        high.staves[0].measures[0].voices[0] = vec![note(Step::C, 5, Duration::Whole)];
368        score.parts.push(high);
369
370        let analysis = analyze_for_accordion(&score);
371        assert_eq!(analysis.candidates.len(), 2);
372        assert_eq!(analysis.candidates[0].name, "High");
373        assert_eq!(analysis.candidates[1].name, "Low");
374    }
375
376    #[test]
377    fn analyze_excludes_percussion_channel() {
378        let mut score = Score::new("T", 120, 4, 4, 0, 1);
379        score.parts[0].midi_channel = 9;
380        score.parts[0].staves[0].measures[0].voices[0] = vec![note(Step::C, 4, Duration::Whole)];
381        assert!(analyze_for_accordion(&score).candidates.is_empty());
382    }
383
384    #[test]
385    fn analyze_excludes_silent_part() {
386        // Score::new's default measures are rest-filled, not truly empty — mean_pitch
387        // must filter is_rest notes out, not just check for an empty voice.
388        let score = Score::new("T", 120, 4, 4, 0, 1);
389        assert!(analyze_for_accordion(&score).candidates.is_empty());
390    }
391
392    #[test]
393    fn arrange_no_candidates_errors() {
394        let score = Score::new("T", 120, 4, 4, 0, 1);
395        assert!(arrange_for_accordion(&score, None).is_err());
396    }
397
398    #[test]
399    fn arrange_rejects_out_of_range_part_index() {
400        let mut score = Score::new("T", 120, 4, 4, 0, 1);
401        score.parts[0].staves[0].measures[0].voices[0] = vec![note(Step::C, 4, Duration::Whole)];
402        let result = arrange_for_accordion(&score, Some(99));
403        assert!(matches!(result, Err(Error::PartNotFound(99))));
404    }
405
406    #[test]
407    fn arrange_single_part_splits_chord_and_sets_accordion_program() {
408        let mut score = Score::new("T", 120, 4, 4, 0, 1);
409        let mut chord = note(Step::C, 5, Duration::Whole);
410        chord.pitches.push(Pitch::new(Step::C, 3));
411        score.parts[0].staves[0].measures[0].voices[0] = vec![chord];
412
413        let result = arrange_for_accordion(&score, None).unwrap();
414        assert_eq!(result.score.parts.len(), 1);
415        assert_eq!(result.score.parts[0].midi_program, ACCORDION_PROGRAM);
416        assert_eq!(result.score.parts[0].staves.len(), 2);
417        assert_eq!(result.score.parts[0].staves[0].clef, Clef::Treble);
418        assert_eq!(result.score.parts[0].staves[1].clef, Clef::Bass);
419    }
420
421    #[test]
422    fn arrange_two_parts_puts_higher_mean_pitch_on_treble() {
423        let mut score = Score::new("T", 120, 4, 4, 0, 2);
424        score.parts[0].staves[0].measures[0].voices[0] = vec![note(Step::C, 3, Duration::Whole)];
425        score.parts[0].staves[0].measures[1].voices[0] = vec![note(Step::C, 3, Duration::Whole)];
426
427        let mut melody = score.parts[0].clone();
428        melody.name = "Melody".to_string();
429        melody.staves[0].measures[0].voices[0] = vec![note(Step::C, 5, Duration::Whole)];
430        melody.staves[0].measures[1].voices[0] = vec![note(Step::C, 5, Duration::Whole)];
431        score.parts.push(melody);
432
433        let result = arrange_for_accordion(&score, None).unwrap();
434        assert_eq!(result.score.parts[0].midi_program, ACCORDION_PROGRAM);
435        let treble_note = &result.score.parts[0].staves[0].measures[0].voices[0][0];
436        assert!(!treble_note.is_rest);
437        assert_eq!(treble_note.pitches[0].octave, 5);
438    }
439
440    #[test]
441    fn arrange_right_hand_override_picks_requested_part() {
442        let mut score = Score::new("T", 120, 4, 4, 0, 1);
443        score.parts[0].name = "Low".to_string();
444        score.parts[0].staves[0].measures[0].voices[0] = vec![note(Step::C, 3, Duration::Whole)];
445
446        let mut high = score.parts[0].clone();
447        high.name = "High".to_string();
448        high.staves[0].measures[0].voices[0] = vec![note(Step::C, 5, Duration::Whole)];
449        score.parts.push(high);
450
451        // Force the LOWER part (index 0) onto the treble staff, against the default ranking.
452        let result = arrange_for_accordion(&score, Some(0)).unwrap();
453        let treble_note = &result.score.parts[0].staves[0].measures[0].voices[0][0];
454        assert!(!treble_note.is_rest);
455        assert_eq!(treble_note.pitches[0].octave, 3);
456    }
457
458    #[test]
459    fn arranged_measures_pass_beat_count_validation() {
460        // Deliberately mismatched rhythmic density (2 halves vs 4 quarters vs a
461        // whole note) across the two source parts — exercises the onset
462        // bucketing + gap-fill path most likely to leave a measure underfull.
463        let mut score = Score::new("T", 120, 4, 4, 0, 2);
464        score.parts[0].staves[0].measures[0].voices[0] =
465            vec![note(Step::C, 3, Duration::Half), note(Step::E, 3, Duration::Half)];
466        score.parts[0].staves[0].measures[1].voices[0] = vec![note(Step::G, 3, Duration::Whole)];
467
468        let mut melody = score.parts[0].clone();
469        melody.staves[0].measures[0].voices[0] = vec![
470            note(Step::C, 5, Duration::Quarter), note(Step::D, 5, Duration::Quarter),
471            note(Step::E, 5, Duration::Quarter), note(Step::F, 5, Duration::Quarter),
472        ];
473        melody.staves[0].measures[1].voices[0] = vec![note(Step::G, 5, Duration::Whole)];
474        score.parts.push(melody);
475
476        let result = arrange_for_accordion(&score, None).unwrap();
477        let report = validate(&result.score);
478        assert!(report.errors.is_empty(), "expected no beat-count errors, got {:?}", report.errors);
479    }
480}