mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 14:50:18 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7883c978f7 | |||
| 36d558573c | |||
| 1e2f09e2ab | |||
| 9404afc1f3 | |||
| 72aa097552 | |||
| 5ebf0bdd97 | |||
| 1d897c6609 |
@@ -7,3 +7,4 @@ t2.py
|
||||
__pycache__
|
||||
pytheory.egg-info
|
||||
docs/_build
|
||||
.claude/worktrees/
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user