acorde-core 1.2.12

Platform-agnostic music score model and command engine
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
//! Versioned, host-neutral score fragments for copy and paste workflows.
//!
//! Clipboard transport and selection UI belong to a host.  This module keeps
//! the musical snapshot, source voice numbers, and typed-spanner boundaries in
//! the score model so a host does not need to flatten notation into renderer
//! objects before copying it.

use super::notation::{
    Barline, Clef, FiguredBassFigure, KeySignature, StyledText, TablatureConfig, TimeSignature,
};
use super::score::{
    HarpPedalDiagram, InstrumentDefinition, Measure, MidMeasureClef, NotationSpanner, Note,
    NoteAddr, Score, VoltaBracket,
};
use crate::Error;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;

/// Current schema version for [`ScoreFragment`].
pub const SCORE_FRAGMENT_CONTRACT_VERSION: u16 = 3;
/// Oldest fragment version accepted by the current paste contract.
pub const MIN_SUPPORTED_SCORE_FRAGMENT_CONTRACT_VERSION: u16 = 1;

/// One inclusive, whole-measure voice range to extract.
///
/// The note indexes are retained as source provenance, but range boundaries
/// are measure based.  Partial-note selection is a host/UI concern and cannot
/// be represented without splitting rhythmic values.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScoreFragmentSelection {
    pub start: NoteAddr,
    pub end: NoteAddr,
}

/// A portable score snapshot.  All addresses in `voices` and `spanners` are
/// relative to the selection's lowest part, staff and measure indexes.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScoreFragment {
    pub contract_version: u16,
    #[serde(default)]
    pub voices: Vec<ScoreFragmentVoice>,
    #[serde(default)]
    pub spanners: Vec<NotationSpanner>,
    #[serde(default)]
    pub diagnostics: Vec<ScoreFragmentDiagnostic>,
}

/// Music in one relative voice lane.  The note values remain exact clones of
/// the score model, retaining lyrics, tuplets, grace/cue state, articulations,
/// chord symbols and placement data.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScoreFragmentVoice {
    pub relative_part: usize,
    pub relative_staff: usize,
    pub relative_voice: usize,
    #[serde(default)]
    pub measures: Vec<ScoreFragmentMeasure>,
}

/// One measure in a fragment voice lane.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScoreFragmentMeasure {
    pub relative_measure: usize,
    /// Original positive MusicXML voice number, when imported from a sparse
    /// source voice.  Keeping this per measure avoids flattening cursor
    /// semantics on paste.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub source_voice_number: Option<u32>,
    #[serde(default)]
    pub notes: Vec<Note>,
    /// Cross-staff targets expressed relative to this lane's source staff.
    /// An empty vector denotes a v1 fragment with no remappable targets.
    #[serde(default)]
    pub cross_staff_targets: Vec<Option<ScoreFragmentCrossStaffTarget>>,
    /// Staff-local measure attributes captured alongside this voice lane.
    /// v1/v2 payloads deserialize with `present: false` and therefore retain
    /// their historical notes-only paste behavior.
    #[serde(default)]
    pub attributes: ScoreFragmentMeasureAttributes,
}

/// Measure-level semantics carried by a v3 score fragment.
///
/// Measure numbers are deliberately not copied: the destination physical
/// position owns numbering. All other fields map directly to `Measure` so a
/// host does not need to rebuild staff-local time, key, instrument, or text
/// state around the pasted music.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ScoreFragmentMeasureAttributes {
    #[serde(default)]
    pub present: bool,
    #[serde(default)]
    pub time_sig: Option<TimeSignature>,
    #[serde(default)]
    pub key_sig: Option<KeySignature>,
    #[serde(default)]
    pub clef: Option<Clef>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub mid_clefs: Vec<MidMeasureClef>,
    #[serde(default)]
    pub tempo: Option<u16>,
    #[serde(default)]
    pub tempo_ramp_to: Option<u16>,
    #[serde(default)]
    pub instrument_change: Option<InstrumentDefinition>,
    #[serde(default)]
    pub tablature_change: Option<TablatureConfig>,
    #[serde(default)]
    pub barline_left: Option<Barline>,
    #[serde(default)]
    pub barline_right: Option<Barline>,
    #[serde(default)]
    pub volta: Option<VoltaBracket>,
    #[serde(default)]
    pub tempo_text: Option<String>,
    #[serde(default)]
    pub rehearsal: Option<String>,
    #[serde(default)]
    pub navigation: Option<String>,
    #[serde(default)]
    pub expression_text: Option<String>,
    #[serde(default)]
    pub texts: Vec<StyledText>,
    #[serde(default)]
    pub figured_bass: Vec<FiguredBassFigure>,
    #[serde(default)]
    pub harp_pedal_diagrams: Vec<HarpPedalDiagram>,
    #[serde(default)]
    pub multi_rest_count: Option<u8>,
    #[serde(default)]
    pub system_break: bool,
    #[serde(default)]
    pub page_break: bool,
    #[serde(default)]
    pub section_break: bool,
}

impl ScoreFragmentMeasureAttributes {
    pub(crate) fn from_measure(measure: &Measure) -> Self {
        Self {
            present: true,
            time_sig: measure.time_sig.clone(),
            key_sig: measure.key_sig.clone(),
            clef: measure.clef.clone(),
            mid_clefs: measure.mid_clefs.clone(),
            tempo: measure.tempo,
            tempo_ramp_to: measure.tempo_ramp_to,
            instrument_change: measure.instrument_change.clone(),
            tablature_change: measure.tablature_change.clone(),
            barline_left: Some(measure.barline_left.clone()),
            barline_right: Some(measure.barline_right.clone()),
            volta: measure.volta.clone(),
            tempo_text: measure.tempo_text.clone(),
            rehearsal: measure.rehearsal.clone(),
            navigation: measure.navigation.clone(),
            expression_text: measure.expression_text.clone(),
            texts: measure.texts.clone(),
            figured_bass: measure.figured_bass.clone(),
            harp_pedal_diagrams: measure.harp_pedal_diagrams.clone(),
            multi_rest_count: measure.multi_rest_count,
            system_break: measure.system_break,
            page_break: measure.page_break,
            section_break: measure.section_break,
        }
    }

    /// Apply captured attributes without changing the destination's physical
    /// measure number, voices, or source voice-number slots.
    pub fn apply_to_measure(&self, measure: &mut Measure) {
        if !self.present {
            return;
        }
        measure.time_sig = self.time_sig.clone();
        measure.key_sig = self.key_sig.clone();
        measure.clef = self.clef.clone();
        measure.mid_clefs = self.mid_clefs.clone();
        measure.tempo = self.tempo;
        measure.tempo_ramp_to = self.tempo_ramp_to;
        measure.instrument_change = self.instrument_change.clone();
        measure.tablature_change = self.tablature_change.clone();
        if let Some(barline) = &self.barline_left {
            measure.barline_left = barline.clone();
        }
        if let Some(barline) = &self.barline_right {
            measure.barline_right = barline.clone();
        }
        measure.volta = self.volta.clone();
        measure.tempo_text = self.tempo_text.clone();
        measure.rehearsal = self.rehearsal.clone();
        measure.navigation = self.navigation.clone();
        measure.expression_text = self.expression_text.clone();
        measure.texts = self.texts.clone();
        measure.figured_bass = self.figured_bass.clone();
        measure.harp_pedal_diagrams = self.harp_pedal_diagrams.clone();
        measure.multi_rest_count = self.multi_rest_count;
        measure.system_break = self.system_break;
        measure.page_break = self.page_break;
        measure.section_break = self.section_break;
    }
}

/// A cross-staff target carried independently from renderer-oriented note data.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScoreFragmentCrossStaffTarget {
    pub staff_offset: i64,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub target_voice: Option<usize>,
}

/// Loss or policy decision made while extracting a fragment.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ScoreFragmentDiagnostic {
    /// Exactly one endpoint of a typed spanner was selected, so it is not
    /// copied as an orphaned span.
    PartialSpanner { id: String },
}

/// Extract a deterministic multi-voice, multi-staff fragment.
///
/// Every selection must cover one voice on one staff, use inclusive measure
/// bounds, and selections may not overlap.  Fully selected typed spanners are
/// copied with relative endpoints; partial spanners are diagnosed explicitly.
pub fn extract_score_fragment(
    score: &Score,
    selections: &[ScoreFragmentSelection],
) -> Result<ScoreFragment, Error> {
    if selections.is_empty() {
        return Err(Error::InvalidCommand(
            "score fragment requires at least one voice selection".into(),
        ));
    }

    let base_part = selections
        .iter()
        .map(|selection| selection.start.part)
        .min()
        .ok_or_else(|| Error::InvalidCommand("score fragment requires selections".into()))?;
    let base_staff = selections
        .iter()
        .map(|selection| selection.start.staff)
        .min()
        .ok_or_else(|| Error::InvalidCommand("score fragment requires selections".into()))?;
    let base_measure = selections
        .iter()
        .map(|selection| selection.start.measure.min(selection.end.measure))
        .min()
        .ok_or_else(|| Error::InvalidCommand("score fragment requires selections".into()))?;
    let base_voice = selections
        .iter()
        .map(|selection| selection.start.voice)
        .min()
        .ok_or_else(|| Error::InvalidCommand("score fragment requires selections".into()))?;

    let mut seen_lanes = BTreeSet::new();
    let mut source_to_relative = Vec::new();
    let mut voices = Vec::with_capacity(selections.len());
    for selection in selections {
        if selection.start.part != selection.end.part
            || selection.start.staff != selection.end.staff
            || selection.start.voice != selection.end.voice
        {
            return Err(Error::InvalidCommand(
                "score fragment selection endpoints must share part, staff, and voice".into(),
            ));
        }
        let lane = (
            selection.start.part,
            selection.start.staff,
            selection.start.voice,
        );
        if !seen_lanes.insert(lane) {
            return Err(Error::InvalidCommand(
                "score fragment selections must not overlap a voice lane".into(),
            ));
        }
        let part = score
            .parts
            .get(selection.start.part)
            .ok_or(Error::PartNotFound(selection.start.part))?;
        let staff = part
            .staves
            .get(selection.start.staff)
            .ok_or(Error::StaffNotFound(selection.start.staff))?;
        if selection.start.voice >= 4 {
            return Err(Error::VoiceOutOfRange(selection.start.voice));
        }
        let from = selection.start.measure.min(selection.end.measure);
        let to = selection.start.measure.max(selection.end.measure);
        let mut measures = Vec::with_capacity(to - from + 1);
        for measure_index in from..=to {
            let measure = staff
                .measures
                .get(measure_index)
                .ok_or(Error::MeasureNotFound(measure_index))?;
            let relative = NoteAddr {
                part: selection.start.part - base_part,
                staff: selection.start.staff - base_staff,
                measure: measure_index - base_measure,
                voice: selection.start.voice - base_voice,
                note: 0,
            };
            for note_index in 0..measure.voices[selection.start.voice].len() {
                source_to_relative.push((
                    NoteAddr {
                        part: selection.start.part,
                        staff: selection.start.staff,
                        measure: measure_index,
                        voice: selection.start.voice,
                        note: note_index,
                    },
                    NoteAddr {
                        note: note_index,
                        ..relative.clone()
                    },
                ));
            }
            let notes = measure.voices[selection.start.voice].clone();
            let cross_staff_targets = notes
                .iter()
                .map(|note| {
                    note.cross_staff
                        .as_ref()
                        .map(|cross_staff| ScoreFragmentCrossStaffTarget {
                            staff_offset: cross_staff.target_staff as i64
                                - selection.start.staff as i64,
                            target_voice: cross_staff.target_voice,
                        })
                })
                .collect();
            measures.push(ScoreFragmentMeasure {
                relative_measure: measure_index - base_measure,
                source_voice_number: measure.source_voice_numbers[selection.start.voice],
                notes,
                cross_staff_targets,
                attributes: ScoreFragmentMeasureAttributes::from_measure(measure),
            });
        }
        voices.push(ScoreFragmentVoice {
            relative_part: selection.start.part - base_part,
            relative_staff: selection.start.staff - base_staff,
            relative_voice: selection.start.voice - base_voice,
            measures,
        });
    }
    voices.sort_by_key(|voice| {
        (
            voice.relative_part,
            voice.relative_staff,
            voice.relative_voice,
        )
    });

    let mut diagnostics = Vec::new();
    let mut spanners = Vec::new();
    for spanner in &score.spanners {
        let start = source_to_relative
            .iter()
            .find(|(source, _)| source == &spanner.start)
            .map(|(_, relative)| relative);
        let end = source_to_relative
            .iter()
            .find(|(source, _)| source == &spanner.end)
            .map(|(_, relative)| relative);
        match (start, end) {
            (Some(start), Some(end)) => {
                let mut copied = spanner.clone();
                copied.start = start.clone();
                copied.end = end.clone();
                spanners.push(copied);
            }
            (Some(_), None) | (None, Some(_)) => {
                diagnostics.push(ScoreFragmentDiagnostic::PartialSpanner {
                    id: spanner.id.clone(),
                });
            }
            (None, None) => {}
        }
    }
    spanners.sort_by(|left, right| left.id.cmp(&right.id));
    diagnostics.sort_by(|left, right| match (left, right) {
        (
            ScoreFragmentDiagnostic::PartialSpanner { id: left },
            ScoreFragmentDiagnostic::PartialSpanner { id: right },
        ) => left.cmp(right),
    });

    Ok(ScoreFragment {
        contract_version: SCORE_FRAGMENT_CONTRACT_VERSION,
        voices,
        spanners,
        diagnostics,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{Duration, NotationSpannerKind, Pitch, Step};

    fn address(staff: usize, measure: usize, voice: usize, note: usize) -> NoteAddr {
        NoteAddr {
            part: 0,
            staff,
            measure,
            voice,
            note,
        }
    }

    #[test]
    fn extracts_multivoice_fragment_and_keeps_only_complete_spanners() {
        let mut score = Score::template(crate::ScoreTemplate::Piano);
        for staff in &mut score.parts[0].staves {
            for measure in &mut staff.measures {
                measure.voices[0] =
                    vec![crate::Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
            }
        }
        score.parts[0].staves[0].measures[0].source_voice_numbers[0] = Some(5);
        score.spanners = vec![
            NotationSpanner {
                id: "complete".into(),
                kind: NotationSpannerKind::Slur,
                start: address(0, 0, 0, 0),
                end: address(1, 1, 0, 0),
                number: None,
                placement: None,
                line_type: None,
                ottava_size: None,
                ottava_type: None,
                text: None,
            },
            NotationSpanner {
                id: "partial".into(),
                kind: NotationSpannerKind::Slur,
                start: address(0, 0, 0, 0),
                end: address(0, 1, 0, 0),
                number: None,
                placement: None,
                line_type: None,
                ottava_size: None,
                ottava_type: None,
                text: None,
            },
        ];
        let fragment = extract_score_fragment(
            &score,
            &[
                ScoreFragmentSelection {
                    start: address(0, 0, 0, 0),
                    end: address(0, 0, 0, 0),
                },
                ScoreFragmentSelection {
                    start: address(1, 1, 0, 0),
                    end: address(1, 1, 0, 0),
                },
            ],
        )
        .expect("fragment extracts");

        assert_eq!(fragment.contract_version, SCORE_FRAGMENT_CONTRACT_VERSION);
        assert_eq!(fragment.voices.len(), 2);
        assert_eq!(fragment.voices[0].measures[0].source_voice_number, Some(5));
        assert_eq!(fragment.spanners.len(), 1);
        assert_eq!(fragment.spanners[0].id, "complete");
        assert_eq!(fragment.spanners[0].start.staff, 0);
        assert_eq!(fragment.spanners[0].end.staff, 1);
        assert_eq!(fragment.spanners[0].end.measure, 1);
        assert_eq!(
            fragment.diagnostics,
            vec![ScoreFragmentDiagnostic::PartialSpanner {
                id: "partial".into()
            }]
        );
    }
}