Compare commits

...

3 Commits

Author SHA1 Message Date
kennethreitz 6ecef688e1 ABC notation export via Score.to_abc() — v0.41.0
New method converts scores to ABC notation with support for multi-voice,
chords, rests, accidentals, and all durations. Pass html=True for a
self-contained HTML page with abcjs sheet music rendering.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:37:56 -04:00
kennethreitz fcc5db8e3d Add Score.to_abc() for ABC notation export with optional HTML rendering
Supports multi-voice scores, chords, rests, accidentals, and all durations.
Pass html=True to get a self-contained page using abcjs for sheet music.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:31:02 -04:00
kennethreitz 9de113b6e7 Add sound examples for hard sync, ring mod, wavefold, drift, karplus-strong, mellotron docs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 14:04:37 -04:00
93 changed files with 248 additions and 4 deletions
+7
View File
@@ -2,6 +2,13 @@
All notable changes to PyTheory are documented here.
## 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,
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+56
View File
@@ -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
View File
@@ -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
View File
@@ -1,6 +1,6 @@
[project]
name = "pytheory"
version = "0.40.9"
version = "0.41.0"
description = "Music Theory for Humans"
readme = "README.md"
license = "MIT"
+1 -1
View File
@@ -1,6 +1,6 @@
"""PyTheory: Music Theory for Humans."""
__version__ = "0.40.9"
__version__ = "0.41.0"
from .tones import Tone, Interval
from .systems import System, SYSTEMS, TET
+157
View File
@@ -4369,6 +4369,163 @@ 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 drums)
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))
multi = len(voices) > 1
if multi:
for i, (vname, _) in enumerate(voices, 1):
lines.append(f"V:{i} name=\"{vname}\"")
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 _tone_to_abc(tone, default_unit):
"""Convert a single Tone to an ABC note string."""
if tone is None:
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}"
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:
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)
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
]
abc_note = "[" + "".join(chord_notes) + "]"
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}"
tokens.append(f"{abc_note}{dur_str}")
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
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
Generated
+1 -1
View File
@@ -690,7 +690,7 @@ wheels = [
[[package]]
name = "pytheory"
version = "0.40.9"
version = "0.41.0"
source = { editable = "." }
dependencies = [
{ name = "rich" },