Skip to main content

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