mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 23:00:20 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5dd1c5e15d | |||
| e46732fb5a | |||
| 833ab56857 | |||
| 6b2b1e201e | |||
| f9c81fe05f | |||
| 931ec905c3 | |||
| 799ffbdac9 | |||
| b29b33524f | |||
| 25f25c1f23 | |||
| 3f1d632285 | |||
| 1938037458 | |||
| f7c05e1b31 | |||
| c375785bb9 | |||
| 9ebd54b7fc | |||
| ce68ad8f19 | |||
| f402e76480 | |||
| 4d3c7e0d6c | |||
| 5a74a6f715 | |||
| 5416674858 | |||
| 9a5f305ac6 |
@@ -2,6 +2,74 @@
|
||||
|
||||
All notable changes to PyTheory are documented here.
|
||||
|
||||
## 0.32.0
|
||||
|
||||
- **8 new synth engine features:**
|
||||
- Filter envelope: per-note lowpass sweep (`filter_amount`, `filter_attack`, `filter_decay`, `filter_sustain`)
|
||||
- Velocity → brightness: harder notes = brighter filter (`vel_to_filter`)
|
||||
- Sub-oscillator: octave-below sine for bass weight (`sub_osc`)
|
||||
- Tremolo: amplitude LFO modulation (`tremolo_depth`, `tremolo_rate`)
|
||||
- Saturation: even-harmonic tape/tube warmth (`saturation`)
|
||||
- Noise layer: per-note breath/air texture (`noise_mix`)
|
||||
- Phaser: swept allpass filter chain (`phaser`, `phaser_rate`)
|
||||
- Configurable FM: `fm_ratio` and `fm_index` params
|
||||
- **Highpass filter** (12 dB/oct biquad) on any part
|
||||
- **2 new envelopes:** `bowed` (bow attack with sustain), `mallet` (strike with ringing sustain)
|
||||
- **Improved `strings_synth`:** additive synthesis with body resonance curve, per-harmonic phase randomization, delayed vibrato onset, bow pressure variation
|
||||
- **Instrument preset overhaul:** every preset sanity-checked against real instrument behavior
|
||||
- Mallet instruments (vibraphone, celesta, music box, glockenspiel, tubular bells) now ring properly
|
||||
- Trumpet uses sustaining envelope instead of pluck
|
||||
- Woodwinds have breath noise, brass has velocity brightness
|
||||
- Bass instruments have sub-oscillators, synth presets have filter envelopes
|
||||
- Piano has velocity-to-brightness and subtle hammer noise
|
||||
- Signal chain: saturation → tremolo → distortion → chorus → phaser → highpass → lowpass → delay → reverb
|
||||
- Song #21: Cinematic Showcase (Orchestral)
|
||||
|
||||
## 0.31.0
|
||||
|
||||
- 3 new synth engines: Karplus-Strong pluck, Hammond organ, string ensemble with body formants
|
||||
- 38 instrument presets: `score.part("lead", instrument="violin")`
|
||||
- Keys, strings, woodwinds, brass, plucked, synth, and mallet categories
|
||||
- 13 total synth waveforms
|
||||
|
||||
## 0.30.0
|
||||
|
||||
- Drums are a real Part — same effects pipeline as any voice
|
||||
- `score.drums("rock", split=True)` splits kit into kick/snare/hats/toms/cymbals/percussion Parts
|
||||
- Each split Part gets independent effects (reverb on snare, LP on hats, etc.)
|
||||
- `set_drum_effects()` applies to all drum Parts (split or not)
|
||||
- Sidechain triggers on kick only — hats and snare don't duck the pad
|
||||
- MIDI import via `Score.from_midi(path)`
|
||||
|
||||
## 0.29.3
|
||||
|
||||
- Drums are now a real Part — same effects pipeline as any other voice, zero code duplication
|
||||
- `score.parts["drums"]` is a standard Part with reverb, delay, lowpass, etc.
|
||||
- `set_drum_effects()` is sugar over the Part's attributes
|
||||
|
||||
## 0.29.2
|
||||
|
||||
- Add `score.set_drum_effects()` — reverb, delay, lowpass, distortion, chorus on the drum bus
|
||||
- Same effects engine as parts, zero code duplication
|
||||
|
||||
## 0.29.1
|
||||
|
||||
- Rename song.py → songs.py
|
||||
- Polish all 20 example songs with stereo, convolution reverb, humanize, detune, sidechain
|
||||
|
||||
## 0.29.0
|
||||
|
||||
- Add `Score.from_midi(path)` — import any Standard MIDI File into a Score
|
||||
- Minimal zero-dependency MIDI parser (Type 0 and Type 1)
|
||||
- Each channel becomes a named Part, channel 10 becomes drum hits
|
||||
- Tempo, time signature, velocities, and note durations preserved
|
||||
- Roundtrip: save_midi → from_midi works
|
||||
|
||||
## 0.28.3
|
||||
|
||||
- Rewrite `pytheory demo` — 8 moods with stereo, effects, humanize, convolution reverb, sidechain
|
||||
- Added Dub and Temple moods
|
||||
|
||||
## 0.28.2
|
||||
|
||||
- Lower drum_humanize default to 0.15 — tighter, more professional feel
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Claude Code Instructions
|
||||
|
||||
## Release Process
|
||||
|
||||
When releasing to PyPI, always do all three:
|
||||
|
||||
1. **Tag the commit**: `git tag v0.X.Y`
|
||||
2. **Push the tag**: `git push origin --tags`
|
||||
3. **Create a GitHub release**: `gh release create v0.X.Y --title "v0.X.Y: Short description" --notes "Release notes" --latest`
|
||||
|
||||
Don't forget to update `CHANGELOG.md` *before* the release commit.
|
||||
|
||||
## Version Bumping
|
||||
|
||||
- `pyproject.toml` and `pytheory/__init__.py` must match
|
||||
- Run `uv lock` after changing the version
|
||||
- Patch releases (0.X.Y) for bug fixes and small additions
|
||||
- Minor releases (0.X.0) for new features
|
||||
|
||||
## Testing
|
||||
|
||||
```
|
||||
uv run python -m pytest test_pytheory.py -x -q --tb=short -m "not slow"
|
||||
```
|
||||
|
||||
## Publishing
|
||||
|
||||
```
|
||||
uv build && uv publish --token <token> dist/pytheory-0.X.Y*
|
||||
```
|
||||
|
||||
## Music Preferences
|
||||
|
||||
- Detune: keep at 8-15, don't go above 25
|
||||
- Humanize: 0.2 is the sweet spot for melodic parts
|
||||
- Drum humanize: 0.15 default is good
|
||||
- No swing unless specifically asked
|
||||
- Sine and triangle are underrated — use them more
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Kenneth Reitz
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -29,6 +29,50 @@ Score:
|
||||
|
||||
The default is 0.15 — just enough to feel alive without sounding loose.
|
||||
|
||||
Drums Are Parts
|
||||
~~~~~~~~~~~~~~~~
|
||||
|
||||
Drums are a real Part — the same as any melodic voice. You can set
|
||||
effects on them the same way:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
score.drums("rock", repeats=4)
|
||||
score.parts["drums"].reverb_mix = 0.2
|
||||
score.parts["drums"].reverb_type = "plate"
|
||||
|
||||
Or use the shorthand:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
score.set_drum_effects(reverb=0.2, reverb_type="plate", lowpass=8000)
|
||||
|
||||
Split Drums
|
||||
~~~~~~~~~~~
|
||||
|
||||
For maximum control, split the kit into separate Parts — kick, snare,
|
||||
hats, toms, cymbals, and percussion — each with independent effects:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
score.drums("rock", repeats=4, split=True)
|
||||
|
||||
# Now each group is its own Part
|
||||
score.parts["snare"].reverb_mix = 0.3
|
||||
score.parts["snare"].reverb_type = "plate"
|
||||
score.parts["hats"].lowpass = 7000
|
||||
score.parts["kick"] # dry, no effects
|
||||
|
||||
# set_drum_effects still works — applies to all drum Parts
|
||||
score.set_drum_effects(reverb=0.1)
|
||||
|
||||
This is how real studios work — the snare gets its own reverb send,
|
||||
the hats get their own EQ, the kick stays dry and punchy. Now you
|
||||
can do the same thing in Python.
|
||||
|
||||
Sidechain compression triggers on kick hits only — hi-hats and snares
|
||||
don't duck the pad.
|
||||
|
||||
Every drum sound is stereo-panned like a real kit — kick and snare
|
||||
center, hi-hat right, crash left, toms spread across the field,
|
||||
percussion instruments placed naturally. Put on headphones and you'll
|
||||
|
||||
+238
-9
@@ -32,13 +32,26 @@ It's a well-tested order that sounds good by default.
|
||||
|
||||
Effects are applied in this fixed order::
|
||||
|
||||
Signal --> Distortion --> Chorus --> Lowpass Filter --> Delay --> Reverb --> Mix
|
||||
Signal --> Saturation --> Tremolo --> Distortion --> Chorus --> Phaser
|
||||
--> Highpass --> Lowpass --> Delay --> Reverb --> Mix
|
||||
|
||||
- **Distortion** first: drives the raw signal before filtering (like
|
||||
plugging a guitar into a fuzz pedal before the amp).
|
||||
- **Chorus** second: thickens the distorted signal.
|
||||
- **Lowpass** third: shapes the tone (like a tone knob on an amp).
|
||||
- **Delay** fourth: echoes the shaped signal (tap delay / tape echo).
|
||||
Additionally, these per-note effects are applied before the part effects chain:
|
||||
|
||||
- **Sub-oscillator**: octave-below sine mixed in at the oscillator stage
|
||||
- **Noise layer**: filtered noise mixed per-note for breath/transients
|
||||
- **Filter envelope**: per-note lowpass sweep (attack/decay/sustain)
|
||||
- **Velocity → brightness**: harder velocity = brighter filter cutoff
|
||||
|
||||
Part-level effects:
|
||||
|
||||
- **Saturation** first: subtle even-harmonic warmth (tape/tube color).
|
||||
- **Tremolo** second: amplitude LFO modulation.
|
||||
- **Distortion** third: drives the signal before filtering.
|
||||
- **Chorus** fourth: thickens the signal.
|
||||
- **Phaser** fifth: swept allpass notches.
|
||||
- **Highpass** sixth: removes low-frequency mud.
|
||||
- **Lowpass** seventh: shapes the tone (like a tone knob on an amp).
|
||||
- **Delay** eighth: echoes the shaped signal (tap delay / tape echo).
|
||||
- **Reverb** last: places everything in a space (room / hall).
|
||||
|
||||
Distortion
|
||||
@@ -498,6 +511,221 @@ whole mix will gasp for air:
|
||||
delay=0.2,
|
||||
)
|
||||
|
||||
Saturation
|
||||
----------
|
||||
|
||||
Saturation is the warm, subtle harmonic enhancement of analog tape
|
||||
machines and tube preamps. Unlike distortion (which uses ``tanh`` and
|
||||
adds harsh odd harmonics), saturation uses a polynomial waveshaper
|
||||
that adds even harmonics -- 2nd and 4th -- which the ear perceives as
|
||||
warmth and fullness. It's why records mixed through a Neve console
|
||||
sound "bigger" than the same mix done in the box.
|
||||
|
||||
Parameters:
|
||||
|
||||
- ``saturation``: Amount, 0.0--1.0 (default 0, off).
|
||||
|
||||
- 0.05--0.15 = subtle analog warmth (tape machine)
|
||||
- 0.2--0.4 = noticeable color (tube preamp)
|
||||
- 0.5+ = heavy coloring
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Warm up a bass
|
||||
bass = score.part("bass", synth="saw", saturation=0.2)
|
||||
|
||||
# Glue a string ensemble
|
||||
strings = score.part("strings", instrument="string_ensemble",
|
||||
saturation=0.1)
|
||||
|
||||
Tremolo
|
||||
-------
|
||||
|
||||
Amplitude modulation by a sine LFO. The classic vibrating-amp sound.
|
||||
Essential for vibraphone (the rotating discs in the resonator tubes),
|
||||
Rhodes electric piano, and surf guitar. Not to be confused with
|
||||
vibrato (pitch modulation).
|
||||
|
||||
Parameters:
|
||||
|
||||
- ``tremolo_depth``: Modulation depth, 0.0--1.0 (default 0, off).
|
||||
- ``tremolo_rate``: LFO speed in Hz (default 5.0).
|
||||
|
||||
- 3--5 Hz = classic tremolo
|
||||
- 5--7 Hz = vibraphone motor speed
|
||||
- 8+ Hz = ring-mod territory
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Classic Fender amp tremolo
|
||||
guitar = score.part("guitar", synth="saw", envelope="pluck",
|
||||
tremolo_depth=0.3, tremolo_rate=4.0)
|
||||
|
||||
# Vibraphone with motor
|
||||
vib = score.part("vib", instrument="vibraphone") # built in
|
||||
|
||||
Phaser
|
||||
------
|
||||
|
||||
A chain of allpass filters whose center frequencies are swept by an
|
||||
LFO, creating moving notches in the spectrum. The classic "jet
|
||||
engine" or "underwater" effect. Think Small Stone, MXR Phase 90, or
|
||||
the intro to "Eruption." Different from chorus -- chorus adds a
|
||||
detuned copy, phaser cancels specific frequencies.
|
||||
|
||||
Parameters:
|
||||
|
||||
- ``phaser``: Wet/dry mix, 0.0--1.0 (default 0, off).
|
||||
- ``phaser_rate``: LFO sweep speed in Hz (default 0.5).
|
||||
|
||||
- 0.1--0.3 = slow, lush sweep
|
||||
- 0.5--1.0 = classic phaser
|
||||
- 2.0+ = fast, Leslie-like
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Slow sweep on a pad
|
||||
pad = score.part("pad", synth="supersaw", envelope="pad",
|
||||
phaser=0.4, phaser_rate=0.2)
|
||||
|
||||
# Leslie sim on organ (built in)
|
||||
organ = score.part("organ", instrument="organ")
|
||||
|
||||
Highpass Filter
|
||||
---------------
|
||||
|
||||
The opposite of lowpass -- removes low-frequency content below the
|
||||
cutoff. Useful for cleaning up mud from pads, keeping multiple bass
|
||||
parts from masking each other, or thinning out a sound to sit better
|
||||
in a mix.
|
||||
|
||||
Parameters:
|
||||
|
||||
- ``highpass``: Cutoff frequency in Hz (0 = off).
|
||||
|
||||
- 80--150 Hz = clean up sub rumble
|
||||
- 200--400 Hz = thin out a pad
|
||||
- 500+ Hz = telephone / radio effect
|
||||
|
||||
- ``highpass_q``: Resonance / Q factor (default 0.707).
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Clean up sub rumble from a pad
|
||||
pad = score.part("pad", synth="supersaw", highpass=120)
|
||||
|
||||
# Thin out rhythm guitar to leave room for bass
|
||||
rhythm = score.part("rhythm", synth="saw", highpass=250)
|
||||
|
||||
Filter Envelope
|
||||
---------------
|
||||
|
||||
A per-note lowpass filter whose cutoff sweeps over time. This is the
|
||||
core of subtractive synthesis -- the reason a Moog bass goes "bwow"
|
||||
instead of "boop." The filter opens on the attack and closes during
|
||||
decay, giving each note a distinctive timbral shape.
|
||||
|
||||
Parameters:
|
||||
|
||||
- ``filter_amount``: Sweep range in Hz (0 = off). How far the filter
|
||||
opens above the base cutoff.
|
||||
- ``filter_attack``: Time to reach peak cutoff, in seconds (default 0.01).
|
||||
- ``filter_decay``: Time to fall to sustain level (default 0.3).
|
||||
- ``filter_sustain``: Sustain level as fraction of amount, 0.0--1.0
|
||||
(default 0.0 = filter closes completely after decay).
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Classic synth bass "bwow"
|
||||
bass = score.part("bass", instrument="synth_bass") # built in
|
||||
|
||||
# Acid squelch
|
||||
acid = score.part("acid", instrument="acid_bass") # built in
|
||||
|
||||
# Custom filter sweep on a lead
|
||||
lead = score.part("lead", synth="saw",
|
||||
filter_amount=4000, filter_attack=0.01,
|
||||
filter_decay=0.4, filter_sustain=0.1)
|
||||
|
||||
Velocity to Brightness
|
||||
~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Real instruments get brighter when played harder. ``vel_to_filter``
|
||||
maps note velocity to filter cutoff boost, so louder notes have more
|
||||
high-frequency content.
|
||||
|
||||
- ``vel_to_filter``: Cutoff boost in Hz at max velocity (default 0, off).
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Piano: soft = mellow, loud = bright
|
||||
piano = score.part("piano", instrument="piano") # built in
|
||||
|
||||
# Manual: custom velocity mapping on a lead
|
||||
lead = score.part("lead", synth="saw", vel_to_filter=3000)
|
||||
|
||||
Sub-Oscillator
|
||||
--------------
|
||||
|
||||
An octave-below sine wave mixed in with the main oscillator. Adds
|
||||
low-end weight without muddiness -- the sub fills in the fundamental
|
||||
while the main oscillator provides harmonic character above.
|
||||
|
||||
- ``sub_osc``: Mix level, 0.0--1.0 (default 0, off).
|
||||
|
||||
- 0.1--0.2 = subtle weight (tuba, bass guitar)
|
||||
- 0.3--0.5 = heavy sub (808, synth bass)
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Fat 808 kick-bass
|
||||
bass = score.part("bass", instrument="808_bass") # built in
|
||||
|
||||
# Add weight to any part
|
||||
lead = score.part("lead", synth="saw", sub_osc=0.3)
|
||||
|
||||
Noise Layer
|
||||
-----------
|
||||
|
||||
White noise mixed into each note, following the same amplitude
|
||||
envelope. Adds breath for woodwinds, hammer/felt noise for piano,
|
||||
bow rosin for strings, and attack transients for percussion.
|
||||
|
||||
- ``noise_mix``: Mix level, 0.0--1.0 (default 0, off).
|
||||
|
||||
- 0.02--0.04 = subtle texture (strings, piano)
|
||||
- 0.05--0.08 = noticeable breath (woodwinds)
|
||||
- 0.1+ = heavy air/texture
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Breathy flute
|
||||
flute = score.part("flute", instrument="flute") # noise_mix=0.08
|
||||
|
||||
# Add air to any synth
|
||||
pad = score.part("pad", synth="supersaw", noise_mix=0.05)
|
||||
|
||||
Configurable FM
|
||||
---------------
|
||||
|
||||
The FM synth now accepts ``fm_ratio`` and ``fm_index`` parameters,
|
||||
letting you dial in specific FM timbres instead of using the defaults.
|
||||
|
||||
- ``fm_ratio``: Modulator frequency as multiple of carrier (default 2.0).
|
||||
Integer ratios = harmonic timbres; non-integer = metallic/inharmonic.
|
||||
- ``fm_index``: Modulation depth (default 3.0). Higher = more harmonics.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Warm electric piano (low ratio, low index)
|
||||
ep = score.part("ep", synth="fm", fm_ratio=1.0, fm_index=1.5)
|
||||
|
||||
# Bright metallic bell (high ratio, high index)
|
||||
bell = score.part("bell", synth="fm", fm_ratio=3.5, fm_index=5.0)
|
||||
|
||||
# Glockenspiel
|
||||
glock = score.part("glock", instrument="glockenspiel") # built in
|
||||
|
||||
Automation
|
||||
----------
|
||||
|
||||
@@ -528,9 +756,10 @@ processes each section independently:
|
||||
lead.set(lowpass=4000, distortion=0.7, reverb=0.3)
|
||||
lead.arpeggio("Gm", bars=4, pattern="updown", octaves=2)
|
||||
|
||||
Any parameter can be automated: ``lowpass``, ``lowpass_q``, ``reverb``,
|
||||
``reverb_decay``, ``delay``, ``delay_time``, ``delay_feedback``,
|
||||
``distortion``, ``distortion_drive``, ``chorus``, ``volume``.
|
||||
Any parameter can be automated: ``lowpass``, ``lowpass_q``, ``highpass``,
|
||||
``reverb``, ``reverb_decay``, ``delay``, ``delay_time``, ``delay_feedback``,
|
||||
``distortion``, ``distortion_drive``, ``chorus``, ``phaser``, ``phaser_rate``,
|
||||
``saturation``, ``tremolo_depth``, ``tremolo_rate``, ``volume``.
|
||||
|
||||
LFO Automation
|
||||
--------------
|
||||
|
||||
@@ -177,3 +177,33 @@ Optional synth, envelope, and gap parameters:
|
||||
play_progression(chords, t=2000, envelope=Envelope.PAD)
|
||||
|
||||
That's the workflow: hear it, tweak it, hear it again. When it sounds right, export to WAV or MIDI and take it somewhere bigger.
|
||||
|
||||
MIDI Import
|
||||
-----------
|
||||
|
||||
Load any Standard MIDI File into a Score — then play it through
|
||||
PyTheory's synth engine with effects, or analyze the theory:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from pytheory import Score
|
||||
from pytheory.play import play_score
|
||||
|
||||
score = Score.from_midi("song.mid")
|
||||
|
||||
# See what's inside
|
||||
for name, part in score.parts.items():
|
||||
print(f"{name}: {len(part.notes)} notes")
|
||||
|
||||
# Change the synth and add effects
|
||||
score.parts["ch1"].synth = "saw"
|
||||
score.parts["ch1"].reverb_mix = 0.3
|
||||
|
||||
play_score(score)
|
||||
|
||||
Each MIDI channel becomes a named Part (``ch1``, ``ch2``, etc.).
|
||||
Channel 10 (drums) becomes drum hits. Tempo, time signature,
|
||||
note durations, and velocities are all preserved.
|
||||
|
||||
Download any MIDI file from the internet, load it, play it through
|
||||
the synth engine with reverb and delay. That's the whole idea.
|
||||
|
||||
@@ -233,7 +233,7 @@ drum voices with stereo panning.
|
||||
mandolin family, violin family, banjo, harp, oud, sitar, erhu, and
|
||||
more) with chord fingering generation and scale diagrams.
|
||||
|
||||
**Output** — stereo playback, WAV export, MIDI export.
|
||||
**Output** — stereo playback, WAV export, MIDI import/export.
|
||||
|
||||
**Interface** — REPL with tab completion (``pytheory repl``), CLI with
|
||||
15 commands. ``pytheory demo``, ``pytheory key``, ``pytheory chord``,
|
||||
|
||||
+89
-2
@@ -1,7 +1,7 @@
|
||||
Synthesizers
|
||||
============
|
||||
|
||||
PyTheory includes 10 built-in waveforms and 8 ADSR envelope presets.
|
||||
PyTheory includes 13 built-in waveforms and 10 ADSR envelope presets.
|
||||
Every sound is generated from scratch -- no samples or external audio
|
||||
files needed.
|
||||
|
||||
@@ -247,6 +247,8 @@ PyTheory includes 8 presets:
|
||||
play(tone, envelope=Envelope.ORGAN) # Instant on/off, no shaping
|
||||
play(tone, envelope=Envelope.BELL) # Instant attack, long ring
|
||||
play(tone, envelope=Envelope.STRINGS) # Gradual bow attack
|
||||
play(tone, envelope=Envelope.BOWED) # Bow bite into sustain
|
||||
play(tone, envelope=Envelope.MALLET) # Strike with ringing sustain
|
||||
play(tone, envelope=Envelope.STACCATO) # Short and punchy
|
||||
play(tone, envelope=Envelope.NONE) # Raw waveform, no shaping
|
||||
|
||||
@@ -260,8 +262,10 @@ Name Character
|
||||
``"pluck"`` Sharp attack, fast decay -- guitar pick, harp
|
||||
``"pad"`` Slow fade in, lush sustain -- strings, synth pads
|
||||
``"organ"`` Instant on/off -- Hammond organ, no shaping
|
||||
``"bell"`` Instant attack, long ring -- vibraphone, tubular
|
||||
``"bell"`` Instant attack, no sustain -- short metallic ring
|
||||
``"strings"`` Gradual bow attack -- orchestral strings, slow
|
||||
``"bowed"`` Bow bite into sustain -- solo strings, brass
|
||||
``"mallet"`` Strike with ringing sustain -- vibraphone, celesta
|
||||
``"staccato"`` Short and punchy -- funk stabs, percussive hits
|
||||
``"none"`` Raw waveform, no amplitude shaping at all
|
||||
=============== ================================================
|
||||
@@ -341,6 +345,89 @@ Reverb is also stereo — the left and right channels get different
|
||||
early reflection patterns, so the reverb tail occupies real space
|
||||
in the stereo field rather than sitting dead center.
|
||||
|
||||
Physical Modeling
|
||||
-----------------
|
||||
|
||||
Three synths go beyond traditional waveform synthesis into physical
|
||||
modeling territory — they simulate how real instruments produce sound.
|
||||
|
||||
Karplus-Strong Pluck
|
||||
~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
A burst of noise fed into a short delay line. The delay length sets
|
||||
the pitch, the feedback filter models the string decaying. This is
|
||||
how every physical modeling synth since 1983 does plucked strings.
|
||||
It sounds genuinely like a real guitar, harp, or koto.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
guitar = score.part("guitar", synth="pluck_synth")
|
||||
harp = score.part("harp", instrument="harp") # uses pluck_synth
|
||||
|
||||
Hammond Organ
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
Additive synthesis with drawbar harmonics — sine waves at the
|
||||
fundamental plus 2nd, 3rd, 4th, 5th, 6th, and 8th harmonics mixed
|
||||
at musical levels. Warm, round, unmistakably organ.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
organ = score.part("organ", synth="organ_synth")
|
||||
|
||||
String Ensemble
|
||||
~~~~~~~~~~~~~~~
|
||||
|
||||
Filtered sawtooth with body resonance formants at ~500 Hz and ~1500 Hz,
|
||||
modeling the way a violin or cello body shapes the sound. Warmer and
|
||||
more "wooden" than a raw saw wave.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
violin = score.part("violin", synth="strings_synth")
|
||||
|
||||
Instrument Presets
|
||||
------------------
|
||||
|
||||
Instead of choosing synth + envelope + effects manually, use an
|
||||
instrument preset — 38 predefined combinations that approximate real
|
||||
instruments:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
piano = score.part("piano", instrument="piano")
|
||||
violin = score.part("violin", instrument="violin")
|
||||
guitar = score.part("guitar", instrument="acoustic_guitar")
|
||||
organ = score.part("organ", instrument="organ")
|
||||
bass = score.part("bass", instrument="upright_bass")
|
||||
|
||||
Available instruments:
|
||||
|
||||
**Keys**: piano, electric_piano, organ, harpsichord, celesta, music_box
|
||||
|
||||
**Strings**: violin, viola, cello, contrabass, string_ensemble
|
||||
|
||||
**Woodwinds**: flute, clarinet, oboe, bassoon
|
||||
|
||||
**Brass**: trumpet, trombone, french_horn, tuba, brass_ensemble
|
||||
|
||||
**Plucked**: acoustic_guitar, electric_guitar, distorted_guitar,
|
||||
bass_guitar, upright_bass, harp, sitar, koto
|
||||
|
||||
**Synth**: synth_lead, synth_pad, synth_bass, acid_bass, 808_bass
|
||||
|
||||
**Percussion**: vibraphone, marimba, xylophone, glockenspiel, tubular_bells
|
||||
|
||||
Explicit kwargs override preset defaults:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Piano with extra reverb
|
||||
piano = score.part("piano", instrument="piano", reverb=0.5)
|
||||
|
||||
# Violin panned left
|
||||
violin = score.part("v", instrument="violin", pan=-0.4)
|
||||
|
||||
Choosing Synth and Envelope Combos
|
||||
----------------------------------
|
||||
|
||||
|
||||
+8
-5
@@ -77,13 +77,16 @@ What's Inside
|
||||
numbers), scale recommendation, modulation, voice leading
|
||||
- **Sequencing** — Score, Parts, arpeggiator, legato/glide, velocity,
|
||||
swing, humanize, tempo changes, song sections with repeat
|
||||
- **Synthesis** — 10 waveforms, 8 envelopes, detune, stereo pan/spread,
|
||||
58 drum patterns (stereo panned), 21 fills
|
||||
- **Synthesis** — 13 waveforms (including Karplus-Strong pluck, Hammond organ,
|
||||
bowed string), 10 envelopes, 38 instrument presets, configurable FM,
|
||||
sub-oscillator, noise layer, filter envelope, velocity-to-brightness,
|
||||
detune, stereo pan/spread, 58 drum patterns (stereo panned), 21 fills
|
||||
- **Effects** — reverb (algorithmic + 7 convolution IRs, stereo), delay,
|
||||
lowpass (with resonance), distortion, chorus, sidechain compression,
|
||||
automation, LFOs. Master bus compressor/limiter
|
||||
lowpass/highpass (with resonance), distortion, saturation, chorus,
|
||||
phaser, tremolo, sidechain compression, automation, LFOs. Master bus
|
||||
compressor/limiter
|
||||
- **Instruments** — 25 presets with fingering generation
|
||||
- **Output** — stereo playback, WAV, MIDI export
|
||||
- **Output** — stereo playback, WAV export, MIDI import/export
|
||||
- **Interface** — REPL with tab completion, CLI (15 commands), ``pytheory demo``
|
||||
- **AI-friendly** — Claude Code can compose
|
||||
and play music through PyTheory from natural language
|
||||
|
||||
@@ -46,12 +46,17 @@ def bossa_nova_girl():
|
||||
score.drums("bossa nova", repeats=4)
|
||||
|
||||
rhodes = score.part("rhodes", synth="fm", envelope="piano",
|
||||
volume=0.3, reverb=0.4, reverb_decay=1.8)
|
||||
volume=0.3, pan=-0.3,
|
||||
reverb=0.4, reverb_decay=1.8, reverb_type="plate",
|
||||
detune=8, humanize=0.2)
|
||||
lead = score.part("lead", synth="triangle", envelope="pluck",
|
||||
volume=0.45, delay=0.25, delay_time=0.32,
|
||||
delay_feedback=0.35, reverb=0.2)
|
||||
volume=0.45, pan=0.3,
|
||||
delay=0.25, delay_time=0.32, delay_feedback=0.35,
|
||||
reverb=0.2, reverb_type="plate",
|
||||
humanize=0.2)
|
||||
bass = score.part("bass", synth="sine", envelope="pluck",
|
||||
volume=0.45, lowpass=600)
|
||||
volume=0.45, pan=0.0, lowpass=600,
|
||||
humanize=0.15)
|
||||
|
||||
for sym in ["Am", "Am", "Dm", "Dm", "E7", "E7", "Am", "Am"]:
|
||||
rhodes.add(Chord.from_symbol(sym), Duration.WHOLE)
|
||||
@@ -86,11 +91,18 @@ def bebop_in_bb():
|
||||
score.drums("bebop", repeats=8, fill="jazz", fill_every=8)
|
||||
|
||||
rhodes = score.part("rhodes", synth="fm", envelope="piano",
|
||||
volume=0.25, reverb=0.35, reverb_decay=1.2)
|
||||
volume=0.25, pan=-0.3,
|
||||
reverb=0.35, reverb_decay=1.2, reverb_type="plate",
|
||||
detune=8, humanize=0.2)
|
||||
lead = score.part("lead", synth="saw", envelope="pluck",
|
||||
volume=0.4, lowpass=4000, lowpass_q=1.1)
|
||||
volume=0.4, pan=0.25,
|
||||
lowpass=4000, lowpass_q=1.1,
|
||||
delay=0.15, delay_time=0.19, delay_feedback=0.25,
|
||||
reverb=0.15, reverb_type="plate",
|
||||
humanize=0.2)
|
||||
bass = score.part("bass", synth="triangle", envelope="pluck",
|
||||
volume=0.4, lowpass=900)
|
||||
volume=0.4, pan=0.0, lowpass=500,
|
||||
humanize=0.15)
|
||||
|
||||
for sym in ["Bb", "Gm", "Cm", "F7"] * 2:
|
||||
rhodes.add(Chord.from_symbol(sym), Duration.WHOLE)
|
||||
@@ -133,12 +145,17 @@ def salsa_descarga():
|
||||
score.drums("salsa", repeats=8, fill="salsa", fill_every=4)
|
||||
|
||||
pads = score.part("pads", synth="pwm_slow", envelope="pad",
|
||||
volume=0.2, reverb=0.3, lowpass=2000)
|
||||
volume=0.2, pan=-0.35,
|
||||
reverb=0.3, reverb_type="plate", lowpass=2000,
|
||||
detune=10, humanize=0.2)
|
||||
lead = score.part("lead", synth="saw", envelope="pluck",
|
||||
volume=0.4, delay=0.2, delay_time=0.167,
|
||||
delay_feedback=0.3)
|
||||
volume=0.4, pan=0.3,
|
||||
delay=0.2, delay_time=0.167, delay_feedback=0.3,
|
||||
reverb=0.15, reverb_type="plate",
|
||||
humanize=0.2)
|
||||
bass = score.part("bass", synth="pulse", envelope="pluck",
|
||||
volume=0.45, lowpass=500, lowpass_q=1.3)
|
||||
volume=0.45, pan=0.0, lowpass=500, lowpass_q=1.3,
|
||||
humanize=0.15)
|
||||
|
||||
for sym in ["Em7b5", "A7", "Dm7", "Bbmaj7"] * 2:
|
||||
pads.add(Chord.from_symbol(sym), Duration.WHOLE)
|
||||
@@ -175,12 +192,19 @@ def afrobeat_groove():
|
||||
score.drums("afrobeat", repeats=8, fill="afrobeat", fill_every=8)
|
||||
|
||||
pads = score.part("pads", synth="supersaw", envelope="pad",
|
||||
volume=0.2, reverb=0.4, reverb_decay=2.0,
|
||||
lowpass=3000)
|
||||
volume=0.2, pan=-0.3,
|
||||
reverb=0.4, reverb_decay=2.0, reverb_type="cathedral",
|
||||
lowpass=3000, detune=10, spread=0.6,
|
||||
humanize=0.2)
|
||||
lead = score.part("lead", synth="saw", envelope="pluck",
|
||||
volume=0.4, lowpass=3000, lowpass_q=1.0)
|
||||
volume=0.4, pan=0.3,
|
||||
lowpass=3000, lowpass_q=1.0,
|
||||
delay=0.2, delay_time=0.26, delay_feedback=0.3,
|
||||
reverb=0.15, reverb_type="plate",
|
||||
humanize=0.2)
|
||||
bass = score.part("bass", synth="sine", envelope="pluck",
|
||||
volume=0.5, lowpass=500)
|
||||
volume=0.5, pan=0.0, lowpass=500,
|
||||
humanize=0.15)
|
||||
|
||||
for sym in ["Em", "Am", "D", "C"] * 2:
|
||||
pads.add(Chord.from_symbol(sym), Duration.WHOLE)
|
||||
@@ -213,13 +237,18 @@ def reggae_one_drop():
|
||||
score.drums("reggae", repeats=8, fill="reggae", fill_every=8)
|
||||
|
||||
chords = score.part("chords", synth="square", envelope="staccato",
|
||||
volume=0.2, reverb=0.5, reverb_decay=2.0,
|
||||
lowpass=2000)
|
||||
volume=0.2, pan=-0.4,
|
||||
reverb=0.5, reverb_decay=2.0, reverb_type="cathedral",
|
||||
lowpass=2000, detune=8,
|
||||
humanize=0.2)
|
||||
lead = score.part("lead", synth="triangle", envelope="strings",
|
||||
volume=0.4, delay=0.35, delay_time=0.5625,
|
||||
delay_feedback=0.45, reverb=0.3)
|
||||
volume=0.4, pan=0.3,
|
||||
delay=0.35, delay_time=0.5625, delay_feedback=0.45,
|
||||
reverb=0.3, reverb_type="cathedral",
|
||||
humanize=0.2)
|
||||
bass = score.part("bass", synth="sine", envelope="pluck",
|
||||
volume=0.55, lowpass=400, lowpass_q=1.3)
|
||||
volume=0.55, pan=0.0, lowpass=400, lowpass_q=1.3,
|
||||
humanize=0.15)
|
||||
|
||||
for sym in ["G", "C", "D", "C"] * 2:
|
||||
chords.add(Chord.from_symbol(sym), Duration.WHOLE)
|
||||
@@ -254,12 +283,18 @@ def funk_workout():
|
||||
score.drums("funk", repeats=8, fill="funk", fill_every=4)
|
||||
|
||||
chords = score.part("chords", synth="square", envelope="staccato",
|
||||
volume=0.25, lowpass=2500, reverb=0.15)
|
||||
volume=0.25, pan=-0.4,
|
||||
lowpass=2500, reverb=0.15, reverb_type="plate",
|
||||
sidechain=0.4, humanize=0.2)
|
||||
lead = score.part("lead", synth="saw", envelope="pluck",
|
||||
volume=0.4, lowpass=3500, lowpass_q=1.5,
|
||||
delay=0.15, delay_time=0.15, delay_feedback=0.25)
|
||||
volume=0.4, pan=0.35,
|
||||
lowpass=3500, lowpass_q=1.5,
|
||||
delay=0.15, delay_time=0.15, delay_feedback=0.25,
|
||||
reverb=0.1, reverb_type="plate",
|
||||
humanize=0.2)
|
||||
bass = score.part("bass", synth="pulse", envelope="pluck",
|
||||
volume=0.5, lowpass=600, lowpass_q=1.2)
|
||||
volume=0.5, pan=0.0, lowpass=600, lowpass_q=1.2,
|
||||
humanize=0.15)
|
||||
|
||||
for sym in ["Em", "Am", "D", "B7"] * 2:
|
||||
chords.add(Chord.from_symbol(sym), Duration.WHOLE)
|
||||
@@ -298,13 +333,17 @@ def blues_shuffle():
|
||||
score.drums("12/8 blues", repeats=6)
|
||||
|
||||
chords = score.part("chords", synth="fm", envelope="piano",
|
||||
volume=0.3, reverb=0.3, reverb_decay=1.5)
|
||||
volume=0.3, pan=-0.3,
|
||||
reverb=0.3, reverb_decay=1.5, reverb_type="plate",
|
||||
detune=8, humanize=0.2)
|
||||
lead = score.part("lead", synth="saw", envelope="pluck",
|
||||
volume=0.45, reverb=0.3, reverb_decay=1.2,
|
||||
volume=0.45, pan=0.25,
|
||||
reverb=0.3, reverb_decay=1.2, reverb_type="plate",
|
||||
delay=0.2, delay_time=0.43, delay_feedback=0.3,
|
||||
lowpass=3500)
|
||||
lowpass=3500, humanize=0.2)
|
||||
bass = score.part("bass", synth="sine", envelope="pluck",
|
||||
volume=0.5, lowpass=500)
|
||||
volume=0.5, pan=0.0, lowpass=500,
|
||||
humanize=0.15)
|
||||
|
||||
for sym in ["A", "A", "D", "D", "E7", "A"]:
|
||||
chords.add(Chord.from_symbol(sym), Duration.DOTTED_HALF)
|
||||
@@ -343,13 +382,18 @@ def samba_de_janeiro():
|
||||
score.drums("samba", repeats=8, fill="samba", fill_every=8)
|
||||
|
||||
pads = score.part("pads", synth="supersaw", envelope="pad",
|
||||
volume=0.2, reverb=0.45, reverb_decay=2.0,
|
||||
lowpass=4000)
|
||||
volume=0.2, pan=-0.3,
|
||||
reverb=0.45, reverb_decay=2.0, reverb_type="plate",
|
||||
lowpass=4000, detune=10, spread=0.5,
|
||||
humanize=0.2)
|
||||
lead = score.part("lead", synth="triangle", envelope="pluck",
|
||||
volume=0.45, delay=0.2, delay_time=0.176,
|
||||
delay_feedback=0.3)
|
||||
volume=0.45, pan=0.3,
|
||||
delay=0.2, delay_time=0.176, delay_feedback=0.3,
|
||||
reverb=0.15, reverb_type="plate",
|
||||
humanize=0.2)
|
||||
bass = score.part("bass", synth="sine", envelope="pluck",
|
||||
volume=0.45, lowpass=700)
|
||||
volume=0.45, pan=0.0, lowpass=500,
|
||||
humanize=0.15)
|
||||
|
||||
for sym in ["G", "Em", "Am", "D7"] * 2:
|
||||
pads.add(Chord.from_symbol(sym), Duration.WHOLE)
|
||||
@@ -383,12 +427,17 @@ def jazz_waltz():
|
||||
score.drums("waltz", repeats=16)
|
||||
|
||||
rhodes = score.part("rhodes", synth="fm", envelope="piano",
|
||||
volume=0.3, reverb=0.4, reverb_decay=2.0)
|
||||
volume=0.3, pan=-0.3,
|
||||
reverb=0.4, reverb_decay=2.0, reverb_type="cathedral",
|
||||
detune=8, humanize=0.2)
|
||||
lead = score.part("lead", synth="triangle", envelope="strings",
|
||||
volume=0.4, reverb=0.3, reverb_decay=1.5,
|
||||
delay=0.2, delay_time=0.4, delay_feedback=0.3)
|
||||
volume=0.4, pan=0.25,
|
||||
reverb=0.3, reverb_decay=1.5, reverb_type="plate",
|
||||
delay=0.2, delay_time=0.4, delay_feedback=0.3,
|
||||
humanize=0.2)
|
||||
bass = score.part("bass", synth="sine", envelope="pluck",
|
||||
volume=0.4, lowpass=600)
|
||||
volume=0.4, pan=0.0, lowpass=500,
|
||||
humanize=0.15)
|
||||
|
||||
for _ in range(2):
|
||||
for sym in ["Fmaj7", "Gm", "C7", "Fmaj7"]:
|
||||
@@ -423,14 +472,19 @@ def house_anthem():
|
||||
score.drums("house", repeats=8, fill="house", fill_every=8)
|
||||
|
||||
pads = score.part("pads", synth="supersaw", envelope="pad",
|
||||
volume=0.25, reverb=0.5, reverb_decay=2.5,
|
||||
lowpass=5000)
|
||||
volume=0.25, pan=-0.3,
|
||||
reverb=0.5, reverb_decay=2.5, reverb_type="cathedral",
|
||||
lowpass=5000, detune=12, spread=0.7,
|
||||
sidechain=0.6, humanize=0.2)
|
||||
lead = score.part("lead", synth="saw", envelope="staccato",
|
||||
volume=0.35, lowpass=2000, lowpass_q=2.0,
|
||||
delay=0.2, delay_time=0.242,
|
||||
delay_feedback=0.35)
|
||||
volume=0.35, pan=0.3,
|
||||
lowpass=2000, lowpass_q=2.0,
|
||||
delay=0.2, delay_time=0.242, delay_feedback=0.35,
|
||||
reverb=0.15, reverb_type="plate",
|
||||
humanize=0.2)
|
||||
bass = score.part("bass", synth="sine", envelope="pluck",
|
||||
volume=0.55, lowpass=300)
|
||||
volume=0.55, pan=0.0, lowpass=300,
|
||||
sidechain=0.5, humanize=0.15)
|
||||
|
||||
for sym in ["Cm", "Ab", "Bb", "Cm"] * 2:
|
||||
pads.add(Chord.from_symbol(sym), Duration.WHOLE)
|
||||
@@ -480,16 +534,22 @@ def dub_kingston():
|
||||
score.drums("dub", repeats=8)
|
||||
|
||||
chords = score.part("chords", synth="square", envelope="staccato",
|
||||
volume=0.2, reverb=0.6, reverb_decay=2.5,
|
||||
lowpass=1500, lowpass_q=0.9)
|
||||
volume=0.2, pan=-0.4,
|
||||
reverb=0.6, reverb_decay=2.5, reverb_type="cathedral",
|
||||
lowpass=1500, lowpass_q=0.9, detune=8,
|
||||
humanize=0.2)
|
||||
lead = score.part("lead", synth="triangle", envelope="strings",
|
||||
volume=0.4, delay=0.45, delay_time=0.625,
|
||||
delay_feedback=0.5, reverb=0.35, reverb_decay=2.0)
|
||||
volume=0.4, pan=0.3,
|
||||
delay=0.45, delay_time=0.625, delay_feedback=0.5,
|
||||
reverb=0.35, reverb_decay=2.0, reverb_type="cathedral",
|
||||
humanize=0.2)
|
||||
bass = score.part("bass", synth="sine", envelope="pluck",
|
||||
volume=0.6, lowpass=400, lowpass_q=1.5)
|
||||
volume=0.6, pan=0.0, lowpass=400, lowpass_q=1.5,
|
||||
humanize=0.15)
|
||||
siren = score.part("siren", synth="pwm_slow", envelope="pad",
|
||||
volume=0.15, reverb=0.7, reverb_decay=3.0,
|
||||
lowpass=1200)
|
||||
volume=0.15, pan=0.5,
|
||||
reverb=0.7, reverb_decay=3.0, reverb_type="cathedral",
|
||||
lowpass=1200, detune=10)
|
||||
|
||||
for sym in ["Am", "Am", "Dm", "Dm", "Am", "Am", "Em", "Am"]:
|
||||
chords.add(Chord.from_symbol(sym), Duration.WHOLE)
|
||||
@@ -526,14 +586,19 @@ def techno_minimal():
|
||||
score.drums("techno", repeats=8, fill="house", fill_every=8)
|
||||
|
||||
pad = score.part("pad", synth="supersaw", envelope="pad",
|
||||
volume=0.2, reverb=0.5, reverb_decay=3.0,
|
||||
lowpass=3000)
|
||||
volume=0.2, pan=-0.3,
|
||||
reverb=0.5, reverb_decay=3.0, reverb_type="cathedral",
|
||||
lowpass=3000, detune=12, spread=0.7,
|
||||
sidechain=0.6, humanize=0.2)
|
||||
lead = score.part("lead", synth="pwm_fast", envelope="staccato",
|
||||
volume=0.35, lowpass=1500, lowpass_q=3.0,
|
||||
delay=0.3, delay_time=0.231,
|
||||
delay_feedback=0.4)
|
||||
volume=0.35, pan=0.3,
|
||||
lowpass=1500, lowpass_q=3.0,
|
||||
delay=0.3, delay_time=0.231, delay_feedback=0.4,
|
||||
reverb=0.1, reverb_type="plate",
|
||||
humanize=0.2)
|
||||
bass = score.part("bass", synth="sine", envelope="pluck",
|
||||
volume=0.55, lowpass=250)
|
||||
volume=0.55, pan=0.0, lowpass=250,
|
||||
sidechain=0.5, humanize=0.15)
|
||||
|
||||
for sym in ["Fm", "Db", "Eb", "Fm"] * 2:
|
||||
pad.add(Chord.from_symbol(sym), Duration.WHOLE)
|
||||
@@ -560,12 +625,17 @@ def gospel_shuffle():
|
||||
score.drums("gospel", repeats=8, fill="buildup", fill_every=8)
|
||||
|
||||
organ = score.part("organ", synth="fm", envelope="organ",
|
||||
volume=0.3, reverb=0.45, reverb_decay=2.0)
|
||||
volume=0.3, pan=-0.3,
|
||||
reverb=0.45, reverb_decay=2.0, reverb_type="cathedral",
|
||||
detune=8, humanize=0.2)
|
||||
lead = score.part("lead", synth="triangle", envelope="pluck",
|
||||
volume=0.4, delay=0.2, delay_time=0.278,
|
||||
delay_feedback=0.3, reverb=0.2)
|
||||
volume=0.4, pan=0.3,
|
||||
delay=0.2, delay_time=0.278, delay_feedback=0.3,
|
||||
reverb=0.2, reverb_type="plate",
|
||||
humanize=0.2)
|
||||
bass = score.part("bass", synth="sine", envelope="pluck",
|
||||
volume=0.45, lowpass=500)
|
||||
volume=0.45, pan=0.0, lowpass=500,
|
||||
humanize=0.15)
|
||||
|
||||
for sym in ["C", "Am", "F", "G"] * 2:
|
||||
organ.add(Chord.from_symbol(sym), Duration.WHOLE)
|
||||
@@ -618,18 +688,23 @@ def dub_delay_madness():
|
||||
score._drum_hits.append(_Hit(DrumSound.RIMSHOT, offset + 3.5, 60))
|
||||
|
||||
chords = score.part("skank", synth="square", envelope="staccato",
|
||||
volume=0.15, reverb=0.7, reverb_decay=3.0,
|
||||
lowpass=1200)
|
||||
volume=0.15, pan=-0.4,
|
||||
reverb=0.7, reverb_decay=3.0, reverb_type="cathedral",
|
||||
lowpass=1200, detune=8, humanize=0.2)
|
||||
bass = score.part("bass", synth="sine", envelope="pluck",
|
||||
volume=0.6, lowpass=350, lowpass_q=1.5)
|
||||
volume=0.6, pan=0.0, lowpass=350, lowpass_q=1.5,
|
||||
humanize=0.15)
|
||||
siren = score.part("siren", synth="pwm_slow", envelope="pad",
|
||||
volume=0.12, reverb=0.8, reverb_decay=4.0,
|
||||
volume=0.12, pan=0.5,
|
||||
reverb=0.8, reverb_decay=4.0, reverb_type="cathedral",
|
||||
delay=0.4, delay_time=0.88, delay_feedback=0.6,
|
||||
lowpass=900)
|
||||
lowpass=900, detune=10)
|
||||
# Melodica stabs — sparse, lots of delay
|
||||
melodica = score.part("melodica", synth="triangle", envelope="pluck",
|
||||
volume=0.35, delay=0.6, delay_time=0.66,
|
||||
delay_feedback=0.55, reverb=0.5, reverb_decay=2.5)
|
||||
volume=0.35, pan=0.3,
|
||||
delay=0.6, delay_time=0.66, delay_feedback=0.55,
|
||||
reverb=0.5, reverb_decay=2.5, reverb_type="cathedral",
|
||||
humanize=0.2)
|
||||
|
||||
for sym in ["Em", "Em", "Am", "Am", "Em", "Em", "Bm", "Em"]:
|
||||
chords.add(Chord.from_symbol(sym), Duration.WHOLE)
|
||||
@@ -667,13 +742,18 @@ def drum_and_bass():
|
||||
score.drums("drum and bass", repeats=8, fill="buildup", fill_every=8)
|
||||
|
||||
pads = score.part("pads", synth="supersaw", envelope="pad",
|
||||
volume=0.25, reverb=0.5, reverb_decay=2.5,
|
||||
lowpass=4000)
|
||||
volume=0.25, pan=-0.3,
|
||||
reverb=0.5, reverb_decay=2.5, reverb_type="plate",
|
||||
lowpass=4000, detune=10, spread=0.6,
|
||||
humanize=0.2)
|
||||
lead = score.part("lead", synth="triangle", envelope="strings",
|
||||
volume=0.4, delay=0.3, delay_time=0.172,
|
||||
delay_feedback=0.4, reverb=0.25)
|
||||
volume=0.4, pan=0.3,
|
||||
delay=0.3, delay_time=0.172, delay_feedback=0.4,
|
||||
reverb=0.25, reverb_type="plate",
|
||||
humanize=0.2)
|
||||
bass = score.part("bass", synth="sine", envelope="pluck",
|
||||
volume=0.55, lowpass=300)
|
||||
volume=0.55, pan=0.0, lowpass=300,
|
||||
humanize=0.15)
|
||||
|
||||
for sym in ["Am", "F", "C", "G"] * 2:
|
||||
pads.add(Chord.from_symbol(sym), Duration.WHOLE)
|
||||
@@ -708,18 +788,24 @@ def drake_vibes():
|
||||
score.drums("trap", repeats=8, fill="trap", fill_every=8)
|
||||
|
||||
pads = score.part("pads", synth="supersaw", envelope="pad",
|
||||
volume=0.2, reverb=0.5, reverb_decay=3.0,
|
||||
lowpass=2500)
|
||||
volume=0.2, pan=-0.25,
|
||||
reverb=0.5, reverb_decay=3.0, reverb_type="cathedral",
|
||||
lowpass=2500, detune=12, spread=0.6,
|
||||
sidechain=0.4, humanize=0.2)
|
||||
bells = score.part("bells", synth="fm", envelope="bell",
|
||||
volume=0.3, reverb=0.4, reverb_decay=2.0,
|
||||
delay=0.25, delay_time=0.44,
|
||||
delay_feedback=0.35)
|
||||
volume=0.3, pan=0.4,
|
||||
reverb=0.4, reverb_decay=2.0, reverb_type="plate",
|
||||
delay=0.25, delay_time=0.44, delay_feedback=0.35,
|
||||
humanize=0.2)
|
||||
lead = score.part("lead", synth="pwm_slow", envelope="strings",
|
||||
volume=0.35, reverb=0.3, lowpass=2000,
|
||||
delay=0.2, delay_time=0.88, delay_feedback=0.3)
|
||||
volume=0.35, pan=-0.2,
|
||||
reverb=0.3, reverb_type="cathedral", lowpass=2000,
|
||||
delay=0.2, delay_time=0.88, delay_feedback=0.3,
|
||||
humanize=0.2)
|
||||
bass = score.part("bass", synth="sine", envelope="pluck",
|
||||
volume=0.6, lowpass=200, lowpass_q=1.8,
|
||||
distortion=0.4, distortion_drive=2.0)
|
||||
volume=0.6, pan=0.0, lowpass=200, lowpass_q=1.8,
|
||||
distortion=0.4, distortion_drive=2.0,
|
||||
sidechain=0.3, humanize=0.15)
|
||||
|
||||
for sym in ["Ebm", "B", "Gb", "Db"] * 2:
|
||||
pads.add(Chord.from_symbol(sym), Duration.WHOLE)
|
||||
@@ -1030,6 +1116,94 @@ def temple_bell():
|
||||
play_song(score)
|
||||
|
||||
|
||||
def cinematic_showcase():
|
||||
"""Cinematic orchestral showcase — tubular bells, strings, organ, harp, acid bass."""
|
||||
score = Score("4/4", bpm=100)
|
||||
|
||||
# Tubular bells — dramatic intro
|
||||
bells = score.part("bells", instrument="tubular_bells",
|
||||
reverb=0.5, reverb_type="cathedral")
|
||||
bells.add("A3", Duration.WHOLE)
|
||||
for _ in range(7):
|
||||
bells.rest(Duration.WHOLE)
|
||||
|
||||
# String ensemble — lush wide pad
|
||||
strings = score.part("strings", instrument="string_ensemble",
|
||||
reverb=0.4, reverb_type="hall")
|
||||
strings.rest(Duration.WHOLE)
|
||||
for sym in ["Am", "F", "C", "G", "Dm", "Am", "E"]:
|
||||
strings.add(Chord.from_symbol(sym), Duration.WHOLE)
|
||||
|
||||
# Cello — deep foundation
|
||||
cello = score.part("cello", instrument="cello",
|
||||
reverb=0.3, reverb_type="hall")
|
||||
cello.rest(Duration.WHOLE)
|
||||
for n in ["A2", "F2", "C3", "G2", "D3", "A2", "E2"]:
|
||||
cello.add(n, Duration.WHOLE)
|
||||
|
||||
# Violin — legato melody enters bar 3
|
||||
violin = score.part("violin", instrument="violin",
|
||||
reverb=0.25, reverb_type="hall", legato=True)
|
||||
violin.rest(Duration.WHOLE)
|
||||
violin.rest(Duration.WHOLE)
|
||||
for note, dur in [
|
||||
("E5", Duration.HALF), ("C5", Duration.HALF),
|
||||
("D5", Duration.QUARTER), ("E5", Duration.QUARTER), ("G5", Duration.HALF),
|
||||
("A5", Duration.HALF), ("G5", Duration.QUARTER), ("E5", Duration.QUARTER),
|
||||
("F5", Duration.WHOLE),
|
||||
("E5", Duration.HALF), ("D5", Duration.HALF),
|
||||
("C5", Duration.HALF), ("B4", Duration.HALF),
|
||||
("A4", Duration.WHOLE),
|
||||
]:
|
||||
violin.add(note, dur)
|
||||
|
||||
# Organ — enters halfway, cathedral weight
|
||||
organ = score.part("organ", instrument="organ",
|
||||
reverb=0.3, reverb_type="cathedral")
|
||||
for _ in range(4):
|
||||
organ.rest(Duration.WHOLE)
|
||||
for sym in ["Dm", "Am", "E", "Am"]:
|
||||
organ.add(Chord.from_symbol(sym), Duration.WHOLE)
|
||||
|
||||
# Harp — arpeggiated flourishes bars 3-4
|
||||
harp = score.part("harp", instrument="harp")
|
||||
harp.rest(Duration.WHOLE)
|
||||
harp.rest(Duration.WHOLE)
|
||||
for n in ["A3", "C4", "E4", "A4", "C5", "E5", "A5", "E5",
|
||||
"G3", "B3", "D4", "G4", "B4", "D5", "G5", "D5"]:
|
||||
harp.add(n, Duration.EIGHTH)
|
||||
for _ in range(4):
|
||||
harp.rest(Duration.WHOLE)
|
||||
|
||||
# Vibraphone — shimmer in last bars with delay
|
||||
vib = score.part("vib", instrument="vibraphone",
|
||||
delay=0.25, delay_time=0.375, delay_feedback=0.35)
|
||||
for _ in range(5):
|
||||
vib.rest(Duration.WHOLE)
|
||||
for note, dur in [
|
||||
("E5", Duration.QUARTER), ("D5", Duration.QUARTER),
|
||||
("C5", Duration.QUARTER), ("A4", Duration.QUARTER),
|
||||
("B4", Duration.HALF), ("E5", Duration.HALF),
|
||||
("A5", Duration.WHOLE),
|
||||
]:
|
||||
vib.add(note, dur)
|
||||
|
||||
# Acid bass — gritty texture bars 4-5
|
||||
acid = score.part("acid", instrument="acid_bass")
|
||||
for _ in range(3):
|
||||
acid.rest(Duration.WHOLE)
|
||||
for n in ["C2", "C2", "E2", "G2", "G2", "G2", "A2", "E2",
|
||||
"D2", "D2", "F2", "A2", "A2", "A2", "E2", "E2"]:
|
||||
acid.add(n, Duration.EIGHTH)
|
||||
for _ in range(2):
|
||||
acid.rest(Duration.WHOLE)
|
||||
|
||||
# Half time drums
|
||||
score.drums("half time", repeats=8)
|
||||
|
||||
play_song(score, "Cinematic Showcase — A minor")
|
||||
|
||||
|
||||
SONGS = {
|
||||
"1": ("Bossa Nova in A minor", bossa_nova_girl),
|
||||
"2": ("Bebop in Bb major", bebop_in_bb),
|
||||
@@ -1051,6 +1225,7 @@ SONGS = {
|
||||
"18": ("Glass and Silk (Sine+Triangle)", glass_and_silk),
|
||||
"19": ("Dance Party at the Reitz House", dance_party),
|
||||
"20": ("Temple Bell (Japanese)", temple_bell),
|
||||
"21": ("Cinematic Showcase (Orchestral)", cinematic_showcase),
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -1064,7 +1239,7 @@ if __name__ == "__main__":
|
||||
print(f" {key:>2}. {name}")
|
||||
|
||||
print()
|
||||
choice = input(" Pick a song (1-20, or 'all'): ").strip()
|
||||
choice = input(" Pick a song (1-21, or 'all'): ").strip()
|
||||
print()
|
||||
|
||||
if choice == "all":
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "pytheory"
|
||||
version = "0.28.2"
|
||||
version = "0.32.0"
|
||||
description = "Music Theory for Humans"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""PyTheory: Music Theory for Humans."""
|
||||
|
||||
__version__ = "0.28.2"
|
||||
__version__ = "0.32.0"
|
||||
|
||||
from .tones import Tone, Interval
|
||||
from .systems import System, SYSTEMS
|
||||
@@ -8,7 +8,7 @@ from .scales import TonedScale, Key, PROGRESSIONS
|
||||
from .chords import Chord, Fretboard, analyze_progression
|
||||
from .charts import CHARTS, Fingering, charts_for_fretboard
|
||||
|
||||
from .rhythm import Duration, TimeSignature, Rest, Score, Part, Section, DrumSound, Pattern
|
||||
from .rhythm import Duration, TimeSignature, Rest, Score, Part, Section, DrumSound, Pattern, INSTRUMENTS
|
||||
from .rhythm import Note as RhythmNote # rhythm.Note (tone + duration pairing)
|
||||
|
||||
from .play import (play, save, save_midi, play_progression, play_pattern,
|
||||
@@ -25,5 +25,5 @@ __all__ = [
|
||||
"play", "save", "save_midi", "play_progression", "play_pattern",
|
||||
"play_score", "Synth", "Envelope",
|
||||
"Duration", "TimeSignature", "RhythmNote", "Rest", "Score", "Part",
|
||||
"DrumSound", "Pattern", "Section",
|
||||
"DrumSound", "Pattern", "Section", "INSTRUMENTS",
|
||||
]
|
||||
|
||||
+99
-47
@@ -228,75 +228,127 @@ def cmd_demo(args):
|
||||
|
||||
moods = [
|
||||
{"name": "Bossa Nova", "key": ("A", "minor"), "drums": "bossa nova",
|
||||
"bpm": 140, "prog": ("i", "iv", "V", "i"),
|
||||
"lead_synth": "triangle", "pad_synth": "fm"},
|
||||
"fill": "bossa nova", "bpm": 140,
|
||||
"prog": ("i", "iv", "V", "i"),
|
||||
"lead": ("pluck_synth", "none", 0.2, -0.1),
|
||||
"pad": ("fm", "pad", -0.2),
|
||||
"bass_lp": 600, "reverb_type": "plate"},
|
||||
{"name": "Jazz Club", "key": ("Bb", "major"), "drums": "jazz",
|
||||
"bpm": 105, "prog": ("I", "vi", "ii", "V"),
|
||||
"lead_synth": "saw", "pad_synth": "fm"},
|
||||
{"name": "Afrobeat Groove", "key": ("E", "minor"), "drums": "afrobeat",
|
||||
"bpm": 115, "prog": ("i", "iv", "V", "i"),
|
||||
"lead_synth": "saw", "pad_synth": "supersaw"},
|
||||
{"name": "House Pump", "key": ("C", "minor"), "drums": "house",
|
||||
"bpm": 124, "prog": ("i", "IV", "V", "i"),
|
||||
"lead_synth": "saw", "pad_synth": "supersaw"},
|
||||
{"name": "Reggae One-Drop", "key": ("G", "major"), "drums": "reggae",
|
||||
"bpm": 80, "prog": ("I", "IV", "V", "IV"),
|
||||
"lead_synth": "triangle", "pad_synth": "pwm_slow"},
|
||||
{"name": "Funk Workout", "key": ("E", "minor"), "drums": "funk",
|
||||
"bpm": 100, "prog": ("i", "iv", "V", "i"),
|
||||
"lead_synth": "saw", "pad_synth": "square"},
|
||||
"fill": "jazz", "bpm": 108,
|
||||
"prog": ("I", "vi", "ii", "V"),
|
||||
"lead": ("triangle", "strings", 0.3, 0.2),
|
||||
"pad": ("fm", "piano", -0.3),
|
||||
"bass_lp": 700, "reverb_type": "plate"},
|
||||
{"name": "Afrobeat", "key": ("E", "minor"), "drums": "afrobeat",
|
||||
"fill": "afrobeat", "bpm": 115,
|
||||
"prog": ("i", "iv", "V", "i"),
|
||||
"lead": ("saw", "pluck", 0.15, 0.3),
|
||||
"pad": ("supersaw", "pad", 0.0),
|
||||
"bass_lp": 500, "reverb_type": "cathedral"},
|
||||
{"name": "House", "key": ("C", "minor"), "drums": "house",
|
||||
"fill": "house", "bpm": 124,
|
||||
"prog": ("i", "IV", "V", "i"),
|
||||
"lead": ("saw", "staccato", 0.2, 0.4),
|
||||
"pad": ("supersaw", "pad", 0.0),
|
||||
"bass_lp": 300, "reverb_type": "plate"},
|
||||
{"name": "Reggae", "key": ("G", "major"), "drums": "reggae",
|
||||
"fill": "reggae", "bpm": 80,
|
||||
"prog": ("I", "IV", "V", "IV"),
|
||||
"lead": ("pluck_synth", "none", 0.25, 0.15),
|
||||
"pad": ("organ_synth", "organ", -0.3),
|
||||
"bass_lp": 400, "reverb_type": "cathedral"},
|
||||
{"name": "Funk", "key": ("E", "minor"), "drums": "funk",
|
||||
"fill": "funk", "bpm": 100,
|
||||
"prog": ("i", "iv", "V", "i"),
|
||||
"lead": ("saw", "pluck", 0.15, 0.3),
|
||||
"pad": ("square", "staccato", -0.4),
|
||||
"bass_lp": 500, "reverb_type": "plate"},
|
||||
{"name": "Dub", "key": ("A", "minor"), "drums": "dub",
|
||||
"fill": "reggae", "bpm": 72,
|
||||
"prog": ("i", "iv", "i", "V"),
|
||||
"lead": ("triangle", "strings", 0.4, 0.2),
|
||||
"pad": ("pwm_slow", "pad", -0.3),
|
||||
"bass_lp": 350, "reverb_type": "cathedral"},
|
||||
{"name": "Temple", "key": ("E", "minor"), "drums": "bolero",
|
||||
"fill": "bossa nova", "bpm": 65,
|
||||
"prog": ("i", "iv", "V", "i"),
|
||||
"lead": ("pluck_synth", "none", 0.3, 0.2),
|
||||
"pad": ("strings_synth", "pad", 0.0),
|
||||
"bass_lp": 200, "reverb_type": "taj_mahal"},
|
||||
]
|
||||
|
||||
mood = random.choice(moods)
|
||||
tonic, mode = mood["key"]
|
||||
key = Key(tonic, mode)
|
||||
chords = key.progression(*mood["prog"])
|
||||
lead_synth, lead_env, lead_reverb, lead_pan = mood["lead"]
|
||||
pad_synth, pad_env, pad_pan = mood["pad"]
|
||||
|
||||
score = Score("4/4", bpm=mood["bpm"], swing=random.uniform(0.1, 0.4))
|
||||
score.drums(mood["drums"], repeats=4, fill=random.choice(Pattern.list_fills()))
|
||||
score = Score("4/4", bpm=mood["bpm"], drum_humanize=0.15)
|
||||
score.drums(mood["drums"], repeats=4, fill=mood["fill"])
|
||||
|
||||
pad = score.part("pad", synth=mood["pad_synth"], envelope="pad",
|
||||
volume=0.25, reverb=0.4, reverb_decay=2.0,
|
||||
chorus=0.2)
|
||||
lead = score.part("lead", synth=mood["lead_synth"], envelope="pluck",
|
||||
volume=0.4, delay=0.25, delay_time=0.375,
|
||||
delay_feedback=0.35, reverb=0.2,
|
||||
lowpass=3000, humanize=0.2)
|
||||
bass = score.part("bass", synth="sine", envelope="pluck",
|
||||
volume=0.45, lowpass=500)
|
||||
pad = score.part(
|
||||
"pad", synth=pad_synth, envelope=pad_env,
|
||||
volume=0.2, pan=pad_pan,
|
||||
detune=10, spread=0.5,
|
||||
reverb=0.4, reverb_type=mood["reverb_type"],
|
||||
chorus=0.2,
|
||||
sidechain=0.4 if mood["bpm"] > 100 else 0.0,
|
||||
)
|
||||
lead = score.part(
|
||||
"lead", synth=lead_synth, envelope=lead_env,
|
||||
volume=0.4, pan=lead_pan,
|
||||
delay=0.2, delay_time=round(30 / mood["bpm"], 3),
|
||||
delay_feedback=0.35,
|
||||
reverb=lead_reverb, reverb_type=mood["reverb_type"],
|
||||
lowpass=3500,
|
||||
humanize=0.2,
|
||||
)
|
||||
bass = score.part(
|
||||
"bass", synth="sine", envelope="pluck",
|
||||
volume=0.45, pan=0.0,
|
||||
lowpass=mood["bass_lp"],
|
||||
humanize=0.15,
|
||||
)
|
||||
|
||||
for chord in chords * 2:
|
||||
pad.add(chord, Duration.WHOLE)
|
||||
|
||||
# Generate a melody from scale tones
|
||||
# Melody: chord tones with passing tones, rests for breathing
|
||||
scale_tones = [t.name for t in key.scale.tones[:-1]]
|
||||
for chord in chords:
|
||||
for i, chord in enumerate(chords):
|
||||
chord_tones = [t.name for t in chord.tones]
|
||||
for _ in range(4):
|
||||
if random.random() < 0.2:
|
||||
lead.rest(random.choice([0.5, 1.0]))
|
||||
beats_left = 4.0
|
||||
while beats_left > 0.5:
|
||||
if random.random() < 0.25:
|
||||
r = random.choice([0.5, 1.0, 1.5])
|
||||
r = min(r, beats_left)
|
||||
lead.rest(r)
|
||||
beats_left -= r
|
||||
else:
|
||||
n = random.choice(chord_tones if random.random() < 0.6 else scale_tones)
|
||||
oct = random.choice([4, 5])
|
||||
dur = random.choice([0.5, 0.67, 1.0])
|
||||
lead.add(f"{n}{oct}", dur, velocity=random.randint(60, 120))
|
||||
n = random.choice(chord_tones if random.random() < 0.65 else scale_tones)
|
||||
oct = 5 if random.random() < 0.6 else 4
|
||||
dur = random.choice([0.67, 1.0, 1.5, 2.0])
|
||||
dur = min(dur, beats_left)
|
||||
vel = random.randint(65, 105)
|
||||
lead.add(f"{n}{oct}", dur, velocity=vel)
|
||||
beats_left -= dur
|
||||
|
||||
root = chords[0].root
|
||||
if root:
|
||||
for chord in chords * 2:
|
||||
r = chord.root
|
||||
if r:
|
||||
bass.add(f"{r.name}2", Duration.QUARTER, velocity=100)
|
||||
bass.add(f"{r.name}2", Duration.QUARTER, velocity=60)
|
||||
fifth = r.add(7)
|
||||
bass.add(f"{fifth.name}2", Duration.QUARTER, velocity=70)
|
||||
bass.add(f"{r.name}2", Duration.QUARTER, velocity=80)
|
||||
# Bass: root-fifth with velocity accents
|
||||
for chord in chords * 2:
|
||||
r = chord.root
|
||||
if r:
|
||||
fifth = r.add(7)
|
||||
bass.add(f"{r.name}2", Duration.QUARTER, velocity=95)
|
||||
bass.add(f"{r.name}2", Duration.QUARTER, velocity=55)
|
||||
bass.add(f"{fifth.name}2", Duration.QUARTER, velocity=65)
|
||||
bass.add(f"{r.name}2", Duration.QUARTER, velocity=75)
|
||||
|
||||
prog_str = " → ".join(c.symbol or str(c) for c in chords)
|
||||
print(f" ♫ {mood['name']}")
|
||||
print(f" {tonic} {mode} | {mood['bpm']} bpm")
|
||||
print(f" {prog_str}")
|
||||
print(f" {mood['drums']} drums | {mood['lead_synth']} lead | {mood['pad_synth']} pad")
|
||||
print(f" {mood['drums']} | {lead_synth} lead | {pad_synth} pad | {mood['reverb_type']} reverb")
|
||||
print()
|
||||
|
||||
play_score(score)
|
||||
|
||||
+473
-49
@@ -190,6 +190,126 @@ def pwm_fast_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||||
return pwm_wave(hz, peak, n_samples, lfo_rate=3.0)
|
||||
|
||||
|
||||
def pluck_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||||
"""Karplus-Strong plucked string synthesis.
|
||||
|
||||
A burst of noise is fed into a short delay line with feedback —
|
||||
the delay length determines the pitch, and the feedback filter
|
||||
determines the decay. This is how every physical modeling synth
|
||||
since 1983 does plucked strings. It sounds genuinely like a real
|
||||
guitar, harp, or koto — not a synth approximation.
|
||||
|
||||
The algorithm: fill a buffer with random noise the length of one
|
||||
period, then repeatedly average adjacent samples. The averaging
|
||||
acts as a lowpass filter, gradually removing high harmonics —
|
||||
exactly what a real vibrating string does as energy dissipates.
|
||||
"""
|
||||
period = int(SAMPLE_RATE / hz)
|
||||
if period < 2:
|
||||
period = 2
|
||||
# Initial noise burst — the "pluck"
|
||||
buf = numpy.random.uniform(-1.0, 1.0, period).astype(numpy.float64)
|
||||
out = numpy.zeros(n_samples, dtype=numpy.float64)
|
||||
for i in range(n_samples):
|
||||
out[i] = buf[i % period]
|
||||
# Averaging filter: smooth adjacent samples (Karplus-Strong)
|
||||
buf[i % period] = 0.5 * (buf[i % period] + buf[(i + 1) % period]) * 0.998
|
||||
return (peak * out).astype(numpy.int16)
|
||||
|
||||
|
||||
def organ_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||||
"""Hammond organ — additive synthesis with drawbar harmonics.
|
||||
|
||||
A real Hammond B3 has 9 drawbars that mix sine waves at different
|
||||
harmonics. This models the classic "full" registration with all
|
||||
drawbars pulled: fundamental, 2nd, 3rd, 4th, 5th, 6th, and 8th
|
||||
harmonics at musical levels.
|
||||
|
||||
The result is warm, rich, and unmistakably organ — somewhere
|
||||
between a sine wave and a square wave, with that characteristic
|
||||
hollow roundness.
|
||||
"""
|
||||
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
|
||||
# Drawbar levels (inspired by 888800000 — full even harmonics)
|
||||
wave = (numpy.sin(2 * numpy.pi * hz * t) * 1.0 + # 16' fundamental
|
||||
numpy.sin(2 * numpy.pi * hz * 2 * t) * 0.8 + # 8'
|
||||
numpy.sin(2 * numpy.pi * hz * 3 * t) * 0.6 + # 5 1/3'
|
||||
numpy.sin(2 * numpy.pi * hz * 4 * t) * 0.5 + # 4'
|
||||
numpy.sin(2 * numpy.pi * hz * 5 * t) * 0.3 + # 2 2/3'
|
||||
numpy.sin(2 * numpy.pi * hz * 6 * t) * 0.25 + # 2'
|
||||
numpy.sin(2 * numpy.pi * hz * 8 * t) * 0.15) # 1 3/5'
|
||||
wave /= 3.5 # normalize
|
||||
return (peak * wave).astype(numpy.int16)
|
||||
|
||||
|
||||
def strings_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||||
"""Bowed string — additive synthesis with natural harmonic rolloff.
|
||||
|
||||
Models bowed string physics:
|
||||
- Additive harmonics with 1/n rolloff shaped by body resonance
|
||||
- Delayed vibrato (develops ~200ms in, like a real player)
|
||||
- Subtle bow pressure variation (amplitude modulation)
|
||||
- Per-harmonic phase randomization for natural timbre
|
||||
- Gentle spectral tilt to avoid synthetic brightness
|
||||
"""
|
||||
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
|
||||
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
|
||||
|
||||
# Delayed vibrato: ramps in over ~200ms, like a real bow
|
||||
vib_rate = 5.2 + rng.uniform(-0.3, 0.3) # slight randomness per note
|
||||
vib_depth = hz * 0.003 # ~5 cents
|
||||
vib_onset = numpy.clip(t / 0.2, 0.0, 1.0) # ramp over 200ms
|
||||
vibrato = vib_depth * vib_onset * numpy.sin(2 * numpy.pi * vib_rate * t)
|
||||
|
||||
# Additive synthesis — build harmonics with natural rolloff
|
||||
nyquist = SAMPLE_RATE / 2.0
|
||||
n_harmonics = min(40, int(nyquist / hz))
|
||||
wave = numpy.zeros(n_samples, dtype=numpy.float64)
|
||||
|
||||
# Body resonance curve — emphasizes certain harmonic regions
|
||||
# Modeled after violin/cello response: peaks around 300Hz, 1kHz, 2.5kHz
|
||||
def body_response(f):
|
||||
"""Approximate string instrument body resonance."""
|
||||
r = 1.0
|
||||
# Main air resonance (~280 Hz for violin, scales with pitch)
|
||||
air_f = max(200, min(400, hz * 1.5))
|
||||
r += 0.6 * numpy.exp(-((f - air_f) / 100) ** 2)
|
||||
# Wood resonance (~1 kHz)
|
||||
r += 0.4 * numpy.exp(-((f - 1000) / 300) ** 2)
|
||||
# Bridge resonance (~2.5 kHz) — the "presence" peak
|
||||
r += 0.3 * numpy.exp(-((f - 2500) / 500) ** 2)
|
||||
return r
|
||||
|
||||
for n in range(1, n_harmonics + 1):
|
||||
f_n = hz * n
|
||||
if f_n >= nyquist:
|
||||
break
|
||||
# Amplitude: 1/n rolloff (sawtooth-like) shaped by body
|
||||
amp = (1.0 / n) * body_response(f_n)
|
||||
# Even harmonics slightly weaker (bowing point ~1/8 from bridge)
|
||||
if n % 2 == 0:
|
||||
amp *= 0.85
|
||||
# Random phase per harmonic — prevents the "buzzy" coherent-phase sound
|
||||
phi = rng.uniform(0, 2 * numpy.pi)
|
||||
wave += amp * numpy.sin(2 * numpy.pi * (f_n * t + vibrato * n / hz) + phi)
|
||||
|
||||
# Normalize
|
||||
max_val = numpy.abs(wave).max()
|
||||
if max_val > 0:
|
||||
wave /= max_val
|
||||
|
||||
# Subtle bow pressure variation — slow amplitude wobble
|
||||
bow_pressure = 1.0 + 0.03 * numpy.sin(2 * numpy.pi * 3.7 * t)
|
||||
wave *= bow_pressure
|
||||
|
||||
# Gentle lowpass — real instruments don't have infinite bandwidth
|
||||
cutoff = min(10000, hz * 10)
|
||||
bl, al = scipy.signal.butter(2, cutoff, btype='low', fs=SAMPLE_RATE)
|
||||
wave = scipy.signal.lfilter(bl, al, wave)
|
||||
|
||||
return (peak * wave).astype(numpy.int16)
|
||||
|
||||
|
||||
def _apply_envelope(samples, attack, decay, sustain, release, sample_rate=SAMPLE_RATE):
|
||||
"""Apply an ADSR amplitude envelope to a sample array.
|
||||
|
||||
@@ -255,7 +375,9 @@ class Envelope(Enum):
|
||||
PLUCK = (0.002, 0.15, 0.0, 0.1)
|
||||
PAD = (0.4, 0.2, 0.7, 0.5)
|
||||
STRINGS = (0.15, 0.1, 0.8, 0.3)
|
||||
BOWED = (0.04, 0.08, 0.75, 0.25)
|
||||
BELL = (0.001, 0.3, 0.0, 0.5)
|
||||
MALLET = (0.002, 0.05, 0.6, 0.8)
|
||||
STACCATO = (0.005, 0.05, 0.0, 0.02)
|
||||
|
||||
|
||||
@@ -291,6 +413,9 @@ class Synth(Enum):
|
||||
SUPERSAW = "supersaw"
|
||||
PWM_SLOW = "pwm_slow"
|
||||
PWM_FAST = "pwm_fast"
|
||||
PLUCK = "pluck_synth"
|
||||
ORGAN = "organ_synth"
|
||||
STRINGS = "strings_synth"
|
||||
|
||||
def __call__(self, hz, **kwargs):
|
||||
"""Make Synth members callable — dispatches to the wave function."""
|
||||
@@ -302,6 +427,8 @@ _SYNTH_FUNCTIONS = {
|
||||
"square": square_wave, "pulse": pulse_wave, "fm": fm_wave,
|
||||
"noise": noise_wave, "supersaw": supersaw_wave,
|
||||
"pwm_slow": pwm_slow_wave, "pwm_fast": pwm_fast_wave,
|
||||
"pluck_synth": pluck_wave, "organ_synth": organ_wave,
|
||||
"strings_synth": strings_wave,
|
||||
}
|
||||
|
||||
|
||||
@@ -1362,6 +1489,41 @@ def _apply_lowpass(samples, cutoff, q=0.707, sample_rate=SAMPLE_RATE):
|
||||
return scipy.signal.lfilter(b, a, samples).astype(numpy.float32)
|
||||
|
||||
|
||||
def _apply_highpass(samples, cutoff, q=0.707, sample_rate=SAMPLE_RATE):
|
||||
"""Apply a 2nd-order Butterworth highpass filter (12 dB/octave).
|
||||
|
||||
Removes low-frequency content below the cutoff. Useful for cleaning
|
||||
up mud from pads, keeping bass parts from masking each other, or
|
||||
thinning out a sound.
|
||||
|
||||
Args:
|
||||
samples: Float32 numpy array.
|
||||
cutoff: Cutoff frequency in Hz.
|
||||
q: Resonance / Q factor (default 0.707 = Butterworth flat).
|
||||
sample_rate: Sample rate in Hz.
|
||||
|
||||
Returns:
|
||||
Float32 array with filter applied.
|
||||
"""
|
||||
if cutoff <= 0 or cutoff >= sample_rate / 2:
|
||||
return samples
|
||||
|
||||
w0 = 2 * numpy.pi * cutoff / sample_rate
|
||||
alpha = numpy.sin(w0) / (2 * q)
|
||||
|
||||
b0 = (1 + numpy.cos(w0)) / 2
|
||||
b1 = -(1 + numpy.cos(w0))
|
||||
b2 = (1 + numpy.cos(w0)) / 2
|
||||
a0 = 1 + alpha
|
||||
a1 = -2 * numpy.cos(w0)
|
||||
a2 = 1 - alpha
|
||||
|
||||
b = numpy.array([b0/a0, b1/a0, b2/a0])
|
||||
a = numpy.array([1.0, a1/a0, a2/a0])
|
||||
|
||||
return scipy.signal.lfilter(b, a, samples).astype(numpy.float32)
|
||||
|
||||
|
||||
def _apply_chorus(samples, mix=0.5, rate=1.5, depth=0.003,
|
||||
sample_rate=SAMPLE_RATE):
|
||||
"""Apply a chorus effect — slightly detuned delayed copy mixed in.
|
||||
@@ -1407,6 +1569,171 @@ def _apply_chorus(samples, mix=0.5, rate=1.5, depth=0.003,
|
||||
return samples * (1 - mix * 0.5) + wet * mix * 0.5
|
||||
|
||||
|
||||
def _apply_filter_envelope(samples, base_cutoff, amount, f_attack, f_decay,
|
||||
f_sustain, q=0.707, vel_cutoff_boost=0.0,
|
||||
sample_rate=SAMPLE_RATE):
|
||||
"""Apply a per-note filter envelope — cutoff sweeps over time.
|
||||
|
||||
This is the core of subtractive synthesis: the filter opens on the
|
||||
attack and closes during decay, giving notes a characteristic
|
||||
"bwow" or "wah" shape.
|
||||
|
||||
Uses block-based processing (64-sample blocks) with biquad coefficient
|
||||
interpolation for efficiency and smooth sweeps.
|
||||
"""
|
||||
n = len(samples)
|
||||
if n == 0 or amount <= 0:
|
||||
return samples
|
||||
|
||||
block_size = 64
|
||||
out = numpy.empty_like(samples)
|
||||
|
||||
# Build the filter cutoff envelope
|
||||
a_samps = int(f_attack * sample_rate)
|
||||
d_samps = int(f_decay * sample_rate)
|
||||
sustain_level = f_sustain * amount
|
||||
|
||||
cutoff_env = numpy.full(n, sustain_level + base_cutoff + vel_cutoff_boost,
|
||||
dtype=numpy.float64)
|
||||
# Attack ramp: 0 → amount
|
||||
if a_samps > 0:
|
||||
a_end = min(a_samps, n)
|
||||
cutoff_env[:a_end] = (base_cutoff + vel_cutoff_boost +
|
||||
numpy.linspace(0, amount, a_end))
|
||||
# Decay ramp: amount → sustain_level
|
||||
if d_samps > 0:
|
||||
d_start = min(a_samps, n)
|
||||
d_end = min(a_samps + d_samps, n)
|
||||
if d_end > d_start:
|
||||
cutoff_env[d_start:d_end] = (base_cutoff + vel_cutoff_boost +
|
||||
numpy.linspace(amount, sustain_level,
|
||||
d_end - d_start))
|
||||
|
||||
# Clamp cutoff to valid range
|
||||
cutoff_env = numpy.clip(cutoff_env, 20.0, sample_rate / 2 - 1)
|
||||
|
||||
# Block-based biquad processing with varying cutoff
|
||||
# State variables for the filter
|
||||
x1 = x2 = y1 = y2 = 0.0
|
||||
pos = 0
|
||||
while pos < n:
|
||||
end = min(pos + block_size, n)
|
||||
block = samples[pos:end]
|
||||
# Use cutoff at block midpoint
|
||||
mid = (pos + end) // 2
|
||||
fc = cutoff_env[mid]
|
||||
# Compute biquad coefficients
|
||||
w0 = 2 * numpy.pi * fc / sample_rate
|
||||
sin_w0 = numpy.sin(w0)
|
||||
cos_w0 = numpy.cos(w0)
|
||||
alpha = sin_w0 / (2 * q)
|
||||
b0 = (1 - cos_w0) / 2
|
||||
b1 = 1 - cos_w0
|
||||
b2 = (1 - cos_w0) / 2
|
||||
a0 = 1 + alpha
|
||||
a1 = -2 * cos_w0
|
||||
a2 = 1 - alpha
|
||||
# Normalize
|
||||
b0 /= a0; b1 /= a0; b2 /= a0
|
||||
a1 /= a0; a2 /= a0
|
||||
# Process block sample by sample (maintaining state)
|
||||
out_block = numpy.empty(len(block), dtype=numpy.float32)
|
||||
for i in range(len(block)):
|
||||
x0 = float(block[i])
|
||||
y0 = b0 * x0 + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2
|
||||
out_block[i] = y0
|
||||
x2 = x1; x1 = x0
|
||||
y2 = y1; y1 = y0
|
||||
out[pos:end] = out_block
|
||||
pos = end
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _apply_saturation(samples, amount=0.5):
|
||||
"""Apply tape/tube saturation — subtle even-harmonic warmth.
|
||||
|
||||
Unlike distortion (tanh, odd harmonics), saturation uses a
|
||||
polynomial waveshaper that adds 2nd and 4th harmonics — the
|
||||
warm, pleasing character of analog tape and tube preamps.
|
||||
"""
|
||||
if amount <= 0:
|
||||
return samples
|
||||
# Asymmetric polynomial: x + k*x^2 adds even harmonics
|
||||
driven = samples + amount * samples * samples
|
||||
# Normalize to prevent gain buildup
|
||||
driven = driven / (1.0 + amount)
|
||||
# Soft clip any overs
|
||||
return numpy.clip(driven, -1.0, 1.0).astype(numpy.float32)
|
||||
|
||||
|
||||
def _apply_tremolo(samples, depth=0.5, rate=5.0, sample_rate=SAMPLE_RATE):
|
||||
"""Apply tremolo — amplitude modulation by a sine LFO.
|
||||
|
||||
The classic vibrating amp sound. Essential for vibraphone,
|
||||
electric guitar, and organ Leslie speaker simulation.
|
||||
"""
|
||||
if depth <= 0:
|
||||
return samples
|
||||
t = numpy.arange(len(samples), dtype=numpy.float64) / sample_rate
|
||||
lfo = 1.0 - depth * 0.5 * (1.0 + numpy.sin(2 * numpy.pi * rate * t))
|
||||
return (samples * lfo).astype(numpy.float32)
|
||||
|
||||
|
||||
def _apply_phaser(samples, mix=0.5, rate=0.5, stages=4,
|
||||
sample_rate=SAMPLE_RATE):
|
||||
"""Apply phaser — swept allpass filter chain.
|
||||
|
||||
Creates moving notches in the frequency spectrum by passing
|
||||
the signal through a chain of allpass filters whose center
|
||||
frequencies are modulated by an LFO. Classic effect for
|
||||
electric piano, pads, and guitar.
|
||||
"""
|
||||
if mix <= 0:
|
||||
return samples
|
||||
n = len(samples)
|
||||
block_size = 64
|
||||
t = numpy.arange(n, dtype=numpy.float64) / sample_rate
|
||||
|
||||
# LFO sweeps center frequency between 200Hz and 4000Hz (log scale)
|
||||
lfo = 0.5 + 0.5 * numpy.sin(2 * numpy.pi * rate * t)
|
||||
center_freqs = 200.0 * (20.0 ** lfo) # 200Hz to 4000Hz
|
||||
|
||||
# Process through allpass stages
|
||||
wet = samples.copy().astype(numpy.float64)
|
||||
for _stage in range(stages):
|
||||
out = numpy.empty(n, dtype=numpy.float64)
|
||||
# Allpass state
|
||||
x1 = x2 = y1 = y2 = 0.0
|
||||
pos = 0
|
||||
while pos < n:
|
||||
end = min(pos + block_size, n)
|
||||
mid = (pos + end) // 2
|
||||
fc = center_freqs[mid]
|
||||
# Allpass biquad coefficients
|
||||
w0 = 2 * numpy.pi * fc / sample_rate
|
||||
alpha = numpy.sin(w0) / 2.0 # Q=0.5 for wide sweep
|
||||
cos_w0 = numpy.cos(w0)
|
||||
b0 = 1 - alpha
|
||||
b1 = -2 * cos_w0
|
||||
b2 = 1 + alpha
|
||||
a0 = 1 + alpha
|
||||
a1 = -2 * cos_w0
|
||||
a2 = 1 - alpha
|
||||
b0 /= a0; b1 /= a0; b2 /= a0
|
||||
a1 /= a0; a2 /= a0
|
||||
for i in range(pos, end):
|
||||
x0 = wet[i]
|
||||
y0 = b0 * x0 + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2
|
||||
out[i] = y0
|
||||
x2 = x1; x1 = x0
|
||||
y2 = y1; y1 = y0
|
||||
pos = end
|
||||
wet = out
|
||||
|
||||
return (samples * (1 - mix) + wet.astype(numpy.float32) * mix).astype(numpy.float32)
|
||||
|
||||
|
||||
def _apply_distortion(samples, drive=1.0, mix=1.0):
|
||||
"""Apply soft-clip distortion (tanh waveshaping).
|
||||
|
||||
@@ -1438,7 +1765,13 @@ def _apply_distortion(samples, drive=1.0, mix=1.0):
|
||||
|
||||
def _apply_effects_with_params(samples, params, skip_reverb=False):
|
||||
"""Apply effects using a params dict. Used for both static and automated rendering."""
|
||||
# Signal chain: distortion → chorus → lowpass → delay → reverb
|
||||
# Signal chain: saturation → tremolo → distortion → chorus → phaser
|
||||
# → highpass → lowpass → delay → reverb
|
||||
if params.get("saturation", 0) > 0:
|
||||
samples = _apply_saturation(samples, amount=params["saturation"])
|
||||
if params.get("tremolo_depth", 0) > 0:
|
||||
samples = _apply_tremolo(samples, depth=params["tremolo_depth"],
|
||||
rate=params.get("tremolo_rate", 5.0))
|
||||
if params.get("distortion_mix", 0) > 0:
|
||||
samples = _apply_distortion(samples,
|
||||
drive=params.get("distortion_drive", 3.0),
|
||||
@@ -1448,6 +1781,12 @@ def _apply_effects_with_params(samples, params, skip_reverb=False):
|
||||
mix=params["chorus_mix"],
|
||||
rate=params.get("chorus_rate", 1.5),
|
||||
depth=params.get("chorus_depth", 0.003))
|
||||
if params.get("phaser_mix", 0) > 0:
|
||||
samples = _apply_phaser(samples, mix=params["phaser_mix"],
|
||||
rate=params.get("phaser_rate", 0.5))
|
||||
if params.get("highpass", 0) > 0:
|
||||
samples = _apply_highpass(samples, params["highpass"],
|
||||
params.get("highpass_q", 0.707))
|
||||
if params.get("lowpass", 0) > 0:
|
||||
samples = _apply_lowpass(samples, params["lowpass"],
|
||||
params.get("lowpass_q", 0.707))
|
||||
@@ -1472,11 +1811,18 @@ def _apply_effects_with_params(samples, params, skip_reverb=False):
|
||||
def _apply_part_effects(samples, part):
|
||||
"""Apply all effects configured on a Part to a float32 buffer."""
|
||||
params = {
|
||||
"saturation": part.saturation,
|
||||
"tremolo_depth": part.tremolo_depth,
|
||||
"tremolo_rate": part.tremolo_rate,
|
||||
"distortion_mix": part.distortion_mix,
|
||||
"distortion_drive": part.distortion_drive,
|
||||
"chorus_mix": part.chorus_mix,
|
||||
"chorus_rate": part.chorus_rate,
|
||||
"chorus_depth": part.chorus_depth,
|
||||
"phaser_mix": part.phaser_mix,
|
||||
"phaser_rate": part.phaser_rate,
|
||||
"highpass": part.highpass,
|
||||
"highpass_q": part.highpass_q,
|
||||
"lowpass": part.lowpass,
|
||||
"lowpass_q": part.lowpass_q,
|
||||
"delay_mix": part.delay_mix,
|
||||
@@ -1620,11 +1966,17 @@ def _total_samples_from_tempo_map(total_beats, tempo_map):
|
||||
def _render_notes_to_buf(notes, buf, samples_per_beat, total_samples,
|
||||
synth_fn, envelope_tuple, volume, bpm,
|
||||
swing=0.0, tempo_map=None, humanize=0.0,
|
||||
detune=0.0, spread=0.0, stereo_buf=None):
|
||||
detune=0.0, spread=0.0, stereo_buf=None,
|
||||
sub_osc=0.0, noise_mix=0.0,
|
||||
filter_attack=0.01, filter_decay=0.3,
|
||||
filter_sustain=0.0, filter_amount=0.0,
|
||||
vel_to_filter=0.0, filter_q=0.707,
|
||||
synth_kwargs=None):
|
||||
"""Render a list of Notes into an existing buffer at the correct positions."""
|
||||
import random as _rnd
|
||||
|
||||
a, d, s, r = envelope_tuple
|
||||
_skw = synth_kwargs or {}
|
||||
beat_pos = 0.0
|
||||
for note_index, note in enumerate(notes):
|
||||
if note.tone is not None:
|
||||
@@ -1651,8 +2003,14 @@ def _render_notes_to_buf(notes, buf, samples_per_beat, total_samples,
|
||||
pitches = [t.pitch() for t in note.tone.tones]
|
||||
else:
|
||||
pitches = [note.tone.pitch()]
|
||||
# Render oscillators
|
||||
waves = [synth_fn(hz, n_samples=n_samples) for hz in pitches]
|
||||
# Render oscillators (pass synth_kwargs for FM etc.)
|
||||
waves = [synth_fn(hz, n_samples=n_samples, **_skw)
|
||||
for hz in pitches]
|
||||
# Sub-oscillator: octave-below sine
|
||||
if sub_osc > 0:
|
||||
for hz in pitches:
|
||||
sub = sine_wave(hz / 2, n_samples=n_samples)
|
||||
waves.append(sub)
|
||||
# Detune: add oscillators shifted by ±cents
|
||||
detune_up = None
|
||||
detune_down = None
|
||||
@@ -1662,8 +2020,8 @@ def _render_notes_to_buf(notes, buf, samples_per_beat, total_samples,
|
||||
for hz in pitches:
|
||||
hz_up = hz * (2 ** (detune / 1200))
|
||||
hz_down = hz * (2 ** (-detune / 1200))
|
||||
up_waves.append(synth_fn(hz_up, n_samples=n_samples))
|
||||
down_waves.append(synth_fn(hz_down, n_samples=n_samples))
|
||||
up_waves.append(synth_fn(hz_up, n_samples=n_samples, **_skw))
|
||||
down_waves.append(synth_fn(hz_down, n_samples=n_samples, **_skw))
|
||||
if spread > 0 and stereo_buf is not None:
|
||||
# Spread: detuned oscillators go to opposite channels
|
||||
detune_up = sum(w.astype(numpy.float32) for w in up_waves) / SAMPLE_PEAK
|
||||
@@ -1672,14 +2030,44 @@ def _render_notes_to_buf(notes, buf, samples_per_beat, total_samples,
|
||||
waves.extend(up_waves + down_waves)
|
||||
n_osc = len(waves)
|
||||
mixed = sum(w.astype(numpy.float32) for w in waves) / (SAMPLE_PEAK * max(1, n_osc))
|
||||
# Mix sub-oscillator with appropriate gain
|
||||
if sub_osc > 0:
|
||||
# Sub was already included in waves; scale the mix
|
||||
# Boost the sub contribution relative to main
|
||||
sub_count = len(pitches)
|
||||
main_count = n_osc - sub_count
|
||||
if main_count > 0:
|
||||
# Re-render: main only + sub at controlled level
|
||||
main_waves = waves[:n_osc - sub_count]
|
||||
sub_waves = waves[n_osc - sub_count:]
|
||||
main_mix = sum(w.astype(numpy.float32) for w in main_waves) / (SAMPLE_PEAK * max(1, len(main_waves)))
|
||||
sub_mix = sum(w.astype(numpy.float32) for w in sub_waves) / (SAMPLE_PEAK * max(1, len(sub_waves)))
|
||||
mixed = main_mix * (1.0 - sub_osc * 0.3) + sub_mix * sub_osc * 0.3
|
||||
# Noise layer: add noise following the note
|
||||
if noise_mix > 0:
|
||||
noise = numpy.random.uniform(-1, 1, n_samples).astype(numpy.float32)
|
||||
mixed = mixed * (1.0 - noise_mix * 0.5) + noise * noise_mix * 0.5
|
||||
# Amplitude envelope
|
||||
if a > 0 or d > 0 or s < 1.0 or r > 0:
|
||||
mixed = _apply_envelope(mixed, a, d, s, r)
|
||||
# Apply per-note velocity scaling + humanize velocity
|
||||
# Per-note velocity
|
||||
vel = getattr(note, 'velocity', 100)
|
||||
if humanize > 0.0:
|
||||
vel_jitter = int(humanize * 15)
|
||||
vel = max(1, min(127, vel + _rnd.randint(-vel_jitter, vel_jitter)))
|
||||
vel_scale = vel / 127.0
|
||||
# Filter envelope (per-note subtractive filter sweep)
|
||||
if filter_amount > 0:
|
||||
base_cut = 200.0 # base cutoff before envelope opens it
|
||||
vel_boost = vel_to_filter * vel_scale if vel_to_filter > 0 else 0.0
|
||||
mixed = _apply_filter_envelope(
|
||||
mixed, base_cut, filter_amount,
|
||||
filter_attack, filter_decay, filter_sustain,
|
||||
q=filter_q, vel_cutoff_boost=vel_boost)
|
||||
elif vel_to_filter > 0:
|
||||
# Velocity brightness without filter envelope
|
||||
vel_cutoff = vel_to_filter * vel_scale + 1000
|
||||
mixed = _apply_lowpass(mixed, vel_cutoff, q=filter_q)
|
||||
end = min(start + len(mixed), total_samples)
|
||||
buf[start:end] += mixed[:end - start] * volume * vel_scale
|
||||
# Spread detuned oscillators into stereo L/R
|
||||
@@ -1836,12 +2224,19 @@ def render_score(score):
|
||||
# Named parts — each rendered to own buffer for per-part effects
|
||||
_pending_sidechain = []
|
||||
for part in score.parts.values():
|
||||
if part.is_drums:
|
||||
continue # drums are rendered separately via _drum_hits
|
||||
if part.notes:
|
||||
part_buf = numpy.zeros(total_samples, dtype=numpy.float32)
|
||||
synth_fn = _resolve_synth(part.synth)
|
||||
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 = {}
|
||||
if part.synth in ("fm",):
|
||||
synth_kwargs["mod_ratio"] = part.fm_ratio
|
||||
synth_kwargs["mod_index"] = part.fm_index
|
||||
if part.legato:
|
||||
_render_legato_to_buf(
|
||||
part.notes, part_buf, samples_per_beat, total_samples,
|
||||
@@ -1857,7 +2252,16 @@ def render_score(score):
|
||||
humanize=part.humanize,
|
||||
detune=part.detune,
|
||||
spread=part.spread,
|
||||
stereo_buf=stereo_buf)
|
||||
stereo_buf=stereo_buf,
|
||||
sub_osc=part.sub_osc,
|
||||
noise_mix=part.noise_mix,
|
||||
filter_attack=part.filter_attack,
|
||||
filter_decay=part.filter_decay,
|
||||
filter_sustain=part.filter_sustain,
|
||||
filter_amount=part.filter_amount,
|
||||
vel_to_filter=part.vel_to_filter,
|
||||
filter_q=part.lowpass_q,
|
||||
synth_kwargs=synth_kwargs)
|
||||
|
||||
# Apply effects — segmented if automation exists
|
||||
auto_points = part._get_automation_points()
|
||||
@@ -1875,8 +2279,10 @@ def render_score(score):
|
||||
params = part._get_params_at(seg_start_beat)
|
||||
segment = part_buf[seg_start:seg_end].copy()
|
||||
has_fx = any(params.get(k, 0) > 0 for k in
|
||||
["distortion_mix", "chorus_mix", "lowpass",
|
||||
"delay_mix", "reverb_mix"])
|
||||
["saturation", "tremolo_depth",
|
||||
"distortion_mix", "chorus_mix", "phaser_mix",
|
||||
"highpass", "lowpass", "delay_mix",
|
||||
"reverb_mix"])
|
||||
if has_fx:
|
||||
segment = _apply_effects_with_params(segment, params)
|
||||
# Apply volume automation
|
||||
@@ -1885,9 +2291,11 @@ def render_score(score):
|
||||
segment = segment * (seg_vol / part.volume) if part.volume > 0 else segment
|
||||
part_buf[seg_start:seg_end] = segment
|
||||
else:
|
||||
has_fx = (part.lowpass > 0 or part.delay_mix > 0
|
||||
or part.reverb_mix > 0 or part.distortion_mix > 0
|
||||
or part.chorus_mix > 0)
|
||||
has_fx = (part.saturation > 0 or part.tremolo_depth > 0
|
||||
or part.distortion_mix > 0 or part.chorus_mix > 0
|
||||
or part.phaser_mix > 0 or part.highpass > 0
|
||||
or part.lowpass > 0 or part.delay_mix > 0
|
||||
or part.reverb_mix > 0)
|
||||
if has_fx:
|
||||
part_buf = _apply_part_effects(part_buf, part)
|
||||
# Apply sidechain compression if enabled
|
||||
@@ -1950,44 +2358,60 @@ def render_score(score):
|
||||
DrumSound.MARACAS.value: 0.3,
|
||||
}
|
||||
|
||||
# Drum hits — render to mono sidechain trigger + stereo output
|
||||
# Render all drum Parts (may be one "drums" or split into kick/snare/hats/etc.)
|
||||
import random as _drum_rnd
|
||||
drum_buf = numpy.zeros(total_samples, dtype=numpy.float32) # mono for sidechain
|
||||
drum_buf = numpy.zeros(total_samples, dtype=numpy.float32) # mono for sidechain (kick only)
|
||||
drum_stereo = numpy.zeros((total_samples, 2), dtype=numpy.float32)
|
||||
drum_swing = score.swing
|
||||
drum_humanize = getattr(score, '_drum_humanize', 0.3) # subtle by default
|
||||
for hit in score._drum_hits:
|
||||
pos = hit.position
|
||||
if drum_swing > 0:
|
||||
beat_frac = pos % 1.0
|
||||
if 0.1 < beat_frac < 0.9:
|
||||
pos += drum_swing * 0.15
|
||||
if has_tempo_changes:
|
||||
start = _beat_to_sample(pos, tempo_map)
|
||||
else:
|
||||
start = int(pos * samples_per_beat)
|
||||
# Humanize: random timing jitter + velocity variation
|
||||
if drum_humanize > 0:
|
||||
max_offset = int(drum_humanize * 0.03 * samples_per_beat)
|
||||
start += _drum_rnd.randint(-max_offset, max_offset)
|
||||
start = max(0, start)
|
||||
if start >= total_samples or start < 0:
|
||||
continue
|
||||
remaining = total_samples - start
|
||||
hit_len = min(int(SAMPLE_RATE * 0.5), remaining)
|
||||
wave = _render_drum_hit(hit.sound.value, hit_len)
|
||||
vel = hit.velocity
|
||||
if drum_humanize > 0:
|
||||
vel_jitter = int(drum_humanize * 10)
|
||||
vel = max(1, min(127, vel + _drum_rnd.randint(-vel_jitter, vel_jitter)))
|
||||
vel_scale = vel / 127.0
|
||||
mono_hit = wave * vel_scale * 0.7
|
||||
# Mono sidechain trigger (always center)
|
||||
drum_buf[start:start + hit_len] += mono_hit
|
||||
# Stereo panned output
|
||||
pan = _DRUM_PAN.get(hit.sound.value, 0.0)
|
||||
panned = _pan_to_stereo(mono_hit, pan)
|
||||
drum_stereo[start:start + hit_len] += panned
|
||||
drum_humanize = getattr(score, '_drum_humanize', 0.15)
|
||||
|
||||
drum_parts = [p for p in score.parts.values() if p.is_drums]
|
||||
for drum_part in drum_parts:
|
||||
part_stereo = numpy.zeros((total_samples, 2), dtype=numpy.float32)
|
||||
for hit in drum_part._drum_hits:
|
||||
pos = hit.position
|
||||
if drum_swing > 0:
|
||||
beat_frac = pos % 1.0
|
||||
if 0.1 < beat_frac < 0.9:
|
||||
pos += drum_swing * 0.15
|
||||
if has_tempo_changes:
|
||||
start = _beat_to_sample(pos, tempo_map)
|
||||
else:
|
||||
start = int(pos * samples_per_beat)
|
||||
if drum_humanize > 0:
|
||||
max_offset = int(drum_humanize * 0.03 * samples_per_beat)
|
||||
start += _drum_rnd.randint(-max_offset, max_offset)
|
||||
start = max(0, start)
|
||||
if start >= total_samples or start < 0:
|
||||
continue
|
||||
remaining = total_samples - start
|
||||
hit_len = min(int(SAMPLE_RATE * 0.5), remaining)
|
||||
wave = _render_drum_hit(hit.sound.value, hit_len)
|
||||
vel = hit.velocity
|
||||
if drum_humanize > 0:
|
||||
vel_jitter = int(drum_humanize * 10)
|
||||
vel = max(1, min(127, vel + _drum_rnd.randint(-vel_jitter, vel_jitter)))
|
||||
vel_scale = vel / 127.0
|
||||
mono_hit = wave * vel_scale * 0.7
|
||||
# Sidechain trigger — kick only
|
||||
if hit.sound.value == DrumSound.KICK.value:
|
||||
drum_buf[start:start + hit_len] += mono_hit
|
||||
# Stereo panned output for this drum Part
|
||||
pan = _DRUM_PAN.get(hit.sound.value, 0.0)
|
||||
panned = _pan_to_stereo(mono_hit, pan)
|
||||
part_stereo[start:start + hit_len] += panned
|
||||
|
||||
# Apply this drum Part's effects
|
||||
has_drum_fx = (drum_part.saturation > 0 or drum_part.tremolo_depth > 0
|
||||
or drum_part.phaser_mix > 0
|
||||
or drum_part.highpass > 0 or drum_part.lowpass > 0
|
||||
or drum_part.delay_mix > 0
|
||||
or drum_part.reverb_mix > 0 or drum_part.distortion_mix > 0
|
||||
or drum_part.chorus_mix > 0)
|
||||
if has_drum_fx:
|
||||
for ch in range(2):
|
||||
part_stereo[:, ch] = _apply_part_effects(part_stereo[:, ch], drum_part)
|
||||
drum_stereo += part_stereo
|
||||
|
||||
# Apply sidechain compression to parts that request it
|
||||
for part, part_buf in _pending_sidechain:
|
||||
@@ -2001,7 +2425,7 @@ def render_score(score):
|
||||
if score.notes:
|
||||
stereo_buf += _pan_to_stereo(buf, 0.0)
|
||||
|
||||
# Drums: stereo panned
|
||||
# Drums: stereo panned (with effects already applied)
|
||||
stereo_buf += drum_stereo
|
||||
|
||||
# Master bus compressor/limiter (per channel)
|
||||
|
||||
+821
-58
@@ -7,6 +7,252 @@ from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ── Instrument presets ────────────────────────────────────────────────────────
|
||||
# Predefined combinations of synth, envelope, effects, and parameters that
|
||||
# approximate real instruments. Used by ``Score.part(instrument=...)``.
|
||||
|
||||
INSTRUMENTS = {
|
||||
# ── Keys ──
|
||||
"piano": {
|
||||
"synth": "fm", "envelope": "piano",
|
||||
"fm_ratio": 1.0, "fm_index": 1.5,
|
||||
"detune": 5, "chorus": 0.1, "chorus_rate": 0.3,
|
||||
"lowpass": 6000, "saturation": 0.1,
|
||||
"vel_to_filter": 3000, "noise_mix": 0.02,
|
||||
},
|
||||
"electric_piano": { # Rhodes/Wurlitzer
|
||||
"synth": "fm", "envelope": "piano",
|
||||
"fm_ratio": 1.0, "fm_index": 2.0,
|
||||
"detune": 6, "chorus": 0.2, "chorus_rate": 1.0,
|
||||
"lowpass": 4000, "saturation": 0.15,
|
||||
"tremolo_depth": 0.15, "tremolo_rate": 4.5,
|
||||
},
|
||||
"organ": {
|
||||
"synth": "organ_synth", "envelope": "organ",
|
||||
"chorus": 0.2, "chorus_rate": 5.5,
|
||||
"lowpass": 5000,
|
||||
"phaser": 0.15, "phaser_rate": 0.4,
|
||||
},
|
||||
"harpsichord": {
|
||||
"synth": "pluck_synth", "envelope": "none",
|
||||
"lowpass": 3500,
|
||||
},
|
||||
"celesta": {
|
||||
"synth": "fm", "envelope": "mallet",
|
||||
"fm_ratio": 3.0, "fm_index": 5.0,
|
||||
"lowpass": 8000,
|
||||
"reverb": 0.3, "reverb_type": "plate",
|
||||
},
|
||||
"music_box": {
|
||||
"synth": "sine", "envelope": "mallet",
|
||||
"lowpass": 6000,
|
||||
"reverb": 0.25, "reverb_type": "plate",
|
||||
},
|
||||
|
||||
# ── Strings ──
|
||||
"violin": {
|
||||
"synth": "strings_synth", "envelope": "bowed",
|
||||
"detune": 2, "lowpass": 5000,
|
||||
"humanize": 0.15, "vel_to_filter": 1500,
|
||||
"noise_mix": 0.03,
|
||||
},
|
||||
"viola": {
|
||||
"synth": "strings_synth", "envelope": "bowed",
|
||||
"detune": 2, "lowpass": 3500,
|
||||
"humanize": 0.15, "vel_to_filter": 1200,
|
||||
"noise_mix": 0.03,
|
||||
},
|
||||
"cello": {
|
||||
"synth": "strings_synth", "envelope": "bowed",
|
||||
"detune": 2, "lowpass": 2500,
|
||||
"humanize": 0.15, "vel_to_filter": 1000,
|
||||
"noise_mix": 0.02,
|
||||
},
|
||||
"contrabass": {
|
||||
"synth": "strings_synth", "envelope": "bowed",
|
||||
"detune": 2, "lowpass": 1500,
|
||||
"humanize": 0.1, "vel_to_filter": 800,
|
||||
"sub_osc": 0.15,
|
||||
},
|
||||
"string_ensemble": {
|
||||
"synth": "strings_synth", "envelope": "pad",
|
||||
"detune": 10, "spread": 0.5,
|
||||
"chorus": 0.2, "chorus_rate": 0.5,
|
||||
"lowpass": 4000,
|
||||
"noise_mix": 0.02, "saturation": 0.05,
|
||||
},
|
||||
|
||||
# ── Woodwinds ──
|
||||
"flute": {
|
||||
"synth": "sine", "envelope": "strings",
|
||||
"lowpass": 4000,
|
||||
"humanize": 0.2, "noise_mix": 0.08,
|
||||
"vel_to_filter": 2000,
|
||||
},
|
||||
"clarinet": {
|
||||
"synth": "square", "envelope": "strings",
|
||||
"lowpass": 3000,
|
||||
"humanize": 0.15, "noise_mix": 0.05,
|
||||
"vel_to_filter": 1500,
|
||||
},
|
||||
"oboe": {
|
||||
"synth": "saw", "envelope": "strings",
|
||||
"lowpass": 3500, "lowpass_q": 1.2,
|
||||
"humanize": 0.15, "noise_mix": 0.04,
|
||||
"vel_to_filter": 1000,
|
||||
},
|
||||
"bassoon": {
|
||||
"synth": "saw", "envelope": "strings",
|
||||
"lowpass": 2000,
|
||||
"humanize": 0.15, "noise_mix": 0.04,
|
||||
"vel_to_filter": 800,
|
||||
},
|
||||
|
||||
# ── Brass ──
|
||||
"trumpet": {
|
||||
"synth": "saw", "envelope": "bowed",
|
||||
"detune": 3, "lowpass": 4000, "lowpass_q": 1.1,
|
||||
"humanize": 0.15, "vel_to_filter": 2000,
|
||||
"saturation": 0.1,
|
||||
},
|
||||
"trombone": {
|
||||
"synth": "saw", "envelope": "strings",
|
||||
"detune": 3, "lowpass": 2500,
|
||||
"humanize": 0.15, "vel_to_filter": 1500,
|
||||
"saturation": 0.1,
|
||||
},
|
||||
"french_horn": {
|
||||
"synth": "saw", "envelope": "strings",
|
||||
"detune": 4, "lowpass": 2000,
|
||||
"chorus": 0.1,
|
||||
"humanize": 0.15, "vel_to_filter": 1200,
|
||||
"saturation": 0.1,
|
||||
},
|
||||
"tuba": {
|
||||
"synth": "saw", "envelope": "strings",
|
||||
"detune": 3, "lowpass": 1200,
|
||||
"humanize": 0.1, "vel_to_filter": 600,
|
||||
"sub_osc": 0.2,
|
||||
},
|
||||
"brass_ensemble": {
|
||||
"synth": "saw", "envelope": "strings",
|
||||
"detune": 10, "spread": 0.4,
|
||||
"lowpass": 3000,
|
||||
"chorus": 0.15,
|
||||
},
|
||||
|
||||
# ── Plucked ──
|
||||
"acoustic_guitar": {
|
||||
"synth": "pluck_synth", "envelope": "none",
|
||||
"lowpass": 4000,
|
||||
"humanize": 0.2,
|
||||
},
|
||||
"electric_guitar": {
|
||||
"synth": "saw", "envelope": "pluck",
|
||||
"detune": 5, "lowpass": 3500,
|
||||
"humanize": 0.15,
|
||||
},
|
||||
"distorted_guitar": {
|
||||
"synth": "saw", "envelope": "pluck",
|
||||
"detune": 8, "distortion": 0.6, "distortion_drive": 5.0,
|
||||
"lowpass": 3000, "saturation": 0.3,
|
||||
"humanize": 0.15,
|
||||
},
|
||||
"bass_guitar": {
|
||||
"synth": "triangle", "envelope": "pluck",
|
||||
"lowpass": 1000,
|
||||
"humanize": 0.1, "sub_osc": 0.2,
|
||||
},
|
||||
"upright_bass": {
|
||||
"synth": "triangle", "envelope": "pluck",
|
||||
"lowpass": 800,
|
||||
"humanize": 0.15, "saturation": 0.1,
|
||||
},
|
||||
"harp": {
|
||||
"synth": "pluck_synth", "envelope": "none",
|
||||
"lowpass": 5000,
|
||||
"reverb": 0.3, "reverb_type": "plate",
|
||||
},
|
||||
"sitar": {
|
||||
"synth": "saw", "envelope": "pluck",
|
||||
"detune": 12, "lowpass": 3000, "lowpass_q": 1.5,
|
||||
"humanize": 0.2,
|
||||
},
|
||||
"koto": {
|
||||
"synth": "pluck_synth", "envelope": "none",
|
||||
"lowpass": 4000,
|
||||
"reverb": 0.2,
|
||||
},
|
||||
|
||||
# ── Synth presets ──
|
||||
"synth_lead": {
|
||||
"synth": "saw", "envelope": "pluck",
|
||||
"detune": 8, "lowpass": 3000,
|
||||
"delay": 0.2, "delay_time": 0.25, "delay_feedback": 0.3,
|
||||
"filter_attack": 0.01, "filter_decay": 0.3,
|
||||
"filter_sustain": 0.2, "filter_amount": 3000,
|
||||
},
|
||||
"synth_pad": {
|
||||
"synth": "supersaw", "envelope": "pad",
|
||||
"detune": 12, "spread": 0.6,
|
||||
"chorus": 0.2,
|
||||
"phaser": 0.3, "phaser_rate": 0.3,
|
||||
"sub_osc": 0.2,
|
||||
},
|
||||
"synth_bass": {
|
||||
"synth": "saw", "envelope": "pluck",
|
||||
"lowpass": 800, "lowpass_q": 1.3,
|
||||
"filter_attack": 0.005, "filter_decay": 0.2,
|
||||
"filter_sustain": 0.0, "filter_amount": 2000,
|
||||
"sub_osc": 0.4,
|
||||
},
|
||||
"acid_bass": {
|
||||
"synth": "saw", "envelope": "pad",
|
||||
"legato": True, "glide": 0.03,
|
||||
"distortion": 0.7, "distortion_drive": 8.0,
|
||||
"lowpass": 800, "lowpass_q": 5.0,
|
||||
"filter_attack": 0.005, "filter_decay": 0.15,
|
||||
"filter_sustain": 0.0, "filter_amount": 4000,
|
||||
"vel_to_filter": 3000,
|
||||
},
|
||||
"808_bass": {
|
||||
"synth": "sine", "envelope": "pluck",
|
||||
"distortion": 0.4, "distortion_drive": 2.5,
|
||||
"lowpass": 200, "lowpass_q": 1.5,
|
||||
"sub_osc": 0.5, "saturation": 0.2,
|
||||
},
|
||||
|
||||
# ── Percussion / Mallet ──
|
||||
"vibraphone": {
|
||||
"synth": "fm", "envelope": "mallet",
|
||||
"fm_ratio": 1.0, "fm_index": 1.0,
|
||||
"lowpass": 5000,
|
||||
"tremolo_depth": 0.3, "tremolo_rate": 5.5,
|
||||
"reverb": 0.3, "reverb_type": "plate",
|
||||
},
|
||||
"marimba": {
|
||||
"synth": "sine", "envelope": "mallet",
|
||||
"lowpass": 3000,
|
||||
},
|
||||
"xylophone": {
|
||||
"synth": "fm", "envelope": "pluck",
|
||||
"fm_ratio": 3.0, "fm_index": 5.0,
|
||||
"lowpass": 6000,
|
||||
},
|
||||
"glockenspiel": {
|
||||
"synth": "fm", "envelope": "mallet",
|
||||
"fm_ratio": 4.0, "fm_index": 6.0,
|
||||
"lowpass": 8000,
|
||||
"reverb": 0.2,
|
||||
},
|
||||
"tubular_bells": {
|
||||
"synth": "fm", "envelope": "mallet",
|
||||
"fm_ratio": 2.0, "fm_index": 3.0,
|
||||
"reverb": 0.4, "reverb_type": "cathedral",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class Duration(Enum):
|
||||
"""Note durations in beats (quarter note = 1 beat)."""
|
||||
|
||||
@@ -1360,6 +1606,7 @@ class Part:
|
||||
reverb_type: str = "algorithmic",
|
||||
delay: float = 0.0, delay_time: float = 0.375,
|
||||
delay_feedback: float = 0.4,
|
||||
highpass: float = 0.0, highpass_q: float = 0.707,
|
||||
lowpass: float = 0.0, lowpass_q: float = 0.707,
|
||||
distortion: float = 0.0, distortion_drive: float = 3.0,
|
||||
legato: bool = False, glide: float = 0.0,
|
||||
@@ -1371,7 +1618,22 @@ class Part:
|
||||
sidechain_release: float = 0.1,
|
||||
detune: float = 0.0,
|
||||
pan: float = 0.0,
|
||||
spread: float = 0.0):
|
||||
spread: float = 0.0,
|
||||
# ── New synth engine params ──
|
||||
sub_osc: float = 0.0,
|
||||
noise_mix: float = 0.0,
|
||||
filter_attack: float = 0.01,
|
||||
filter_decay: float = 0.3,
|
||||
filter_sustain: float = 0.0,
|
||||
filter_amount: float = 0.0,
|
||||
vel_to_filter: float = 0.0,
|
||||
saturation: float = 0.0,
|
||||
tremolo_depth: float = 0.0,
|
||||
tremolo_rate: float = 5.0,
|
||||
phaser: float = 0.0,
|
||||
phaser_rate: float = 0.5,
|
||||
fm_ratio: float = 2.0,
|
||||
fm_index: float = 3.0):
|
||||
self.name = name
|
||||
self.synth = synth
|
||||
self.envelope = envelope
|
||||
@@ -1386,6 +1648,8 @@ class Part:
|
||||
self.delay_mix = delay
|
||||
self.delay_time = delay_time
|
||||
self.delay_feedback = delay_feedback
|
||||
self.highpass = highpass
|
||||
self.highpass_q = highpass_q
|
||||
self.lowpass = lowpass
|
||||
self.lowpass_q = lowpass_q
|
||||
self.distortion_mix = distortion
|
||||
@@ -1398,7 +1662,24 @@ class Part:
|
||||
self.detune = detune
|
||||
self.pan = pan
|
||||
self.spread = spread
|
||||
# New synth engine params
|
||||
self.sub_osc = sub_osc
|
||||
self.noise_mix = noise_mix
|
||||
self.filter_attack = filter_attack
|
||||
self.filter_decay = filter_decay
|
||||
self.filter_sustain = filter_sustain
|
||||
self.filter_amount = filter_amount
|
||||
self.vel_to_filter = vel_to_filter
|
||||
self.saturation = saturation
|
||||
self.tremolo_depth = tremolo_depth
|
||||
self.tremolo_rate = tremolo_rate
|
||||
self.phaser_mix = phaser
|
||||
self.phaser_rate = phaser_rate
|
||||
self.fm_ratio = fm_ratio
|
||||
self.fm_index = fm_index
|
||||
self.notes: list[Note] = []
|
||||
self._drum_hits: list[_Hit] = []
|
||||
self._drum_pattern_beats: float = 0.0
|
||||
self._automation: list[tuple[float, dict]] = [] # (beat, {param: value})
|
||||
|
||||
def add(self, tone_or_string, duration=Duration.QUARTER, *, velocity: int = 100) -> "Part":
|
||||
@@ -1460,6 +1741,8 @@ class Part:
|
||||
mapped["distortion_mix"] = v
|
||||
elif k == "chorus":
|
||||
mapped["chorus_mix"] = v
|
||||
elif k == "phaser":
|
||||
mapped["phaser_mix"] = v
|
||||
else:
|
||||
mapped[k] = v
|
||||
self._automation.append((beat_pos, mapped))
|
||||
@@ -1470,10 +1753,14 @@ class Part:
|
||||
# Start with initial values
|
||||
params = {
|
||||
"volume": self.volume,
|
||||
"saturation": self.saturation,
|
||||
"tremolo_depth": self.tremolo_depth, "tremolo_rate": self.tremolo_rate,
|
||||
"reverb_mix": self.reverb_mix, "reverb_decay": self.reverb_decay,
|
||||
"reverb_type": self.reverb_type,
|
||||
"delay_mix": self.delay_mix, "delay_time": self.delay_time,
|
||||
"delay_feedback": self.delay_feedback,
|
||||
"phaser_mix": self.phaser_mix, "phaser_rate": self.phaser_rate,
|
||||
"highpass": self.highpass, "highpass_q": self.highpass_q,
|
||||
"lowpass": self.lowpass, "lowpass_q": self.lowpass_q,
|
||||
"distortion_mix": self.distortion_mix,
|
||||
"distortion_drive": self.distortion_drive,
|
||||
@@ -1690,12 +1977,21 @@ class Part:
|
||||
|
||||
return self
|
||||
|
||||
@property
|
||||
def is_drums(self) -> bool:
|
||||
"""True if this part contains drum hits."""
|
||||
return len(self._drum_hits) > 0
|
||||
|
||||
@property
|
||||
def total_beats(self) -> float:
|
||||
return sum(n.beats for n in self.notes)
|
||||
note_beats = sum(n.beats for n in self.notes)
|
||||
if self._drum_hits:
|
||||
drum_beats = self._drum_pattern_beats
|
||||
return max(note_beats, drum_beats)
|
||||
return note_beats
|
||||
|
||||
def __len__(self):
|
||||
return len(self.notes)
|
||||
return len(self.notes) + len(self._drum_hits)
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.notes)
|
||||
@@ -1786,34 +2082,110 @@ class Score:
|
||||
self._drum_humanize = drum_humanize
|
||||
self.notes: list[Note] = []
|
||||
self.parts: dict[str, Part] = {}
|
||||
self._drum_hits: list[_Hit] = []
|
||||
self._drum_pattern_beats: float = 0.0
|
||||
self._tempo_changes: list[tuple[float, int]] = []
|
||||
self._sections: dict[str, Section] = {}
|
||||
self._current_section: Optional[Section] = None
|
||||
|
||||
def part(self, name: str, *, synth: str = "sine",
|
||||
envelope: str = "piano", volume: float = 0.5,
|
||||
reverb: float = 0.0, reverb_decay: float = 1.0,
|
||||
reverb_type: str = "algorithmic",
|
||||
delay: float = 0.0, delay_time: float = 0.375,
|
||||
delay_feedback: float = 0.4,
|
||||
lowpass: float = 0.0, lowpass_q: float = 0.707,
|
||||
distortion: float = 0.0, distortion_drive: float = 3.0,
|
||||
legato: bool = False, glide: float = 0.0,
|
||||
chorus: float = 0.0, chorus_rate: float = 1.5,
|
||||
chorus_depth: float = 0.003,
|
||||
def _ensure_drums_part(self) -> Part:
|
||||
"""Get or create the drums Part."""
|
||||
if "drums" not in self.parts:
|
||||
self.parts["drums"] = Part("drums", synth="sine", volume=0.7)
|
||||
return self.parts["drums"]
|
||||
|
||||
@property
|
||||
def _drum_hits(self) -> list:
|
||||
"""Proxy: drum hits live on the drums Part."""
|
||||
return self._ensure_drums_part()._drum_hits
|
||||
|
||||
@property
|
||||
def _drum_pattern_beats(self) -> float:
|
||||
"""Proxy: drum pattern beats live on the drums Part."""
|
||||
return self._ensure_drums_part()._drum_pattern_beats
|
||||
|
||||
@_drum_pattern_beats.setter
|
||||
def _drum_pattern_beats(self, value: float):
|
||||
self._ensure_drums_part()._drum_pattern_beats = value
|
||||
|
||||
@property
|
||||
def drum_effects(self) -> dict:
|
||||
"""Proxy: drum effects are just the drums Part's effect settings."""
|
||||
p = self._ensure_drums_part()
|
||||
return {
|
||||
"reverb_mix": p.reverb_mix, "reverb_decay": p.reverb_decay,
|
||||
"reverb_type": p.reverb_type,
|
||||
"delay_mix": p.delay_mix, "delay_time": p.delay_time,
|
||||
"delay_feedback": p.delay_feedback,
|
||||
"lowpass": p.lowpass, "lowpass_q": p.lowpass_q,
|
||||
"distortion_mix": p.distortion_mix,
|
||||
"distortion_drive": p.distortion_drive,
|
||||
"chorus_mix": p.chorus_mix,
|
||||
}
|
||||
|
||||
def set_drum_effects(self, **kwargs) -> "Score":
|
||||
"""Set effects on all drum parts.
|
||||
|
||||
When drums are split, applies to every drum Part (kick, snare,
|
||||
hats, etc.). When not split, applies to the single drums Part.
|
||||
|
||||
Example::
|
||||
|
||||
score.set_drum_effects(reverb=0.2, reverb_type="plate")
|
||||
"""
|
||||
param_map = {"reverb": "reverb_mix", "delay": "delay_mix",
|
||||
"distortion": "distortion_mix", "chorus": "chorus_mix"}
|
||||
drum_parts = [p for p in self.parts.values() if p.is_drums]
|
||||
if not drum_parts:
|
||||
drum_parts = [self._ensure_drums_part()]
|
||||
for p in drum_parts:
|
||||
for k, v in kwargs.items():
|
||||
attr = param_map.get(k, k)
|
||||
setattr(p, attr, v)
|
||||
return self
|
||||
|
||||
def part(self, name: str, *, instrument: str = None,
|
||||
synth: str = None, envelope: str = None,
|
||||
volume: float = None,
|
||||
reverb: float = None, reverb_decay: float = None,
|
||||
reverb_type: str = None,
|
||||
delay: float = None, delay_time: float = None,
|
||||
delay_feedback: float = None,
|
||||
highpass: float = None, highpass_q: float = None,
|
||||
lowpass: float = None, lowpass_q: float = None,
|
||||
distortion: float = None, distortion_drive: float = None,
|
||||
legato: bool = None, glide: float = None,
|
||||
chorus: float = None, chorus_rate: float = None,
|
||||
chorus_depth: float = None,
|
||||
swing: Optional[float] = None,
|
||||
humanize: float = 0.0,
|
||||
sidechain: float = 0.0,
|
||||
sidechain_release: float = 0.1,
|
||||
detune: float = 0.0,
|
||||
pan: float = 0.0,
|
||||
spread: float = 0.0) -> Part:
|
||||
humanize: float = None,
|
||||
sidechain: float = None,
|
||||
sidechain_release: float = None,
|
||||
detune: float = None,
|
||||
pan: float = None,
|
||||
spread: float = None,
|
||||
# New synth engine params
|
||||
sub_osc: float = None,
|
||||
noise_mix: float = None,
|
||||
filter_attack: float = None,
|
||||
filter_decay: float = None,
|
||||
filter_sustain: float = None,
|
||||
filter_amount: float = None,
|
||||
vel_to_filter: float = None,
|
||||
saturation: float = None,
|
||||
tremolo_depth: float = None,
|
||||
tremolo_rate: float = None,
|
||||
phaser: float = None,
|
||||
phaser_rate: float = None,
|
||||
fm_ratio: float = None,
|
||||
fm_index: float = None) -> Part:
|
||||
"""Create a named part with its own synth voice and effects.
|
||||
|
||||
Args:
|
||||
name: Part name (e.g. ``"lead"``, ``"bass"``, ``"pads"``).
|
||||
instrument: Instrument preset name (e.g. ``"piano"``,
|
||||
``"violin"``, ``"808_bass"``). See :data:`INSTRUMENTS`
|
||||
for the full list. When set, the preset's synth, envelope,
|
||||
and effects are used as defaults; any explicit keyword
|
||||
argument still overrides the preset value.
|
||||
synth: Waveform — ``"sine"``, ``"saw"``, ``"triangle"``,
|
||||
``"square"``, ``"pulse"``, ``"fm"``, ``"noise"``,
|
||||
``"supersaw"``, ``"pwm_slow"``, ``"pwm_fast"``.
|
||||
@@ -1861,23 +2233,81 @@ class Score:
|
||||
|
||||
lead = score.part("lead", synth="saw", envelope="pluck",
|
||||
reverb=0.3, delay=0.25, lowpass=3000)
|
||||
|
||||
# Or use an instrument preset:
|
||||
piano = score.part("keys", instrument="piano")
|
||||
"""
|
||||
p = Part(name, synth=synth, envelope=envelope, volume=volume,
|
||||
reverb=reverb, reverb_decay=reverb_decay,
|
||||
reverb_type=reverb_type,
|
||||
delay=delay, delay_time=delay_time,
|
||||
delay_feedback=delay_feedback,
|
||||
lowpass=lowpass, lowpass_q=lowpass_q,
|
||||
distortion=distortion, distortion_drive=distortion_drive,
|
||||
legato=legato, glide=glide,
|
||||
chorus=chorus, chorus_rate=chorus_rate,
|
||||
chorus_depth=chorus_depth,
|
||||
swing=swing, humanize=humanize,
|
||||
sidechain=sidechain, sidechain_release=sidechain_release,
|
||||
detune=detune, pan=pan, spread=spread)
|
||||
# Default values for all Part parameters.
|
||||
_defaults = {
|
||||
"synth": "sine", "envelope": "piano", "volume": 0.5,
|
||||
"reverb": 0.0, "reverb_decay": 1.0, "reverb_type": "algorithmic",
|
||||
"delay": 0.0, "delay_time": 0.375, "delay_feedback": 0.4,
|
||||
"lowpass": 0.0, "lowpass_q": 0.707,
|
||||
"distortion": 0.0, "distortion_drive": 3.0,
|
||||
"legato": False, "glide": 0.0,
|
||||
"chorus": 0.0, "chorus_rate": 1.5, "chorus_depth": 0.003,
|
||||
"swing": None, "humanize": 0.0,
|
||||
"sidechain": 0.0, "sidechain_release": 0.1,
|
||||
"detune": 0.0, "pan": 0.0, "spread": 0.0,
|
||||
}
|
||||
|
||||
# If an instrument preset is specified, layer it on top of defaults.
|
||||
if instrument is not None:
|
||||
preset = INSTRUMENTS.get(instrument)
|
||||
if preset is None:
|
||||
raise ValueError(
|
||||
f"Unknown instrument: {instrument!r}. "
|
||||
f"Use Score.list_instruments() to see available presets."
|
||||
)
|
||||
_defaults.update(preset)
|
||||
|
||||
# Collect explicitly-provided kwargs (non-None) and override defaults.
|
||||
explicit = {}
|
||||
_locals = {
|
||||
"synth": synth, "envelope": envelope, "volume": volume,
|
||||
"reverb": reverb, "reverb_decay": reverb_decay,
|
||||
"reverb_type": reverb_type,
|
||||
"delay": delay, "delay_time": delay_time,
|
||||
"delay_feedback": delay_feedback,
|
||||
"highpass": highpass, "highpass_q": highpass_q,
|
||||
"lowpass": lowpass, "lowpass_q": lowpass_q,
|
||||
"distortion": distortion, "distortion_drive": distortion_drive,
|
||||
"legato": legato, "glide": glide,
|
||||
"chorus": chorus, "chorus_rate": chorus_rate,
|
||||
"chorus_depth": chorus_depth,
|
||||
"swing": swing, "humanize": humanize,
|
||||
"sidechain": sidechain, "sidechain_release": sidechain_release,
|
||||
"detune": detune, "pan": pan, "spread": spread,
|
||||
"sub_osc": sub_osc, "noise_mix": noise_mix,
|
||||
"filter_attack": filter_attack, "filter_decay": filter_decay,
|
||||
"filter_sustain": filter_sustain, "filter_amount": filter_amount,
|
||||
"vel_to_filter": vel_to_filter,
|
||||
"saturation": saturation,
|
||||
"tremolo_depth": tremolo_depth, "tremolo_rate": tremolo_rate,
|
||||
"phaser": phaser, "phaser_rate": phaser_rate,
|
||||
"fm_ratio": fm_ratio, "fm_index": fm_index,
|
||||
}
|
||||
for k, v in _locals.items():
|
||||
if v is not None:
|
||||
explicit[k] = v
|
||||
|
||||
merged = {**_defaults, **explicit}
|
||||
|
||||
p = Part(name, **merged)
|
||||
self.parts[name] = p
|
||||
return p
|
||||
|
||||
@classmethod
|
||||
def list_instruments(cls) -> list:
|
||||
"""Return a sorted list of available instrument preset names.
|
||||
|
||||
Example::
|
||||
|
||||
Score.list_instruments()
|
||||
# ['808_bass', 'acid_bass', 'acoustic_guitar', ...]
|
||||
"""
|
||||
return sorted(INSTRUMENTS.keys())
|
||||
|
||||
def add_pattern(self, pattern, repeats: int = 1) -> "Score":
|
||||
"""Add a drum pattern to this score.
|
||||
|
||||
@@ -1904,46 +2334,85 @@ class Score:
|
||||
fill_pattern = Pattern.fill(name)
|
||||
return self.add_pattern(fill_pattern, repeats=1)
|
||||
|
||||
def drums(self, preset: str, repeats: int = 4, fill: str = None,
|
||||
fill_every: int = None) -> "Score":
|
||||
"""Add a drum pattern by preset name, with optional auto-fills.
|
||||
|
||||
Shorthand for ``score.add_pattern(Pattern.preset(name), repeats=n)``.
|
||||
# Drum sound groups for split mode
|
||||
_DRUM_GROUPS = {
|
||||
"kick": {DrumSound.KICK.value},
|
||||
"snare": {DrumSound.SNARE.value, DrumSound.RIMSHOT.value, DrumSound.CLAP.value},
|
||||
"hats": {DrumSound.CLOSED_HAT.value, DrumSound.OPEN_HAT.value, DrumSound.PEDAL_HAT.value},
|
||||
"toms": {DrumSound.LOW_TOM.value, DrumSound.MID_TOM.value, DrumSound.HIGH_TOM.value},
|
||||
"cymbals": {DrumSound.CRASH.value, DrumSound.RIDE.value, DrumSound.RIDE_BELL.value},
|
||||
"percussion": {DrumSound.COWBELL.value, DrumSound.CLAVE.value, DrumSound.SHAKER.value,
|
||||
DrumSound.TAMBOURINE.value, DrumSound.CONGA_HIGH.value, DrumSound.CONGA_LOW.value,
|
||||
DrumSound.BONGO_HIGH.value, DrumSound.BONGO_LOW.value, DrumSound.TIMBALE_HIGH.value,
|
||||
DrumSound.TIMBALE_LOW.value, DrumSound.AGOGO_HIGH.value, DrumSound.AGOGO_LOW.value,
|
||||
DrumSound.GUIRO.value, DrumSound.MARACAS.value},
|
||||
}
|
||||
|
||||
def drums(self, preset: str, repeats: int = 4, fill: str = None,
|
||||
fill_every: int = None, split: bool = False) -> "Score":
|
||||
"""Add a drum pattern by preset name, with optional auto-fills.
|
||||
|
||||
Args:
|
||||
preset: Pattern preset name (e.g. ``"bossa nova"``, ``"rock"``).
|
||||
repeats: Number of times to repeat (default 4).
|
||||
fill: Optional fill name. When provided, groove bars are
|
||||
periodically replaced with the named fill pattern.
|
||||
fill_every: Replace every Nth bar with a fill. If *fill* is
|
||||
provided but *fill_every* is not, defaults to filling only
|
||||
the last bar.
|
||||
fill: Optional fill name.
|
||||
fill_every: Replace every Nth bar with a fill.
|
||||
split: If True, create separate Parts for kick, snare, hats,
|
||||
toms, cymbals, and percussion — each with independent
|
||||
effects. Access via ``score.parts["kick"]``, etc.
|
||||
|
||||
Returns:
|
||||
Self for chaining.
|
||||
|
||||
Example::
|
||||
|
||||
>>> score = Score("4/4", bpm=140)
|
||||
>>> score.drums("bossa nova", repeats=4)
|
||||
>>> score.drums("rock", repeats=4, split=True)
|
||||
>>> score.parts["snare"].reverb_mix = 0.3
|
||||
>>> score.parts["hats"].lowpass = 6000
|
||||
"""
|
||||
if fill is None:
|
||||
return self.add_pattern(Pattern.preset(preset), repeats=repeats)
|
||||
self.add_pattern(Pattern.preset(preset), repeats=repeats)
|
||||
else:
|
||||
groove = Pattern.preset(preset)
|
||||
fill_pattern = Pattern.fill(fill)
|
||||
if fill_every is None:
|
||||
fill_every = repeats
|
||||
for bar in range(1, repeats + 1):
|
||||
if bar % fill_every == 0:
|
||||
self.add_pattern(fill_pattern, repeats=1)
|
||||
else:
|
||||
self.add_pattern(groove, repeats=1)
|
||||
|
||||
groove = Pattern.preset(preset)
|
||||
fill_pattern = Pattern.fill(fill)
|
||||
if split:
|
||||
self._split_drums()
|
||||
|
||||
if fill_every is None:
|
||||
# Fill only the last bar
|
||||
fill_every = repeats
|
||||
|
||||
for bar in range(1, repeats + 1):
|
||||
if bar % fill_every == 0:
|
||||
self.add_pattern(fill_pattern, repeats=1)
|
||||
else:
|
||||
self.add_pattern(groove, repeats=1)
|
||||
return self
|
||||
|
||||
def _split_drums(self):
|
||||
"""Move drum hits from the 'drums' Part into separate group Parts."""
|
||||
drums_part = self.parts.get("drums")
|
||||
if not drums_part:
|
||||
return
|
||||
|
||||
all_hits = list(drums_part._drum_hits)
|
||||
pattern_beats = drums_part._drum_pattern_beats
|
||||
drums_part._drum_hits.clear()
|
||||
drums_part._drum_pattern_beats = 0.0
|
||||
|
||||
for group_name, sound_values in self._DRUM_GROUPS.items():
|
||||
group_hits = [h for h in all_hits if h.sound.value in sound_values]
|
||||
if group_hits:
|
||||
if group_name not in self.parts:
|
||||
self.parts[group_name] = Part(group_name, synth="sine", volume=0.7)
|
||||
p = self.parts[group_name]
|
||||
p._drum_hits.extend(group_hits)
|
||||
p._drum_pattern_beats = max(p._drum_pattern_beats, pattern_beats)
|
||||
|
||||
# Remove empty drums Part
|
||||
if not drums_part._drum_hits and "drums" in self.parts:
|
||||
del self.parts["drums"]
|
||||
|
||||
def add(self, tone_or_chord, duration=Duration.QUARTER) -> "Score":
|
||||
"""Add a note to the default (unnamed) part.
|
||||
|
||||
@@ -2163,3 +2632,297 @@ class Score:
|
||||
f.write(b"MTrk")
|
||||
f.write(struct.pack(">I", len(events)))
|
||||
f.write(events)
|
||||
|
||||
# ── MIDI Import ──────────────────────────────────────────────────────
|
||||
|
||||
@classmethod
|
||||
def from_midi(cls, path, synth="sine", envelope="pluck") -> "Score":
|
||||
"""Import a Standard MIDI File into a Score.
|
||||
|
||||
Reads notes, tempo, and time signature from any Type 0 or Type 1
|
||||
MIDI file. Each MIDI channel becomes a named Part. Channel 10
|
||||
(drums) becomes drum hits.
|
||||
|
||||
Args:
|
||||
path: Path to a .mid file.
|
||||
synth: Default synth for all parts (default "sine").
|
||||
envelope: Default envelope for all parts (default "pluck").
|
||||
|
||||
Returns:
|
||||
A Score with Parts populated from the MIDI data.
|
||||
|
||||
Example::
|
||||
|
||||
>>> score = Score.from_midi("song.mid")
|
||||
>>> score.parts["ch1"].synth = "saw"
|
||||
>>> score.parts["ch1"].reverb_mix = 0.3
|
||||
"""
|
||||
midi = _parse_midi(path)
|
||||
|
||||
# Compute BPM from tempo (microseconds per beat)
|
||||
bpm = round(60_000_000 / midi["tempo"])
|
||||
|
||||
# Build time signature string
|
||||
ts_num, ts_den = midi["time_sig"]
|
||||
ts_str = f"{ts_num}/{ts_den}"
|
||||
|
||||
score = cls(time_signature=ts_str, bpm=bpm)
|
||||
tpb = midi["ticks_per_beat"]
|
||||
|
||||
# Build reverse DrumSound lookup: MIDI note number -> DrumSound
|
||||
_drum_by_note = {}
|
||||
for ds in DrumSound:
|
||||
# First one wins (SHAKER and MARACAS both map to 70)
|
||||
if ds.value not in _drum_by_note:
|
||||
_drum_by_note[ds.value] = ds
|
||||
|
||||
# Collect note events per channel from all tracks
|
||||
# Each entry: (abs_tick, 'on'/'off', pitch, velocity)
|
||||
channel_events: dict[int, list] = {}
|
||||
for track_events in midi["tracks"]:
|
||||
for ev in track_events:
|
||||
abs_tick, etype, channel, data = ev
|
||||
if etype in ("note_on", "note_off"):
|
||||
if channel not in channel_events:
|
||||
channel_events[channel] = []
|
||||
channel_events[channel].append(ev)
|
||||
|
||||
for ch in sorted(channel_events.keys()):
|
||||
events = sorted(channel_events[ch], key=lambda e: e[0])
|
||||
is_drum = (ch == 9) # channel 10 in 0-indexed
|
||||
|
||||
if is_drum:
|
||||
# Convert to _Hit objects
|
||||
for ev in events:
|
||||
abs_tick, etype, channel, data = ev
|
||||
if etype == "note_on" and data["velocity"] > 0:
|
||||
pitch = data["pitch"]
|
||||
beat_pos = abs_tick / tpb
|
||||
velocity = data["velocity"]
|
||||
drum_sound = _drum_by_note.get(pitch)
|
||||
if drum_sound is not None:
|
||||
score._drum_hits.append(
|
||||
_Hit(drum_sound, beat_pos, velocity))
|
||||
else:
|
||||
# Melodic channel: pair note_on/note_off to get durations
|
||||
active: dict[int, tuple] = {} # pitch -> (on_tick, velocity)
|
||||
completed = [] # (beat_pos, pitch, velocity, duration_beats)
|
||||
|
||||
for ev in events:
|
||||
abs_tick, etype, channel_num, data = ev
|
||||
pitch = data["pitch"]
|
||||
vel = data["velocity"]
|
||||
|
||||
if etype == "note_on" and vel > 0:
|
||||
active[pitch] = (abs_tick, vel)
|
||||
else:
|
||||
# note_off or note_on with vel=0
|
||||
if pitch in active:
|
||||
on_tick, on_vel = active.pop(pitch)
|
||||
dur_ticks = abs_tick - on_tick
|
||||
if dur_ticks > 0:
|
||||
beat_pos = on_tick / tpb
|
||||
dur_beats = dur_ticks / tpb
|
||||
completed.append(
|
||||
(beat_pos, pitch, on_vel, dur_beats))
|
||||
|
||||
if not completed:
|
||||
continue
|
||||
|
||||
completed.sort(key=lambda x: (x[0], x[1]))
|
||||
|
||||
part_name = f"ch{ch + 1}"
|
||||
part = score.part(part_name, synth=synth, envelope=envelope)
|
||||
|
||||
# Walk through notes, inserting rests for gaps
|
||||
cursor = 0.0 # current beat position
|
||||
for beat_pos, pitch, velocity, dur_beats in completed:
|
||||
gap = beat_pos - cursor
|
||||
if gap > 0.001: # tolerance for floating point
|
||||
part.notes.append(Rest(_RawDuration(gap)))
|
||||
from .tones import Tone
|
||||
tone = Tone.from_midi(pitch)
|
||||
part.notes.append(
|
||||
Note(tone=tone, duration=_RawDuration(dur_beats),
|
||||
velocity=velocity))
|
||||
cursor = beat_pos + dur_beats
|
||||
|
||||
return score
|
||||
|
||||
|
||||
# ── MIDI File Parser ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _read_vlq(data, pos):
|
||||
"""Read a MIDI variable-length quantity.
|
||||
|
||||
Returns:
|
||||
(value, new_pos) tuple.
|
||||
"""
|
||||
value = 0
|
||||
while True:
|
||||
byte = data[pos]
|
||||
value = (value << 7) | (byte & 0x7F)
|
||||
pos += 1
|
||||
if not (byte & 0x80):
|
||||
break
|
||||
return value, pos
|
||||
|
||||
|
||||
def _parse_midi(path):
|
||||
"""Parse a Standard MIDI File (Type 0 or Type 1).
|
||||
|
||||
Returns a dict with:
|
||||
- ticks_per_beat: int
|
||||
- tempo: int (microseconds per beat, default 500000 = 120 bpm)
|
||||
- time_sig: (numerator, denominator)
|
||||
- tracks: list of lists of events
|
||||
|
||||
Each event is a tuple: (abs_tick, type_str, channel, data_dict)
|
||||
where type_str is 'note_on' or 'note_off' and data_dict has
|
||||
'pitch' and 'velocity' keys.
|
||||
"""
|
||||
with open(path, "rb") as f:
|
||||
raw = f.read()
|
||||
|
||||
pos = 0
|
||||
|
||||
# ── Header chunk ──
|
||||
if raw[pos:pos + 4] != b"MThd":
|
||||
raise ValueError("Not a MIDI file (missing MThd header)")
|
||||
pos += 4
|
||||
header_len = struct.unpack(">I", raw[pos:pos + 4])[0]
|
||||
pos += 4
|
||||
fmt, num_tracks, ticks_per_beat = struct.unpack(">HHH", raw[pos:pos + 6])
|
||||
pos += header_len # usually 6
|
||||
|
||||
if fmt > 1:
|
||||
raise ValueError(f"MIDI format {fmt} not supported (only 0 and 1)")
|
||||
|
||||
tempo = 500000 # default 120 BPM
|
||||
time_sig = (4, 4) # default
|
||||
tracks = []
|
||||
|
||||
# ── Track chunks ──
|
||||
for _ in range(num_tracks):
|
||||
if raw[pos:pos + 4] != b"MTrk":
|
||||
raise ValueError("Expected MTrk chunk")
|
||||
pos += 4
|
||||
track_len = struct.unpack(">I", raw[pos:pos + 4])[0]
|
||||
pos += 4
|
||||
track_end = pos + track_len
|
||||
|
||||
track_events = []
|
||||
abs_tick = 0
|
||||
running_status = 0
|
||||
|
||||
while pos < track_end:
|
||||
# Read delta time
|
||||
delta, pos = _read_vlq(raw, pos)
|
||||
abs_tick += delta
|
||||
|
||||
# Read event
|
||||
byte = raw[pos]
|
||||
|
||||
if byte == 0xFF:
|
||||
# Meta event
|
||||
pos += 1
|
||||
meta_type = raw[pos]
|
||||
pos += 1
|
||||
meta_len, pos = _read_vlq(raw, pos)
|
||||
meta_data = raw[pos:pos + meta_len]
|
||||
pos += meta_len
|
||||
|
||||
if meta_type == 0x51 and meta_len == 3:
|
||||
# Tempo: 3 bytes, microseconds per beat
|
||||
tempo = (meta_data[0] << 16) | (meta_data[1] << 8) | meta_data[2]
|
||||
elif meta_type == 0x58 and meta_len >= 2:
|
||||
# Time signature: nn dd cc bb
|
||||
ts_num = meta_data[0]
|
||||
ts_den = 2 ** meta_data[1]
|
||||
time_sig = (ts_num, ts_den)
|
||||
# End of track (0x2F) and others: just skip
|
||||
|
||||
elif byte == 0xF0 or byte == 0xF7:
|
||||
# SysEx event
|
||||
pos += 1
|
||||
sysex_len, pos = _read_vlq(raw, pos)
|
||||
pos += sysex_len
|
||||
|
||||
elif byte & 0x80:
|
||||
# Channel message with status byte
|
||||
status = byte
|
||||
running_status = status
|
||||
pos += 1
|
||||
msg_type = status & 0xF0
|
||||
channel = status & 0x0F
|
||||
|
||||
if msg_type == 0x90:
|
||||
# Note On
|
||||
pitch = raw[pos]; pos += 1
|
||||
vel = raw[pos]; pos += 1
|
||||
if vel == 0:
|
||||
track_events.append(
|
||||
(abs_tick, "note_off", channel,
|
||||
{"pitch": pitch, "velocity": 0}))
|
||||
else:
|
||||
track_events.append(
|
||||
(abs_tick, "note_on", channel,
|
||||
{"pitch": pitch, "velocity": vel}))
|
||||
elif msg_type == 0x80:
|
||||
# Note Off
|
||||
pitch = raw[pos]; pos += 1
|
||||
vel = raw[pos]; pos += 1
|
||||
track_events.append(
|
||||
(abs_tick, "note_off", channel,
|
||||
{"pitch": pitch, "velocity": vel}))
|
||||
elif msg_type in (0xA0, 0xB0, 0xE0):
|
||||
# Aftertouch, Control Change, Pitch Bend: 2 data bytes
|
||||
pos += 2
|
||||
elif msg_type in (0xC0, 0xD0):
|
||||
# Program Change, Channel Pressure: 1 data byte
|
||||
pos += 1
|
||||
else:
|
||||
# Unknown channel message, skip 2 bytes as safe default
|
||||
pos += 2
|
||||
else:
|
||||
# Running status (no status byte, reuse previous)
|
||||
if running_status == 0:
|
||||
# No previous status, skip byte
|
||||
pos += 1
|
||||
continue
|
||||
msg_type = running_status & 0xF0
|
||||
channel = running_status & 0x0F
|
||||
|
||||
if msg_type == 0x90:
|
||||
pitch = raw[pos]; pos += 1
|
||||
vel = raw[pos]; pos += 1
|
||||
if vel == 0:
|
||||
track_events.append(
|
||||
(abs_tick, "note_off", channel,
|
||||
{"pitch": pitch, "velocity": 0}))
|
||||
else:
|
||||
track_events.append(
|
||||
(abs_tick, "note_on", channel,
|
||||
{"pitch": pitch, "velocity": vel}))
|
||||
elif msg_type == 0x80:
|
||||
pitch = raw[pos]; pos += 1
|
||||
vel = raw[pos]; pos += 1
|
||||
track_events.append(
|
||||
(abs_tick, "note_off", channel,
|
||||
{"pitch": pitch, "velocity": vel}))
|
||||
elif msg_type in (0xA0, 0xB0, 0xE0):
|
||||
pos += 2
|
||||
elif msg_type in (0xC0, 0xD0):
|
||||
pos += 1
|
||||
else:
|
||||
pos += 2
|
||||
|
||||
tracks.append(track_events)
|
||||
|
||||
return {
|
||||
"ticks_per_beat": ticks_per_beat,
|
||||
"tempo": tempo,
|
||||
"time_sig": time_sig,
|
||||
"tracks": tracks,
|
||||
}
|
||||
|
||||
+204
-2
@@ -4248,7 +4248,7 @@ def test_parallel_modes_g_major():
|
||||
@needs_portaudio
|
||||
def test_envelope_enum_presets():
|
||||
from pytheory.play import Envelope
|
||||
assert len(Envelope) == 8
|
||||
assert len(Envelope) == 10
|
||||
for e in Envelope:
|
||||
a, d, s, r = e.value
|
||||
assert a >= 0
|
||||
@@ -5312,7 +5312,7 @@ def test_supersaw_wave():
|
||||
@needs_portaudio
|
||||
def test_all_synths_in_enum():
|
||||
from pytheory.play import Synth
|
||||
assert len(Synth) == 10
|
||||
assert len(Synth) == 13
|
||||
for s in Synth:
|
||||
wave = s(440, n_samples=1000)
|
||||
assert len(wave) == 1000
|
||||
@@ -6324,3 +6324,205 @@ def test_recommend_fitness_descending():
|
||||
results = Scale.recommend("C", "D", "E", "F#", "G")
|
||||
for i in range(len(results) - 1):
|
||||
assert results[i][2] >= results[i + 1][2]
|
||||
|
||||
|
||||
# ── MIDI Import (Score.from_midi) ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_from_midi_basic(tmp_path):
|
||||
"""Create a simple MIDI with save_midi, re-import with from_midi."""
|
||||
from pytheory import Score, Duration, Tone
|
||||
score = Score("4/4", bpm=120)
|
||||
score.add(Tone.from_string("C4"), Duration.QUARTER)
|
||||
score.add(Tone.from_string("E4"), Duration.QUARTER)
|
||||
score.add(Tone.from_string("G4"), Duration.QUARTER)
|
||||
|
||||
midi_path = str(tmp_path / "basic.mid")
|
||||
score.save_midi(midi_path)
|
||||
|
||||
imported = Score.from_midi(midi_path)
|
||||
# Should have at least one part with notes
|
||||
assert len(imported.parts) >= 1
|
||||
total_notes = sum(
|
||||
1 for p in imported.parts.values()
|
||||
for n in p.notes if n.tone is not None
|
||||
)
|
||||
assert total_notes == 3
|
||||
|
||||
|
||||
def test_from_midi_tempo(tmp_path):
|
||||
"""Verify BPM is preserved through save/import."""
|
||||
from pytheory import Score, Duration, Tone
|
||||
score = Score("4/4", bpm=140)
|
||||
score.add(Tone.from_string("A4"), Duration.QUARTER)
|
||||
|
||||
midi_path = str(tmp_path / "tempo.mid")
|
||||
score.save_midi(midi_path)
|
||||
|
||||
imported = Score.from_midi(midi_path)
|
||||
assert imported.bpm == 140
|
||||
|
||||
|
||||
def test_from_midi_roundtrip(tmp_path):
|
||||
"""Save a progression as MIDI, import it, check parts/notes."""
|
||||
from pytheory import Score, Duration, Tone
|
||||
score = Score("3/4", bpm=100)
|
||||
score.add(Tone.from_string("C4"), Duration.QUARTER)
|
||||
score.add(Tone.from_string("D4"), Duration.QUARTER)
|
||||
score.add(Tone.from_string("E4"), Duration.QUARTER)
|
||||
score.add(Tone.from_string("F4"), Duration.QUARTER)
|
||||
|
||||
midi_path = str(tmp_path / "roundtrip.mid")
|
||||
score.save_midi(midi_path)
|
||||
|
||||
imported = Score.from_midi(midi_path)
|
||||
assert imported.bpm == 100
|
||||
assert imported.time_signature == TimeSignature(3, 4)
|
||||
total_notes = sum(
|
||||
1 for p in imported.parts.values()
|
||||
for n in p.notes if n.tone is not None
|
||||
)
|
||||
assert total_notes == 4
|
||||
|
||||
|
||||
def test_from_midi_velocity(tmp_path):
|
||||
"""Verify velocity is preserved through save/import."""
|
||||
from pytheory import Score, Duration, Tone
|
||||
score = Score("4/4", bpm=120)
|
||||
# save_midi uses a fixed velocity param, default 100
|
||||
score.add(Tone.from_string("C4"), Duration.QUARTER)
|
||||
score.add(Tone.from_string("E4"), Duration.HALF)
|
||||
|
||||
midi_path = str(tmp_path / "velocity.mid")
|
||||
score.save_midi(midi_path, velocity=80)
|
||||
|
||||
imported = Score.from_midi(midi_path)
|
||||
sounding = [
|
||||
n for p in imported.parts.values()
|
||||
for n in p.notes if n.tone is not None
|
||||
]
|
||||
assert len(sounding) == 2
|
||||
for n in sounding:
|
||||
assert n.velocity == 80
|
||||
|
||||
|
||||
def test_from_midi_drums(tmp_path):
|
||||
"""Verify drum hits survive a roundtrip."""
|
||||
from pytheory import Score, Pattern
|
||||
score = Score("4/4", bpm=120)
|
||||
score.add_pattern(Pattern.preset("rock"), repeats=1)
|
||||
|
||||
midi_path = str(tmp_path / "drums.mid")
|
||||
score.save_midi(midi_path)
|
||||
|
||||
imported = Score.from_midi(midi_path)
|
||||
assert len(imported._drum_hits) > 0
|
||||
|
||||
|
||||
def test_from_midi_time_signature(tmp_path):
|
||||
"""Verify time signature is preserved."""
|
||||
from pytheory import Score, Duration, Tone
|
||||
score = Score("6/8", bpm=150)
|
||||
score.add(Tone.from_string("C4"), Duration.QUARTER)
|
||||
|
||||
midi_path = str(tmp_path / "timesig.mid")
|
||||
score.save_midi(midi_path)
|
||||
|
||||
imported = Score.from_midi(midi_path)
|
||||
assert imported.time_signature == TimeSignature(6, 8)
|
||||
assert imported.bpm == 150
|
||||
|
||||
|
||||
def test_from_midi_note_durations(tmp_path):
|
||||
"""Verify note durations are approximately preserved."""
|
||||
from pytheory import Score, Duration, Tone
|
||||
score = Score("4/4", bpm=120)
|
||||
score.add(Tone.from_string("C4"), Duration.WHOLE) # 4 beats
|
||||
score.add(Tone.from_string("E4"), Duration.HALF) # 2 beats
|
||||
|
||||
midi_path = str(tmp_path / "durations.mid")
|
||||
score.save_midi(midi_path)
|
||||
|
||||
imported = Score.from_midi(midi_path)
|
||||
sounding = [
|
||||
n for p in imported.parts.values()
|
||||
for n in p.notes if n.tone is not None
|
||||
]
|
||||
assert len(sounding) == 2
|
||||
assert abs(sounding[0].beats - 4.0) < 0.01
|
||||
assert abs(sounding[1].beats - 2.0) < 0.01
|
||||
|
||||
|
||||
# ── Instrument presets ────────────────────────────────────────────────────────
|
||||
|
||||
def test_instrument_piano():
|
||||
from pytheory import Score, Duration
|
||||
score = Score("4/4", bpm=120)
|
||||
p = score.part("p", instrument="piano")
|
||||
assert p.synth == "fm"
|
||||
assert p.envelope == "piano"
|
||||
assert p.detune == 5
|
||||
assert p.lowpass == 6000
|
||||
assert p.chorus_mix == 0.1
|
||||
|
||||
|
||||
def test_instrument_violin():
|
||||
from pytheory import Score
|
||||
score = Score("4/4", bpm=120)
|
||||
p = score.part("v", instrument="violin")
|
||||
assert p.synth == "strings_synth"
|
||||
assert p.envelope == "bowed"
|
||||
assert p.humanize == 0.15
|
||||
assert p.lowpass == 5000
|
||||
assert p.detune == 2
|
||||
|
||||
|
||||
def test_instrument_override():
|
||||
from pytheory import Score
|
||||
score = Score("4/4", bpm=120)
|
||||
# Explicit synth overrides the preset's "fm"
|
||||
p = score.part("p", instrument="piano", synth="saw")
|
||||
assert p.synth == "saw"
|
||||
# Other preset values still apply
|
||||
assert p.envelope == "piano"
|
||||
assert p.detune == 5
|
||||
|
||||
|
||||
def test_instrument_unknown_raises():
|
||||
from pytheory import Score
|
||||
score = Score("4/4", bpm=120)
|
||||
with pytest.raises(ValueError, match="Unknown instrument"):
|
||||
score.part("x", instrument="kazoo")
|
||||
|
||||
|
||||
def test_list_instruments():
|
||||
from pytheory import Score, INSTRUMENTS
|
||||
result = Score.list_instruments()
|
||||
assert isinstance(result, list)
|
||||
assert result == sorted(result)
|
||||
assert "piano" in result
|
||||
assert "violin" in result
|
||||
assert "808_bass" in result
|
||||
assert len(result) == len(INSTRUMENTS)
|
||||
|
||||
|
||||
def test_instrument_effects():
|
||||
from pytheory import Score
|
||||
score = Score("4/4", bpm=120)
|
||||
p = score.part("c", instrument="celesta")
|
||||
assert p.reverb_mix == 0.3
|
||||
assert p.reverb_type == "plate"
|
||||
assert p.synth == "fm"
|
||||
assert p.envelope == "mallet"
|
||||
|
||||
|
||||
def test_instrument_808_bass():
|
||||
from pytheory import Score
|
||||
score = Score("4/4", bpm=120)
|
||||
p = score.part("b", instrument="808_bass")
|
||||
assert p.distortion_mix == 0.4
|
||||
assert p.distortion_drive == 2.5
|
||||
assert p.lowpass == 200
|
||||
assert p.lowpass_q == 1.5
|
||||
assert p.synth == "sine"
|
||||
assert p.envelope == "pluck"
|
||||
|
||||
Reference in New Issue
Block a user