Skip to main content

sim_lib_music_notation/
musicxml_export.rs

1//! Canonical `Score` to bounded MusicXML partwise export.
2
3use num_rational::Ratio;
4use sim_lib_music_core::{Articulation, MelodyItem, Music, Score, Time};
5use sim_lib_pitch_core::{Letter, SpelledPitch};
6
7use crate::{
8    model::{
9        NotationError, NotationIdentity, NotationIdentityKind, NotationLoss, NotationLossKind,
10        NotationReport, loss_diagnostic, musicxml_error,
11    },
12    musicxml_support::{
13        MAX_DIVISIONS, checked_lcm, ensure_unique_identity_ids, escape_xml, fifths_from_key,
14        identity_map, item_duration, retained_id, validate_xml_id,
15    },
16    spell::spell_pitch_in_key,
17};
18
19/// Exports canonical `Score` through the bounded MusicXML partwise profile.
20///
21/// Matching canonical paths in `identities` reproduce identifiers retained by
22/// a prior import. Missing identifiers are allocated deterministically.
23pub fn export_musicxml_partwise_report(
24    score: &Score,
25    identities: &[NotationIdentity],
26) -> Result<NotationReport<String>, NotationError> {
27    let parts = export_parts(score)?;
28    let divisions = divisions_for(&parts)?;
29    let measure_duration = Ratio::new(
30        i64::from(score.time_signature.0),
31        i64::from(score.time_signature.1),
32    );
33    if measure_duration <= Ratio::from_integer(0) {
34        return Err(musicxml_error(
35            "MusicXML export requires a positive time signature",
36            None,
37        ));
38    }
39    let identity_map = identity_map(identities)?;
40    let mut retained = Vec::new();
41    let mut losses = Vec::new();
42    let mut output = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
43    output.push_str("<score-partwise version=\"4.0\">\n  <part-list>\n");
44    for (part_index, part) in parts.iter().enumerate() {
45        let path = format!("part/{part_index}");
46        let id =
47            retained_id(&identity_map, &path).unwrap_or_else(|| format!("P{}", part_index + 1));
48        validate_xml_id(&id)?;
49        retained.push(NotationIdentity {
50            kind: NotationIdentityKind::Part,
51            canonical_path: path,
52            xml_id: id.clone(),
53        });
54        output.push_str(&format!(
55            "    <score-part id=\"{}\"><part-name>{}</part-name></score-part>\n",
56            escape_xml(&id),
57            escape_xml(&part.name),
58        ));
59    }
60    output.push_str("  </part-list>\n");
61
62    for (part_index, part) in parts.iter().enumerate() {
63        let part_path = format!("part/{part_index}");
64        let part_id = retained_id(&identity_map, &part_path)
65            .unwrap_or_else(|| format!("P{}", part_index + 1));
66        let measures = partition_measures(&part.items, measure_duration)?;
67        output.push_str(&format!("  <part id=\"{}\">\n", escape_xml(&part_id)));
68        let mut event_index = 0usize;
69        for (measure_index, measure) in measures.iter().enumerate() {
70            output.push_str(&format!("    <measure number=\"{}\">\n", measure_index + 1));
71            if measure_index == 0 {
72                render_attributes(&mut output, score, divisions)?;
73                output.push_str(&format!(
74                    "      <direction><sound tempo=\"{}\"/></direction>\n",
75                    score.tempo_bpm
76                ));
77            }
78            for item in *measure {
79                let path = format!("part/{part_index}/event/{event_index}");
80                let id = retained_id(&identity_map, &path)
81                    .unwrap_or_else(|| format!("P{}-E{}", part_index + 1, event_index + 1));
82                validate_xml_id(&id)?;
83                retained.push(NotationIdentity {
84                    kind: NotationIdentityKind::Event,
85                    canonical_path: path.clone(),
86                    xml_id: id.clone(),
87                });
88                render_item(
89                    &mut output,
90                    item,
91                    &id,
92                    divisions,
93                    score.key.as_deref(),
94                    &path,
95                    &mut losses,
96                )?;
97                event_index += 1;
98            }
99            output.push_str("    </measure>\n");
100        }
101        output.push_str("  </part>\n");
102    }
103    output.push_str("</score-partwise>\n");
104    ensure_unique_identity_ids(&retained)?;
105    let diagnostics = losses.iter().map(loss_diagnostic).collect();
106    Ok(NotationReport {
107        value: output,
108        diagnostics,
109        identities: retained,
110        losses,
111    })
112}
113
114/// Exports with deterministic profile identifiers and discards sidecar evidence.
115pub fn export_musicxml_partwise(score: &Score) -> Result<String, NotationError> {
116    Ok(export_musicxml_partwise_report(score, &[])?.value)
117}
118
119#[derive(Clone)]
120struct ExportPart {
121    name: String,
122    items: Vec<MelodyItem>,
123}
124
125fn export_parts(score: &Score) -> Result<Vec<ExportPart>, NotationError> {
126    let parts = match &score.body {
127        Music::Note(note) => vec![ExportPart {
128            name: "Music".to_owned(),
129            items: vec![MelodyItem::Note(note.clone())],
130        }],
131        Music::Rest(rest) => vec![ExportPart {
132            name: "Music".to_owned(),
133            items: vec![MelodyItem::Rest(rest.clone())],
134        }],
135        Music::Melody(melody) => vec![ExportPart {
136            name: "Music".to_owned(),
137            items: melody.items.clone(),
138        }],
139        Music::Counterpoint(counterpoint) => counterpoint
140            .voices
141            .iter()
142            .zip(counterpoint.normalized_voice_names())
143            .map(|(melody, name)| ExportPart {
144                name,
145                items: melody.items.clone(),
146            })
147            .collect(),
148        other => {
149            return Err(NotationError::UnsupportedMusicObject(match other {
150                Music::Chord(_) => "Chord",
151                Music::Progression(_) => "Progression",
152                Music::Par(_) => "Par",
153                Music::Seq(_) => "Seq",
154                Music::PianoRoll(_) => "PianoRoll",
155                Music::Arranger(_) => "Arranger",
156                Music::MidiTrack(_) => "MidiTrack",
157                Music::MidiFile(_) => "MidiFile",
158                _ => "Unknown",
159            }));
160        }
161    };
162    if parts.is_empty() || parts.iter().any(|part| part.items.is_empty()) {
163        return Err(musicxml_error(
164            "bounded MusicXML export requires at least one event in every part",
165            None,
166        ));
167    }
168    Ok(parts)
169}
170
171fn divisions_for(parts: &[ExportPart]) -> Result<i64, NotationError> {
172    let mut divisions = 1i64;
173    for item in parts.iter().flat_map(|part| &part.items) {
174        let quarters = item_duration(item) * Ratio::from_integer(4);
175        if quarters <= Ratio::from_integer(0) {
176            return Err(musicxml_error(
177                "bounded MusicXML export requires positive event durations",
178                None,
179            ));
180        }
181        divisions = checked_lcm(divisions, *quarters.denom()).ok_or_else(|| {
182            musicxml_error(
183                "MusicXML divisions overflow while preserving exact time",
184                None,
185            )
186        })?;
187        if divisions > MAX_DIVISIONS {
188            return Err(musicxml_error(
189                format!("exact score requires divisions above {MAX_DIVISIONS}"),
190                None,
191            ));
192        }
193    }
194    Ok(divisions)
195}
196
197fn partition_measures(
198    items: &[MelodyItem],
199    measure_duration: Time,
200) -> Result<Vec<&[MelodyItem]>, NotationError> {
201    let mut measures = Vec::new();
202    let mut start = 0usize;
203    let mut elapsed = Ratio::from_integer(0);
204    for (index, item) in items.iter().enumerate() {
205        elapsed += item_duration(item);
206        if elapsed > measure_duration {
207            return Err(musicxml_error(
208                "an event crosses a measure boundary; split it explicitly before MusicXML export",
209                None,
210            ));
211        }
212        if elapsed == measure_duration {
213            measures.push(&items[start..=index]);
214            start = index + 1;
215            elapsed = Ratio::from_integer(0);
216        }
217    }
218    if elapsed != Ratio::from_integer(0) {
219        return Err(musicxml_error(
220            "bounded MusicXML export requires complete measures",
221            None,
222        ));
223    }
224    Ok(measures)
225}
226
227fn render_attributes(
228    output: &mut String,
229    score: &Score,
230    divisions: i64,
231) -> Result<(), NotationError> {
232    output.push_str("      <attributes>\n");
233    output.push_str(&format!("        <divisions>{divisions}</divisions>\n"));
234    if let Some(key) = score.key.as_deref() {
235        let (fifths, mode) =
236            fifths_from_key(key).ok_or_else(|| NotationError::InvalidKey(key.to_owned()))?;
237        output.push_str(&format!(
238            "        <key><fifths>{fifths}</fifths><mode>{mode}</mode></key>\n"
239        ));
240    }
241    output.push_str(&format!(
242        "        <time><beats>{}</beats><beat-type>{}</beat-type></time>\n",
243        score.time_signature.0, score.time_signature.1
244    ));
245    output.push_str("      </attributes>\n");
246    Ok(())
247}
248
249fn render_item(
250    output: &mut String,
251    item: &MelodyItem,
252    id: &str,
253    divisions: i64,
254    key: Option<&str>,
255    path: &str,
256    losses: &mut Vec<NotationLoss>,
257) -> Result<(), NotationError> {
258    output.push_str(&format!("      <note id=\"{}\">\n", escape_xml(id)));
259    let duration = item_duration(item) * Ratio::from_integer(divisions.saturating_mul(4));
260    if *duration.denom() != 1 {
261        return Err(musicxml_error(
262            "internal MusicXML divisions failed to preserve an exact duration",
263            None,
264        ));
265    }
266    match item {
267        MelodyItem::Rest(_) => output.push_str("        <rest/>\n"),
268        MelodyItem::Note(note) => {
269            render_pitch(output, spell_pitch_in_key(note.pitch, key)?)?;
270            if note.velocity != 100 {
271                losses.push(NotationLoss {
272                    kind: NotationLossKind::Velocity,
273                    canonical_path: Some(path.to_owned()),
274                    detail: format!(
275                        "note velocity {} is not represented by the bounded MusicXML profile",
276                        note.velocity
277                    ),
278                });
279            }
280            if note.channel.0 != 0 {
281                losses.push(NotationLoss {
282                    kind: NotationLossKind::Channel,
283                    canonical_path: Some(path.to_owned()),
284                    detail: format!(
285                        "MIDI channel {} is not represented by the bounded MusicXML profile",
286                        note.channel.0
287                    ),
288                });
289            }
290        }
291    }
292    output.push_str(&format!(
293        "        <duration>{}</duration>\n",
294        duration.numer()
295    ));
296    output.push_str("        <voice>1</voice>\n");
297    render_note_type(output, item_duration(item))?;
298    if let MelodyItem::Note(note) = item {
299        render_articulation(output, note.articulation)?;
300    }
301    output.push_str("      </note>\n");
302    Ok(())
303}
304
305fn render_pitch(output: &mut String, pitch: SpelledPitch) -> Result<(), NotationError> {
306    let step = match pitch.letter {
307        Letter::C => "C",
308        Letter::D => "D",
309        Letter::E => "E",
310        Letter::F => "F",
311        Letter::G => "G",
312        Letter::A => "A",
313        Letter::B => "B",
314    };
315    output.push_str("        <pitch>");
316    output.push_str(&format!("<step>{step}</step>"));
317    if pitch.accidental != 0 {
318        output.push_str(&format!("<alter>{}</alter>", pitch.accidental));
319    }
320    output.push_str(&format!("<octave>{}</octave></pitch>\n", pitch.octave));
321    Ok(())
322}
323
324fn render_note_type(output: &mut String, duration: Time) -> Result<(), NotationError> {
325    let (name, dotted) = match (duration.numer(), duration.denom()) {
326        (1, 1) => ("whole", false),
327        (1, 2) => ("half", false),
328        (3, 4) => ("half", true),
329        (1, 4) => ("quarter", false),
330        (3, 8) => ("quarter", true),
331        (1, 8) => ("eighth", false),
332        (3, 16) => ("eighth", true),
333        (1, 16) => ("16th", false),
334        (3, 32) => ("16th", true),
335        (1, 32) => ("32nd", false),
336        (3, 64) => ("32nd", true),
337        (1, 64) => ("64th", false),
338        _ => {
339            return Err(NotationError::UnsupportedDuration(duration.to_string()));
340        }
341    };
342    output.push_str(&format!("        <type>{name}</type>\n"));
343    if dotted {
344        output.push_str("        <dot/>\n");
345    }
346    Ok(())
347}
348
349fn render_articulation(
350    output: &mut String,
351    articulation: Articulation,
352) -> Result<(), NotationError> {
353    let element = match articulation {
354        Articulation::Normal => return Ok(()),
355        Articulation::Staccato => "staccato",
356        Articulation::Tenuto => "tenuto",
357        Articulation::Accent => "accent",
358        Articulation::Marcato => "strong-accent",
359        Articulation::Legato => {
360            return Err(musicxml_error(
361                "Legato requires slur identity outside the bounded MusicXML profile",
362                None,
363            ));
364        }
365    };
366    output.push_str(&format!(
367        "        <notations><articulations><{element}/></articulations></notations>\n"
368    ));
369    Ok(())
370}