Compare commits

..

17 Commits

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:43:51 -04:00
kennethreitz 4113aad5d0 Fix to_abc() crash on parts with drum tones — v0.41.1
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:40:07 -04:00
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
kennethreitz 0b98f7bd77 Mellotron, hard sync, ring mod, wavefold, drift synths + analog presets — v0.40.9
Five new synth waveforms: tape-replay Mellotron (strings/flute/choir
tapes with wow, flutter, saturation, 8s fadeout), hard sync oscillator,
ring modulation, wavefolding, and analog drift VCO with pitch
instability. 14 new instrument presets for Score.part(). Synth kwargs
now pass through play()/save()/_render(). 808 bass envelope fixed
from pluck to piano.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 23:14:18 -04:00
kennethreitz e0a1ce9d18 Fix hold() inflating Part.total_beats and Score.duration_ms — v0.40.8
Note.beats now returns 0.0 for held notes (_hold=True), matching the
renderer which already skipped advancing the beat position. Previously
every hold() call added its full duration to the part's total, causing
duration reports to be 2-3x too long on tracks with drone notes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 13:27:08 -04:00
kennethreitz de7575fe0a Expose rhodes, wurlitzer, vibraphone, pipe organ, choir in Synth enum — v0.40.7
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 01:59:11 -04:00
kennethreitz 665a6f5de5 Remove lowpass/vel_to_filter from sax presets, let wave shape its own tone — v0.40.6
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 00:44:01 -04:00
kennethreitz 63362df697 Saxophone synth overhaul: reed clipping, formants, breath noise — v0.40.5
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 00:38:10 -04:00
kennethreitz 755b33a63b Fix test: update Synth enum count 42 → 46
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 07:12:43 -04:00
99 changed files with 2090 additions and 68 deletions
+1
View File
@@ -7,3 +7,4 @@ t2.py
__pycache__
pytheory.egg-info
docs/_build
.claude/worktrees/
+104
View File
@@ -2,6 +2,110 @@
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,
bandwidth limiting, hiss, and 8-second tape fadeout. Three tape banks via the
`tape` parameter: `"strings"` (default), `"flute"`, and `"choir"`.
- **Analog oscillator synths** — four new waveform generators for fat, alive,
analog-style sounds:
- `Synth.HARD_SYNC` — slave oscillator hard-synced to a master (Prophet-5
leads). `slave_ratio` parameter controls harmonic content.
- `Synth.RING_MOD` — two oscillators multiplied for metallic, bell-like
inharmonic tones. `mod_ratio` parameter.
- `Synth.WAVEFOLD` — west coast wavefolding (Buchla-style). `folds` parameter
sweeps from warm to gnarly.
- `Synth.DRIFT` — analog VCO with pitch drift, jitter, and noise floor.
`shape` parameter (`"saw"`, `"square"`, `"triangle"`, `"pulse"`) and
`drift_amount` for instability level.
- **Synth kwargs passthrough** — `play()`, `save()`, and `_render()` now accept
`**synth_kw` for forwarding parameters to synth wave functions (e.g.
`play(tone, synth=Synth.MELLOTRON, tape="choir")`).
- **14 new instrument presets** — `mellotron`, `mellotron_strings`,
`mellotron_flute`, `mellotron_choir`, `sync_lead`, `sync_lead_bright`,
`ring_mod_bell`, `ring_mod_metallic`, `wavefold_warm`, `wavefold_gnarly`,
`drift_saw`, `drift_square`, `analog_pad`, `analog_bass`.
- **808 bass envelope fix** — changed from `pluck` (zero sustain, wrong for 808)
to `piano` (sharp attack with long decay tail).
## 0.40.8
- **Fix hold() inflating duration** — `Note.beats` was returning the full
duration for held notes (`_hold=True`), causing `Part.total_beats` and
`Score.duration_ms` to overcount. A part with `hold(Sa, WHOLE * 4)` followed
by `add(Pa, QUARTER)` would report 17 beats instead of 1. Now held notes
return 0 beats, matching the renderer which already skipped advancing the
timeline for held notes.
## 0.40.7
- **Expose missing Synth enum entries** — rhodes, wurlitzer, vibraphone,
pipe organ, and choir wave functions were already implemented but not
accessible via the Synth enum. Now available as `Synth.RHODES`,
`Synth.WURLITZER`, `Synth.VIBRAPHONE`, `Synth.PIPE_ORGAN`, `Synth.CHOIR`.
## 0.40.6
- **Saxophone presets cleaned up** — removed lowpass filters and vel_to_filter
from all sax instrument presets (saxophone, alto_sax, tenor_sax, bari_sax).
The saxophone wave function already shapes its own spectrum; the extra
filters were dulling the tone.
## 0.40.5
- **Saxophone synth overhaul** — reed nonlinearity (asymmetric soft clipping),
conical bore formant resonances, breath noise with attack envelope, separate
reed buzz, key click transient, and sub-harmonic warmth. Vibrato dialed back
to subtle, delayed onset.
## 0.40.4
- **Distortion overhaul** — multi-stage clipping (preamp → power amp →
+207
View File
@@ -0,0 +1,207 @@
"""Demo the 5 new synths: Mellotron, Hard Sync, Ring Mod, Wavefold, Drift.
Each synth gets a short musical phrase — not just a scale run — with
reverb and rhythmic variety to show off its character.
"""
from pytheory import Score, Duration, play_score
EIGHTH = Duration.EIGHTH
QUARTER = Duration.QUARTER
HALF = Duration.HALF
DOTTED_Q = Duration.DOTTED_QUARTER
WHOLE = Duration.WHOLE
# ── Mellotron Strings ────────────────────────────────────────────────────────
# Strawberry Fields vibes — slow, haunted, with rests that breathe.
print("=== MELLOTRON STRINGS ===")
s = Score("4/4", bpm=72)
p = s.part("tape", instrument="mellotron_strings",
reverb=0.45, reverb_type="cathedral", reverb_decay=2.0)
p.add("G4", HALF).add("B4", QUARTER).add("D5", QUARTER)
p.add("C5", DOTTED_Q).add("B4", EIGHTH).add("A4", HALF)
p.rest(QUARTER)
p.add("G4", DOTTED_Q).add("F#4", EIGHTH).add("G4", WHOLE)
play_score(s)
# ── Mellotron Flute ──────────────────────────────────────────────────────────
# Lonely, breathy, with space between phrases.
print("\n=== MELLOTRON FLUTE ===")
s = Score("3/4", bpm=84)
p = s.part("flute", instrument="mellotron_flute",
reverb=0.5, reverb_type="taj_mahal", reverb_decay=2.5)
p.add("E5", HALF).add("D5", QUARTER)
p.add("C5", DOTTED_Q).add("B4", EIGHTH).rest(QUARTER)
p.add("A4", HALF).add("G4", QUARTER)
p.add("A4", HALF).rest(QUARTER)
p.add("E5", QUARTER).add("D5", QUARTER).add("C5", QUARTER)
p.add("B4", HALF + QUARTER)
play_score(s)
# ── Mellotron Choir ──────────────────────────────────────────────────────────
# Ghostly pad — slow chords, big reverb.
print("\n=== MELLOTRON CHOIR ===")
s = Score("4/4", bpm=60)
p = s.part("choir", instrument="mellotron_choir",
reverb=0.6, reverb_type="cathedral", reverb_decay=3.0)
p.add("C4", WHOLE)
p.add("E4", HALF).add("G4", HALF)
p.add("A4", DOTTED_Q).add("G4", EIGHTH).add("F4", HALF)
p.add("E4", WHOLE)
play_score(s)
# ── Hard Sync Lead ───────────────────────────────────────────────────────────
# Aggressive, punchy — fast 16ths and syncopation.
print("\n=== HARD SYNC LEAD ===")
s = Score("4/4", bpm=128)
p = s.part("sync", instrument="sync_lead",
reverb=0.25, reverb_type="plate")
p.add("E4", EIGHTH).add("E4", EIGHTH).rest(EIGHTH).add("G4", EIGHTH)
p.add("A4", QUARTER).add("G4", EIGHTH).add("E4", EIGHTH)
p.add("D4", EIGHTH).rest(EIGHTH).add("E4", EIGHTH).add("G4", EIGHTH)
p.add("A4", HALF)
p.rest(QUARTER).add("B4", EIGHTH).add("A4", EIGHTH)
p.add("G4", QUARTER).add("E4", QUARTER).add("D4", HALF)
play_score(s)
# ── Hard Sync Bright ─────────────────────────────────────────────────────────
# Higher slave ratio — more harmonics, screaming lead.
print("\n=== HARD SYNC BRIGHT ===")
s = Score("4/4", bpm=138)
p = s.part("sync2", instrument="sync_lead_bright",
reverb=0.2, reverb_type="plate")
p.add("A4", EIGHTH).add("C5", EIGHTH).add("D5", QUARTER)
p.rest(EIGHTH).add("E5", EIGHTH).add("D5", EIGHTH).add("C5", EIGHTH)
p.add("A4", QUARTER).rest(QUARTER).add("G4", EIGHTH).add("A4", EIGHTH)
p.add("C5", HALF)
play_score(s)
# ── Ring Mod Bell ────────────────────────────────────────────────────────────
# Shimmery, metallic — sparse hits with long reverb tail.
print("\n=== RING MOD BELL ===")
s = Score("4/4", bpm=66)
p = s.part("bell", instrument="ring_mod_bell",
reverb=0.6, reverb_type="cave", reverb_decay=3.0)
p.add("C5", HALF).rest(QUARTER).add("G4", QUARTER)
p.rest(HALF).add("E5", HALF)
p.add("D5", QUARTER).rest(QUARTER).add("C5", HALF)
p.rest(WHOLE)
p.add("G4", QUARTER).add("A4", QUARTER).add("C5", HALF)
play_score(s)
# ── Ring Mod Metallic ────────────────────────────────────────────────────────
# Alien, inharmonic — atonal stabs.
print("\n=== RING MOD METALLIC ===")
s = Score("4/4", bpm=100)
p = s.part("metal", instrument="ring_mod_metallic",
reverb=0.4, reverb_type="parking_garage", reverb_decay=2.0)
p.add("F4", EIGHTH).rest(EIGHTH).add("Ab4", EIGHTH).add("F4", EIGHTH)
p.rest(QUARTER).add("Db5", QUARTER).rest(QUARTER)
p.add("C5", EIGHTH).add("Ab4", EIGHTH).rest(QUARTER).add("F4", HALF)
p.rest(HALF).add("Db5", QUARTER).add("C5", QUARTER)
play_score(s)
# ── Wavefold Warm ────────────────────────────────────────────────────────────
# Gentle folds — round and musical, like a filtered saw with overtones.
print("\n=== WAVEFOLD WARM ===")
s = Score("4/4", bpm=108)
p = s.part("fold", instrument="wavefold_warm",
reverb=0.3, reverb_type="plate")
p.add("A3", QUARTER).add("C4", QUARTER).add("E4", QUARTER).add("A4", QUARTER)
p.add("G4", DOTTED_Q).add("E4", EIGHTH).add("C4", HALF)
p.add("D4", QUARTER).add("F4", QUARTER).add("A4", HALF)
p.add("G4", WHOLE)
play_score(s)
# ── Wavefold Gnarly ──────────────────────────────────────────────────────────
# Cranked folds — buzzy, aggressive, with syncopation.
print("\n=== WAVEFOLD GNARLY ===")
s = Score("4/4", bpm=130)
p = s.part("gnarly", instrument="wavefold_gnarly",
reverb=0.2, reverb_type="spring")
p.add("E3", EIGHTH).add("E3", EIGHTH).rest(EIGHTH).add("G3", EIGHTH)
p.add("A3", EIGHTH).rest(EIGHTH).add("B3", EIGHTH).add("A3", EIGHTH)
p.add("E3", QUARTER).add("G3", EIGHTH).add("A3", EIGHTH).add("B3", QUARTER)
p.rest(QUARTER)
p.add("E4", EIGHTH).add("D4", EIGHTH).add("B3", QUARTER).add("A3", HALF)
play_score(s)
# ── Drift Saw ────────────────────────────────────────────────────────────────
# Warm, alive analog saw — the Minimoog pad.
print("\n=== DRIFT SAW (vintage VCO) ===")
s = Score("4/4", bpm=88)
p = s.part("drift", instrument="drift_saw",
reverb=0.35, reverb_type="taj_mahal", reverb_decay=2.0)
p.add("D4", HALF).add("F4", HALF)
p.add("A4", DOTTED_Q).add("G4", EIGHTH).add("F4", QUARTER).rest(QUARTER)
p.add("D4", QUARTER).add("E4", QUARTER).add("F4", HALF)
p.add("D4", WHOLE)
play_score(s)
# ── Drift Square ─────────────────────────────────────────────────────────────
# Hollow, wobbly — 8-bit with analog soul.
print("\n=== DRIFT SQUARE ===")
s = Score("4/4", bpm=110)
p = s.part("dsq", instrument="drift_square",
reverb=0.25, reverb_type="plate")
p.add("C4", EIGHTH).add("E4", EIGHTH).add("G4", QUARTER).add("E4", QUARTER)
p.rest(QUARTER)
p.add("A4", EIGHTH).add("G4", EIGHTH).add("E4", QUARTER).add("C4", HALF)
p.add("D4", QUARTER).add("F4", EIGHTH).add("G4", EIGHTH).add("A4", HALF)
p.add("G4", WHOLE)
play_score(s)
# ── Analog Pad ───────────────────────────────────────────────────────────────
# Slow, drifting chords — Juno-style lushness.
print("\n=== ANALOG PAD ===")
s = Score("4/4", bpm=70)
p = s.part("pad", instrument="analog_pad",
reverb=0.5, reverb_type="taj_mahal", reverb_decay=3.0)
p.add("A3", WHOLE)
p.add("C4", HALF).add("E4", HALF)
p.add("F4", WHOLE)
p.add("E4", HALF).add("D4", HALF)
p.add("C4", WHOLE)
play_score(s)
# ── Analog Bass ──────────────────────────────────────────────────────────────
# Tight, punchy — Moog bass with filter sweep.
print("\n=== ANALOG BASS ===")
s = Score("4/4", bpm=120)
p = s.part("bass", instrument="analog_bass",
reverb=0.1, reverb_type="plate")
p.add("E2", EIGHTH).add("E2", EIGHTH).rest(EIGHTH).add("G2", EIGHTH)
p.add("A2", QUARTER).rest(QUARTER)
p.add("E2", EIGHTH).rest(EIGHTH).add("B2", EIGHTH).add("A2", EIGHTH)
p.add("G2", QUARTER).add("E2", QUARTER).rest(HALF)
p.add("E2", EIGHTH).add("E2", EIGHTH).add("G2", EIGHTH).add("A2", EIGHTH)
p.add("B2", QUARTER).add("A2", QUARTER).add("E2", HALF)
play_score(s)
print("\nDone!")
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,
+173 -7
View File
@@ -3,19 +3,21 @@ Playback and Export
This is the output layer. You've built your theory, composed your
arrangement, shaped your sounds -- now you need to hear it. PyTheory
gives you three ways to get your music out: speakers, WAV files, and
MIDI files.
gives you four ways to get your music out: speakers, WAV files, MIDI
files, and sheet music.
Use **speakers** for immediate feedback while you're sketching and
experimenting. Use **WAV export** when you want to share actual audio
-- post it, send it, drop it into a video. Use **MIDI export** when you
want to bring your sketch into a real DAW and finish it with
professional instruments, mixing, and mastering. Each output serves a
different stage of the creative process.
professional instruments, mixing, and mastering. Use **ABC notation
export** when you want sheet music -- rendered in the browser or shared
as plain text. Each output serves a different stage of the creative
process.
PyTheory can play audio through your speakers, save to WAV, or export
to MIDI. Everything is synthesized from waveforms -- no samples or
external audio files needed.
PyTheory can play audio through your speakers, save to WAV, export to
MIDI, or generate sheet music as ABC notation. Everything is synthesized
from waveforms -- no samples or external audio files needed.
.. note::
@@ -44,6 +46,22 @@ Optional parameters for synth, envelope, and temperament:
play(Tone.from_string("C4"), synth=Synth.SAW, envelope=Envelope.PLUCK, t=1_000)
play(Tone.from_string("C4"), temperament="pythagorean", t=1_000)
Synth-specific parameters are passed through as keyword arguments:
.. code-block:: python
# Mellotron with flute tape
play(Tone.from_string("C4"), synth=Synth.MELLOTRON, tape="choir", t=2_000)
# Hard sync with custom slave ratio
play(Tone.from_string("C4"), synth=Synth.HARD_SYNC, slave_ratio=2.5)
# Wavefolding with 4 folds
play(Tone.from_string("C4"), synth=Synth.WAVEFOLD, folds=4.0)
# Drift oscillator with square shape
play(Tone.from_string("C4"), synth=Synth.DRIFT, shape="square")
play_score() -- Full Arrangements
---------------------------------
@@ -155,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
-------------------------------
+181 -3
View File
@@ -1,7 +1,7 @@
Synthesizers
============
PyTheory includes 41 built-in waveforms and 10 ADSR envelope presets.
PyTheory includes 56 built-in waveforms and 10 ADSR envelope presets.
Every sound is generated from scratch -- no samples or external audio
files needed.
@@ -249,6 +249,130 @@ produces a natural chorus/vibrato effect built into the waveform itself.
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_pwm_fast.wav" type="audio/wav"></audio>
Analog Synthesis
----------------
These waveforms model the behavior of real analog hardware — the
imperfections, interactions, and nonlinearities that make a room full
of vintage synths sound so much more alive than a room full of VSTs.
Each one is a different approach to the same question: how do you make
a digital oscillator sound like it has a soul?
Hard Sync
~~~~~~~~~
A "slave" oscillator is forced to restart its cycle every time a
"master" oscillator completes one. The abrupt restart creates bright
formant peaks that sweep as the slave ratio changes. This is THE sound
of the Prophet-5, Moog Prodigy, and every screaming analog lead since
1978.
**Use for:** aggressive leads, formant sweeps, cutting solos.
.. code-block:: python
lead = score.part("lead", synth="hard_sync", envelope="pluck")
# Higher slave ratio = more harmonics, brighter
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
~~~~~~~~~~~~~~~
Two oscillators multiplied together, producing sum and difference
frequencies. Unlike FM, ring mod outputs only sidebands — no carrier
or modulator fundamental. The result is metallic, bell-like, and often
inharmonic. Classic Dalek voice, Stockhausen, and every sci-fi
soundtrack.
**Use for:** metallic bells, alien textures, inharmonic percussion.
.. code-block:: python
bells = score.part("bells", instrument="ring_mod_bell",
reverb=0.5, reverb_type="cave")
# 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
~~~~~~~~~~~
The heart of west coast synthesis (Buchla, Make Noise, Verbos). A sine
wave is amplified past ±1.0, then "folded" — the overflow bounces back
instead of clipping. Each fold adds new harmonic pairs. At low fold
counts it's warm and round; crank it up and it gets buzzy, gnarly, and
alive.
This sounds completely different from subtractive synthesis — instead of
*removing* harmonics with a filter, you're *generating* them by shaping
the wave. Pairs beautifully with a lowpass filter after the fold.
**Use for:** complex leads, evolving textures, west coast basslines.
.. code-block:: python
# Warm, musical folding
warm = score.part("fold", instrument="wavefold_warm")
# Cranked and aggressive
gnarly = score.part("gnarly", instrument="wavefold_gnarly")
# 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
~~~~~~~~~~~~~~~~
Real analog oscillators are never perfectly stable. Capacitor charging,
thermal variations, and component tolerances make the pitch wander
slightly. This is what makes a Minimoog sound "fat" and a VST sound
"thin" — the constant micro-motion of imperfect hardware.
The drift oscillator models slow pitch drift (< 1 Hz wander), fast
jitter (per-cycle randomness), a soft analog noise floor, and slightly
rounded waveform edges. It turns any basic shape into something that
breathes.
**Use for:** analog-style pads, warm basses, vintage leads, any voice
that needs to feel "alive."
.. code-block:: python
# Vintage Minimoog-style saw
pad = score.part("pad", instrument="drift_saw",
reverb=0.35, reverb_type="taj_mahal")
# Hollow square with analog wobble
sq = score.part("sq", instrument="drift_square")
# Control the shape and instability directly
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)
- **0.15** = classic vintage (Minimoog, ARP) — the default
- **0.30** = barely-holding-it-together (old SH-101)
ADSR Envelopes
--------------
@@ -406,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
~~~~~~~~~~~~~
@@ -440,11 +568,11 @@ Dedicated Instrument Synths
--------------------------
Beyond the classic and physical modeling waveforms, PyTheory includes
31 dedicated instrument synths. Each one uses tailored synthesis
36 dedicated instrument synths. Each one uses tailored synthesis
techniques -- additive harmonics, formant shaping, body resonance
modeling, and specialized envelopes -- to capture the character of a
specific acoustic instrument. These are the waveforms that bring the
total count to 41.
total count to 56.
Piano Synth
~~~~~~~~~~~
@@ -495,6 +623,42 @@ picked up by an electrostatic pickup. More nasal, reedy, and biting
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_wurlitzer.wav" type="audio/wav"></audio>
Mellotron
~~~~~~~~~
The original "sampler" — a 1960s keyboard where each key triggers a
strip of magnetic tape with a pre-recorded instrument. The mechanical
tape transport gives it a haunted, lo-fi quality that no digital
emulation fully captures: pitch wobbles from uneven capstan speed,
bandwidth limited to 300 Hz6 kHz (like a worn cassette), soft tape
saturation, and tapes that physically run out after 8 seconds.
The Mellotron defined the sound of *Strawberry Fields Forever*,
*Stairway to Heaven*, and every prog rock record from 19691977.
Three tape banks are available via the ``tape`` parameter:
- ``"strings"`` (default) — the iconic MkII string section
- ``"flute"`` — breathy, haunting solo flute
- ``"choir"`` — ghostly vocal pad
**Use for:** prog rock, haunted textures, vintage orchestral color.
.. code-block:: python
# Use instrument presets (includes reverb)
strings = score.part("strings", instrument="mellotron_strings")
flute = score.part("flute", instrument="mellotron_flute")
choir = score.part("choir", instrument="mellotron_choir")
# Or select the tape directly
from pytheory import play, Synth, Tone
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
~~~~~~~~~~~~~~~~
@@ -1127,6 +1291,12 @@ bagpipe, singing_bowl, singing_bowl_ring, tingsha
**Synth**: synth_lead, synth_pad, synth_bass, acid_bass, 808_bass,
granular_pad, granular_texture, vocal, choir
**Mellotron**: mellotron, mellotron_strings, mellotron_flute, mellotron_choir
**Analog**: sync_lead, sync_lead_bright, ring_mod_bell, ring_mod_metallic,
wavefold_warm, wavefold_gnarly, drift_saw, drift_square, analog_pad,
analog_bass
**Percussion**: vibraphone, marimba, xylophone, glockenspiel, tubular_bells,
timpani, crotales
@@ -1169,3 +1339,11 @@ Some practical combos worth memorizing:
long reverb and you're scoring a nature documentary.
- ``saw`` + ``pluck`` = **funk stab.** Short, sharp, bright. The
sound of Nile Rodgers' right hand.
- ``hard_sync`` + ``pluck`` = **prophet lead.** Bright formant peak
that cuts through any mix. The opening riff of every 80s synth solo.
- ``wavefold`` + ``organ`` = **west coast bass.** Warm, harmonically
rich sine-derivative that pairs beautifully with a lowpass after.
- ``drift`` + ``pad`` = **analog pad.** A sawtooth that breathes and
wobbles like a real VCO. Add chorus and reverb for Juno vibes.
- ``mellotron_synth`` + ``organ`` = **prog strings.** Haunted tape
machine. Add cathedral reverb and you're in 1972.
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pytheory"
version = "0.40.4"
version = "0.42.1"
description = "Music Theory for Humans"
readme = "README.md"
license = "MIT"
+1 -1
View File
@@ -1,6 +1,6 @@
"""PyTheory: Music Theory for Humans."""
__version__ = "0.40.4"
__version__ = "0.42.1"
from .tones import Tone, Interval
from .systems import System, SYSTEMS, TET
+434 -43
View File
@@ -203,6 +203,168 @@ def pwm_fast_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
return pwm_wave(hz, peak, n_samples, lfo_rate=3.0)
def hard_sync_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE,
slave_ratio=1.5):
"""Hard-sync oscillator — slave saw reset by master clock.
The quintessential analog lead sound. A "slave" oscillator runs at
a different frequency but is forced to restart its cycle every time
the "master" oscillator completes one. The abrupt restart creates
bright, harmonically complex formant peaks that sweep as the slave
ratio changes.
This is THE sound of the Prophet-5, Moog Prodigy, and every
screaming analog lead since 1978.
Args:
slave_ratio: Slave frequency as a multiple of master.
1.0 = unison (plain saw). 1.53.0 = sweet spot for leads.
Higher = more metallic, ring-mod-like.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
# Master phase ramps 0→1 at hz
master_phase = (t * hz) % 1.0
# Detect master zero-crossings (phase resets)
resets = numpy.diff(master_phase, prepend=master_phase[0]) < -0.5
# Slave phase: runs at slave_ratio * hz, but resets with master
slave_phase = numpy.zeros(n_samples, dtype=numpy.float64)
phase = 0.0
slave_freq = hz * slave_ratio
dt = 1.0 / SAMPLE_RATE
for i in range(n_samples):
if resets[i]:
phase = 0.0
slave_phase[i] = phase
phase += slave_freq * dt
phase %= 1.0
# Slave is a sawtooth: 2*phase - 1
wave = 2.0 * slave_phase - 1.0
return (peak * wave).astype(numpy.int16)
def ring_mod_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE,
mod_ratio=1.5):
"""Ring modulation — two oscillators multiplied together.
Multiplying two signals produces sum and difference frequencies,
creating inharmonic, metallic, bell-like tones. Unlike FM, ring mod
produces only sidebands no carrier or modulator in the output.
Classic Dalek voice, Stockhausen elektronische Musik, and the
metallic clang of every sci-fi soundtrack.
Args:
mod_ratio: Modulator frequency as a multiple of carrier.
Integer ratios (2, 3) = harmonic (bell-like).
Non-integer (1.5, 2.1) = inharmonic (metallic, alien).
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
carrier = numpy.sin(2 * numpy.pi * hz * t)
modulator = numpy.sin(2 * numpy.pi * hz * mod_ratio * t)
wave = carrier * modulator
return (peak * wave).astype(numpy.int16)
def wavefold_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE,
folds=3.0):
"""Wavefolding — signal folded back on itself for complex harmonics.
The heart of west coast synthesis (Buchla, Make Noise, Verbos).
A sine wave is amplified past ±1.0, then "folded" the overflow
bounces back instead of clipping. Each fold adds a new pair of
harmonics. At low fold counts it's warm and round; crank it up
and it gets buzzy, gnarly, and alive.
Sounds completely different from subtractive synthesis instead
of removing harmonics with a filter, you're *generating* them
by shaping the wave. Pairs beautifully with a lowpass filter.
Args:
folds: Drive amount. 1.0 = clean sine. 24 = sweet spot.
6+ = harsh, buzzy territory.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
wave = numpy.sin(2 * numpy.pi * hz * t) * folds
# Triangle-fold: repeatedly reflect at ±1
# Uses the mathematical identity for folding
wave = 4.0 * numpy.abs((wave / 4.0 + 0.25) % 1.0 - 0.5) - 1.0
return (peak * wave).astype(numpy.int16)
def drift_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE,
shape="saw", drift_amount=0.15):
"""Analog VCO with pitch drift, instability, and soft noise floor.
Real analog oscillators are never perfectly stable. Capacitor
charging, thermal variations, and component tolerances make the
pitch wander slightly. This is what makes a Minimoog sound "fat"
and a VST sound "thin" the constant micro-motion of imperfect
hardware.
Models:
- Slow pitch drift (< 1 Hz wander, like warming up)
- Fast jitter (subtle per-cycle randomness)
- Soft analog noise floor (faint hiss blended in)
- Slightly rounded edges (no mathematically perfect transitions)
Args:
shape: Base oscillator "saw", "square", "triangle", or "pulse".
drift_amount: How unstable the oscillator is.
0.05 = studio-grade (Sequential, Oberheim).
0.15 = classic vintage (Minimoog, ARP).
0.3 = barely-holding-it-together (old SH-101).
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Slow pitch drift — 2 LFOs at sub-Hz rates with random phase
drift1 = drift_amount * 0.6 * numpy.sin(
2 * numpy.pi * 0.07 * t + rng.uniform(0, 2 * numpy.pi))
drift2 = drift_amount * 0.4 * numpy.sin(
2 * numpy.pi * 0.23 * t + rng.uniform(0, 2 * numpy.pi))
# Fast jitter — per-sample noise filtered to ~50 Hz bandwidth
jitter_raw = rng.normal(0, drift_amount * 0.08, n_samples)
# Simple one-pole lowpass for jitter smoothing
alpha = 2 * numpy.pi * 50.0 / SAMPLE_RATE
jitter = numpy.zeros(n_samples, dtype=numpy.float64)
jitter[0] = jitter_raw[0]
for i in range(1, n_samples):
jitter[i] = jitter[i-1] + alpha * (jitter_raw[i] - jitter[i-1])
# Instantaneous frequency with drift (in cents, converted to ratio)
cents_offset = drift1 + drift2 + jitter
freq_ratio = 2.0 ** (cents_offset / 1200.0)
# Accumulate phase with varying frequency
phase = numpy.cumsum(hz * freq_ratio / SAMPLE_RATE)
phase %= 1.0
# Generate waveform from phase
if shape == "square":
wave = numpy.where(phase < 0.5, 1.0, -1.0)
elif shape == "triangle":
wave = 4.0 * numpy.abs(phase - 0.5) - 1.0
elif shape == "pulse":
wave = numpy.where(phase < 0.25, 1.0, -1.0)
else: # saw
wave = 2.0 * phase - 1.0
# Soft edges — gentle lowpass to round off transitions
cutoff = min(16000, hz * 12)
bl, al = scipy.signal.butter(1, cutoff, btype='low', fs=SAMPLE_RATE)
wave = scipy.signal.lfilter(bl, al, wave)
# Subtle analog noise floor
noise = rng.normal(0, 0.005, n_samples)
wave = wave + noise
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
def pluck_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Karplus-Strong plucked string synthesis.
@@ -534,6 +696,145 @@ def wurlitzer_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
return (peak * wave).astype(numpy.int16)
def mellotron_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE,
tape="strings"):
"""Mellotron — tape-replay keyboard from the 1960s.
Each key triggers a strip of magnetic tape with a pre-recorded
instrument the original "sampler." The mechanical transport gives
it a lo-fi, haunted quality that no digital emulation fully captures:
- Tape flutter: pitch wobbles from uneven capstan speed
- Limited bandwidth: 300 Hz6 kHz, like a worn cassette
- Tape saturation: soft compression, rounded transients
- 8-second limit: tapes physically run out (we model the fadeout)
- Head noise: faint hiss baked into the character
The Mellotron defined the sound of Strawberry Fields Forever,
Stairway to Heaven, and every prog rock record from 19691977.
Args:
tape: Which tape bank to simulate.
"strings" the iconic MkII string section
"flute" breathy, haunting solo flute
"choir" ghostly vocal pad
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# --- Tape flutter: slow wow + faster flutter ---
wow = 0.12 * numpy.sin(2 * numpy.pi * 0.4 * t + rng.uniform(0, 6.28))
flutter = 0.06 * numpy.sin(2 * numpy.pi * 6.3 * t + rng.uniform(0, 6.28))
flutter += 0.03 * numpy.sin(2 * numpy.pi * 9.7 * t + rng.uniform(0, 6.28))
# Cents of pitch deviation
pitch_cents = wow + flutter
freq_ratio = 2.0 ** (pitch_cents / 1200.0)
# Accumulate phase with flutter
inst_freq = hz * freq_ratio
phase = numpy.cumsum(inst_freq / SAMPLE_RATE)
# --- Generate the "tape" source ---
nyquist = SAMPLE_RATE / 2.0
wave = numpy.zeros(n_samples, dtype=numpy.float64)
if tape == "flute":
# Breathy flute: fundamental + weak odd harmonics + breath noise
wave += 0.7 * numpy.sin(2 * numpy.pi * phase)
if hz * 3 < nyquist:
wave += 0.12 * numpy.sin(2 * numpy.pi * 3 * phase + rng.uniform(0, 6.28))
if hz * 5 < nyquist:
wave += 0.04 * numpy.sin(2 * numpy.pi * 5 * phase + rng.uniform(0, 6.28))
# Breath noise — bandpass filtered around fundamental
breath = rng.normal(0, 0.25, n_samples)
bw = max(100, hz * 0.3)
lo = max(20, hz - bw)
hi = min(nyquist * 0.95, hz + bw)
bb, ab = scipy.signal.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
breath = scipy.signal.lfilter(bb, ab, breath)
wave += breath
elif tape == "choir":
# Ghostly vocal pad: formant-shaped harmonics with slow drift
formants = [
(800, 100), # first formant ~'ah'
(1200, 120), # second formant
(2500, 200), # third formant
]
n_harmonics = min(20, int(nyquist / hz))
for n in range(1, n_harmonics + 1):
f_n = hz * n
if f_n >= nyquist:
break
amp = 1.0 / n
# Shape by formant peaks
for f_center, f_bw in formants:
amp *= 1.0 + 1.5 * numpy.exp(-((f_n - f_center) / f_bw) ** 2)
p = rng.uniform(0, 2 * numpy.pi)
wave += amp * numpy.sin(2 * numpy.pi * n * phase + p)
# Slow ensemble drift between "voices"
drift = 0.005 * numpy.sin(2 * numpy.pi * 0.15 * t + rng.uniform(0, 6.28))
wave2 = numpy.zeros(n_samples, dtype=numpy.float64)
phase2 = numpy.cumsum(hz * (1.0 + drift) * freq_ratio / SAMPLE_RATE)
for n in range(1, min(8, int(nyquist / hz)) + 1):
f_n = hz * n
if f_n >= nyquist:
break
amp = 0.6 / n
for f_center, f_bw in formants:
amp *= 1.0 + 1.5 * numpy.exp(-((f_n - f_center) / f_bw) ** 2)
wave2 += amp * numpy.sin(2 * numpy.pi * n * phase2 + rng.uniform(0, 6.28))
wave += wave2
else:
# Strings (default): layered ensemble with detuned unison
n_harmonics = min(25, int(nyquist / hz))
# Two "sections" slightly detuned for ensemble width
for section_detune in [-1.5, 0, 1.5]:
section_hz = hz * (2 ** (section_detune / 1200.0))
section_phase = numpy.cumsum(section_hz * freq_ratio / SAMPLE_RATE)
for n in range(1, n_harmonics + 1):
f_n = section_hz * n
if f_n >= nyquist:
break
# String-like: 1/n rolloff, even harmonics slightly weaker
amp = 1.0 / n
if n % 2 == 0:
amp *= 0.8
p = rng.uniform(0, 2 * numpy.pi)
wave += amp * numpy.sin(2 * numpy.pi * n * section_phase + p)
# Normalize before processing
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
# --- Tape bandwidth limiting: 300 Hz 6 kHz ---
lo_cut = min(300, hz * 0.9) # don't cut fundamental
hi_cut = min(6000, nyquist * 0.95)
if lo_cut < hi_cut and lo_cut > 0:
bb, ab = scipy.signal.butter(2, [lo_cut, hi_cut], btype='band', fs=SAMPLE_RATE)
wave = scipy.signal.lfilter(bb, ab, wave)
# --- Tape saturation: soft compression ---
wave = numpy.tanh(wave * 1.4) / 1.2
# --- Tape run-out: gentle fadeout after ~7 seconds ---
if n_samples > int(SAMPLE_RATE * 7):
fadeout_start = int(SAMPLE_RATE * 7)
fadeout_len = n_samples - fadeout_start
fade = numpy.linspace(1.0, 0.0, fadeout_len)
wave[fadeout_start:] *= fade
# --- Head noise / tape hiss ---
hiss = rng.normal(0, 0.008, n_samples)
wave += hiss
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
def vibraphone_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Vibraphone — struck aluminum bars with motor-driven tremolo.
@@ -1187,62 +1488,131 @@ def timpani_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
def saxophone_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Saxophone — single reed through a conical brass bore.
"""Saxophone — single reed driving a conical brass bore.
The conical bore produces all harmonics (like oboe), but the
brass body and larger mouthpiece give a warmer, fatter, more
vocal quality. The reed adds a slight buzz. Saxophone is
between clarinet (odd harmonics) and oboe (nasal even+odd)
it has everything, with a strong fundamental and rich mids.
Models the key acoustic properties of a saxophone:
1. Reed-bore interaction nonlinear clipping creates the characteristic
bright, edgy tone (not just additive sines)
2. Conical bore formants vocal-like resonances at ~500, ~1400, ~2300,
~3200 Hz that give sax its singing quality
3. Breath noise turbulent airflow through the mouthpiece, strongest
at attack and blending into sustained tone
4. Sub-harmonic warmth the conical bore's coupling creates warmth
below the fundamental
5. Vibrato delayed onset, ~5 Hz, characteristic of jazz/classical sax
"""
import scipy.signal as _sig
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Vibrato develops after ~250ms, wider than flute
vib_onset = numpy.clip(t / 0.25, 0.0, 1.0)
vib = hz * 0.0012 * vib_onset * numpy.sin(2 * numpy.pi * 5.2 * t)
# --- Vibrato: delayed onset, subtle depth ---
vib_onset = numpy.clip((t - 0.3) / 0.3, 0.0, 1.0)
vib_rate = 5.0 + 0.15 * numpy.sin(2 * numpy.pi * 0.4 * t)
vib = hz * 0.0006 * vib_onset * numpy.sin(2 * numpy.pi * vib_rate * t)
# --- Core tone: sawtooth-like waveform with reed clipping ---
# Real sax reed creates a quasi-sawtooth pressure wave, not pure sines.
# Build from harmonics with sax-specific spectral envelope, then clip.
wave = numpy.zeros(n_samples, dtype=numpy.float64)
n_harmonics = min(20, int((SAMPLE_RATE / 2) / hz))
n_harmonics = min(25, int((SAMPLE_RATE / 2) / hz))
for n in range(1, n_harmonics + 1):
f_n = hz * n
if f_n >= SAMPLE_RATE / 2:
break
# Sax spectral shape: strong fundamental, broad mid peak (3-6),
# slower rolloff than oboe (brass body carries harmonics further)
# Saxophone spectral envelope from acoustic measurements:
# Strong fundamental, nearly-as-strong 2nd and 3rd harmonics,
# broad energy peak around harmonics 4-8 (the "body"), then
# gradual rolloff — but slower than other woodwinds (brass bore
# sustains upper partials).
if n == 1:
amp = 1.0
elif n <= 3:
amp = 0.6
elif n <= 6:
amp = 0.4 * numpy.exp(-0.1 * (n - 4) ** 2)
elif n == 2:
amp = 0.85
elif n == 3:
amp = 0.7
elif n <= 8:
# Broad mid peak — this is the sax "meat"
amp = 0.55 * numpy.exp(-0.06 * (n - 5) ** 2)
else:
amp = 0.2 / n
# Slower rolloff than oboe/clarinet
amp = 0.35 / (n ** 0.7)
# Slight even/odd asymmetry — conical bore has all harmonics
# but evens are ~10% weaker (midway between cylinder and cone)
if n % 2 == 0:
amp *= 0.9
phase = rng.uniform(0, 2 * numpy.pi)
wave += amp * numpy.sin(2 * numpy.pi * (f_n + vib * n) * t + phase)
# Reed buzz — more present than oboe but still warm
reed = rng.normal(0, 0.07, n_samples)
# Bandpass the reed noise around 1-3kHz (the "honk" range)
import scipy.signal as _sig
reed_lo = max(20, int(hz * 2))
reed_hi = min(SAMPLE_RATE // 2 - 1, int(hz * 6))
# --- Reed nonlinearity: soft clipping ---
# The reed closes against the mouthpiece, creating asymmetric clipping
# that adds brightness and "edge". This is what makes sax sound like
# sax and not a flute.
wave_max = numpy.abs(wave).max()
if wave_max > 0:
wave /= wave_max
# Asymmetric soft clip: positive peaks clip harder (reed closure)
wave = numpy.tanh(1.8 * wave) * 0.7 + numpy.tanh(2.5 * wave) * 0.3
# --- Formant resonances: conical bore creates vocal quality ---
# These fixed resonances are what make sax sound "vocal" — they
# emphasize certain frequency bands regardless of the note played.
formant_freqs = [520, 1380, 2300, 3200]
formant_bws = [120, 200, 280, 350]
formant_gains = [0.25, 0.18, 0.12, 0.08]
formant_sum = numpy.zeros(n_samples, dtype=numpy.float64)
for fc, bw, gain in zip(formant_freqs, formant_bws, formant_gains):
lo = max(20, int(fc - bw))
hi = min(SAMPLE_RATE // 2 - 1, int(fc + bw))
if lo < hi:
bf, af = _sig.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
formant_sum += _sig.lfilter(bf, af, wave) * gain
wave = wave * 0.7 + formant_sum
# --- Breath noise: turbulent air through the mouthpiece ---
# Strongest at the attack, then settles to a subtle constant hiss
# that gives the tone "life" and prevents it from sounding synthetic.
breath = rng.normal(0, 1.0, n_samples)
# Shape breath noise into the sax's "hiss" band (2-6 kHz)
breath_lo = max(20, 2000)
breath_hi = min(SAMPLE_RATE // 2 - 1, 6000)
if breath_lo < breath_hi:
bb, ab = _sig.butter(2, [breath_lo, breath_hi], btype='band', fs=SAMPLE_RATE)
breath = _sig.lfilter(bb, ab, breath)
# Attack envelope for breath — strong at onset, then quiet
breath_env = 0.15 * numpy.exp(-8.0 * t) + 0.03
wave += breath * breath_env
# --- Reed buzz: low-frequency interaction noise ---
# Different from breath — this is the "buzz" from reed vibration
# against the mouthpiece, centered around the playing frequency.
reed_noise = rng.normal(0, 1.0, n_samples)
reed_lo = max(20, int(hz * 0.8))
reed_hi = min(SAMPLE_RATE // 2 - 1, int(hz * 4))
if reed_lo < reed_hi:
br, ar = _sig.butter(2, [reed_lo, reed_hi], btype='band', fs=SAMPLE_RATE)
reed = _sig.lfilter(br, ar, reed).astype(numpy.float64) * 2.0
wave += reed
reed_noise = _sig.lfilter(br, ar, reed_noise) * 0.06
wave += reed_noise
# Brass body warmth — low-mid boost
center = min(1500, hz * 4)
bw = 500
lo = max(20, int(center - bw))
hi = min(SAMPLE_RATE // 2 - 1, int(center + bw))
if lo < hi:
bp, ap = _sig.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
body = _sig.lfilter(bp, ap, wave) * 0.2
wave += body
# --- Attack transient: key click + breath burst ---
attack_len = min(int(SAMPLE_RATE * 0.015), n_samples)
if attack_len > 0:
click = rng.uniform(-1.0, 1.0, attack_len)
click *= numpy.exp(-numpy.linspace(0, 8, attack_len))
wave[:attack_len] += click * 0.12
# --- Sub-harmonic warmth ---
# Conical bore coupling produces energy slightly below fundamental
if hz > 80: # only if there's room
sub = numpy.sin(2 * numpy.pi * (hz * 0.5) * t) * 0.04
sub *= numpy.clip(t / 0.1, 0.0, 1.0) # fade in gently
wave += sub
# --- Final shaping ---
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
@@ -2384,6 +2754,16 @@ class Synth(Enum):
TINGSHA = "tingsha_synth"
SINGING_BOWL_STRIKE = "singing_bowl_strike_synth"
SINGING_BOWL_RING = "singing_bowl_ring_synth"
RHODES = "rhodes_synth"
WURLITZER = "wurlitzer_synth"
VIBRAPHONE = "vibraphone_synth"
PIPE_ORGAN = "pipe_organ_synth"
CHOIR = "choir_synth"
MELLOTRON = "mellotron_synth"
HARD_SYNC = "hard_sync"
RING_MOD = "ring_mod"
WAVEFOLD = "wavefold"
DRIFT = "drift"
def __call__(self, hz, **kwargs):
"""Make Synth members callable — dispatches to the wave function."""
@@ -2418,11 +2798,14 @@ _SYNTH_FUNCTIONS = {
"tingsha_synth": tingsha_wave,
"singing_bowl_strike_synth": singing_bowl_strike_wave,
"singing_bowl_ring_synth": singing_bowl_ring_wave,
"mellotron_synth": mellotron_wave,
"hard_sync": hard_sync_wave, "ring_mod": ring_mod_wave,
"wavefold": wavefold_wave, "drift": drift_wave,
}
def _render(tone_or_chord, temperament="equal", synth=Synth.SINE, t=1_000,
envelope=Envelope.PIANO):
envelope=Envelope.PIANO, **synth_kw):
"""Render a tone or chord to a NumPy sample array.
Args:
@@ -2434,6 +2817,9 @@ def _render(tone_or_chord, temperament="equal", synth=Synth.SINE, t=1_000,
t: Duration in milliseconds.
envelope: ADSR envelope preset. Use ``Envelope.NONE`` for raw
output (old behavior).
**synth_kw: Extra keyword arguments forwarded to the synth wave
function (e.g. ``tape="flute"`` for Mellotron,
``slave_ratio=2.0`` for Hard Sync).
Returns:
A NumPy int16 array of audio samples.
@@ -2441,10 +2827,10 @@ def _render(tone_or_chord, temperament="equal", synth=Synth.SINE, t=1_000,
n_samples = int(SAMPLE_RATE * t / 1_000)
if isinstance(tone_or_chord, Tone):
waves = [synth(tone_or_chord.pitch(temperament=temperament), n_samples=n_samples)]
waves = [synth(tone_or_chord.pitch(temperament=temperament), n_samples=n_samples, **synth_kw)]
else:
waves = [
synth(tone.pitch(temperament=temperament), n_samples=n_samples)
synth(tone.pitch(temperament=temperament), n_samples=n_samples, **synth_kw)
for tone in tone_or_chord.tones
]
@@ -2459,7 +2845,7 @@ def _render(tone_or_chord, temperament="equal", synth=Synth.SINE, t=1_000,
def play(tone_or_chord, temperament="equal", synth=Synth.SINE, t=1_000,
envelope=Envelope.PIANO):
envelope=Envelope.PIANO, **synth_kw):
"""Play a tone or chord through the speakers.
Args:
@@ -2471,19 +2857,22 @@ def play(tone_or_chord, temperament="equal", synth=Synth.SINE, t=1_000,
t: Duration in milliseconds (default 1000).
envelope: ADSR envelope preset (default ``Envelope.PIANO``).
Use ``Envelope.NONE`` for raw waveform.
**synth_kw: Extra keyword arguments forwarded to the synth wave
function (e.g. ``tape="flute"`` for Mellotron).
Example::
>>> play(Tone.from_string("A4"), t=1_000)
>>> play(Chord.from_name("Am7"), synth=Synth.TRIANGLE, t=2_000)
>>> play(tone, envelope=Envelope.PAD, t=3_000)
>>> play(tone, synth=Synth.MELLOTRON, tape="choir", t=2_000)
"""
_play_for(_render(tone_or_chord, temperament=temperament, synth=synth,
t=t, envelope=envelope), ms=t)
t=t, envelope=envelope, **synth_kw), ms=t)
def save(tone_or_chord, path, temperament="equal", synth=Synth.SINE, t=1_000,
envelope=Envelope.PIANO):
envelope=Envelope.PIANO, **synth_kw):
"""Render a tone or chord and save it as a WAV file.
Args:
@@ -2493,6 +2882,8 @@ def save(tone_or_chord, path, temperament="equal", synth=Synth.SINE, t=1_000,
synth: Waveform type.
t: Duration in milliseconds (default 1000).
envelope: ADSR envelope preset (default ``Envelope.PIANO``).
**synth_kw: Extra keyword arguments forwarded to the synth wave
function.
Example::
@@ -2502,7 +2893,7 @@ def save(tone_or_chord, path, temperament="equal", synth=Synth.SINE, t=1_000,
import scipy.io.wavfile
samples = _render(tone_or_chord, temperament=temperament, synth=synth,
t=t, envelope=envelope)
t=t, envelope=envelope, **synth_kw)
normalized = samples.astype(numpy.float32) / SAMPLE_PEAK
# Convert to 16-bit PCM
pcm = (normalized * 32767).astype(numpy.int16)
@@ -5503,8 +5894,8 @@ def render_score(score):
env_tuple = _resolve_envelope(part.envelope)
# Use part swing if set, otherwise score swing
effective_swing = part.swing if part.swing is not None else score.swing
# Build synth-specific kwargs (e.g. FM ratio/index)
synth_kwargs = {}
# Build synth-specific kwargs (e.g. FM ratio/index, tape, folds)
synth_kwargs = dict(getattr(part, 'synth_kw', None) or {})
if part.synth in ("fm",):
synth_kwargs["mod_ratio"] = part.fm_ratio
synth_kwargs["mod_index"] = part.fm_index
+6
View File
@@ -806,6 +806,12 @@ _INSTRUMENT_NAMES = [
# Synth presets
"synth_lead", "synth_pad", "synth_bass", "acid_bass",
"granular_pad", "vocal", "choir", "granular_texture", "808_bass",
# Mellotron
"mellotron", "mellotron_strings", "mellotron_flute", "mellotron_choir",
# Analog
"sync_lead", "sync_lead_bright", "ring_mod_bell", "ring_mod_metallic",
"wavefold_warm", "wavefold_gnarly", "drift_saw", "drift_square",
"analog_pad", "analog_bass",
# Percussion / Mallet
"vibraphone", "marimba", "xylophone", "glockenspiel", "tubular_bells", "timpani",
# Woodwinds (continued)
+922 -9
View File
@@ -342,12 +342,110 @@ INSTRUMENTS = {
"delay": 0.3, "delay_time": 0.4, "delay_feedback": 0.4,
},
"808_bass": {
"synth": "sine", "envelope": "pluck",
"synth": "sine", "envelope": "piano",
"distortion": 0.4, "distortion_drive": 2.5,
"lowpass": 200, "lowpass_q": 1.5,
"sub_osc": 0.5, "saturation": 0.2,
},
# ── Mellotron ──
"mellotron": {
"synth": "mellotron_synth", "envelope": "organ",
"reverb": 0.3, "reverb_type": "plate",
"humanize": 0.2,
},
"mellotron_strings": {
"synth": "mellotron_synth", "envelope": "organ",
"reverb": 0.3, "reverb_type": "plate",
"humanize": 0.2,
},
"mellotron_flute": {
"synth": "mellotron_synth", "envelope": "organ",
"synth_kw": {"tape": "flute"},
"reverb": 0.35, "reverb_type": "hall",
"humanize": 0.2,
},
"mellotron_choir": {
"synth": "mellotron_synth", "envelope": "organ",
"synth_kw": {"tape": "choir"},
"reverb": 0.4, "reverb_type": "cathedral",
"humanize": 0.2,
},
# ── Analog oscillator presets ──
"sync_lead": {
"synth": "hard_sync", "envelope": "pluck",
"synth_kw": {"slave_ratio": 1.5},
"detune": 8, "lowpass": 4000,
"filter_attack": 0.01, "filter_decay": 0.25,
"filter_sustain": 0.3, "filter_amount": 3000,
"delay": 0.15, "delay_time": 0.2, "delay_feedback": 0.25,
"analog": 0.3,
},
"sync_lead_bright": {
"synth": "hard_sync", "envelope": "pluck",
"synth_kw": {"slave_ratio": 2.5},
"detune": 10, "lowpass": 6000,
"filter_attack": 0.005, "filter_decay": 0.2,
"filter_sustain": 0.1, "filter_amount": 4000,
"analog": 0.3,
},
"ring_mod_bell": {
"synth": "ring_mod", "envelope": "bell",
"synth_kw": {"mod_ratio": 2.1},
"reverb": 0.4, "reverb_type": "plate",
},
"ring_mod_metallic": {
"synth": "ring_mod", "envelope": "mallet",
"synth_kw": {"mod_ratio": 3.7},
"reverb": 0.3, "reverb_type": "hall",
"delay": 0.2, "delay_time": 0.3, "delay_feedback": 0.3,
},
"wavefold_warm": {
"synth": "wavefold", "envelope": "organ",
"synth_kw": {"folds": 2.0},
"lowpass": 3000, "lowpass_q": 1.2,
"analog": 0.3,
},
"wavefold_gnarly": {
"synth": "wavefold", "envelope": "pluck",
"synth_kw": {"folds": 5.0},
"lowpass": 2000, "lowpass_q": 2.5,
"filter_attack": 0.01, "filter_decay": 0.3,
"filter_sustain": 0.1, "filter_amount": 4000,
"distortion": 0.3, "distortion_drive": 2.0,
"analog": 0.3,
},
"drift_saw": {
"synth": "drift", "envelope": "organ",
"synth_kw": {"shape": "saw", "drift_amount": 0.15},
"detune": 10,
"analog": 0.4,
},
"drift_square": {
"synth": "drift", "envelope": "organ",
"synth_kw": {"shape": "square", "drift_amount": 0.15},
"detune": 10,
"analog": 0.4,
},
"analog_pad": {
"synth": "drift", "envelope": "pad",
"synth_kw": {"shape": "saw", "drift_amount": 0.12},
"detune": 12, "spread": 0.5,
"chorus": 0.2,
"lowpass": 2500, "lowpass_q": 1.0,
"analog": 0.5,
},
"analog_bass": {
"synth": "drift", "envelope": "pluck",
"synth_kw": {"shape": "saw", "drift_amount": 0.1},
"lowpass": 600, "lowpass_q": 2.0,
"filter_attack": 0.005, "filter_decay": 0.15,
"filter_sustain": 0.0, "filter_amount": 2000,
"sub_osc": 0.4,
"analog": 0.3,
},
# ── Percussion / Mallet ──
"vibraphone": {
"synth": "vibraphone_synth", "envelope": "none",
@@ -380,22 +478,19 @@ INSTRUMENTS = {
# ── Woodwinds (continued) ──
"saxophone": {
"synth": "saxophone_synth", "envelope": "bowed",
"humanize": 0.15, "vel_to_filter": 1500,
"humanize": 0.15,
},
"alto_sax": {
"synth": "saxophone_synth", "envelope": "bowed",
"humanize": 0.15, "vel_to_filter": 1800,
"humanize": 0.15,
},
"tenor_sax": {
"synth": "saxophone_synth", "envelope": "bowed",
"lowpass": 3000,
"humanize": 0.15, "vel_to_filter": 1200,
"humanize": 0.15,
},
"bari_sax": {
"synth": "saxophone_synth", "envelope": "bowed",
"lowpass": 2000,
"humanize": 0.15, "vel_to_filter": 800,
"sub_osc": 0.15,
"humanize": 0.15, "sub_osc": 0.15,
},
}
@@ -478,6 +573,8 @@ class Note:
@property
def beats(self) -> float:
if self._hold:
return 0.0
return self.duration.value
@@ -2855,7 +2952,8 @@ class Part:
analog: float = 0.0,
ensemble: int = 1,
fm_ratio: float = 2.0,
fm_index: float = 3.0):
fm_index: float = 3.0,
synth_kw: dict = None):
self.name = name
self.synth = synth
self.envelope = envelope
@@ -2903,6 +3001,7 @@ class Part:
self.ensemble = ensemble
self.fm_ratio = fm_ratio
self.fm_index = fm_index
self.synth_kw = synth_kw or {}
self._system = "western" # default, overridden by Score.part()
self._fretboard = None # set by Score.part(fretboard=...)
self.notes: list[Note] = []
@@ -3697,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)
@@ -4270,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
+3 -3
View File
@@ -5333,7 +5333,7 @@ def test_supersaw_wave():
@needs_portaudio
def test_all_synths_in_enum():
from pytheory.play import Synth
assert len(Synth) == 42
assert len(Synth) == 56
for s in Synth:
wave = s(440, n_samples=1000)
assert len(wave) == 1000
@@ -6540,7 +6540,7 @@ def test_instrument_808_bass():
assert p.lowpass == 200
assert p.lowpass_q == 1.5
assert p.synth == "sine"
assert p.envelope == "pluck"
assert p.envelope == "piano"
# ── Non-12-TET / Microtonal systems ─────────────────────────────────────────
@@ -7155,7 +7155,7 @@ def test_score_system_propagates():
def test_synth_enum_count():
from pytheory.play import Synth
assert len(Synth) == 42
assert len(Synth) == 56
def test_all_synths_render_and_enum_match():
Generated
+1 -1
View File
@@ -690,7 +690,7 @@ wheels = [
[[package]]
name = "pytheory"
version = "0.40.4"
version = "0.42.1"
source = { editable = "." }
dependencies = [
{ name = "rich" },