mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 23:00:20 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 72aa097552 | |||
| 5ebf0bdd97 | |||
| 1d897c6609 | |||
| 4113aad5d0 | |||
| 6ecef688e1 | |||
| fcc5db8e3d | |||
| 9de113b6e7 |
@@ -2,6 +2,34 @@
|
||||
|
||||
All notable changes to PyTheory are documented here.
|
||||
|
||||
## 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,
|
||||
|
||||
+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.41.4"
|
||||
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.41.4"
|
||||
|
||||
from .tones import Tone, Interval
|
||||
from .systems import System, SYSTEMS, TET
|
||||
|
||||
@@ -4369,6 +4369,211 @@ 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
|
||||
|
||||
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