diff --git a/pytheory/rhythm.py b/pytheory/rhythm.py index 1d0abef..e42c585 100644 --- a/pytheory/rhythm.py +++ b/pytheory/rhythm.py @@ -4369,6 +4369,157 @@ class Score: f"{part_info} {self.measures:.1f} measures>" ) + # ── ABC notation export ──────────────────────────────────────────── + + def to_abc(self, *, title="Untitled", key="C", html=False): + """Export the score as ABC notation. + + Args: + title: Tune title for the ``T:`` field. + key: Key signature (e.g. ``"C"``, ``"Gm"``, ``"D"``) for the + ``K:`` field. + html: If *True*, wrap the ABC string in a self-contained HTML + page that renders sheet music via abcjs. + + Returns: + An ABC notation string, or a full HTML document string when + *html* is True. + """ + ts = self.time_signature + default_unit = 8 # L:1/8 + + lines = [ + "X:1", + f"T:{title}", + f"M:{ts.beats}/{ts.unit}", + f"Q:1/4={self.bpm}", + f"L:1/{default_unit}", + ] + + # Collect voices: default notes first, then named parts (skip drums) + voices: list[tuple[str, list]] = [] + if self.notes: + voices.append(("default", self.notes)) + for name, part in self.parts.items(): + if part.is_drums: + continue + if part.notes: + voices.append((name, part.notes)) + + multi = len(voices) > 1 + + if multi: + for i, (vname, _) in enumerate(voices, 1): + lines.append(f"V:{i} name=\"{vname}\"") + lines.append(f"K:{key}") + for i, (_, notes) in enumerate(voices, 1): + lines.append(f"V:{i}") + lines.append(self._notes_to_abc(notes, default_unit, ts)) + else: + lines.append(f"K:{key}") + if voices: + lines.append(self._notes_to_abc(voices[0][1], default_unit, ts)) + + abc = "\n".join(lines) + "\n" + + if not html: + return abc + + return ( + "\n\n" + "" + title + "\n" + "\n" + "\n
\n\n\n" + ) + + @staticmethod + def _tone_to_abc(tone, default_unit): + """Convert a single Tone to an ABC note string.""" + if tone is None: + return "z" + + name = tone.name # e.g. "C", "C#", "Bb" + octave = tone.octave if tone.octave is not None else 4 + + # ABC accidentals: ^ = sharp, _ = flat, ^^ = double sharp, __ = double flat + letter = name[0].upper() + acc = name[1:] if len(name) > 1 else "" + abc_acc = acc.replace("##", "^^").replace("#", "^").replace("bb", "__").replace("b", "_") + + # ABC octave: C-B = octave 4, c-b = octave 5, + # c' = 6, c'' = 7, C, = 3, C,, = 2 + if octave >= 5: + note_char = letter.lower() + ticks = octave - 5 + oct_str = "'" * ticks + else: + note_char = letter.upper() + commas = 4 - octave + oct_str = "," * commas + + return f"{abc_acc}{note_char}{oct_str}" + + def _notes_to_abc(self, notes, default_unit, ts): + """Convert a list of Note objects to an ABC body string.""" + beats_per_measure = ts.beats_per_measure + parts = [] + beat_in_measure = 0.0 + + for note in notes: + beats = note.duration.value + + # ABC length multiplier relative to L:1/default_unit + # L:1/8 means 1 unit = 0.5 beats (an eighth note) + unit_beats = 4.0 / default_unit # beats per L unit + multiplier = beats / unit_beats + + if note.tone is None: + abc_note = "z" + elif hasattr(note.tone, "tones"): + # Chord: [CEG] + chord_notes = [ + self._tone_to_abc(t, default_unit) + for t in note.tone.tones + ] + abc_note = "[" + "".join(chord_notes) + "]" + else: + abc_note = self._tone_to_abc(note.tone, default_unit) + + # Format duration multiplier + if multiplier == 1: + dur_str = "" + elif multiplier == int(multiplier): + dur_str = str(int(multiplier)) + elif multiplier == 0.5: + dur_str = "/2" + elif multiplier == 0.25: + dur_str = "/4" + elif multiplier == 1.5: + dur_str = "3/2" + else: + # General fraction + from fractions import Fraction + frac = Fraction(multiplier).limit_denominator(16) + dur_str = f"{frac.numerator}/{frac.denominator}" + + parts.append(f"{abc_note}{dur_str}") + + beat_in_measure += beats + if beat_in_measure >= beats_per_measure - 0.001: + parts.append("|") + beat_in_measure -= beats_per_measure + + body = " ".join(parts) + # Clean up trailing/double barlines + body = body.replace("| |", "|").rstrip("| ").rstrip() + if not body.endswith("|"): + body += " |" + return body + def save_midi(self, path, velocity=100): """Export to Standard MIDI File, measure-aware.""" ticks_per_beat = 480