Skip to main content

music21_rs/
abc.rs

1//! ABC notation format helpers.
2//!
3//! This module intentionally stays close to reusable ABC token conversion,
4//! similar in spirit to `music21.abcFormat`. Complete score layout and
5//! application-specific snippets belong in callers.
6
7use crate::{Error, Pitch, Result};
8
9/// Returns an ABC note token for a pitch.
10///
11/// Octave-less pitches are written in octave 4, matching the crate's default
12/// pitch-space behavior.
13pub fn abc_note(pitch: &Pitch) -> Result<String> {
14    let name = pitch.name();
15    let mut chars = name.chars();
16    let step = chars
17        .next()
18        .ok_or_else(|| Error::Pitch("cannot write empty pitch name as ABC".to_string()))?;
19    if !matches!(step, 'A'..='G') {
20        return Err(Error::Pitch(format!(
21            "cannot write pitch step {step:?} as ABC"
22        )));
23    }
24
25    let accidental = chars
26        .map(|modifier| match modifier {
27            '#' => Ok('^'),
28            '-' => Ok('_'),
29            _ => Err(Error::Pitch(format!(
30                "cannot write accidental modifier {modifier:?} as ABC"
31            ))),
32        })
33        .collect::<Result<String>>()?;
34    let octave = pitch.octave().unwrap_or(4);
35
36    if octave >= 5 {
37        Ok(format!(
38            "{accidental}{}{}",
39            step.to_ascii_lowercase(),
40            "'".repeat((octave - 5) as usize)
41        ))
42    } else {
43        Ok(format!(
44            "{accidental}{step}{}",
45            ",".repeat((4 - octave).max(0) as usize)
46        ))
47    }
48}
49
50/// Returns an ABC rest token.
51pub fn abc_rest() -> &'static str {
52    "z"
53}
54
55/// Returns an ABC chord token for the supplied pitches.
56pub fn abc_chord(pitches: &[Pitch]) -> Result<String> {
57    let notes = pitches.iter().map(abc_note).collect::<Result<Vec<_>>>()?;
58    if notes.is_empty() {
59        Ok(abc_rest().to_string())
60    } else {
61        Ok(format!("[{}]", notes.join("")))
62    }
63}
64
65/// Returns an ABC duration suffix for a rational note length.
66pub fn abc_duration(numerator: u32, denominator: u32) -> Result<String> {
67    if denominator == 0 {
68        return Err(Error::Music21Object(
69            "ABC duration denominator cannot be zero".to_string(),
70        ));
71    }
72
73    let divisor = gcd(numerator, denominator);
74    let top = numerator / divisor;
75    let bottom = denominator / divisor;
76
77    if bottom == 1 {
78        if top == 1 {
79            Ok(String::new())
80        } else {
81            Ok(top.to_string())
82        }
83    } else if top == 1 {
84        Ok(format!("/{bottom}"))
85    } else {
86        Ok(format!("{top}/{bottom}"))
87    }
88}
89
90/// Returns a music21-style pitch name for an ABC note token.
91///
92/// Rests return `Ok(None)`. Length suffixes are accepted and ignored.
93pub fn pitch_name_from_abc_note(token: &str) -> Result<Option<String>> {
94    let token = token.trim();
95    if token.is_empty() {
96        return Err(Error::Pitch("ABC note token cannot be empty".to_string()));
97    }
98
99    let token = token
100        .strip_prefix('{')
101        .and_then(|inner| inner.strip_suffix('}'))
102        .unwrap_or(token);
103
104    let mut chars = token.char_indices().peekable();
105    let mut accidental = String::new();
106
107    while let Some((_, ch)) = chars.peek().copied() {
108        match ch {
109            '^' => {
110                accidental.push('#');
111                chars.next();
112            }
113            '_' => {
114                accidental.push('-');
115                chars.next();
116            }
117            '=' => {
118                accidental.clear();
119                accidental.push('n');
120                chars.next();
121            }
122            _ => break,
123        }
124    }
125
126    let Some((_, step)) = chars.next() else {
127        return Err(Error::Pitch(format!(
128            "ABC note token {token:?} is missing a pitch step"
129        )));
130    };
131
132    if matches!(step, 'z' | 'Z' | 'x' | 'X') {
133        ensure_duration_suffix(token, chars.map(|(_, ch)| ch))?;
134        return Ok(None);
135    }
136
137    if !matches!(step, 'A'..='G' | 'a'..='g') {
138        return Err(Error::Pitch(format!(
139            "ABC note token {token:?} has invalid pitch step {step:?}"
140        )));
141    }
142
143    let mut octave = if step.is_ascii_lowercase() { 5 } else { 4 };
144    while let Some((_, ch)) = chars.peek().copied() {
145        match ch {
146            '\'' => {
147                octave += 1;
148                chars.next();
149            }
150            ',' => {
151                octave -= 1;
152                chars.next();
153            }
154            _ => break,
155        }
156    }
157
158    ensure_duration_suffix(token, chars.map(|(_, ch)| ch))?;
159    Ok(Some(format!(
160        "{}{accidental}{octave}",
161        step.to_ascii_uppercase()
162    )))
163}
164
165/// Returns music21-style pitch names for a simple ABC chord token.
166///
167/// Length suffixes on the chord or individual notes are accepted and ignored.
168pub fn pitch_names_from_abc_chord(token: &str) -> Result<Vec<String>> {
169    let token = token.trim();
170    let Some(open) = token.find('[') else {
171        return match pitch_name_from_abc_note(token)? {
172            Some(pitch_name) => Ok(vec![pitch_name]),
173            None => Ok(Vec::new()),
174        };
175    };
176    let Some(close_offset) = token[open + 1..].find(']') else {
177        return Err(Error::Pitch(format!(
178            "ABC chord token {token:?} is missing a closing bracket"
179        )));
180    };
181    let close = open + 1 + close_offset;
182    ensure_duration_suffix(token, token[close + 1..].chars())?;
183
184    let mut names = Vec::new();
185    let inner = &token[open + 1..close];
186    let mut start = 0;
187    while start < inner.len() {
188        let end = next_abc_note_end(inner, start)?;
189        if let Some(name) = pitch_name_from_abc_note(&inner[start..end])? {
190            names.push(name);
191        }
192        start = end;
193    }
194
195    Ok(names)
196}
197
198fn next_abc_note_end(value: &str, start: usize) -> Result<usize> {
199    let mut end = start;
200    let mut saw_step = false;
201
202    for (offset, ch) in value[start..].char_indices() {
203        let idx = start + offset;
204        if !saw_step {
205            end = idx + ch.len_utf8();
206            match ch {
207                '^' | '_' | '=' => {}
208                'A'..='G' | 'a'..='g' | 'z' | 'Z' | 'x' | 'X' => saw_step = true,
209                _ => {
210                    return Err(Error::Pitch(format!(
211                        "ABC chord token has invalid note character {ch:?}"
212                    )));
213                }
214            }
215            continue;
216        }
217
218        if matches!(ch, '\'' | ',' | '/' | '0'..='9') {
219            end = idx + ch.len_utf8();
220        } else {
221            break;
222        }
223    }
224
225    if saw_step {
226        Ok(end)
227    } else {
228        Err(Error::Pitch(
229            "ABC chord token is missing a pitch step".to_string(),
230        ))
231    }
232}
233
234fn ensure_duration_suffix(token: &str, suffix: impl IntoIterator<Item = char>) -> Result<()> {
235    let invalid = suffix
236        .into_iter()
237        .find(|ch| !ch.is_ascii_digit() && *ch != '/');
238
239    if let Some(ch) = invalid {
240        Err(Error::Pitch(format!(
241            "ABC token {token:?} has unsupported suffix character {ch:?}"
242        )))
243    } else {
244        Ok(())
245    }
246}
247
248fn gcd(mut left: u32, mut right: u32) -> u32 {
249    while right != 0 {
250        (left, right) = (right, left % right);
251    }
252    left
253}
254
255#[cfg(test)]
256mod tests {
257    use super::{
258        abc_chord, abc_duration, abc_note, abc_rest, pitch_name_from_abc_note,
259        pitch_names_from_abc_chord,
260    };
261    use crate::{Pitch, Result};
262
263    fn pitches(names: &[&str]) -> Result<Vec<Pitch>> {
264        names.iter().map(|name| Pitch::from_name(*name)).collect()
265    }
266
267    #[test]
268    fn writes_pitch_tokens_with_accidentals_and_octaves() -> Result<()> {
269        assert_eq!(abc_note(&Pitch::from_name("C4")?)?, "C");
270        assert_eq!(abc_note(&Pitch::from_name("C#4")?)?, "^C");
271        assert_eq!(abc_note(&Pitch::from_name("E-4")?)?, "_E");
272        assert_eq!(abc_note(&Pitch::from_name("C5")?)?, "c");
273        assert_eq!(abc_note(&Pitch::from_name("B3")?)?, "B,");
274        Ok(())
275    }
276
277    #[test]
278    fn writes_chord_and_rest_tokens_without_score_layout() -> Result<()> {
279        assert_eq!(abc_rest(), "z");
280        assert_eq!(abc_chord(&pitches(&["C4", "E4", "G4"])?)?, "[CEG]");
281        assert_eq!(abc_chord(&[])?, "z");
282        Ok(())
283    }
284
285    #[test]
286    fn writes_duration_suffixes() -> Result<()> {
287        assert_eq!(abc_duration(1, 1)?, "");
288        assert_eq!(abc_duration(4, 1)?, "4");
289        assert_eq!(abc_duration(1, 4)?, "/4");
290        assert_eq!(abc_duration(4, 10)?, "2/5");
291        assert!(abc_duration(1, 0).is_err());
292        Ok(())
293    }
294
295    #[test]
296    fn reads_pitch_names_from_note_tokens() -> Result<()> {
297        assert_eq!(pitch_name_from_abc_note("C")?.as_deref(), Some("C4"));
298        assert_eq!(pitch_name_from_abc_note("c")?.as_deref(), Some("C5"));
299        assert_eq!(pitch_name_from_abc_note("B,,")?.as_deref(), Some("B2"));
300        assert_eq!(pitch_name_from_abc_note("c''")?.as_deref(), Some("C7"));
301        assert_eq!(pitch_name_from_abc_note("^g2")?.as_deref(), Some("G#5"));
302        assert_eq!(pitch_name_from_abc_note("_g''")?.as_deref(), Some("G-7"));
303        assert_eq!(pitch_name_from_abc_note("=c")?.as_deref(), Some("Cn5"));
304        assert_eq!(pitch_name_from_abc_note("z4")?, None);
305        Ok(())
306    }
307
308    #[test]
309    fn reads_pitch_names_from_chord_tokens() -> Result<()> {
310        assert_eq!(
311            pitch_names_from_abc_chord("[CEG]4")?,
312            vec!["C4", "E4", "G4"]
313        );
314        assert_eq!(
315            pitch_names_from_abc_chord("[^C_Eg']2")?,
316            vec!["C#4", "E-4", "G6"]
317        );
318        Ok(())
319    }
320}