diff --git a/.claude/worktrees/agent-a199bb25 b/.claude/worktrees/agent-a199bb25 new file mode 160000 index 0000000..9404afc --- /dev/null +++ b/.claude/worktrees/agent-a199bb25 @@ -0,0 +1 @@ +Subproject commit 9404afc1f391fa2c3fa374edea41a679d6c35481 diff --git a/.claude/worktrees/agent-ae2f8776 b/.claude/worktrees/agent-ae2f8776 new file mode 160000 index 0000000..9404afc --- /dev/null +++ b/.claude/worktrees/agent-ae2f8776 @@ -0,0 +1 @@ +Subproject commit 9404afc1f391fa2c3fa374edea41a679d6c35481 diff --git a/.claude/worktrees/agent-af0dae97 b/.claude/worktrees/agent-af0dae97 new file mode 160000 index 0000000..9404afc --- /dev/null +++ b/.claude/worktrees/agent-af0dae97 @@ -0,0 +1 @@ +Subproject commit 9404afc1f391fa2c3fa374edea41a679d6c35481 diff --git a/CHANGELOG.md b/CHANGELOG.md index db64b9b..846ac19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to PyTheory are documented here. +## 0.42.0 + +- **LilyPond export** — `Score.to_lilypond()` generates complete LilyPond source + files with multi-staff scores, key/time signatures, tempo markings, and + automatic bass clef detection. Output can be compiled to publication-quality + PDFs with LilyPond. +- **MusicXML export** — `Score.to_musicxml()` generates MusicXML 4.0 documents + that can be opened in MuseScore, Sibelius, Finale, and any notation software. + Includes proper ties, chords, clef detection, and tempo/time signature metadata. +- **Guitar/bass tablature** — `Part.to_tab()` and `Score.to_tab()` generate ASCII + tablature. Supports guitar (6-string), bass (4-string), drop D, and custom + tunings. Automatically maps notes to the best string/fret positions. + ## 0.41.4 - **Fix** — `to_abc()` now ties long notes across barlines instead of emitting diff --git a/docs/guide/playback.rst b/docs/guide/playback.rst index 0ec6c58..826b3ba 100644 --- a/docs/guide/playback.rst +++ b/docs/guide/playback.rst @@ -236,6 +236,91 @@ Parameters: - **html** -- If ``True``, return a full HTML document instead of raw ABC (default ``False``). +to_lilypond() -- LilyPond Export +--------------------------------- + +`LilyPond `_ is the gold standard for +publication-quality music engraving. ``to_lilypond()`` generates +complete LilyPond source files that you can compile to PDF: + +.. code-block:: python + + score = Score("4/4", bpm=120) + lead = score.part("lead") + for note in ["C4", "D4", "E4", "F4"]: + lead.add(note, Duration.QUARTER) + + ly = score.to_lilypond(title="My Score", key="C", mode="major") + + with open("score.ly", "w") as f: + f.write(ly) + +Then compile with ``lilypond score.ly`` to get a PDF. Multi-part scores +get separate staves in a ``StaffGroup``, bass clef is auto-detected, +and long notes are split with ties across barlines. + +Parameters: + +- **title** -- Title for the ``\header`` block (default ``"Untitled"``). +- **key** -- Key signature root (default ``"C"``). Use note names like + ``"Bb"``, ``"F#"``, ``"Eb"``. +- **mode** -- LilyPond mode string (default ``"major"``). Use ``"minor"`` + for minor keys. + +to_musicxml() -- MusicXML Export +--------------------------------- + +MusicXML is the interchange format for notation software. Export your +score and open it in MuseScore, Sibelius, Finale, Dorico, or any +other notation app: + +.. code-block:: python + + xml = score.to_musicxml(title="My Score") + + with open("score.musicxml", "w") as f: + f.write(xml) + +The output is a complete MusicXML 4.0 partwise document with proper +time signatures, tempo markings, clef detection, tied notes across +barlines, and chord notation. No external dependencies needed. + +to_tab() -- Guitar/Bass Tablature +----------------------------------- + +Generate ASCII tablature from any Part or Score: + +.. code-block:: python + + lead = score.part("lead") + lead.add("E4", Duration.QUARTER) + lead.add("B3", Duration.QUARTER) + lead.add("G3", Duration.QUARTER) + lead.add("D3", Duration.QUARTER) + + print(lead.to_tab()) + +Output:: + + e|---0---------| + B|------0------| + G|---------0---| + D|------------0| + A|-------------| + E|-------------| + +Works on Score too -- it picks the first melodic part automatically: + +.. code-block:: python + + print(score.to_tab()) # auto-pick part + print(score.to_tab(part_name="bass")) # specific part + print(score.to_tab(tuning="bass")) # 4-string bass tab + print(score.to_tab(tuning="drop_d")) # drop D guitar + +Supports ``"guitar"`` (6-string standard), ``"bass"`` (4-string), +``"drop_d"``, or a custom list of MIDI note numbers for any tuning. + play_pattern() -- Drum Patterns ------------------------------- diff --git a/pyproject.toml b/pyproject.toml index c7a86d6..4c004e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pytheory" -version = "0.41.4" +version = "0.42.0" description = "Music Theory for Humans" readme = "README.md" license = "MIT" diff --git a/pytheory/__init__.py b/pytheory/__init__.py index 8d874c6..1d85110 100644 --- a/pytheory/__init__.py +++ b/pytheory/__init__.py @@ -1,6 +1,6 @@ """PyTheory: Music Theory for Humans.""" -__version__ = "0.41.4" +__version__ = "0.42.0" from .tones import Tone, Interval from .systems import System, SYSTEMS, TET diff --git a/pytheory/rhythm.py b/pytheory/rhythm.py index 391192b..f83373a 100644 --- a/pytheory/rhythm.py +++ b/pytheory/rhythm.py @@ -3796,6 +3796,122 @@ class Part: return max(note_beats, drum_beats) return note_beats + # ── ASCII tablature export ────────────────────────────────────────── + + _TAB_TUNINGS = { + "guitar": [40, 45, 50, 55, 59, 64], + "bass": [28, 33, 38, 43], + "drop_d": [38, 45, 50, 55, 59, 64], + } + _TAB_LABELS = { + "guitar": ["E", "A", "D", "G", "B", "e"], + "bass": ["E", "A", "D", "G"], + "drop_d": ["D", "A", "D", "G", "B", "e"], + } + + def to_tab(self, *, tuning="guitar", frets=24, time_signature=None): + """Generate ASCII guitar/bass tablature from this part's notes. + + Args: + tuning: ``"guitar"`` (6-string standard), ``"bass"`` (4-string), + ``"drop_d"`` (guitar drop D), or a list of MIDI note numbers + for custom tuning (low string first). + frets: Maximum fret number (default 24). + time_signature: A ``TimeSignature`` or ``None`` for 4/4. + + Returns: + A multi-line ASCII tablature string. + """ + if isinstance(tuning, str): + open_midis = list(self._TAB_TUNINGS[tuning]) + labels = list(self._TAB_LABELS[tuning]) + else: + open_midis = list(tuning) + _note_names = ["C", "C#", "D", "D#", "E", "F", + "F#", "G", "G#", "A", "A#", "B"] + labels = [_note_names[m % 12] for m in open_midis] + + n_strings = len(open_midis) + beats_per_measure = 4.0 + if time_signature is not None: + beats_per_measure = time_signature.beats_per_measure + + # Build columns: each column is a list[str] of length n_strings + columns: list[list[str]] = [] + beat_acc = 0.0 + + for note in self.notes: + dur_beats = note.duration.value + # Insert barline if we've crossed a measure boundary + while beat_acc >= beats_per_measure - 0.001: + columns.append(["|"] * n_strings) + beat_acc -= beats_per_measure + + col = ["---"] * n_strings + + tone = note.tone + if tone is None or isinstance(tone, _DrumTone): + pass + elif hasattr(tone, "tones"): + # Chord — assign each chord tone to a different string + used: set[int] = set() + for ct in tone.tones: + midi_val = getattr(ct, "midi", None) + if midi_val is None: + continue + best_s, best_f = self._find_best_string( + midi_val, open_midis, frets, used) + if best_s is not None: + fret_str = str(best_f) + col[best_s] = fret_str.center(3, "-") + used.add(best_s) + else: + midi_val = getattr(tone, "midi", None) + if midi_val is not None: + best_s, best_f = self._find_best_string( + midi_val, open_midis, frets, set()) + if best_s is not None: + fret_str = str(best_f) + col[best_s] = fret_str.center(3, "-") + + columns.append(col) + if not note._hold: + beat_acc += dur_beats + + # Trailing barline + if columns and columns[-1] != ["|"] * n_strings: + while beat_acc >= beats_per_measure - 0.001: + columns.append(["|"] * n_strings) + beat_acc -= beats_per_measure + columns.append(["|"] * n_strings) + + # Build output lines (highest-pitched string first in display) + lines: list[str] = [] + for s_idx in range(n_strings - 1, -1, -1): + label = labels[s_idx] + parts_str = "".join(c[s_idx] for c in columns) + lines.append(f"{label}|{parts_str}") + + return "\n".join(lines) + + @staticmethod + def _find_best_string(midi_val, open_midis, max_fret, used): + """Find the best string/fret for a MIDI note. + + Returns (string_index, fret) or (None, None) if unplayable. + """ + best_s = None + best_f = None + for s_idx, open_m in enumerate(open_midis): + if s_idx in used: + continue + f = midi_val - open_m + if 0 <= f <= max_fret: + if best_f is None or f < best_f: + best_s = s_idx + best_f = f + return best_s, best_f + def __len__(self): return len(self.notes) + len(self._drum_hits) @@ -4574,6 +4690,494 @@ class Score: body += " |" return body + # ── LilyPond notation export ───────────────────────────────────── + + def to_lilypond(self, *, title="Untitled", key="C", mode="major"): + """Export the score as a LilyPond source string. + + Args: + title: Title for the ``\\header`` block. + key: Key signature root (e.g. ``"C"``, ``"D"``, ``"Bb"``). + mode: LilyPond mode string (``"major"``, ``"minor"``, etc.). + + Returns: + A complete LilyPond source string. + """ + ts = self.time_signature + + # Collect voices (same filter as to_abc) + 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 not part.notes: + continue + has_pitched = any( + n.tone is not None + and (hasattr(n.tone, "name") or hasattr(n.tone, "tones")) + for n in part.notes + ) + if not has_pitched: + continue + voices.append((name, part.notes)) + + ly_key = self._tone_name_to_lilypond(key) + + staves = [] + for vname, notes in voices: + clef = self._guess_clef(notes) + body = self._notes_to_lilypond(notes, ts) + staff = ( + f' \\new Staff \\with {{ instrumentName = "{vname}" }} {{\n' + f" \\clef {clef}\n" + f" \\key {ly_key} \\{mode}\n" + f" \\time {ts.beats}/{ts.unit}\n" + f" \\tempo 4 = {self.bpm}\n" + f" {body}\n" + f" }}" + ) + staves.append(staff) + + staves_block = "\n".join(staves) + + return ( + f'\\version "2.24.0"\n' + f"\\header {{\n" + f' title = "{title}"\n' + f"}}\n\n" + f"\\score {{\n" + f" \\new StaffGroup <<\n" + f"{staves_block}\n" + f" >>\n" + f" \\layout {{ }}\n" + f"}}\n" + ) + + @staticmethod + def _tone_name_to_lilypond(name): + """Convert a note name like 'C#', 'Bb', 'F' to LilyPond pitch.""" + if not name: + return "c" + letter = name[0].lower() + acc = name[1:] if len(name) > 1 else "" + ly_acc = ( + acc.replace("##", "isis") + .replace("#", "is") + .replace("bb", "eses") + .replace("b", "es") + ) + return f"{letter}{ly_acc}" + + @staticmethod + def _tone_to_lilypond(tone): + """Convert a single Tone to a LilyPond pitch string (no duration).""" + if tone is None: + return None + if not hasattr(tone, "name") or not hasattr(tone, "octave"): + return None + + name = tone.name + octave = tone.octave if tone.octave is not None else 4 + + letter = name[0].lower() + acc = name[1:] if len(name) > 1 else "" + ly_acc = ( + acc.replace("##", "isis") + .replace("#", "is") + .replace("bb", "eses") + .replace("b", "es") + ) + + # LilyPond: c = C3, c' = C4, c'' = C5, c, = C2, c,, = C1 + if octave >= 4: + oct_str = "'" * (octave - 3) + else: + oct_str = "," * (3 - octave) + + return f"{letter}{ly_acc}{oct_str}" + + @staticmethod + def _beats_to_lilypond_dur(beats): + """Convert a beat count to a LilyPond duration string.""" + _MAP = { + 4.0: "1", + 2.0: "2", + 1.0: "4", + 0.5: "8", + 0.25: "16", + 3.0: "2.", + 1.5: "4.", + } + for ref, ly in _MAP.items(): + if abs(beats - ref) < 0.001: + return ly + if abs(beats - 2 / 3) < 0.05: + return "4" + closest = min(_MAP, key=lambda k: abs(k - beats)) + return _MAP[closest] + + def _notes_to_lilypond(self, notes, ts, bars_per_line=4): + """Convert a list of Note objects to a LilyPond music body string.""" + beats_per_measure = ts.beats_per_measure + tokens: list[str] = [] + beat_in_measure = 0.0 + measure_count = 0 + + for note in notes: + total_beats = note.duration.value + + if note.tone is None: + pitch = None + is_rest = True + elif hasattr(note.tone, "tones"): + chord_pitches = [] + for t in note.tone.tones: + p = self._tone_to_lilypond(t) + if p is not None: + chord_pitches.append(p) + if chord_pitches: + pitch = "<" + " ".join(chord_pitches) + ">" + is_rest = False + else: + pitch = None + is_rest = True + else: + p = self._tone_to_lilypond(note.tone) + if p is not None: + pitch = p + is_rest = False + else: + pitch = None + is_rest = True + + remaining = total_beats + while remaining > 0.001: + room = beats_per_measure - beat_in_measure + chunk = min(remaining, room) if remaining > room + 0.001 else remaining + needs_tie = remaining - chunk > 0.001 + + dur_str = self._beats_to_lilypond_dur(chunk) + + if is_rest or pitch is None: + tokens.append(f"r{dur_str}") + else: + tie_str = "~" if needs_tie else "" + tokens.append(f"{pitch}{dur_str}{tie_str}") + + remaining -= chunk + beat_in_measure += chunk + + if beat_in_measure >= beats_per_measure - 0.001: + measure_count += 1 + if measure_count % bars_per_line == 0: + tokens.append("|\n ") + else: + tokens.append("|") + beat_in_measure -= beats_per_measure + + body = " ".join(tokens) + body = body.replace("| |", "|").rstrip("| \n").rstrip() + if not body.endswith("|"): + body += " |" + return body + + # ── MusicXML export ─────────────────────────────────────────────── + + def to_musicxml(self, *, title="Untitled"): + """Export the score as a MusicXML string. + + Args: + title: Work title embedded in the ```` element. + + Returns: + A MusicXML 4.0 partwise document as a pretty-printed XML string. + """ + import xml.etree.ElementTree as ET + import xml.dom.minidom + + DIVISIONS = 4 # divisions per quarter note + + _DUR_MAP = { + 4.0: ("whole", False), + 3.0: ("half", True), + 2.0: ("half", False), + 1.5: ("quarter", True), + 1.0: ("quarter", False), + 0.5: ("eighth", False), + 0.25: ("16th", False), + } + + def _beats_to_divisions(beats): + return int(round(beats * DIVISIONS)) + + def _best_dur_type(beats): + for val, info in _DUR_MAP.items(): + if abs(beats - val) < 0.001: + return info + return None + + def _split_into_measures(notes, beats_per_measure): + beat_in_measure = 0.0 + for note in notes: + tone = note.tone + if tone is not None and not hasattr(tone, "name") and not hasattr(tone, "tones"): + tone = None + remaining = note.duration.value + is_first = True + while remaining > 0.001: + room = beats_per_measure - beat_in_measure + if room < 0.001: + room = beats_per_measure + beat_in_measure = 0.0 + chunk = min(remaining, room) + needs_tie_start = (remaining - chunk) > 0.001 + needs_tie_stop = not is_first + + yield (tone, chunk, needs_tie_start, needs_tie_stop, + note.velocity, note.articulation) + + remaining -= chunk + beat_in_measure += chunk + is_first = False + + if beat_in_measure >= beats_per_measure - 0.001: + beat_in_measure = 0.0 + + def _tone_to_pitch_el(tone): + pitch = ET.Element("pitch") + name = tone.name + letter = name[0].upper() + acc_str = name[1:] if len(name) > 1 else "" + + step = ET.SubElement(pitch, "step") + step.text = letter + + alter_val = 0 + if acc_str == "#": + alter_val = 1 + elif acc_str == "##": + alter_val = 2 + elif acc_str == "b": + alter_val = -1 + elif acc_str == "bb": + alter_val = -2 + + if alter_val != 0: + alter = ET.SubElement(pitch, "alter") + alter.text = str(alter_val) + + octave_el = ET.SubElement(pitch, "octave") + octave_el.text = str(tone.octave if tone.octave is not None else 4) + + return pitch + + def _add_note_el(measure, tone, dur_beats, is_chord_continuation, + tie_start, tie_stop, velocity): + note_el = ET.SubElement(measure, "note") + + if is_chord_continuation: + ET.SubElement(note_el, "chord") + + if tone is None: + ET.SubElement(note_el, "rest") + elif hasattr(tone, "tones"): + ET.SubElement(note_el, "rest") + else: + note_el.append(_tone_to_pitch_el(tone)) + + dur_el = ET.SubElement(note_el, "duration") + dur_el.text = str(_beats_to_divisions(dur_beats)) + + if tie_stop: + tie_s = ET.SubElement(note_el, "tie") + tie_s.set("type", "stop") + if tie_start: + tie_s = ET.SubElement(note_el, "tie") + tie_s.set("type", "start") + + dur_info = _best_dur_type(dur_beats) + if dur_info: + type_el = ET.SubElement(note_el, "type") + type_el.text = dur_info[0] + if dur_info[1]: + ET.SubElement(note_el, "dot") + + if tie_start or tie_stop: + notations = ET.SubElement(note_el, "notations") + if tie_stop: + tied = ET.SubElement(notations, "tied") + tied.set("type", "stop") + if tie_start: + tied = ET.SubElement(notations, "tied") + tied.set("type", "start") + + # ── Collect voices ────────────────────────────────────────── + voices = [] + if self.notes: + voices.append(("default", self.notes)) + for name, part in self.parts.items(): + if part.is_drums: + continue + if not part.notes: + continue + has_pitched = any( + n.tone is not None + and (hasattr(n.tone, "name") or hasattr(n.tone, "tones")) + for n in part.notes + ) + if not has_pitched: + continue + voices.append((name, part.notes)) + + if not voices: + voices.append(("default", [])) + + # ── Build XML tree ────────────────────────────────────────── + root = ET.Element("score-partwise") + root.set("version", "4.0") + + work = ET.SubElement(root, "work") + work_title = ET.SubElement(work, "work-title") + work_title.text = title + + part_list = ET.SubElement(root, "part-list") + ts = self.time_signature + beats_per_measure = ts.beats_per_measure + + for idx, (vname, notes) in enumerate(voices, 1): + pid = f"P{idx}" + sp = ET.SubElement(part_list, "score-part") + sp.set("id", pid) + pn = ET.SubElement(sp, "part-name") + pn.text = vname + + for idx, (vname, notes) in enumerate(voices, 1): + pid = f"P{idx}" + part_el = ET.SubElement(root, "part") + part_el.set("id", pid) + + clef_type = self._guess_clef(notes) + + chunks = list(_split_into_measures(notes, beats_per_measure)) + + beat_in_measure = 0.0 + measure_num = 1 + measure_el = ET.SubElement(part_el, "measure") + measure_el.set("number", str(measure_num)) + + attrs = ET.SubElement(measure_el, "attributes") + div_el = ET.SubElement(attrs, "divisions") + div_el.text = str(DIVISIONS) + time_el = ET.SubElement(attrs, "time") + beats_el = ET.SubElement(time_el, "beats") + beats_el.text = str(ts.beats) + bt_el = ET.SubElement(time_el, "beat-type") + bt_el.text = str(ts.unit) + clef_el = ET.SubElement(attrs, "clef") + sign_el = ET.SubElement(clef_el, "sign") + line_el = ET.SubElement(clef_el, "line") + if clef_type == "bass": + sign_el.text = "F" + line_el.text = "4" + else: + sign_el.text = "G" + line_el.text = "2" + + direction = ET.SubElement(measure_el, "direction") + dir_type = ET.SubElement(direction, "direction-type") + metronome = ET.SubElement(dir_type, "metronome") + bu = ET.SubElement(metronome, "beat-unit") + bu.text = "quarter" + pm = ET.SubElement(metronome, "per-minute") + pm.text = str(self.bpm) + + for (tone, dur_beats, tie_start, tie_stop, + vel, artic) in chunks: + + if beat_in_measure >= beats_per_measure - 0.001: + measure_num += 1 + measure_el = ET.SubElement(part_el, "measure") + measure_el.set("number", str(measure_num)) + beat_in_measure = 0.0 + + if tone is not None and hasattr(tone, "tones"): + chord_tones = [ + t for t in tone.tones + if hasattr(t, "name") and hasattr(t, "octave") + ] + if not chord_tones: + _add_note_el(measure_el, None, dur_beats, False, + tie_start, tie_stop, vel) + else: + for ci, ct in enumerate(chord_tones): + _add_note_el(measure_el, ct, dur_beats, + ci > 0, tie_start, tie_stop, vel) + else: + _add_note_el(measure_el, tone, dur_beats, False, + tie_start, tie_stop, vel) + + beat_in_measure += dur_beats + + # ── Serialize ─────────────────────────────────────────────── + raw = ET.tostring(root, encoding="unicode") + doctype = ( + '\n' + '\n' + ) + pretty = xml.dom.minidom.parseString(raw).toprettyxml(indent=" ") + lines = pretty.split("\n") + if lines and lines[0].startswith("