mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 23:00:20 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7883c978f7 | |||
| 36d558573c | |||
| 1e2f09e2ab | |||
| 9404afc1f3 | |||
| 72aa097552 | |||
| 5ebf0bdd97 | |||
| 1d897c6609 | |||
| 4113aad5d0 | |||
| 6ecef688e1 | |||
| fcc5db8e3d | |||
| 9de113b6e7 |
@@ -7,3 +7,4 @@ t2.py
|
||||
__pycache__
|
||||
pytheory.egg-info
|
||||
docs/_build
|
||||
.claude/worktrees/
|
||||
|
||||
@@ -2,6 +2,54 @@
|
||||
|
||||
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.
|
||||
|
||||
## 0.41.0
|
||||
|
||||
- **ABC notation export** — `Score.to_abc()` converts scores to ABC notation
|
||||
strings. Supports multi-voice scores (via `V:` directives), chords, rests,
|
||||
accidentals, and all standard durations. Pass `html=True` to get a
|
||||
self-contained HTML page that renders sheet music in the browser via abcjs.
|
||||
|
||||
## 0.40.9
|
||||
|
||||
- **Mellotron synth** — tape-replay keyboard with wow/flutter, tape saturation,
|
||||
|
||||
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -850,6 +850,56 @@ def gen_synth_ukulele():
|
||||
p.strum(ch, Duration.WHOLE, velocity=72)
|
||||
render("synth_ukulele", score)
|
||||
|
||||
def gen_synth_hard_sync():
|
||||
score = Score("4/4", bpm=120)
|
||||
p = score.part("demo", instrument="sync_lead_bright", volume=0.5)
|
||||
for n in ["C4", "E4", "G4", "C5", "G4", "E4", "C4", "E4"]:
|
||||
p.add(n, Duration.QUARTER, velocity=90)
|
||||
render("synth_hard_sync", score)
|
||||
|
||||
|
||||
def gen_synth_ring_mod():
|
||||
score = Score("4/4", bpm=90)
|
||||
p = score.part("demo", instrument="ring_mod_bell", volume=0.5)
|
||||
for n in ["C5", "E5", "G5", "C6", "G5", "E5", "C5", "E5"]:
|
||||
p.add(n, Duration.QUARTER, velocity=80)
|
||||
render("synth_ring_mod", score)
|
||||
|
||||
|
||||
def gen_synth_wavefold():
|
||||
score = Score("4/4", bpm=110)
|
||||
p = score.part("demo", instrument="wavefold_warm", volume=0.5)
|
||||
for n in ["C4", "E4", "G4", "C5", "G4", "E4", "C4", "E4"]:
|
||||
p.add(n, Duration.QUARTER, velocity=85)
|
||||
render("synth_wavefold", score)
|
||||
|
||||
|
||||
def gen_synth_drift():
|
||||
score = Score("4/4", bpm=90)
|
||||
p = score.part("demo", instrument="drift_saw", volume=0.5, reverb=0.35,
|
||||
reverb_type="taj_mahal")
|
||||
for n in ["C4", "E4", "G4", "C5", "G4", "E4", "C4", "E4"]:
|
||||
p.add(n, Duration.HALF, velocity=75)
|
||||
render("synth_drift", score)
|
||||
|
||||
|
||||
def gen_synth_karplus():
|
||||
score = Score("4/4", bpm=100)
|
||||
p = score.part("demo", synth="pluck_synth", envelope="none",
|
||||
volume=0.5, reverb=0.2)
|
||||
for n in ["C4", "E4", "G4", "C5", "G4", "E4", "C4", "E4"]:
|
||||
p.add(n, Duration.QUARTER, velocity=85)
|
||||
render("synth_karplus", score)
|
||||
|
||||
|
||||
def gen_synth_mellotron():
|
||||
score = Score("4/4", bpm=80)
|
||||
p = score.part("demo", instrument="mellotron_flute", volume=0.5)
|
||||
for n in ["C4", "E4", "G4", "C5"]:
|
||||
p.add(n, Duration.WHOLE, velocity=75)
|
||||
render("synth_mellotron", score)
|
||||
|
||||
|
||||
def gen_synth_granular():
|
||||
score = Score("4/4", bpm=80)
|
||||
p = score.part("demo", instrument="granular_pad", volume=0.5, reverb=0.4)
|
||||
@@ -1139,6 +1189,12 @@ GENERATORS = [
|
||||
gen_synth_banjo,
|
||||
gen_synth_mandolin,
|
||||
gen_synth_ukulele,
|
||||
gen_synth_hard_sync,
|
||||
gen_synth_ring_mod,
|
||||
gen_synth_wavefold,
|
||||
gen_synth_drift,
|
||||
gen_synth_karplus,
|
||||
gen_synth_mellotron,
|
||||
gen_synth_granular,
|
||||
gen_synth_crotales,
|
||||
gen_synth_tingsha,
|
||||
|
||||
+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
|
||||
-------------------------------
|
||||
|
||||
|
||||
+25
-1
@@ -277,6 +277,10 @@ of the Prophet-5, Moog Prodigy, and every screaming analog lead since
|
||||
from pytheory import play, Synth, Tone
|
||||
play(Tone.from_string("C4"), synth=Synth.HARD_SYNC, slave_ratio=2.5)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_hard_sync.wav" type="audio/wav"></audio>
|
||||
|
||||
Ring Modulation
|
||||
~~~~~~~~~~~~~~~
|
||||
|
||||
@@ -296,6 +300,10 @@ soundtrack.
|
||||
# Non-integer ratios = more inharmonic
|
||||
play(Tone.from_string("C4"), synth=Synth.RING_MOD, mod_ratio=2.1)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_ring_mod.wav" type="audio/wav"></audio>
|
||||
|
||||
Wavefolding
|
||||
~~~~~~~~~~~
|
||||
|
||||
@@ -322,6 +330,10 @@ the wave. Pairs beautifully with a lowpass filter after the fold.
|
||||
# Direct control over fold amount
|
||||
play(Tone.from_string("C4"), synth=Synth.WAVEFOLD, folds=3.0)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_wavefold.wav" type="audio/wav"></audio>
|
||||
|
||||
Drift Oscillator
|
||||
~~~~~~~~~~~~~~~~
|
||||
|
||||
@@ -351,6 +363,10 @@ that needs to feel "alive."
|
||||
play(Tone.from_string("C4"), synth=Synth.DRIFT,
|
||||
shape="triangle", drift_amount=0.25)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_drift.wav" type="audio/wav"></audio>
|
||||
|
||||
Drift amount controls how unstable the oscillator is:
|
||||
|
||||
- **0.05** = studio-grade (Sequential, Oberheim)
|
||||
@@ -514,6 +530,10 @@ It sounds genuinely like a real guitar, harp, or koto.
|
||||
guitar = score.part("guitar", synth="pluck_synth")
|
||||
harp = score.part("harp", instrument="harp") # uses pluck_synth
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_karplus.wav" type="audio/wav"></audio>
|
||||
|
||||
Hammond Organ
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
@@ -633,7 +653,11 @@ Three tape banks are available via the ``tape`` parameter:
|
||||
|
||||
# Or select the tape directly
|
||||
from pytheory import play, Synth, Tone
|
||||
play(Tone.from_string("C4"), synth=Synth.MELLOTRON, tape="choir", t=3000)
|
||||
play(Tone.from_string("C4"), synth=Synth.MELLOTRON, tape="flute", t=3000)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_mellotron.wav" type="audio/wav"></audio>
|
||||
|
||||
Vibraphone Synth
|
||||
~~~~~~~~~~~~~~~~
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "pytheory"
|
||||
version = "0.40.9"
|
||||
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.40.9"
|
||||
__version__ = "0.42.1"
|
||||
|
||||
from .tones import Tone, Interval
|
||||
from .systems import System, SYSTEMS, TET
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4369,6 +4490,699 @@ 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 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 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, 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}")
|
||||
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 (
|
||||
"<!DOCTYPE html>\n<html><head><meta charset=\"utf-8\">\n"
|
||||
"<title>" + title + "</title>\n"
|
||||
"<script src=\"https://cdn.jsdelivr.net/npm/abcjs@6/dist"
|
||||
"/abcjs-basic-min.js\"></script>\n"
|
||||
"</head><body>\n<div id=\"score\"></div>\n<script>\n"
|
||||
"ABCJS.renderAbc(\"score\", "
|
||||
+ repr(abc)
|
||||
+ ");\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."""
|
||||
if tone is None:
|
||||
return "z"
|
||||
|
||||
# Skip drum tones — they don't have pitched names
|
||||
if not hasattr(tone, "name") or not hasattr(tone, "octave"):
|
||||
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}"
|
||||
|
||||
@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."""
|
||||
beats_per_measure = ts.beats_per_measure
|
||||
tokens = []
|
||||
beat_in_measure = 0.0
|
||||
measure_count = 0
|
||||
|
||||
for note in notes:
|
||||
total_beats = note.duration.value
|
||||
unit_beats = 4.0 / default_unit # beats per L unit
|
||||
|
||||
if note.tone is None:
|
||||
abc_note = "z"
|
||||
elif hasattr(note.tone, "tones"):
|
||||
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)
|
||||
|
||||
# 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
|
||||
|
||||
multiplier = chunk / unit_beats
|
||||
dur_str = self._format_dur(multiplier)
|
||||
|
||||
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
|
||||
body = body.replace("| |", "|").rstrip("| \n").rstrip()
|
||||
if not body.endswith("|"):
|
||||
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