use crate::model::{EventAttachments, TieRole};
use crate::{Accidental, BarlineKind, Pitch, Rational, RestVisibility, Score, TimedEventKind};
#[derive(Debug, Clone, Copy, Default)]
pub struct AbcWriteOptions {}
pub fn write_abc(score: &Score, _options: AbcWriteOptions) -> String {
let mut out = String::new();
let meta = &score.metadata;
out.push_str(&format!("X:{}\n", meta.reference.text.trim()));
for directive in &meta.directives {
out.push_str(&format!("%%score {}\n", directive.value.text.trim()));
}
if let Some(title) = &meta.title {
out.push_str(&format!("T:{}\n", title.text.trim()));
}
if let Some(meter) = &meta.meter {
out.push_str(&format!("M:{}\n", meter.display));
}
let unit = unit_length(score);
out.push_str(&format!("L:{}/{}\n", unit.numerator, unit.denominator));
if let Some(q) = tempo_field(score) {
out.push_str(&format!("Q:{q}\n"));
}
let key_display = meta
.key
.as_ref()
.map(|k| k.display.clone())
.unwrap_or_else(|| "C".to_string());
out.push_str(&format!("K:{key_display}\n"));
out.push_str(&write_body(score, unit));
for line in &meta.post_tune_lyrics {
out.push_str(&format!("W:{}\n", line.text));
}
if !out.ends_with('\n') {
out.push('\n');
}
out
}
fn tempo_display(tempo: &crate::model::TempoModel) -> String {
let mut out = String::new();
if let Some(text) = &tempo.text {
out.push('"');
out.push_str(text);
out.push('"');
}
if let Some(beat) = &tempo.beat {
if !out.is_empty() {
out.push(' ');
}
out.push_str(&format!(
"{}/{}={}",
beat.beat_numerator, beat.beat_denominator, beat.bpm
));
}
out
}
fn unit_length(score: &Score) -> Rational {
let small = Rational::new(1, 16);
let normal = Rational::new(1, 8);
match score.metadata.meter.as_ref().and_then(|m| m.duration) {
Some(dur) if (dur.numerator as u64) * 4 < (dur.denominator as u64) * 3 => small,
_ => normal,
}
}
fn tempo_field(score: &Score) -> Option<String> {
let beat = score.metadata.tempo_model.as_ref()?.beat.as_ref()?;
Some(format!(
"{}/{}={}",
beat.beat_numerator, beat.beat_denominator, beat.bpm
))
}
fn write_body(score: &Score, unit: Rational) -> String {
let single = score.parts.len() == 1
&& score.parts[0].voices.len() == 1
&& score.parts[0].voices[0].id.value == "1"
&& score.parts[0].voices[0].properties == crate::model::VoicePropertiesModel::default();
let mut body = String::new();
for part in &score.parts {
for voice in &part.voices {
if !single {
body.push_str(&voice_header_line(voice));
}
body.push_str(&write_voice(voice, unit));
}
}
body
}
fn voice_header_line(voice: &crate::model::Voice) -> String {
use crate::model::StemDirectionModel;
let p = &voice.properties;
let mut s = format!("V:{}", voice.id.value);
for (key, value) in [
("name", &p.name),
("nm", &p.nm),
("subname", &p.subname),
("snm", &p.snm),
] {
if let Some(text) = value {
s.push_str(&format!(
" {key}=\"{}\"",
text.text.replace('\\', "\\\\").replace('"', "\\\"")
));
}
}
if let Some(clef) = &p.clef {
s.push_str(&format!(" clef={}", clef.text));
}
if let Some(stem) = &p.stem {
s.push_str(match stem {
StemDirectionModel::Up => " stem=up",
StemDirectionModel::Down => " stem=down",
});
}
if let Some(octave) = &p.octave {
s.push_str(&format!(" octave={}", octave.text));
}
if let Some(transpose) = &p.transpose {
s.push_str(&format!(" transpose={}", transpose.text));
}
if let Some(middle) = &p.middle {
s.push_str(&format!(" middle={}", middle.text));
}
s.push('\n');
s
}
fn voice_octave_shift(properties: &crate::model::VoicePropertiesModel) -> i8 {
let mut shift: i32 = 0;
if let Some(clef) = properties.clef.as_ref() {
let clef = clef.text.as_str();
if clef.contains("-15") {
shift -= 2;
} else if clef.contains("+15") {
shift += 2;
} else if clef.contains("-8") {
shift -= 1;
} else if clef.contains("+8") {
shift += 1;
}
}
if let Some(octave) = properties.octave.as_ref()
&& let Ok(value) = octave.text.trim().parse::<i64>()
{
shift += value.clamp(-9, 9) as i32;
}
if let Some(middle) = properties.middle.as_ref() {
shift += i32::from(crate::lower::voice::middle_octave_shift(
middle.text.as_str(),
));
}
shift.clamp(-12, 12) as i8
}
fn shifted(pitch: &Pitch, shift: i8) -> Pitch {
Pitch {
octave: pitch.octave.saturating_sub(shift),
..*pitch
}
}
fn write_voice(voice: &crate::model::Voice, unit: Rational) -> String {
let mut out = String::new();
let shift = voice_octave_shift(&voice.properties);
let mut overlays: std::collections::BTreeMap<u32, Vec<&crate::model::OverlaySegment>> =
std::collections::BTreeMap::new();
for measure in &voice.measures {
for segment in &measure.overlays {
overlays
.entry(segment.measure_index)
.or_default()
.push(segment);
}
}
let (markers, scales) = tuplet_layout(&voice.events);
let mut joined: Vec<Option<&'static str>> = vec![None; voice.events.len()];
let mut skip = vec![false; voice.events.len()];
for i in 0..voice.events.len().saturating_sub(1) {
if skip[i] {
continue;
}
let (TimedEventKind::Barline(a), TimedEventKind::Barline(b)) =
(&voice.events[i].kind, &voice.events[i + 1].kind)
else {
continue;
};
if a.span != b.span {
continue;
}
match (a.kind, b.kind) {
(BarlineKind::Double, BarlineKind::RepeatStart) => {
joined[i] = Some("||:");
skip[i + 1] = true;
}
(BarlineKind::Initial, BarlineKind::RepeatStart) => {
joined[i] = Some("[|:");
skip[i + 1] = true;
}
_ => {}
}
}
let mut current_measure: Option<u32> = None;
let mut measure_event_seen = false;
for (idx, event) in voice.events.iter().enumerate() {
if skip[idx] {
continue;
}
let m = event.measure.index;
if current_measure != Some(m) {
if let Some(prev) = current_measure
&& let Some(segments) = overlays.remove(&prev)
{
for segment in segments {
out.push_str(&overlay_str(segment, unit, shift));
}
}
current_measure = Some(m);
measure_event_seen = false;
}
if measure_event_seen
&& matches!(event.kind, TimedEventKind::Barline(_))
&& let Some(segments) = overlays.remove(&m)
{
for segment in segments {
out.push_str(&overlay_str(segment, unit, shift));
}
}
measure_event_seen = true;
out.push_str(&markers[idx]);
let tuplet = scales[idx];
match &event.kind {
TimedEventKind::Note(note) => {
let written = shifted(¬e.pitch, shift);
out.push_str(&event_prefix(&event.attachments));
out.push_str(note_accidental(
note.written_accidental.as_ref().map(|m| m.kind),
));
out.push_str(&pitch_str(&written));
out.push_str(&length_str(notated_duration(event.duration, tuplet), unit));
out.push_str(&event_suffix(&event.attachments));
out.push(' ');
}
TimedEventKind::Rest(rest) => {
out.push_str(&event_prefix(&event.attachments));
out.push(match rest.visibility {
RestVisibility::Visible => 'z',
RestVisibility::Invisible => 'x',
});
out.push_str(&length_str(notated_duration(event.duration, tuplet), unit));
out.push_str(&event_suffix(&event.attachments));
out.push(' ');
}
TimedEventKind::Chord(chord) => {
let mut merged = event.attachments.clone();
merged.ties.clear();
for member in &chord.members {
for slur in &member.attachments.slurs {
if !merged
.slurs
.iter()
.any(|x| x.pair_id == slur.pair_id && x.role == slur.role)
{
merged.slurs.push(*slur);
}
}
}
out.push_str(&event_prefix(&merged));
let lengths: Vec<String> = chord
.members
.iter()
.map(|m| length_str(notated_duration(m.duration, tuplet), unit))
.collect();
let uniform = lengths.windows(2).all(|w| w[0] == w[1]);
out.push('[');
for (member, len) in chord.members.iter().zip(&lengths) {
let written = shifted(&member.pitch, shift);
out.push_str(note_accidental(
member.written_accidental.as_ref().map(|m| m.kind),
));
out.push_str(&pitch_str(&written));
if !uniform {
out.push_str(len);
}
if member
.attachments
.ties
.iter()
.any(|t| t.role == TieRole::Start)
{
out.push('-');
}
}
out.push(']');
if let Some(len) = lengths.first().filter(|_| uniform) {
out.push_str(len);
}
out.push_str(&event_suffix(&merged));
out.push(' ');
}
TimedEventKind::Barline(b) => {
out.push_str(joined[idx].unwrap_or_else(|| barline_str(b.kind)));
out.push(' ');
}
TimedEventKind::RepeatEnding(r) => {
out.push_str(&ending_str(r));
out.push(' ');
}
TimedEventKind::Spacer => {
out.push_str("y ");
}
TimedEventKind::KeyChange(key) => {
out.push_str(&format!("[K:{}] ", key.display));
}
TimedEventKind::MeterChange(meter) => {
out.push_str(&format!("[M:{}] ", meter.display));
}
TimedEventKind::ClefChange(_) => {}
TimedEventKind::TempoChange(tempo) => {
out.push_str(&format!("[Q:{}] ", tempo_display(tempo)));
}
TimedEventKind::SectionLabel(label) => {
if label.contains('[') || label.contains(']') {
let trimmed = out.trim_end_matches(' ').to_owned();
out.clear();
if !trimmed.is_empty() {
out.push_str(&trimmed);
out.push('\n');
}
out.push_str(&format!("P:{label}\n"));
} else {
out.push_str(&format!("[P:{label}] "));
}
}
}
}
for (_index, segments) in std::mem::take(&mut overlays) {
for segment in segments {
out.push_str(&overlay_str(segment, unit, shift));
}
}
let mut body = format!("{}\n", out.trim_end());
let align_events = lyric_align_events(voice);
for line in lyric_lines(&align_events) {
body.push_str(&line);
body.push('\n');
}
for line in symbol_lines(&align_events) {
body.push_str(&line);
body.push('\n');
}
body
}
type TupletMarkers = Vec<String>;
type TupletScales = Vec<Option<TupletScale>>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TupletScale {
Ratio(u32, u32),
Overflow,
}
fn tuplet_layout(events: &[crate::TimedEvent]) -> (TupletMarkers, TupletScales) {
use std::collections::BTreeMap;
let mut groups: BTreeMap<u32, (u32, u32, usize, usize)> = BTreeMap::new();
for (i, event) in events.iter().enumerate() {
for tuplet in &event.attachments.tuplets {
let entry = groups.entry(tuplet.pair_id).or_insert((
tuplet.actual_notes,
tuplet.normal_notes,
i,
i,
));
entry.0 = tuplet.actual_notes;
entry.1 = tuplet.normal_notes;
entry.2 = entry.2.min(i);
entry.3 = entry.3.max(i);
}
}
let mut markers = vec![String::new(); events.len()];
let mut scales = vec![None; events.len()];
for (_pid, (actual, normal, start, stop)) in groups {
let span = events[start..=stop]
.iter()
.filter(|e| {
matches!(
e.kind,
TimedEventKind::Note(_) | TimedEventKind::Rest(_) | TimedEventKind::Chord(_)
)
})
.count();
markers[start].push_str(&format!("({actual}:{normal}:{span}"));
for slot in scales.iter_mut().take(stop + 1).skip(start) {
multiply_tuplet_scale(slot, actual, normal);
}
}
(markers, scales)
}
fn multiply_tuplet_scale(slot: &mut Option<TupletScale>, actual: u32, normal: u32) {
match slot {
Some(TupletScale::Ratio(active_actual, active_normal)) => {
*slot = checked_ratio_product(*active_actual, *active_normal, actual, normal)
.map(|(actual, normal)| TupletScale::Ratio(actual, normal))
.or(Some(TupletScale::Overflow));
}
Some(TupletScale::Overflow) => {}
None => *slot = Some(TupletScale::Ratio(actual, normal)),
}
}
fn checked_ratio_product(
actual: u32,
normal: u32,
factor_actual: u32,
factor_normal: u32,
) -> Option<(u32, u32)> {
let actual = u64::from(actual) * u64::from(factor_actual);
let normal = u64::from(normal) * u64::from(factor_normal);
ratio_to_u32(actual, normal)
}
fn ratio_to_u32(numerator: u64, denominator: u64) -> Option<(u32, u32)> {
if numerator <= u64::from(u32::MAX) && denominator <= u64::from(u32::MAX) {
return Some((numerator as u32, denominator as u32));
}
let gcd = gcd_u64(numerator, denominator);
let numerator = numerator / gcd;
let denominator = denominator / gcd;
(numerator <= u64::from(u32::MAX) && denominator <= u64::from(u32::MAX))
.then_some((numerator as u32, denominator as u32))
}
fn gcd_u64(mut left: u64, mut right: u64) -> u64 {
while right != 0 {
let remainder = left % right;
left = right;
right = remainder;
}
left.max(1)
}
fn event_prefix(attachments: &crate::EventAttachments) -> String {
use crate::model::SlurRole;
let mut out = String::new();
for grace in &attachments.grace_groups {
for slur in &grace.slurs {
if slur.role == SlurRole::Start {
out.push('(');
}
}
out.push_str(&grace_str(grace));
}
for slur in &attachments.slurs {
if slur.role == SlurRole::Start {
out.push('(');
}
}
for chord_symbol in &attachments.chord_symbols {
out.push_str("ed_str(&chord_symbol.text));
}
for symbol in &attachments.symbols {
if symbol.kind == crate::model::AlignedSymbolKind::ChordSymbol {
out.push_str("ed_str(&symbol.text));
}
}
for annotation in &attachments.annotations {
out.push_str("ed_str(&annotation.text));
}
for deco in &attachments.decorations {
out.push_str(&decoration_str(&deco.name));
}
out
}
fn quoted_str(text: &str) -> String {
format!("\"{text}\"")
}
#[derive(Debug, Clone, Copy)]
struct LyricAlignEvent<'a> {
source_start: usize,
attachments: &'a EventAttachments,
}
fn lyric_align_events(voice: &crate::model::Voice) -> Vec<LyricAlignEvent<'_>> {
let mut alignable = Vec::new();
for event in &voice.events {
if matches!(
event.kind,
TimedEventKind::Note(_) | TimedEventKind::Chord(_)
) {
alignable.push(LyricAlignEvent {
source_start: event.source.start,
attachments: &event.attachments,
});
}
}
for measure in &voice.measures {
for segment in &measure.overlays {
for event in &segment.events {
if event.alignable {
alignable.push(LyricAlignEvent {
source_start: event.span.start,
attachments: &event.attachments,
});
}
}
}
}
alignable.sort_by_key(|event| event.source_start);
alignable
}
fn lyric_lines(events: &[LyricAlignEvent<'_>]) -> Vec<String> {
use crate::model::LyricControl;
let total = events.len();
let mut verses: std::collections::BTreeMap<u32, Vec<Option<String>>> =
std::collections::BTreeMap::new();
let mut max_verse = 0u32;
for (pos, event) in events.iter().enumerate() {
for lyric in &event.attachments.lyrics {
max_verse = max_verse.max(lyric.verse);
let slot = &mut verses
.entry(lyric.verse)
.or_insert_with(|| vec![None; total])[pos];
match lyric.control {
LyricControl::Syllable => slot
.get_or_insert_with(String::new)
.push_str(&lyric_escape(&lyric.text)),
LyricControl::Hyphen => {
let token = slot.get_or_insert_with(String::new);
if !token.ends_with('-') {
token.push('-');
}
}
LyricControl::Extender => *slot = Some("_".to_string()),
LyricControl::Skip => {}
}
}
}
if verses.is_empty() {
return Vec::new();
}
(1..=max_verse)
.map(|v| match verses.get(&v) {
Some(tokens) => {
let body: Vec<String> = tokens
.iter()
.map(|t| match t {
Some(tok) if tok.chars().all(|c| c == '-') => "*".to_string(),
Some(tok) => tok.clone(),
None => "*".to_string(),
})
.collect();
format!("w:{}", body.join(" "))
}
None => "w:*".to_string(),
})
.collect()
}
fn lyric_escape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
match c {
c if c.is_ascii_whitespace() || c.is_ascii_control() => out.push('~'),
'\\' | '-' | '*' | '_' | '|' | '~' => {
out.push('\\');
out.push(c);
}
_ => out.push(c),
}
}
out
}
fn symbol_lines(events: &[LyricAlignEvent<'_>]) -> Vec<String> {
use crate::model::AlignedSymbolKind;
let mut layers: std::collections::BTreeMap<u32, Vec<Option<String>>> =
std::collections::BTreeMap::new();
let mut position = 0usize;
let total = events.len();
for event in events {
for symbol in &event.attachments.symbols {
let token = match symbol.kind {
AlignedSymbolKind::ChordSymbol => continue, AlignedSymbolKind::Decoration => format!("!{}!", symbol.text),
AlignedSymbolKind::Annotation => quoted_str(&symbol.text),
AlignedSymbolKind::Raw => symbol.text.clone(),
};
layers
.entry(symbol.layer)
.or_insert_with(|| vec![None; total])[position] = Some(token);
}
position += 1;
}
layers
.into_values()
.map(|tokens| {
let last = tokens
.iter()
.rposition(Option::is_some)
.map_or(0, |i| i + 1);
let body: Vec<String> = tokens[..last]
.iter()
.map(|t| t.clone().unwrap_or_else(|| "*".to_string()))
.collect();
format!("s:{}", body.join(" "))
})
.filter(|line| line != "s:")
.collect()
}
fn decoration_str(name: &str) -> String {
if name == "." {
".".to_string()
} else {
format!("!{name}!")
}
}
fn event_suffix(attachments: &crate::EventAttachments) -> String {
use crate::model::SlurRole;
let mut out = String::new();
if attachments.ties.iter().any(|t| t.role == TieRole::Start) {
out.push('-');
}
for slur in &attachments.slurs {
if slur.role == SlurRole::Stop {
out.push(')');
}
}
for grace in &attachments.after_grace_groups {
for slur in &grace.slurs {
if slur.role == SlurRole::Start {
out.push('(');
}
}
out.push_str(&grace_str(grace));
}
out
}
fn ending_str(model: &crate::model::RepeatEndingModel) -> String {
use crate::model::RepeatEndingPartModel::{Range, Single, Text};
let parts: Vec<String> = model
.endings
.iter()
.map(|p| match p {
Single(n) => n.to_string(),
Range { start, end } => format!("{start}-{end}"),
Text(text) => format!("\"{}\"", escape_abc_quotes(text)),
})
.collect();
format!("[{}", parts.join(","))
}
fn escape_abc_quotes(text: &str) -> String {
text.replace('\\', "\\\\").replace('"', "\\\"")
}
fn barline_str(kind: BarlineKind) -> &'static str {
match kind {
BarlineKind::Regular => "|",
BarlineKind::Double => "||",
BarlineKind::Final => "|]",
BarlineKind::RepeatStart => "|:",
BarlineKind::RepeatEnd => ":|",
BarlineKind::RepeatBoth => "::",
BarlineKind::Dotted => ".|",
BarlineKind::Invisible => "[|]",
BarlineKind::Initial => "|",
BarlineKind::Liberal => "|||",
}
}
fn accidental_glyph(kind: Accidental) -> &'static str {
match kind {
Accidental::DoubleFlat => "__",
Accidental::Flat => "_",
Accidental::Natural => "=",
Accidental::Sharp => "^",
Accidental::DoubleSharp => "^^",
}
}
fn note_accidental(written: Option<Accidental>) -> &'static str {
written.map(accidental_glyph).unwrap_or("")
}
fn overlay_str(segment: &crate::model::OverlaySegment, unit: Rational, shift: i8) -> String {
use crate::model::TimelineEventKind;
let mut out = String::from("& ");
let events = &segment.events;
let (ov_markers, ov_scales) = overlay_tuplet_layout(events);
let mut i = 0;
while i < events.len() {
let event = &events[i];
out.push_str(&ov_markers[i]);
let tuplet = ov_scales[i];
match &event.kind {
TimelineEventKind::Note { .. } => {
let mut end = i + 1;
while end < events.len() && overlay_same_chord(event, &events[end]) {
end += 1;
}
let group = &events[i..end];
let chord = group.len() > 1;
let mut lead = event.attachments.clone();
if chord {
lead.ties.clear();
}
out.push_str(&event_prefix(&lead));
let lengths: Vec<String> = group
.iter()
.map(|e| length_str(notated_duration(e.duration, tuplet), unit))
.collect();
let uniform = lengths.windows(2).all(|w| w[0] == w[1]);
if chord {
out.push('[');
}
for (e, len) in group.iter().zip(&lengths) {
let TimelineEventKind::Note {
step,
octave,
accidental,
..
} = &e.kind
else {
continue;
};
out.push_str(note_accidental(*accidental));
out.push_str(&pitch_letter_str(*step, *octave - shift));
if !chord || !uniform {
out.push_str(len);
}
if chord && e.attachments.ties.iter().any(|t| t.role == TieRole::Start) {
out.push('-');
}
}
if chord {
out.push(']');
if let Some(len) = lengths.first().filter(|_| uniform) {
out.push_str(len);
}
}
out.push_str(&event_suffix(&lead));
out.push(' ');
i = end;
continue;
}
TimelineEventKind::Rest { visibility, .. } => {
out.push_str(&event_prefix(&event.attachments));
out.push(match visibility {
RestVisibility::Visible => 'z',
RestVisibility::Invisible => 'x',
});
out.push_str(&length_str(notated_duration(event.duration, tuplet), unit));
out.push_str(&event_suffix(&event.attachments));
out.push(' ');
}
TimelineEventKind::Spacer => out.push_str("y "),
TimelineEventKind::Barline { kind } => {
out.push_str(barline_str(*kind));
out.push(' ');
}
TimelineEventKind::VariantEnding { .. }
| TimelineEventKind::KeyChange(_)
| TimelineEventKind::MeterChange(_)
| TimelineEventKind::ClefChange(_)
| TimelineEventKind::TempoChange(_)
| TimelineEventKind::SectionLabel(_) => {}
}
i += 1;
}
out
}
fn overlay_tuplet_layout(
events: &[crate::model::VoiceTimedEvent],
) -> (TupletMarkers, TupletScales) {
use crate::model::{TimelineEventKind, TupletRole};
use std::collections::BTreeMap;
let mut groups: BTreeMap<u32, (u32, u32, usize, usize, bool)> = BTreeMap::new();
for (i, event) in events.iter().enumerate() {
for tuplet in &event.attachments.tuplets {
let entry = groups.entry(tuplet.pair_id).or_insert((
tuplet.actual_notes,
tuplet.normal_notes,
i,
i,
false,
));
entry.0 = tuplet.actual_notes;
entry.1 = tuplet.normal_notes;
entry.2 = entry.2.min(i);
entry.3 = entry.3.max(i);
if tuplet.role == TupletRole::Start {
entry.4 = true;
}
}
}
let mut markers = vec![String::new(); events.len()];
let mut scales = vec![None; events.len()];
for (_pid, (actual, normal, start, stop, has_start)) in groups {
if !has_start {
continue;
}
let span = events[start..=stop]
.iter()
.filter(|e| {
matches!(
e.kind,
TimelineEventKind::Note { .. } | TimelineEventKind::Rest { .. }
)
})
.count();
markers[start].push_str(&format!("({actual}:{normal}:{span}"));
for slot in scales.iter_mut().take(stop + 1).skip(start) {
multiply_tuplet_scale(slot, actual, normal);
}
}
(markers, scales)
}
fn overlay_same_chord(
first: &crate::model::VoiceTimedEvent,
next: &crate::model::VoiceTimedEvent,
) -> bool {
use crate::model::TimelineEventKind;
first.source_order == next.source_order
&& first.onset == next.onset
&& matches!(next.kind, TimelineEventKind::Note { chord: true, .. })
}
fn pitch_str(pitch: &Pitch) -> String {
pitch_letter_str(pitch.step, pitch.octave)
}
fn pitch_letter_str(step: char, octave: i8) -> String {
let letter = step.to_ascii_uppercase();
let octave = i32::from(octave);
if octave >= 5 {
let mut s = letter.to_ascii_lowercase().to_string();
s.push_str(&"'".repeat((octave - 5) as usize));
s
} else {
let mut s = letter.to_string();
s.push_str(&",".repeat((4 - octave) as usize));
s
}
}
fn length_str(duration: Rational, unit: Rational) -> String {
let mult = Rational::new(
duration.numerator.saturating_mul(unit.denominator),
duration.denominator.saturating_mul(unit.numerator),
);
length_ratio_str(mult)
}
fn notated_duration(duration: Rational, tuplet: Option<TupletScale>) -> Rational {
match tuplet {
Some(TupletScale::Ratio(actual, normal)) => {
scaled_rational(duration, actual, normal).unwrap_or(duration)
}
Some(TupletScale::Overflow) => duration,
None => duration,
}
}
fn scaled_rational(duration: Rational, actual: u32, normal: u32) -> Option<Rational> {
let numerator = u64::from(duration.numerator) * u64::from(actual);
let denominator = u64::from(duration.denominator) * u64::from(normal);
ratio_to_u32(numerator, denominator)
.map(|(numerator, denominator)| Rational::new(numerator, denominator))
}
fn length_ratio_str(mult: Rational) -> String {
match (mult.numerator, mult.denominator) {
(1, 1) => String::new(),
(n, 1) => n.to_string(),
(1, 2) => "/".to_string(),
(1, d) => format!("/{d}"),
(n, d) => format!("{n}/{d}"),
}
}
fn grace_str(group: &crate::model::GraceGroupAttachment) -> String {
use crate::model::{GraceEventKind, SlurRole};
let mut out = String::from("{");
let mut trailing_slur_stops = String::new();
if group.slash.is_some() {
out.push('/');
}
for grace in &group.events {
for slur in &grace.slurs {
if slur.role == SlurRole::Start {
out.push('(');
}
}
match &grace.kind {
GraceEventKind::Note(note) => {
out.push_str(note_accidental(
note.written_accidental.as_ref().map(|m| m.kind),
));
out.push_str(&pitch_str(¬e.pitch));
out.push_str(&length_ratio_str(note.length_multiplier));
}
GraceEventKind::Rest(_) => out.push('z'),
GraceEventKind::Chord(members) => {
out.push('[');
for note in members {
out.push_str(note_accidental(
note.written_accidental.as_ref().map(|m| m.kind),
));
out.push_str(&pitch_str(¬e.pitch));
out.push_str(&length_ratio_str(note.length_multiplier));
}
out.push(']');
}
}
for slur in &grace.slurs {
if slur.role == SlurRole::Stop {
if slur.span.start >= group.span.end {
trailing_slur_stops.push(')');
} else {
out.push(')');
}
}
}
}
out.push('}');
out.push_str(&trailing_slur_stops);
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{LowerOptions, ParseOptions, lower_score, parse_document};
fn score_of(src: &str) -> crate::Score {
let doc = parse_document(src, ParseOptions::default());
lower_score(&doc.value, LowerOptions).value.expect("score")
}
fn pitch_seq(score: &crate::Score) -> Vec<(char, i8, i8)> {
let mut v = Vec::new();
for p in &score.parts {
for voice in &p.voices {
for e in &voice.events {
if let crate::TimedEventKind::Note(n) = &e.kind {
v.push((n.pitch.step, n.pitch.alter, n.pitch.octave));
}
}
}
}
v
}
type PitchSeq = Vec<(char, i8, i8)>;
fn roundtrip_pitches(src: &str) -> (PitchSeq, PitchSeq) {
let s1 = score_of(src);
let abc = write_abc(&s1, AbcWriteOptions::default());
let s2 = score_of(&abc);
(pitch_seq(&s1), pitch_seq(&s2))
}
#[test]
fn emits_required_headers() {
let abc = write_abc(
&score_of("X:1\nT:Tune\nM:4/4\nL:1/8\nK:C\nC\n"),
AbcWriteOptions::default(),
);
assert!(abc.starts_with("X:1\n"), "got: {abc:?}");
assert!(abc.contains("\nM:4/4\n"));
assert!(abc.contains("\nL:1/8\n"));
assert!(abc.contains("\nK:C\n"));
assert!(abc.contains("\nT:Tune\n"));
}
#[test]
fn notes_rests_octaves_accidentals_roundtrip() {
for src in [
"X:1\nL:1/8\nK:C\nC E G c c' C, z2 ^F _B =c\n",
"X:1\nM:3/4\nL:1/4\nK:G\nGA B z\n",
] {
let (a, b) = roundtrip_pitches(src);
assert_eq!(a, b, "pitch round-trip failed for {src:?}");
}
}
#[test]
fn extreme_octave_marks_roundtrip_without_panicking() {
let commas = ",".repeat(130);
let quotes = "'".repeat(200);
let src = format!("X:1\nL:1/4\nK:C\nC{commas} c{quotes} D E |\n");
let (a, b) = roundtrip_pitches(&src);
assert_eq!(a, b, "pitch round-trip failed for extreme octave marks");
}
fn barline_kinds(score: &crate::Score) -> Vec<crate::BarlineKind> {
let mut v = Vec::new();
for p in &score.parts {
for voice in &p.voices {
for e in &voice.events {
if let crate::TimedEventKind::Barline(b) = &e.kind {
v.push(b.kind);
}
}
}
}
v
}
#[test]
fn barlines_roundtrip() {
for src in [
"X:1\nL:1/4\nK:C\nCDEF | GABc |\n",
"X:1\nL:1/4\nK:C\n|: CDEF :| GABc |]\n",
"X:1\nL:1/4\nK:C\nCDEF || GABc\n",
] {
let s1 = score_of(src);
let abc = write_abc(&s1, AbcWriteOptions::default());
let s2 = score_of(&abc);
assert_eq!(
barline_kinds(&s1),
barline_kinds(&s2),
"barlines for {src:?} -> {abc:?}"
);
}
}
fn ending_labels(score: &crate::Score) -> Vec<String> {
let mut v = Vec::new();
for p in &score.parts {
for voice in &p.voices {
for e in &voice.events {
if let crate::TimedEventKind::RepeatEnding(r) = &e.kind {
v.push(format!("{:?}", r.endings));
}
}
}
}
v
}
#[test]
fn endings_and_ties_roundtrip() {
let src = "X:1\nL:1/4\nK:C\n|: CDEF |1 GABc :|2 cBAG |]\n";
let s1 = score_of(src);
let abc = write_abc(&s1, AbcWriteOptions::default());
let s2 = score_of(&abc);
assert_eq!(ending_labels(&s1), ending_labels(&s2), "endings: {abc:?}");
let tie = score_of("X:1\nL:1/4\nK:C\nC2- C2 |\n");
let tie_abc = write_abc(&tie, AbcWriteOptions::default());
assert!(tie_abc.contains('-'), "tie not emitted: {tie_abc:?}");
assert_eq!(pitch_seq(&tie), pitch_seq(&score_of(&tie_abc)));
}
fn slur_roles(score: &crate::Score) -> Vec<String> {
let mut v = Vec::new();
for p in &score.parts {
for voice in &p.voices {
for e in &voice.events {
for s in &e.attachments.slurs {
v.push(format!("{:?}", s.role));
}
}
}
}
v
}
#[test]
fn slurs_roundtrip() {
for src in [
"X:1\nL:1/8\nK:C\n(CDE) F |\n",
"X:1\nL:1/8\nK:C\n(C (DE) F) G |\n",
"X:1\nL:1/4\nK:C\nC (D-D) E |\n",
] {
let s1 = score_of(src);
let abc = write_abc(&s1, AbcWriteOptions::default());
let s2 = score_of(&abc);
assert_eq!(
slur_roles(&s1),
slur_roles(&s2),
"slurs for {src:?} -> {abc:?}"
);
assert_eq!(
pitch_seq(&s1),
pitch_seq(&s2),
"pitches for {src:?} -> {abc:?}"
);
}
}
fn decoration_names(score: &crate::Score) -> Vec<String> {
let mut v = Vec::new();
for p in &score.parts {
for voice in &p.voices {
for e in &voice.events {
for d in &e.attachments.decorations {
v.push(d.name.clone());
}
}
}
}
v
}
#[test]
fn decorations_roundtrip() {
for src in [
"X:1\nL:1/8\nK:C\n!fermata!C .D ~E !trill!F |\n",
"X:1\nL:1/8\nK:C\n!accent!G !upbow!A !downbow!B |\n",
] {
let s1 = score_of(src);
let abc = write_abc(&s1, AbcWriteOptions::default());
let s2 = score_of(&abc);
assert_eq!(
decoration_names(&s1),
decoration_names(&s2),
"decorations for {src:?} -> {abc:?}"
);
}
}
fn text_attachments(score: &crate::Score) -> Vec<String> {
let mut v = Vec::new();
for p in &score.parts {
for voice in &p.voices {
for e in &voice.events {
for c in &e.attachments.chord_symbols {
v.push(format!("cs:{}", c.text));
}
for a in &e.attachments.annotations {
v.push(format!("an:{}", a.text));
}
}
}
}
v
}
#[test]
fn chord_symbols_and_annotations_roundtrip() {
for src in [
"X:1\nL:1/8\nK:C\n\"Gm7\"C \"C\"D \"F#m\"E |\n",
"X:1\nL:1/8\nK:C\n\"^above\"C \"_below\"D \"<left\"E \">right\"F |\n",
] {
let s1 = score_of(src);
let abc = write_abc(&s1, AbcWriteOptions::default());
let s2 = score_of(&abc);
assert_eq!(
text_attachments(&s1),
text_attachments(&s2),
"text attachments for {src:?} -> {abc:?}"
);
assert_eq!(pitch_seq(&s1), pitch_seq(&s2));
}
}
fn chord_pitches(score: &crate::Score) -> Vec<Vec<(char, i8, i8)>> {
let mut v = Vec::new();
for p in &score.parts {
for voice in &p.voices {
for e in &voice.events {
if let crate::TimedEventKind::Chord(c) = &e.kind {
v.push(
c.members
.iter()
.map(|m| (m.pitch.step, m.pitch.alter, m.pitch.octave))
.collect(),
);
}
}
}
}
v
}
#[test]
fn chords_roundtrip() {
for src in [
"X:1\nL:1/8\nK:C\n[CEG]2 [DFA] z |\n",
"X:1\nL:1/8\nK:C\n[^C_EG]/ [ceg]4 |\n",
] {
let s1 = score_of(src);
let abc = write_abc(&s1, AbcWriteOptions::default());
let s2 = score_of(&abc);
assert_eq!(
chord_pitches(&s1),
chord_pitches(&s2),
"chords for {src:?} -> {abc:?}"
);
}
}
fn tuplet_ratios(score: &crate::Score) -> Vec<(u32, u32)> {
let mut v = Vec::new();
for p in &score.parts {
for voice in &p.voices {
for e in &voice.events {
for t in &e.attachments.tuplets {
v.push((t.actual_notes, t.normal_notes));
}
}
}
}
v
}
fn tuplet_roles(score: &crate::Score) -> Vec<(bool, crate::model::TupletRole)> {
let mut v = Vec::new();
for p in &score.parts {
for voice in &p.voices {
for e in &voice.events {
let is_rest = matches!(e.kind, crate::TimedEventKind::Rest(_));
for t in &e.attachments.tuplets {
v.push((is_rest, t.role));
}
}
}
}
v
}
#[test]
fn tuplets_roundtrip() {
for src in [
"X:1\nM:4/4\nL:1/8\nK:C\n(3CDE F2 (3GAB |\n",
"X:1\nM:4/4\nL:1/8\nK:C\n(5CDEFG (3:2:3 cde |\n",
] {
let s1 = score_of(src);
let abc = write_abc(&s1, AbcWriteOptions::default());
let s2 = score_of(&abc);
assert_eq!(
tuplet_ratios(&s1),
tuplet_ratios(&s2),
"tuplets for {src:?} -> {abc:?}"
);
assert_eq!(tuplet_roles(&s1), tuplet_roles(&s2), "roles for {src:?}");
assert_eq!(pitch_seq(&s1), pitch_seq(&s2));
}
}
#[test]
fn nested_tuplets_roundtrip_preserves_outer_and_inner_groups() {
let src = "X:1\nM:C\nL:1/4\nK:none\n(7:8:8(3A/A/A/ A/A/A/A/A/|\n";
let s1 = score_of(src);
let abc = write_abc(&s1, AbcWriteOptions::default());
assert!(abc.contains("(7:8:8"), "{abc}");
assert!(abc.contains("(3:2:3"), "{abc}");
assert!(!abc.contains("A8/7"), "{abc}");
let s2 = score_of(&abc);
assert_eq!(tuplet_ratios(&s1), tuplet_ratios(&s2), "{abc}");
assert_eq!(tuplet_roles(&s1), tuplet_roles(&s2), "{abc}");
assert_eq!(pitch_seq(&s1), pitch_seq(&s2), "{abc}");
}
#[test]
fn overflowing_nested_tuplet_product_does_not_saturate_notated_length() {
let src = concat!(
"X:1\n",
"M:C\n",
"L:1/8\n",
"K:C\n",
"(9:8:1(9:8:1(9:8:1(9:8:1(9:8:1(9:8:1",
"(9:8:1(9:8:1(9:8:1(9:8:1(9:8:1A|\n",
);
let abc = write_abc(&score_of(src), AbcWriteOptions::default());
assert_eq!(abc.matches("(9:8:1").count(), 11, "{abc}");
assert!(
!abc.contains("A8"),
"overflowing product must not be baked into a saturated length: {abc}"
);
}
#[test]
fn rest_tuplets_roundtrip() {
for src in [
"X:1\nM:4/4\nL:1/8\nK:C\n(3zBA F2 |\n",
"X:1\nM:4/4\nL:1/8\nK:C\n(3BAz F2 |\n",
"X:1\nM:4/4\nL:1/8\nK:C\n(3zzz F2 |\n",
"X:1\nM:4/4\nL:1/8\nK:C\n(3:2:3z B A F2 |\n",
"X:1\nM:4/4\nL:1/8\nK:C\n(3z>BA F2 |\n",
] {
let s1 = score_of(src);
let abc = write_abc(&s1, AbcWriteOptions::default());
let s2 = score_of(&abc);
assert_eq!(
tuplet_ratios(&s1),
tuplet_ratios(&s2),
"tuplets for {src:?} -> {abc:?}"
);
assert_eq!(
tuplet_roles(&s1),
tuplet_roles(&s2),
"roles for {src:?} -> {abc:?}"
);
assert_eq!(
pitch_seq(&s1),
pitch_seq(&s2),
"pitches for {src:?} -> {abc:?}"
);
let durations = |s: &crate::Score| {
s.parts[0].voices[0]
.events
.iter()
.filter_map(|e| match e.kind {
crate::TimedEventKind::Note(_) | crate::TimedEventKind::Rest(_) => {
Some(e.duration)
}
_ => None,
})
.collect::<Vec<_>>()
};
assert_eq!(
durations(&s1),
durations(&s2),
"durations for {src:?} -> {abc:?}"
);
}
let s1 = score_of("X:1\nM:4/4\nL:1/8\nK:C\n(3BAz F2 |\n");
let abc = write_abc(&s1, AbcWriteOptions::default());
assert!(abc.contains("(3:2:3"), "expected (3:2:3 marker in {abc:?}");
}
fn grace_pitches(score: &crate::Score) -> Vec<(char, i8, i8, bool, bool)> {
let mut v = Vec::new();
for p in &score.parts {
for voice in &p.voices {
for e in &voice.events {
for (after_grace, g) in e
.attachments
.grace_groups
.iter()
.map(|group| (false, group))
.chain(
e.attachments
.after_grace_groups
.iter()
.map(|group| (true, group)),
)
{
let slash = g.slash.is_some();
for ge in &g.events {
if let crate::model::GraceEventKind::Note(n) = &ge.kind {
v.push((
n.pitch.step,
n.pitch.alter,
n.pitch.octave,
slash,
after_grace,
));
}
}
}
}
}
}
v
}
fn grace_slur_roles(score: &crate::Score) -> Vec<(bool, crate::model::SlurRole)> {
let mut v = Vec::new();
for p in &score.parts {
for voice in &p.voices {
for e in &voice.events {
for (after_grace, g) in e
.attachments
.grace_groups
.iter()
.map(|group| (false, group))
.chain(
e.attachments
.after_grace_groups
.iter()
.map(|group| (true, group)),
)
{
v.extend(g.slurs.iter().map(|slur| (after_grace, slur.role)));
for ge in &g.events {
v.extend(ge.slurs.iter().map(|slur| (after_grace, slur.role)));
}
}
}
}
}
v
}
#[test]
fn grace_notes_roundtrip() {
for src in [
"X:1\nL:1/8\nK:C\n{ge}C {/d}E F2 |\n",
"X:1\nL:1/8\nK:C\n{gege}A {^c}B |\n",
] {
let s1 = score_of(src);
let abc = write_abc(&s1, AbcWriteOptions::default());
let s2 = score_of(&abc);
assert_eq!(
grace_pitches(&s1),
grace_pitches(&s2),
"grace for {src:?} -> {abc:?}"
);
assert_eq!(pitch_seq(&s1), pitch_seq(&s2));
}
}
#[test]
fn grace_internal_slurs_roundtrip() {
let src = "X:1\nL:1/8\nK:C\n{(fg)}a2 {(ef)}g2|]\n";
let s1 = score_of(src);
let abc = write_abc(&s1, AbcWriteOptions::default());
let s2 = score_of(&abc);
assert!(
abc.contains("{(fg)}a2 {(ef)}g2"),
"grace-internal slurs missing from ABC: {abc:?}"
);
assert_eq!(grace_pitches(&s1), grace_pitches(&s2));
assert_eq!(pitch_seq(&s1), pitch_seq(&s2));
}
#[test]
fn bare_grace_slur_roundtrips() {
let src = "X:1\nL:1/8\nK:C\n({Bc})D|\n";
let s1 = score_of(src);
let abc = write_abc(&s1, AbcWriteOptions::default());
let s2 = score_of(&abc);
assert!(
abc.contains("({Bc})D"),
"bare grace slur missing from ABC: {abc:?}"
);
assert_eq!(grace_pitches(&s1), grace_pitches(&s2));
assert_eq!(grace_slur_roles(&s1), grace_slur_roles(&s2));
assert_eq!(pitch_seq(&s1), pitch_seq(&s2));
}
#[test]
fn bare_grace_slur_before_barline_roundtrips() {
let src = "X:1\nL:1/8\nK:C\nA2({Bc})|\n";
let s1 = score_of(src);
let abc = write_abc(&s1, AbcWriteOptions::default());
let s2 = score_of(&abc);
assert!(
abc.contains("A2({Bc})"),
"bare grace before barline missing from ABC: {abc:?}"
);
assert_eq!(grace_pitches(&s1), grace_pitches(&s2));
assert_eq!(grace_slur_roles(&s1), grace_slur_roles(&s2));
assert_eq!(pitch_seq(&s1), pitch_seq(&s2));
}
#[test]
fn trailing_trill_grace_notes_roundtrip_after_principal_note() {
let src = "X:1\nT:Trailing Grace\nM:4/4\nL:1/8\nK:C\nTe6{de}|d2f f2f|\n";
let s1 = score_of(src);
let abc = write_abc(&s1, AbcWriteOptions::default());
let s2 = score_of(&abc);
assert!(
abc.contains("!trill!e6{de}"),
"after-grace suffix missing from ABC: {abc:?}"
);
assert_eq!(
grace_pitches(&s1),
grace_pitches(&s2),
"grace for {src:?} -> {abc:?}"
);
assert_eq!(pitch_seq(&s1), pitch_seq(&s2));
}
#[test]
fn chord_symbol_after_slur_open_survives() {
let src = "X:1\nL:1/8\nK:C\n(\"G7\"DE) F |\n";
let s1 = score_of(src);
let abc = write_abc(&s1, AbcWriteOptions::default());
let s2 = score_of(&abc);
assert_eq!(
text_attachments(&s1),
text_attachments(&s2),
"chord symbol lost: {abc:?}"
);
assert!(!text_attachments(&s1).is_empty());
}
#[test]
fn chord_symbol_after_grace_survives() {
let src = "X:1\nL:1/8\nK:C\n{AB}\"F\"c2 d2 |\n";
let s1 = score_of(src);
let abc = write_abc(&s1, AbcWriteOptions::default());
let s2 = score_of(&abc);
assert_eq!(
text_attachments(&s1),
text_attachments(&s2),
"chord symbol lost: {abc:?}"
);
assert!(!text_attachments(&s1).is_empty());
}
fn member_tie_starts(score: &crate::Score) -> Vec<Vec<bool>> {
let mut v = Vec::new();
for p in &score.parts {
for voice in &p.voices {
for e in &voice.events {
if let crate::TimedEventKind::Chord(c) = &e.kind {
v.push(
c.members
.iter()
.map(|m| {
m.attachments.ties.iter().any(|t| t.role == TieRole::Start)
})
.collect(),
);
}
}
}
}
v
}
#[test]
fn chord_member_ties_stay_per_member() {
for src in [
"X:1\nL:1/8\nK:C\n[dg-]2 [dg]2 |\n",
"X:1\nL:1/8\nK:C\n[C-E]2 [CE]2 |\n",
"X:1\nL:1/8\nK:C\n[CE]2- [CE]2 |\n",
] {
let s1 = score_of(src);
let abc = write_abc(&s1, AbcWriteOptions::default());
let s2 = score_of(&abc);
assert_eq!(
member_tie_starts(&s1),
member_tie_starts(&s2),
"member ties for {src:?} -> {abc:?}"
);
}
}
fn harmony_texts(score: &crate::Score) -> Vec<String> {
use crate::model::AlignedSymbolKind;
let mut v = Vec::new();
for p in &score.parts {
for voice in &p.voices {
for e in &voice.events {
for c in &e.attachments.chord_symbols {
v.push(c.text.clone());
}
for s in &e.attachments.symbols {
if s.kind == AlignedSymbolKind::ChordSymbol {
v.push(s.text.clone());
}
}
}
}
}
v
}
#[test]
fn aligned_chord_symbols_inline_roundtrip() {
let src = "X:1\nL:1/4\nK:C\nC \"D7\"D [EG] F |\ns:\"Gm\" * \"C\"\n";
let s1 = score_of(src);
let abc = write_abc(&s1, AbcWriteOptions::default());
let s2 = score_of(&abc);
assert_eq!(
harmony_texts(&s1),
harmony_texts(&s2),
"harmony for {abc:?}"
);
assert_eq!(harmony_texts(&s1).len(), 3);
assert_eq!(pitch_seq(&s1), pitch_seq(&s2));
}
fn symbol_tokens(score: &crate::Score) -> Vec<(String, String)> {
let mut v = Vec::new();
for p in &score.parts {
for voice in &p.voices {
for e in &voice.events {
for s in &e.attachments.symbols {
if s.kind != crate::model::AlignedSymbolKind::ChordSymbol {
v.push((format!("{:?}", s.kind), s.text.clone()));
}
}
}
for measure in &voice.measures {
for segment in &measure.overlays {
for e in &segment.events {
for s in &e.attachments.symbols {
if s.kind != crate::model::AlignedSymbolKind::ChordSymbol {
v.push((format!("{:?}", s.kind), s.text.clone()));
}
}
}
}
}
}
}
v
}
#[test]
fn aligned_symbols_reemit_as_symbol_lines() {
for src in [
"X:1\nL:1/4\nK:C\nCDEF|\ns:!trill! * \"^slow\" foo\n",
"X:1\nL:1/4\nK:C\nCD z EF|\ns:!trill! * !fermata!\ns:* +mordent+\n",
"X:1\nL:1/4\nK:C\nC & E F|\ns:* \"^overlay\" !trill!\n",
"X:1\nL:1/4\nK:C\nC & E & G|\ns:* \"^first\" \"^second\"\n",
"X:1\nL:1/4\nK:C\nC D & z E|\ns:* * \"^after-rest\"\n",
"X:1\nL:1/4\nK:C\nC & [EG] F|\ns:* \"^chord\" !trill!\n",
] {
let s1 = score_of(src);
let abc = write_abc(&s1, AbcWriteOptions::default());
let s2 = score_of(&abc);
assert_eq!(
symbol_tokens(&s1),
symbol_tokens(&s2),
"aligned symbols for {src:?} -> {abc:?}"
);
assert!(!symbol_tokens(&s1).is_empty());
assert_eq!(pitch_seq(&s1), pitch_seq(&s2));
}
}
fn measure_count(score: &crate::Score) -> u32 {
score.parts[0].voices[0]
.events
.iter()
.map(|e| e.measure.index)
.max()
.map_or(0, |m| m + 1)
}
#[test]
fn exotic_barlines_roundtrip() {
for (src, same_kinds) in [
("X:1\nL:1/4\nK:C\nCD .| EF |\n", true),
("X:1\nL:1/4\nK:C\nCD [|] EF |\n", true),
("X:1\nL:1/4\nK:C\n[| CD EF |\n", false),
("X:1\nL:1/4\nK:C\nCD ||| EF |\n", false),
] {
let s1 = score_of(src);
let abc = write_abc(&s1, AbcWriteOptions::default());
let s2 = score_of(&abc);
assert_eq!(pitch_seq(&s1), pitch_seq(&s2), "{abc:?}");
assert_eq!(measure_count(&s1), measure_count(&s2), "{abc:?}");
if same_kinds {
assert_eq!(barline_kinds(&s1), barline_kinds(&s2), "{abc:?}");
}
}
}
#[test]
fn split_token_barline_pairs_rejoin() {
for src in [
"X:1\nL:1/4\nK:C\n||: CDEF :|\n",
"X:1\nL:1/4\nK:C\n[|: CDEF :|\n",
"X:1\nL:1/4\nK:C\nCDEF ||: GABc :|\n",
"X:1\nL:1/4\nK:C\nCDEF |]: GABc :|\n",
] {
let s1 = score_of(src);
let abc = write_abc(&s1, AbcWriteOptions::default());
let s2 = score_of(&abc);
assert_eq!(measure_count(&s1), measure_count(&s2), "{src:?} -> {abc:?}");
assert_eq!(barline_kinds(&s1), barline_kinds(&s2), "{src:?} -> {abc:?}");
}
let src = "X:1\nL:1/4\nK:C\nCDEF || |: GABc :|\n";
let s1 = score_of(src);
let abc = write_abc(&s1, AbcWriteOptions::default());
let s2 = score_of(&abc);
assert_eq!(measure_count(&s1), measure_count(&s2), "{abc:?}");
}
#[test]
fn spacers_roundtrip() {
for src in [
"X:1\nL:1/4\nK:C\nCD y EF | GA y2 BC |\n",
"X:1\nM:4/4\nL:1/8\nK:C\n(3CDE y F2 |\n",
] {
let s1 = score_of(src);
let abc = write_abc(&s1, AbcWriteOptions::default());
let s2 = score_of(&abc);
assert_eq!(pitch_seq(&s1), pitch_seq(&s2), "{src:?} -> {abc:?}");
assert_eq!(measure_count(&s1), measure_count(&s2), "{src:?} -> {abc:?}");
let spacers = |s: &crate::Score| {
s.parts[0].voices[0]
.events
.iter()
.filter(|e| matches!(e.kind, crate::TimedEventKind::Spacer))
.count()
};
assert_eq!(spacers(&s1), spacers(&s2), "{src:?} -> {abc:?}");
}
}
fn lyric_tokens(score: &crate::Score) -> Vec<(u32, String, String)> {
let mut v = Vec::new();
for p in &score.parts {
for voice in &p.voices {
for e in &voice.events {
for l in &e.attachments.lyrics {
v.push((l.verse, format!("{:?}", l.control), l.text.clone()));
}
}
for measure in &voice.measures {
for segment in &measure.overlays {
for e in &segment.events {
for l in &e.attachments.lyrics {
v.push((l.verse, format!("{:?}", l.control), l.text.clone()));
}
}
}
}
}
}
v
}
#[test]
fn lyrics_roundtrip() {
for src in [
"X:1\nL:1/4\nK:C\nCDEF|GAB c|\nw:doe-ray me_ fa * la~la ti\n",
"X:1\nL:1/4\nK:C\nCD EF|\nw:one two three four\nw:uno dos tres cuatro\n",
"X:1\nL:1/4\nK:C\nC z [EG] F|\nw:a b c\nw:*\nw:x y z\n",
"X:1\nL:1/4\nK:C\nCD|\nw:a\\-b c\\*d\n",
] {
let s1 = score_of(src);
let abc = write_abc(&s1, AbcWriteOptions::default());
let s2 = score_of(&abc);
assert_eq!(
lyric_tokens(&s1),
lyric_tokens(&s2),
"lyrics for {src:?} -> {abc:?}"
);
assert!(
!lyric_tokens(&s1).is_empty(),
"fixture has no lyrics: {src:?}"
);
assert_eq!(pitch_seq(&s1), pitch_seq(&s2));
}
}
#[test]
fn overlay_lyrics_roundtrip_in_source_order() {
for src in [
"X:1\nM:C\nL:1/8\nK:G\nD2D2 D2D>D&x6C>D|E2FE D2D2|\nw:And tis down by the green-wood side-oh!\n",
"X:1\nM:C|\nL:1/4\nK:C\nV:1\nd\"^Duo:p\"3/4d/4de&dG3/4G/4Ge|\nw:the king of\n",
] {
let s1 = score_of(src);
let abc = write_abc(&s1, AbcWriteOptions::default());
let s2 = score_of(&abc);
assert_eq!(
lyric_tokens(&s1),
lyric_tokens(&s2),
"lyrics for {src:?} -> {abc:?}"
);
assert_eq!(overlay_pitches(&s1), overlay_pitches(&s2));
}
}
fn voice_shape(score: &crate::Score) -> Vec<(String, Vec<Option<String>>, usize)> {
score
.parts
.iter()
.flat_map(|p| {
p.voices.iter().map(|v| {
let p = &v.properties;
let texts = vec![
p.name.as_ref().map(|t| t.text.clone()),
p.nm.as_ref().map(|t| t.text.clone()),
p.subname.as_ref().map(|t| t.text.clone()),
p.snm.as_ref().map(|t| t.text.clone()),
p.clef.as_ref().map(|t| t.text.clone()),
p.stem.map(|s| format!("{s:?}")),
p.octave.as_ref().map(|t| t.text.clone()),
p.transpose.as_ref().map(|t| t.text.clone()),
p.middle.as_ref().map(|t| t.text.clone()),
];
(v.id.value.clone(), texts, v.events.len())
})
})
.collect()
}
#[test]
fn multi_voice_roundtrip() {
for src in [
"X:1\nL:1/4\nK:C\nV:1\nCDEF|\nV:2\nE,F,G,A,|\n",
"X:1\nL:1/4\nK:C\nV:T name=\"Tenor\" clef=treble-8\nCDEF|\nV:B clef=bass\nC,D,E,F,|\n",
"X:1\nL:1/4\nK:C\nV:1 octave=-1\nCDEF|\nV:2 octave=1\nGABc|\n",
"X:1\nL:1/4\nK:C\nV:1 octave=99999\nCDEF|\nV:2 clef=treble+15 octave=125\nGABc|\n",
] {
let s1 = score_of(src);
let abc = write_abc(&s1, AbcWriteOptions::default());
let s2 = score_of(&abc);
assert_eq!(pitch_seq(&s1), pitch_seq(&s2), "{src:?} -> {abc:?}");
assert_eq!(voice_shape(&s1), voice_shape(&s2), "{src:?} -> {abc:?}");
}
}
fn overlay_pitches(score: &crate::Score) -> Vec<(u32, char, i8)> {
let mut v = Vec::new();
for p in &score.parts {
for voice in &p.voices {
for m in &voice.measures {
for seg in &m.overlays {
for e in &seg.events {
if let crate::model::TimelineEventKind::Note { step, octave, .. } =
e.kind
{
v.push((seg.measure_index, step, octave));
}
}
}
}
}
}
v
}
#[test]
fn overlays_roundtrip() {
for src in [
"X:1\nL:1/4\nK:C\nC2 E2 & G,2 B,2 |\n",
"X:1\nL:1/4\nK:C\nCDEF | G2 A2 & B,2 C2 | E4 |\n",
"X:1\nL:1/4\nK:C\nC2 E2 & G,2 B,2 & E,2 F,2 |\n",
] {
let s1 = score_of(src);
let abc = write_abc(&s1, AbcWriteOptions::default());
let s2 = score_of(&abc);
assert_eq!(pitch_seq(&s1), pitch_seq(&s2), "{src:?} -> {abc:?}");
assert_eq!(
overlay_pitches(&s1),
overlay_pitches(&s2),
"{src:?} -> {abc:?}"
);
}
}
#[test]
fn review_regressions_roundtrip() {
for src in [
"X:1\nL:1/4\nK:C\nV:1 clef=treble-8\n{fg}A B C D|\n",
"X:1\nL:1/4\nK:C\nV:1 middle=d\n{fg}A B C D|\n",
"X:1\nL:1/4\nK:C\nCDEF|\nw:a b\n+:c d\n",
"X:1\nM:4/4\nL:1/8\nK:C\nC (3D y E F|\n",
"X:1\nL:1/4\nK:C\nC2 E2 & (3G,B,D G,2|\n",
"X:1\nL:1/4\nK:C\n| & CDEF|\n",
"X:1\nL:1/4\nK:C\nV:1 name=\"a\\\\b\"\nCDEF|\n",
] {
let s1 = score_of(src);
let abc1 = write_abc(&s1, AbcWriteOptions::default());
let s2 = score_of(&abc1);
let abc2 = write_abc(&s2, AbcWriteOptions::default());
assert_eq!(abc1, abc2, "not idempotent for {src:?}");
assert_eq!(pitch_seq(&s1), pitch_seq(&s2), "{src:?} -> {abc1:?}");
assert_eq!(
measure_count(&s1),
measure_count(&s2),
"{src:?} -> {abc1:?}"
);
assert_eq!(
grace_pitches(&s1),
grace_pitches(&s2),
"{src:?} -> {abc1:?}"
);
assert_eq!(
tuplet_ratios(&s1),
tuplet_ratios(&s2),
"{src:?} -> {abc1:?}"
);
assert_eq!(
overlay_pitches(&s1),
overlay_pitches(&s2),
"{src:?} -> {abc1:?}"
);
assert_eq!(voice_shape(&s1), voice_shape(&s2), "{src:?} -> {abc1:?}");
}
}
fn change_events(score: &crate::Score) -> Vec<(usize, String)> {
let mut v = Vec::new();
for p in &score.parts {
for voice in &p.voices {
for (i, e) in voice.events.iter().enumerate() {
match &e.kind {
crate::TimedEventKind::KeyChange(k) => {
v.push((i, format!("K:{}", k.display)));
}
crate::TimedEventKind::MeterChange(m) => {
v.push((i, format!("M:{}", m.display)));
}
_ => {}
}
}
}
}
v
}
#[test]
fn mid_tune_key_meter_changes_roundtrip() {
for src in [
"X:1\nL:1/4\nK:C\nCDEF|[K:F]GAB_B|[M:3/4]ABc|\n",
"X:1\nL:1/4\nK:C\n^FGA[K:D]F|FGAF|\n",
"X:1\nL:1/4\nK:C\nCDEF|\nK:D\nFGAF|\n",
"X:1\nL:1/4\nK:C\nCDEF|[K:Ador]ABcd|[M:C|]ABcd|\n",
"X:1\nL:1/4\nK:C\nCDEF|[K:C]GABc|\n",
"X:1\nL:1/4\nK:C\n=FGA[K:D]F|FGAF|\n",
"X:1\nL:1/4\nK:C\nV:1\nCDEF|\nK:D\nV:1\nFGAF|\nV:2\nCDEF|\nFGAF|\n",
] {
let s1 = score_of(src);
let abc1 = write_abc(&s1, AbcWriteOptions::default());
let s2 = score_of(&abc1);
let abc2 = write_abc(&s2, AbcWriteOptions::default());
assert_eq!(abc1, abc2, "not idempotent for {src:?}");
assert_eq!(pitch_seq(&s1), pitch_seq(&s2), "{src:?} -> {abc1:?}");
assert_eq!(
change_events(&s1),
change_events(&s2),
"{src:?} -> {abc1:?}"
);
assert_eq!(
measure_count(&s1),
measure_count(&s2),
"{src:?} -> {abc1:?}"
);
}
}
#[test]
fn output_is_a_write_abc_fixed_point() {
for src in [
"X:1\nL:1/8\nK:C\nCDE FGA | c2 z2\n",
"X:1\nL:1/4\nK:C\n|: CDEF |1 GABc :|2 cBAG |]\n",
"X:1\nL:1/4\nK:C\nC2- C2 |\n",
] {
let abc = write_abc(&score_of(src), AbcWriteOptions::default());
let rewritten = write_abc(&score_of(&abc), AbcWriteOptions::default());
assert_eq!(abc, rewritten, "write_abc not idempotent for {src:?}");
}
}
#[test]
fn section_label_with_brackets_emits_whole_line_not_inline() {
let src = "X:1\nT:T\nM:2/4\nL:1/8\nK:Am\nP:Avokatrilli [Am]\nAB cd |\n";
let s1 = score_of(src);
let abc = write_abc(&s1, AbcWriteOptions::default());
assert!(
!abc.contains("[P:Avokatrilli"),
"label containing `[` must not be emitted as inline `[P:...]`; got:\n{abc}"
);
assert!(
abc.contains("\nP:Avokatrilli [Am]\n"),
"label containing `[` must be emitted as whole-line `P:...[Am]`; got:\n{abc}"
);
let s2 = score_of(&abc);
let labels1: Vec<_> = s1
.parts
.iter()
.flat_map(|p| p.voices.iter())
.flat_map(|v| v.events.iter())
.filter_map(|e| {
if let crate::TimedEventKind::SectionLabel(l) = &e.kind {
Some(l.clone())
} else {
None
}
})
.collect();
let labels2: Vec<_> = s2
.parts
.iter()
.flat_map(|p| p.voices.iter())
.flat_map(|v| v.events.iter())
.filter_map(|e| {
if let crate::TimedEventKind::SectionLabel(l) = &e.kind {
Some(l.clone())
} else {
None
}
})
.collect();
assert_eq!(
labels1, labels2,
"section label must survive the ABC → write_abc → re-parse round-trip"
);
}
}