1use 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
19pub 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
114pub 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}