Compare commits

...

7 Commits

Author SHA1 Message Date
kennethreitz 7883c978f7 Support Fretboard objects in to_tab() — v0.42.1
to_tab(tuning=Fretboard.guitar()) now works, along with bass,
ukulele, mandolin, banjo, and any custom Fretboard with capo.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:03:52 -04:00
kennethreitz 36d558573c Remove worktree submodules, add to gitignore
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:02:17 -04:00
kennethreitz 1e2f09e2ab LilyPond, MusicXML, and tablature export — v0.42.0
Three new export methods on Score:
- to_lilypond() — complete LilyPond source files for PDF engraving
- to_musicxml() — MusicXML 4.0 for MuseScore/Sibelius/Finale
- to_tab() — ASCII guitar/bass tablature (also on Part)

All three handle multi-part scores, bass clef detection, tied notes
across barlines, chords, and drum tone filtering.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:02:09 -04:00
kennethreitz 9404afc1f3 Document ABC notation export in playback guide
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:59:46 -04:00
kennethreitz 72aa097552 Tie long notes across barlines in to_abc() — v0.41.4
Notes longer than one measure are split into tied pieces so abcjs
can render them correctly (e.g. 16-beat choir drone becomes four
tied whole notes).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:57:23 -04:00
kennethreitz 5ebf0bdd97 Skip unpitched parts in to_abc(), fix 'pitch is undefined' — v0.41.3
Parts with only drum tones or rests are excluded from ABC output.
Chords correctly recognized as pitched content.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:47:44 -04:00
kennethreitz 1d897c6609 Auto bass clef detection in to_abc() — v0.41.2
Parts with average note octave below C4 get clef=bass automatically.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:43:51 -04:00
7 changed files with 887 additions and 46 deletions
+1
View File
@@ -7,3 +7,4 @@ t2.py
__pycache__
pytheory.egg-info
docs/_build
.claude/worktrees/
+37
View File
@@ -2,6 +2,43 @@
All notable changes to PyTheory are documented here.
## 0.42.1
- **Fretboard tuning support** — `to_tab()` now accepts `Fretboard` objects as
the `tuning` parameter. Works with `Fretboard.guitar()`, `Fretboard.bass()`,
`Fretboard.ukulele()`, `Fretboard.mandolin()`, `Fretboard.banjo()`, and any
custom Fretboard with capo.
## 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
oversized durations that abcjs can't render (e.g. 16-beat notes become four
tied whole notes).
## 0.41.3
- **Fix** — `to_abc()` now skips parts with only drum tones or rests (no pitched
notes), fixing "pitch is undefined" errors in abcjs. Chords are correctly
recognized as pitched content.
## 0.41.2
- **Auto bass clef** — `to_abc()` detects low-register parts (808, bass, timpani)
and assigns `clef=bass` automatically based on average note octave.
## 0.41.1
- **Fix** — `to_abc()` no longer crashes on parts containing drum tones.
+157 -7
View File
@@ -3,19 +3,21 @@ Playback and Export
This is the output layer. You've built your theory, composed your
arrangement, shaped your sounds -- now you need to hear it. PyTheory
gives you three ways to get your music out: speakers, WAV files, and
MIDI files.
gives you four ways to get your music out: speakers, WAV files, MIDI
files, and sheet music.
Use **speakers** for immediate feedback while you're sketching and
experimenting. Use **WAV export** when you want to share actual audio
-- post it, send it, drop it into a video. Use **MIDI export** when you
want to bring your sketch into a real DAW and finish it with
professional instruments, mixing, and mastering. Each output serves a
different stage of the creative process.
professional instruments, mixing, and mastering. Use **ABC notation
export** when you want sheet music -- rendered in the browser or shared
as plain text. Each output serves a different stage of the creative
process.
PyTheory can play audio through your speakers, save to WAV, or export
to MIDI. Everything is synthesized from waveforms -- no samples or
external audio files needed.
PyTheory can play audio through your speakers, save to WAV, export to
MIDI, or generate sheet music as ABC notation. Everything is synthesized
from waveforms -- no samples or external audio files needed.
.. note::
@@ -171,6 +173,154 @@ Score-based export (with time signature, tempo, and parts):
score.add(chord, Duration.WHOLE)
score.save_midi("progression.mid")
to_abc() -- ABC Notation / Sheet Music
---------------------------------------
ABC notation is a human-readable text format for music that tools can
turn into staff notation and MIDI. It's widely used for folk tunes,
lead sheets, and quick sketches. PyTheory can export any Score as ABC
notation -- and optionally wrap it in an HTML page that renders
sheet music right in the browser using `abcjs <https://www.abcjs.net/>`_.
Basic export:
.. code-block:: python
from pytheory import Score, Duration, Key
score = Score("4/4", bpm=120)
lead = score.part("lead")
for chord in Key("C", "major").progression("I", "V", "vi", "IV"):
lead.add(chord, Duration.WHOLE)
print(score.to_abc(title="Pop Chords", key="C"))
Output:
.. code-block:: text
X:1
T:Pop Chords
M:4/4
Q:1/4=120
L:1/8
K:C
[CEG]8 | [GBd]8 | [Ace]8 | [FAc]8 |
Open sheet music in the browser with ``html=True``:
.. code-block:: python
html = score.to_abc(title="Pop Chords", key="C", html=True)
with open("chords.html", "w") as f:
f.write(html)
import webbrowser
webbrowser.open("chords.html")
This generates a self-contained HTML page with an embedded
``<script>`` tag that loads abcjs from a CDN and renders the notation
as SVG -- no build steps, no dependencies, just open the file.
Multi-part scores automatically get ``V:`` (voice) directives so each
instrument appears on its own staff. Bass parts (average note below C4)
get bass clef automatically. Drum-only parts are skipped. Notes longer
than one measure are split into tied notes across barlines.
Parameters:
- **title** -- Tune title for the ``T:`` header (default ``"Untitled"``).
- **key** -- ABC key signature string (default ``"C"``). Use ``"Am"`` for
A minor, ``"Bb"`` for B-flat major, ``"F#m"`` for F-sharp minor, etc.
- **html** -- If ``True``, return a full HTML document instead of raw ABC
(default ``False``).
to_lilypond() -- LilyPond Export
---------------------------------
`LilyPond <https://lilypond.org/>`_ 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
-------------------------------
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pytheory"
version = "0.41.1"
version = "0.42.1"
description = "Music Theory for Humans"
readme = "README.md"
license = "MIT"
+1 -1
View File
@@ -1,6 +1,6 @@
"""PyTheory: Music Theory for Humans."""
__version__ = "0.41.1"
__version__ = "0.42.1"
from .tones import Tone, Interval
from .systems import System, SYSTEMS, TET
+689 -36
View File
@@ -3796,6 +3796,127 @@ 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), a ``Fretboard`` object, 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])
elif hasattr(tuning, "tones"):
# Fretboard object — tones are high-to-low, reverse for low-to-high
fb_tones = list(reversed(tuning.tones))
open_midis = [t.midi for t in fb_tones]
labels = [t.name if len(t.name) <= 2 else t.name[0] for t in fb_tones]
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)
@@ -4396,21 +4517,33 @@ class Score:
f"L:1/{default_unit}",
]
# Collect voices: default notes first, then named parts (skip drums)
# Collect voices: default notes first, then named parts
# Skip drum parts and parts with no pitched notes
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))
if not part.notes:
continue
# Skip parts that have no pitched tones (only drum tones / rests)
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))
multi = len(voices) > 1
if multi:
for i, (vname, _) in enumerate(voices, 1):
lines.append(f"V:{i} name=\"{vname}\"")
for i, (vname, notes) in enumerate(voices, 1):
clef = self._guess_clef(notes)
clef_str = f" clef={clef}" if clef != "treble" else ""
lines.append(f"V:{i} name=\"{vname}\"{clef_str}")
lines.append(f"K:{key}")
for i, (_, notes) in enumerate(voices, 1):
lines.append(f"V:{i}")
@@ -4436,6 +4569,26 @@ class Score:
+ ");\n</script>\n</body></html>\n"
)
@staticmethod
def _guess_clef(notes):
"""Return 'bass' if most pitched notes are below C4, else 'treble'."""
octaves = []
for note in notes:
tone = note.tone
if tone is None or not hasattr(tone, "octave"):
continue
if hasattr(tone, "tones"):
# Chord — use average of chord tones
for t in tone.tones:
if hasattr(t, "octave") and t.octave is not None:
octaves.append(t.octave)
elif tone.octave is not None:
octaves.append(tone.octave)
if not octaves:
return "treble"
avg = sum(octaves) / len(octaves)
return "bass" if avg < 4 else "treble"
@staticmethod
def _tone_to_abc(tone, default_unit):
"""Convert a single Tone to an ABC note string."""
@@ -4467,6 +4620,24 @@ class Score:
return f"{abc_acc}{note_char}{oct_str}"
@staticmethod
def _format_dur(multiplier):
"""Format an ABC duration multiplier string."""
if abs(multiplier - 1) < 0.001:
return ""
elif abs(multiplier - int(multiplier)) < 0.001:
return str(int(multiplier))
elif abs(multiplier - 0.5) < 0.001:
return "/2"
elif abs(multiplier - 0.25) < 0.001:
return "/4"
elif abs(multiplier - 1.5) < 0.001:
return "3/2"
else:
from fractions import Fraction
frac = Fraction(multiplier).limit_denominator(16)
return f"{frac.numerator}/{frac.denominator}"
def _notes_to_abc(self, notes, default_unit, ts,
bars_per_line=4):
"""Convert a list of Note objects to an ABC body string."""
@@ -4476,17 +4647,12 @@ class Score:
measure_count = 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)
total_beats = note.duration.value
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
@@ -4495,33 +4661,32 @@ class Score:
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}"
# Split notes longer than one measure into tied pieces
remaining = total_beats
first_chunk = True
while remaining > 0.001:
# How much room left in this measure?
room = beats_per_measure - beat_in_measure
chunk = min(remaining, room) if remaining > room + 0.001 else remaining
needs_tie = remaining - chunk > 0.001
tokens.append(f"{abc_note}{dur_str}")
multiplier = chunk / unit_beats
dur_str = self._format_dur(multiplier)
beat_in_measure += beats
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
tie_str = "-" if needs_tie and abc_note != "z" else ""
tokens.append(f"{abc_note}{dur_str}{tie_str}")
remaining -= chunk
beat_in_measure += chunk
first_chunk = False
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)
# Clean up trailing/double barlines
@@ -4530,6 +4695,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 ``<work-title>`` 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 = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<!DOCTYPE score-partwise PUBLIC '
'"-//Recordare//DTD MusicXML 4.0 Partwise//EN" '
'"http://www.musicxml.org/dtds/partwise.dtd">\n'
)
pretty = xml.dom.minidom.parseString(raw).toprettyxml(indent=" ")
lines = pretty.split("\n")
if lines and lines[0].startswith("<?xml"):
lines = lines[1:]
return doctype + "\n".join(lines)
# ── ASCII tablature export ──────────────────────────────────────────
def to_tab(self, part_name=None, **kwargs):
"""Generate ASCII tablature for a part in this score.
Args:
part_name: Name of the part to tab. If *None*, tabs the first
non-drum part that has notes.
**kwargs: Passed through to :meth:`Part.to_tab` (e.g.
``tuning``, ``frets``, ``time_signature``).
Returns:
An ASCII tablature string.
Raises:
ValueError: If no suitable part is found.
"""
if "time_signature" not in kwargs:
kwargs["time_signature"] = self.time_signature
if part_name is not None:
if part_name not in self.parts:
raise ValueError(f"No part named {part_name!r}")
return self.parts[part_name].to_tab(**kwargs)
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 not isinstance(n.tone, _DrumTone)
for n in part.notes
)
if has_pitched:
return part.to_tab(**kwargs)
if self.notes:
tmp = Part("_default")
tmp.notes = list(self.notes)
return tmp.to_tab(**kwargs)
raise ValueError("No pitched parts with notes found in score")
def save_midi(self, path, velocity=100):
"""Export to Standard MIDI File, measure-aware."""
ticks_per_beat = 480
Generated
+1 -1
View File
@@ -690,7 +690,7 @@ wheels = [
[[package]]
name = "pytheory"
version = "0.41.1"
version = "0.42.1"
source = { editable = "." }
dependencies = [
{ name = "rich" },