mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 23:00:20 +00:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a6942c8e4 | |||
| db7fabf985 | |||
| a07b7e7cea | |||
| 7245cd0e51 | |||
| 9e85a48d0e | |||
| 95b7bd830c | |||
| 150c57ed3d | |||
| d35d2b12f3 | |||
| 2084473788 | |||
| 970c730012 | |||
| 5f94e1939b | |||
| b649b2e659 | |||
| ed6ba2ab9f | |||
| fd317f9cfd | |||
| c57e29fe28 | |||
| 938024bfa2 | |||
| acc92f9a60 | |||
| 0d340dad30 | |||
| 1762500108 | |||
| ac2801d07d | |||
| c49ec27b1b | |||
| 5f4070c4a7 | |||
| 8735393aaa | |||
| 12f15d5138 | |||
| 20fc5e40b8 | |||
| 91d16595b7 | |||
| 54659d39b1 | |||
| 7cb2c166f9 | |||
| ba2038d7ff | |||
| 198fded20e | |||
| 51159e309a | |||
| 54df949089 | |||
| 30c70da468 | |||
| c633bd6f61 | |||
| bc652c37d0 | |||
| 417d9a6908 | |||
| f7d8f08446 |
@@ -2,6 +2,15 @@
|
||||
|
||||
All notable changes to PyTheory are documented here.
|
||||
|
||||
## 0.40.1
|
||||
|
||||
- **Singing bowl synth** — two variants: strike (mallet hit with chirp
|
||||
and long decay) and ring (rim-rubbed sustained tone with slow build).
|
||||
Inharmonic partials beat against near-degenerate mode pairs for
|
||||
authentic Himalayan bowl shimmer.
|
||||
- `singing_bowl` and `singing_bowl_ring` instrument presets
|
||||
- Audio demos in docs for both variants
|
||||
|
||||
## 0.40.0
|
||||
|
||||
- **Rhodes electric piano synth** — tine + tonebar + electromagnetic
|
||||
|
||||
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -662,6 +662,41 @@ def gen_synth_kalimba():
|
||||
p.add(n, Duration.QUARTER, velocity=85)
|
||||
render("synth_kalimba", score)
|
||||
|
||||
def gen_synth_wurlitzer():
|
||||
score = Score("4/4", bpm=90)
|
||||
p = score.part("demo", instrument="wurlitzer", volume=0.5, reverb=0.25)
|
||||
p.hold("C3", Duration.WHOLE * 2, velocity=60)
|
||||
p.hold("Eb3", Duration.WHOLE * 2, velocity=55)
|
||||
p.hold("G3", Duration.WHOLE * 2, velocity=55)
|
||||
for n in ["G4", "Bb4", "C5", "Bb4", "G4", "F4", "Eb4", "G4"]:
|
||||
p.add(n, Duration.QUARTER, velocity=78)
|
||||
render("synth_wurlitzer", score)
|
||||
|
||||
def gen_synth_vibraphone():
|
||||
score = Score("4/4", bpm=90)
|
||||
p = score.part("demo", instrument="vibraphone", volume=0.5)
|
||||
for n in ["C5", "E5", "G5", "C6", "G5", "E5", "C5", "E5"]:
|
||||
p.add(n, Duration.QUARTER, velocity=75)
|
||||
render("synth_vibraphone", score)
|
||||
|
||||
def gen_synth_pipe_organ():
|
||||
score = Score("4/4", bpm=70)
|
||||
p = score.part("demo", instrument="pipe_organ", volume=0.5)
|
||||
p.hold("C3", Duration.WHOLE * 4, velocity=70)
|
||||
p.hold("G3", Duration.WHOLE * 4, velocity=65)
|
||||
for n in ["C4", "D4", "E4", "F4", "G4", "F4", "E4", "D4",
|
||||
"C4", "E4", "G4", "C5", "G4", "E4", "C4", "C4"]:
|
||||
p.add(n, Duration.QUARTER, velocity=75)
|
||||
render("synth_pipe_organ", score)
|
||||
|
||||
def gen_synth_choir():
|
||||
score = Score("4/4", bpm=70)
|
||||
p = score.part("demo", instrument="choir", volume=0.5)
|
||||
for n, v in [("C4", "ah"), ("E4", "oh"), ("G4", "ah"), ("C5", "ee"),
|
||||
("G4", "oh"), ("E4", "ah"), ("C4", "oo"), ("C4", "ah")]:
|
||||
p.add(n, Duration.HALF, velocity=70, lyric=v)
|
||||
render("synth_choir", score)
|
||||
|
||||
def gen_synth_organ():
|
||||
_synth_demo("organ", "organ_synth", envelope="organ")
|
||||
|
||||
@@ -823,6 +858,24 @@ def gen_synth_granular():
|
||||
render("synth_granular", score)
|
||||
|
||||
|
||||
def gen_synth_singing_bowl_strike():
|
||||
score = Score("4/4", bpm=40)
|
||||
p = score.part("demo", synth="singing_bowl_strike_synth", envelope="none",
|
||||
volume=0.5, reverb=0.4)
|
||||
for n in ["A3", "D4", "F4", "A4"]:
|
||||
p.add(n, Duration.WHOLE, velocity=80)
|
||||
render("synth_singing_bowl_strike", score)
|
||||
|
||||
|
||||
def gen_synth_singing_bowl_ring():
|
||||
score = Score("4/4", bpm=30)
|
||||
p = score.part("demo", synth="singing_bowl_ring_synth", envelope="none",
|
||||
volume=0.5, reverb=0.4)
|
||||
for n in ["A3", "D4", "A4"]:
|
||||
p.add(n, Duration.WHOLE * 2, velocity=75)
|
||||
render("synth_singing_bowl_ring", score)
|
||||
|
||||
|
||||
def gen_arpeggio():
|
||||
score = Score("4/4", bpm=132)
|
||||
score.drums("house", repeats=8)
|
||||
@@ -1004,6 +1057,10 @@ GENERATORS = [
|
||||
gen_synth_electric_guitar,
|
||||
gen_synth_sitar,
|
||||
gen_synth_kalimba,
|
||||
gen_synth_wurlitzer,
|
||||
gen_synth_vibraphone,
|
||||
gen_synth_pipe_organ,
|
||||
gen_synth_choir,
|
||||
gen_synth_organ,
|
||||
gen_synth_marimba,
|
||||
gen_synth_harp,
|
||||
@@ -1021,6 +1078,8 @@ GENERATORS = [
|
||||
gen_synth_mandolin,
|
||||
gen_synth_ukulele,
|
||||
gen_synth_granular,
|
||||
gen_synth_singing_bowl_strike,
|
||||
gen_synth_singing_bowl_ring,
|
||||
gen_arpeggio,
|
||||
gen_legato_glide,
|
||||
gen_acid_house,
|
||||
|
||||
+100
-1
@@ -479,6 +479,72 @@ singing sustain. The sound of jazz clubs, soul, and neo-soul.
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_rhodes.wav" type="audio/wav"></audio>
|
||||
|
||||
Wurlitzer Electric Piano
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The Wurlitzer uses a vibrating steel reed (not a tine like Rhodes)
|
||||
picked up by an electrostatic pickup. More nasal, reedy, and biting
|
||||
— it barks and growls when played hard. Think Supertramp, Ray Charles.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
wurli = score.part("wurli", synth="wurlitzer_synth")
|
||||
wurli = score.part("wurli", instrument="wurlitzer")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_wurlitzer.wav" type="audio/wav"></audio>
|
||||
|
||||
Vibraphone Synth
|
||||
~~~~~~~~~~~~~~~~
|
||||
|
||||
Struck aluminum bars with motor-driven tremolo discs. The spinning
|
||||
motor modulates the sound through the resonator tubes, creating the
|
||||
signature vibraphone shimmer. Inharmonic bar modes at 1x, 2.76x, 5.4x.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
vib = score.part("vib", synth="vibraphone_synth")
|
||||
vib = score.part("vib", instrument="vibraphone")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_vibraphone.wav" type="audio/wav"></audio>
|
||||
|
||||
Pipe Organ Synth
|
||||
~~~~~~~~~~~~~~~~
|
||||
|
||||
Multiple ranks of pipes — principal 8', octave 4', fifteenth 2'.
|
||||
Constant air pressure means no dynamics. Wind chiff at the attack.
|
||||
Best with cathedral reverb.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
organ = score.part("organ", synth="pipe_organ_synth")
|
||||
organ = score.part("organ", instrument="pipe_organ")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_pipe_organ.wav" type="audio/wav"></audio>
|
||||
|
||||
Choir Synth
|
||||
~~~~~~~~~~~
|
||||
|
||||
Voices singing vowels shaped by formant bandpass filters. The glottal
|
||||
source is filtered through vocal tract resonances — F1, F2, F3, F4 —
|
||||
which is what makes "ah" sound different from "oo". Use ``lyric=``
|
||||
to control the vowel. Best with ``ensemble=`` for a full section.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
choir = score.part("choir", synth="choir_synth")
|
||||
choir = score.part("choir", instrument="choir") # ensemble=6 + cathedral reverb
|
||||
choir.add("C4", Duration.WHOLE, lyric="ah")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_choir.wav" type="audio/wav"></audio>
|
||||
|
||||
Bass Guitar Synth
|
||||
~~~~~~~~~~~~~~~~~
|
||||
|
||||
@@ -864,6 +930,39 @@ Parameters (passed as synth kwargs):
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_granular.wav" type="audio/wav"></audio>
|
||||
|
||||
Singing Bowl (Strike)
|
||||
~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Tibetan/Himalayan singing bowl struck with a mallet. The impact excites
|
||||
inharmonic partials that ring and slowly beat against each other as
|
||||
near-degenerate mode pairs interfere. Higher modes fade quickly, leaving
|
||||
the fundamental shimmering for seconds.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
bowl = score.part("bowl", synth="singing_bowl_strike_synth", envelope="none",
|
||||
reverb=0.4)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_singing_bowl_strike.wav" type="audio/wav"></audio>
|
||||
|
||||
Singing Bowl (Ring)
|
||||
~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Rim-rubbed singing bowl — the mallet traces the rim, slowly building the
|
||||
fundamental into a sustained, pulsing tone. Upper harmonics shimmer in
|
||||
and out as the bowl resonates.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
bowl = score.part("bowl", synth="singing_bowl_ring_synth", envelope="none",
|
||||
reverb=0.4)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_singing_bowl_ring.wav" type="audio/wav"></audio>
|
||||
|
||||
Analog Oscillator Drift
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
@@ -919,7 +1018,7 @@ distorted_guitar, orange_crunch, metal_guitar, bass_guitar, upright_bass,
|
||||
harp, sitar, koto, banjo, mandolin, mandola, ukulele
|
||||
|
||||
**World/Exotic**: pedal_steel, theremin, kalimba, steel_drum, didgeridoo,
|
||||
bagpipe
|
||||
bagpipe, singing_bowl, singing_bowl_ring
|
||||
|
||||
**Synth**: synth_lead, synth_pad, synth_bass, acid_bass, 808_bass,
|
||||
granular_pad, granular_texture, vocal, choir
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Play a recorded MIDI file through pytheory's full renderer.
|
||||
|
||||
Takes a MIDI file captured by the live engine and plays it back
|
||||
through the complete synthesis pipeline — with ensemble, effects,
|
||||
reverb, and master compression.
|
||||
|
||||
Usage:
|
||||
python play_recording.py recording.mid
|
||||
python play_recording.py recording.mid --bpm 110
|
||||
"""
|
||||
|
||||
import sys
|
||||
import sounddevice as sd
|
||||
|
||||
from pytheory import Score
|
||||
from pytheory.play import render_score, SAMPLE_RATE
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(" Usage: python play_recording.py <file.mid> [--bpm N]")
|
||||
return
|
||||
|
||||
filename = sys.argv[1]
|
||||
bpm = None
|
||||
if "--bpm" in sys.argv:
|
||||
idx = sys.argv.index("--bpm")
|
||||
if idx + 1 < len(sys.argv):
|
||||
bpm = int(sys.argv[idx + 1])
|
||||
|
||||
print(f" Loading {filename}...")
|
||||
score = Score.from_midi(filename)
|
||||
|
||||
if bpm:
|
||||
score.bpm = bpm
|
||||
|
||||
print(f" {score}")
|
||||
print(f" Rendering...")
|
||||
|
||||
buf = render_score(score)
|
||||
duration = len(buf) / SAMPLE_RATE
|
||||
|
||||
print(f" Playing ({duration:.1f}s)...")
|
||||
try:
|
||||
sd.play(buf, SAMPLE_RATE)
|
||||
sd.wait()
|
||||
except KeyboardInterrupt:
|
||||
sd.stop()
|
||||
print("\n Stopped.")
|
||||
|
||||
print(" Done.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "pytheory"
|
||||
version = "0.40.0"
|
||||
version = "0.40.1"
|
||||
description = "Music Theory for Humans"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
@@ -23,6 +23,7 @@ classifiers = [
|
||||
dependencies = [
|
||||
"sounddevice",
|
||||
"scipy",
|
||||
"rich>=14.3.3",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""PyTheory: Music Theory for Humans."""
|
||||
|
||||
__version__ = "0.40.0"
|
||||
__version__ = "0.40.1"
|
||||
|
||||
from .tones import Tone, Interval
|
||||
from .systems import System, SYSTEMS, TET
|
||||
|
||||
@@ -0,0 +1,826 @@
|
||||
"""Real-time MIDI-driven synthesis engine.
|
||||
|
||||
Listens for MIDI input (e.g. from an OP-XY, keyboard, or DAW) and
|
||||
synthesizes audio in real-time through pytheory's synth engine.
|
||||
|
||||
Usage::
|
||||
|
||||
from pytheory.live import LiveEngine
|
||||
|
||||
engine = LiveEngine()
|
||||
engine.channel(1, instrument="electric_piano")
|
||||
engine.channel(2, instrument="bass_guitar", lowpass=800)
|
||||
engine.channel(10, drums=True)
|
||||
engine.start() # blocks until Ctrl-C
|
||||
|
||||
Note: sustained notes are pre-rendered to a 3-second wavetable.
|
||||
Instruments requiring longer sustain (pads, organ) will cut off
|
||||
after 3 seconds. This is a known limitation of the current
|
||||
wavetable approach.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import numpy
|
||||
import sounddevice as sd
|
||||
|
||||
try:
|
||||
import rtmidi
|
||||
except ImportError:
|
||||
rtmidi = None
|
||||
|
||||
from .play import (
|
||||
_SYNTH_FUNCTIONS, _resolve_synth, _resolve_envelope,
|
||||
_apply_envelope, _apply_lowpass, _render_drum_hit_cached,
|
||||
SAMPLE_RATE, SAMPLE_PEAK,
|
||||
)
|
||||
from .rhythm import INSTRUMENTS, DrumSound
|
||||
|
||||
|
||||
# ── Voice ────────────────────────────────────────────────────────────────
|
||||
|
||||
class _Voice:
|
||||
"""A single sounding note - holds a pre-rendered wavetable and
|
||||
tracks playback position + envelope state."""
|
||||
__slots__ = ('active', 'note', 'pitch_ratio', 'pos', 'release_len',
|
||||
'release_pos', 'releasing', 'velocity', 'wave')
|
||||
|
||||
def __init__(self, wave, velocity, note):
|
||||
self.wave = wave # float32 array - one shot
|
||||
self.pos = 0.0 # current read position (float for pitch bend)
|
||||
self.velocity = velocity
|
||||
self.active = True
|
||||
self.releasing = False
|
||||
self.release_pos = 0
|
||||
self.release_len = int(SAMPLE_RATE * 0.05) # 50ms release
|
||||
self.note = note # MIDI note number
|
||||
self.pitch_ratio = 1.0 # 1.0 = normal, >1 = up, <1 = down
|
||||
|
||||
|
||||
# ── Channel ──────────────────────────────────────────────────────────────
|
||||
|
||||
class _Channel:
|
||||
"""One MIDI channel - has a synth, effects, and a voice pool."""
|
||||
|
||||
def __init__(self, synth_name="sine", envelope_name="piano",
|
||||
is_drums=False, max_voices=12, **kwargs):
|
||||
self.synth_fn = _resolve_synth(synth_name)
|
||||
self.synth_name = synth_name
|
||||
self.envelope_name = envelope_name
|
||||
self.env_tuple = _resolve_envelope(envelope_name)
|
||||
self.is_drums = is_drums
|
||||
self.max_voices = max_voices
|
||||
self.kwargs = kwargs
|
||||
self.lowpass = kwargs.get('lowpass', 0)
|
||||
self.lowpass_q = kwargs.get('lowpass_q', 0.707)
|
||||
self.reverb = kwargs.get('reverb', 0)
|
||||
self.volume = kwargs.get('volume', 0.5)
|
||||
self.pan = kwargs.get('pan', 0.0) # -1 left, 0 center, 1 right
|
||||
self.chorus = kwargs.get('chorus', 0)
|
||||
self.detune = kwargs.get('detune', 0)
|
||||
self.spread = kwargs.get('spread', 0)
|
||||
self.analog = kwargs.get('analog', 0)
|
||||
self.distortion = kwargs.get('distortion', 0)
|
||||
self.delay = kwargs.get('delay', 0)
|
||||
self.tremolo_depth = kwargs.get('tremolo_depth', 0)
|
||||
self.saturation = kwargs.get('saturation', 0)
|
||||
self.phaser = kwargs.get('phaser', 0)
|
||||
self.sub_osc = kwargs.get('sub_osc', 0)
|
||||
self.noise_mix = kwargs.get('noise_mix', 0)
|
||||
|
||||
self.voices = [] # active _Voice objects
|
||||
self._cache = {} # MIDI note -> pre-rendered wave
|
||||
self._lock = threading.Lock()
|
||||
self.level = 0.0 # current output level (for VU meter)
|
||||
|
||||
def _get_wave(self, midi_note, n_samples):
|
||||
"""Get or render a waveform for a MIDI note."""
|
||||
if self.is_drums:
|
||||
return _render_drum_hit_cached(midi_note, n_samples)
|
||||
|
||||
if midi_note in self._cache:
|
||||
cached = self._cache[midi_note]
|
||||
if len(cached) >= n_samples:
|
||||
return cached[:n_samples]
|
||||
|
||||
hz = 440.0 * (2 ** ((midi_note - 69) / 12.0))
|
||||
|
||||
# Synth kwargs
|
||||
skw = {}
|
||||
if self.synth_name in ("fm",):
|
||||
skw["mod_ratio"] = self.kwargs.get("fm_ratio", 2.0)
|
||||
skw["mod_index"] = self.kwargs.get("fm_index", 3.0)
|
||||
|
||||
wave = self.synth_fn(hz, n_samples=n_samples, **skw)
|
||||
wave_f = wave.astype(numpy.float32) / SAMPLE_PEAK
|
||||
|
||||
# Apply envelope
|
||||
a, d, s, r = self.env_tuple
|
||||
if a > 0 or d > 0 or s < 1.0 or r > 0:
|
||||
wave_f = _apply_envelope(wave_f, a, d, s, r)
|
||||
|
||||
# Apply lowpass
|
||||
if self.lowpass > 0:
|
||||
wave_f = _apply_lowpass(wave_f, self.lowpass, q=self.lowpass_q)
|
||||
|
||||
# Apply reverb - simple feedback delay for real-time
|
||||
if self.reverb > 0:
|
||||
wet = self.reverb
|
||||
delay_samples = int(SAMPLE_RATE * 0.03) # 30ms early reflection
|
||||
delay2 = int(SAMPLE_RATE * 0.047) # second tap
|
||||
delay3 = int(SAMPLE_RATE * 0.071) # third tap
|
||||
reverbed = wave_f.copy()
|
||||
for delay, gain in [(delay_samples, 0.4), (delay2, 0.3), (delay3, 0.2)]:
|
||||
if delay < len(reverbed):
|
||||
reverbed[delay:] += wave_f[:-delay] * gain
|
||||
# Feedback loop for tail
|
||||
fb_delay = int(SAMPLE_RATE * 0.05)
|
||||
feedback = 0.35
|
||||
for _ in range(6):
|
||||
if fb_delay < len(reverbed):
|
||||
reverbed[fb_delay:] += reverbed[:-fb_delay] * feedback
|
||||
feedback *= 0.7
|
||||
fb_delay = int(fb_delay * 1.5)
|
||||
wave_f = wave_f * (1.0 - wet) + reverbed * wet
|
||||
|
||||
# Apply distortion/saturation
|
||||
if self.distortion > 0:
|
||||
drive = 3.0
|
||||
wave_f = numpy.tanh(wave_f * drive * (1 + self.distortion * 3)) / drive
|
||||
if self.saturation > 0:
|
||||
wave_f = numpy.tanh(wave_f * (1 + self.saturation * 2))
|
||||
|
||||
# Apply tremolo
|
||||
if self.tremolo_depth > 0:
|
||||
t = numpy.arange(len(wave_f), dtype=numpy.float32) / SAMPLE_RATE
|
||||
trem = 1.0 - self.tremolo_depth * 0.5 * (1 + numpy.sin(2 * numpy.pi * 5.0 * t))
|
||||
wave_f *= trem
|
||||
|
||||
# Apply chorus (simple delay modulation)
|
||||
if self.chorus > 0:
|
||||
t = numpy.arange(len(wave_f), dtype=numpy.float32) / SAMPLE_RATE
|
||||
mod = (numpy.sin(2 * numpy.pi * 1.5 * t) * 0.002 * SAMPLE_RATE).astype(int)
|
||||
chorus_buf = numpy.zeros_like(wave_f)
|
||||
for i in range(len(wave_f)):
|
||||
idx = i - abs(mod[i]) - int(SAMPLE_RATE * 0.015)
|
||||
if 0 <= idx < len(wave_f):
|
||||
chorus_buf[i] = wave_f[idx]
|
||||
wave_f = wave_f * (1 - self.chorus * 0.5) + chorus_buf * self.chorus * 0.5
|
||||
|
||||
self._cache[midi_note] = wave_f
|
||||
return wave_f
|
||||
|
||||
def note_on(self, midi_note, velocity):
|
||||
"""Start a new voice."""
|
||||
vel_scale = velocity / 127.0
|
||||
# Render 3 seconds of audio (extra for reverb tail)
|
||||
n_samples = SAMPLE_RATE * 3
|
||||
wave = self._get_wave(midi_note, n_samples)
|
||||
|
||||
with self._lock:
|
||||
# Voice stealing - kill oldest if at max
|
||||
if len(self.voices) >= self.max_voices:
|
||||
self.voices.pop(0)
|
||||
self.voices.append(_Voice(wave, vel_scale, midi_note))
|
||||
|
||||
def note_off(self, midi_note):
|
||||
"""Trigger release on voices playing this note."""
|
||||
with self._lock:
|
||||
for v in self.voices:
|
||||
if v.note == midi_note and v.active and not v.releasing:
|
||||
v.releasing = True
|
||||
v.release_pos = 0
|
||||
|
||||
def render_stereo(self, n_frames):
|
||||
"""Mix all active voices into a stereo buffer (n_frames, 2)."""
|
||||
mono = numpy.zeros(n_frames, dtype=numpy.float32)
|
||||
dead = []
|
||||
|
||||
with self._lock:
|
||||
for i, v in enumerate(self.voices):
|
||||
if not v.active:
|
||||
dead.append(i)
|
||||
continue
|
||||
|
||||
remaining = len(v.wave) - int(v.pos)
|
||||
chunk = min(n_frames, remaining)
|
||||
|
||||
if chunk <= 0:
|
||||
v.active = False
|
||||
dead.append(i)
|
||||
continue
|
||||
|
||||
# Pitch bend: variable-rate read
|
||||
if abs(v.pitch_ratio - 1.0) > 0.001:
|
||||
read_positions = v.pos + numpy.arange(chunk) * v.pitch_ratio
|
||||
read_positions = numpy.clip(read_positions, 0, len(v.wave) - 2)
|
||||
idx = read_positions.astype(numpy.int64)
|
||||
frac = (read_positions - idx).astype(numpy.float32)
|
||||
samples = (v.wave[idx] * (1 - frac) +
|
||||
v.wave[numpy.minimum(idx + 1, len(v.wave) - 1)] * frac)
|
||||
samples *= v.velocity * self.volume
|
||||
else:
|
||||
int_pos = int(v.pos)
|
||||
samples = v.wave[int_pos:int_pos + chunk] * v.velocity * self.volume
|
||||
|
||||
# Release crossfade
|
||||
if v.releasing:
|
||||
fade_chunk = min(chunk, v.release_len - v.release_pos)
|
||||
if fade_chunk > 0:
|
||||
fade = numpy.linspace(
|
||||
1.0 - v.release_pos / v.release_len,
|
||||
1.0 - (v.release_pos + fade_chunk) / v.release_len,
|
||||
fade_chunk
|
||||
).astype(numpy.float32)
|
||||
samples[:fade_chunk] *= fade
|
||||
v.release_pos += fade_chunk
|
||||
if v.release_pos >= v.release_len:
|
||||
v.active = False
|
||||
samples[fade_chunk:] = 0
|
||||
|
||||
mono[:chunk] += samples
|
||||
v.pos += chunk * v.pitch_ratio
|
||||
|
||||
# Clean up dead voices
|
||||
for i in reversed(dead):
|
||||
if i < len(self.voices):
|
||||
self.voices.pop(i)
|
||||
|
||||
# VU meter
|
||||
peak = numpy.abs(mono).max() if len(mono) > 0 else 0
|
||||
self.level = self.level * 0.7 + peak * 0.3 # smooth
|
||||
|
||||
# Stereo pan (constant power)
|
||||
import math
|
||||
angle = (self.pan + 1) * math.pi / 4 # 0 to pi/2
|
||||
l_gain = math.cos(angle)
|
||||
r_gain = math.sin(angle)
|
||||
stereo = numpy.zeros((n_frames, 2), dtype=numpy.float32)
|
||||
stereo[:, 0] = mono * l_gain
|
||||
stereo[:, 1] = mono * r_gain
|
||||
|
||||
return stereo
|
||||
|
||||
|
||||
# ── LiveEngine ───────────────────────────────────────────────────────────
|
||||
|
||||
class LiveEngine:
|
||||
"""Real-time MIDI-to-audio engine.
|
||||
|
||||
Maps MIDI channels to pytheory instruments and synthesizes
|
||||
audio in real-time via sounddevice.
|
||||
|
||||
Example::
|
||||
|
||||
engine = LiveEngine()
|
||||
engine.channel(1, instrument="electric_piano")
|
||||
engine.channel(2, instrument="bass_guitar")
|
||||
engine.channel(10, drums=True)
|
||||
engine.start()
|
||||
"""
|
||||
|
||||
def __init__(self, buffer_size=512, sample_rate=SAMPLE_RATE):
|
||||
self.buffer_size = buffer_size
|
||||
self.sample_rate = sample_rate
|
||||
self.channels = {} # MIDI channel (1-16) -> _Channel
|
||||
self._cc_map = {} # (channel, cc_number) -> (param_name, min, max)
|
||||
self._midi_in = None
|
||||
self._stream = None
|
||||
self._stop_event = threading.Event()
|
||||
# Recording
|
||||
self._recording = False
|
||||
self._record_events = [] # (timestamp, ch, note, velocity, on/off)
|
||||
self._record_start = 0
|
||||
# Keyboard MIDI
|
||||
self._keyboard_channel = None
|
||||
self._keyboard_octave = 4
|
||||
# Clock sync
|
||||
self._clock_count = 0 # MIDI clock pulses (24 per quarter note)
|
||||
self._clock_times = [] # timestamps for BPM calculation
|
||||
self._bpm = 120.0
|
||||
self._playing = False
|
||||
# Drum pattern
|
||||
self._drum_pattern = None
|
||||
self._drum_channel = None
|
||||
|
||||
def channel(self, ch, *, instrument=None, synth=None, envelope=None,
|
||||
drums=False, **kwargs):
|
||||
"""Configure a MIDI channel.
|
||||
|
||||
Args:
|
||||
ch: MIDI channel number (1-16). Channel 10 = drums by convention.
|
||||
instrument: Instrument preset name (e.g. "electric_piano").
|
||||
synth: Synth waveform name (overrides instrument).
|
||||
envelope: Envelope name (overrides instrument).
|
||||
drums: If True, this channel triggers drum sounds by MIDI note.
|
||||
**kwargs: Any Part parameter (lowpass, reverb, volume, etc.)
|
||||
"""
|
||||
if not isinstance(ch, int) or not (1 <= ch <= 16):
|
||||
raise ValueError(f"MIDI channel must be an integer 1-16, got {ch!r}")
|
||||
|
||||
# Build params from instrument preset
|
||||
params = {}
|
||||
if instrument:
|
||||
preset = INSTRUMENTS.get(instrument)
|
||||
if preset:
|
||||
params.update(preset)
|
||||
if synth:
|
||||
params["synth"] = synth
|
||||
if envelope:
|
||||
params["envelope"] = envelope
|
||||
params.update(kwargs)
|
||||
|
||||
synth_name = params.pop("synth", "sine")
|
||||
env_name = params.pop("envelope", "piano")
|
||||
|
||||
self.channels[ch] = _Channel(
|
||||
synth_name=synth_name,
|
||||
envelope_name=env_name,
|
||||
is_drums=drums or ch == 10,
|
||||
**params,
|
||||
)
|
||||
return self
|
||||
|
||||
def drums(self, pattern_name, *, volume=0.5):
|
||||
"""Add a drum pattern that syncs to MIDI clock.
|
||||
|
||||
The pattern plays in sync with the OP-XY's transport -
|
||||
starts on Start, stops on Stop, tempo from MIDI clock.
|
||||
|
||||
Args:
|
||||
pattern_name: Drum pattern preset name (e.g. "rock", "house").
|
||||
volume: Drum volume (0.0-1.0).
|
||||
"""
|
||||
from .rhythm import Pattern
|
||||
self._drum_pattern = Pattern.preset(pattern_name)
|
||||
self._drum_channel = _Channel(synth_name="sine", is_drums=True,
|
||||
volume=volume)
|
||||
return self
|
||||
|
||||
def cc(self, cc_number, param, *, min_val=0.0, max_val=1.0, ch=None):
|
||||
"""Map a MIDI CC to a channel parameter.
|
||||
|
||||
Args:
|
||||
cc_number: MIDI CC number (0-127).
|
||||
param: Parameter name ("volume", "lowpass", "reverb", etc.)
|
||||
min_val: Value when CC = 0.
|
||||
max_val: Value when CC = 127.
|
||||
ch: MIDI channel (None = all channels).
|
||||
|
||||
Example::
|
||||
|
||||
>>> engine.cc(11, "lowpass", min_val=200, max_val=8000)
|
||||
>>> engine.cc(12, "volume", min_val=0.0, max_val=1.0)
|
||||
>>> engine.cc(13, "reverb", min_val=0.0, max_val=0.8)
|
||||
>>> engine.cc(14, "distortion", min_val=0.0, max_val=0.8)
|
||||
"""
|
||||
self._cc_map[(ch, cc_number)] = (param, min_val, max_val)
|
||||
return self
|
||||
|
||||
def _apply_cc(self, ch, cc_number, value):
|
||||
"""Apply a CC value to the matching channel parameter."""
|
||||
for key in [(ch, cc_number), (None, cc_number)]:
|
||||
if key in self._cc_map:
|
||||
param, min_val, max_val = self._cc_map[key]
|
||||
scaled = min_val + (max_val - min_val) * (value / 127.0)
|
||||
|
||||
target_chs = [ch] if key[0] is not None else list(self.channels.keys())
|
||||
for target_ch in target_chs:
|
||||
if target_ch in self.channels:
|
||||
channel = self.channels[target_ch]
|
||||
if param == "volume":
|
||||
channel.volume = scaled
|
||||
elif param == "lowpass":
|
||||
channel.lowpass = scaled
|
||||
channel._cache.clear()
|
||||
elif param == "reverb":
|
||||
channel.reverb = scaled
|
||||
channel._cache.clear()
|
||||
elif hasattr(channel, param):
|
||||
setattr(channel, param, scaled)
|
||||
channel._cache.clear()
|
||||
print(f" CC {cc_number}: {param}={scaled:.2f}")
|
||||
return
|
||||
|
||||
def _on_clock(self):
|
||||
"""Handle MIDI clock pulse (24 per quarter note)."""
|
||||
import time as _time
|
||||
|
||||
if not self._playing:
|
||||
return
|
||||
|
||||
now = _time.perf_counter()
|
||||
self._clock_times.append(now)
|
||||
if len(self._clock_times) > 240:
|
||||
self._clock_times = self._clock_times[-240:]
|
||||
# Only update BPM every 24 ticks to avoid jitter
|
||||
if self._clock_count % 24 == 0 and len(self._clock_times) >= 48:
|
||||
# Use as many ticks as we have for best accuracy
|
||||
n = min(len(self._clock_times), 240)
|
||||
total_time = self._clock_times[-1] - self._clock_times[-n]
|
||||
if total_time > 0:
|
||||
ticks = n - 1
|
||||
self._bpm = round(60.0 * ticks / (24.0 * total_time))
|
||||
|
||||
# Trigger drum hits at the right time
|
||||
if self._drum_pattern and self._drum_channel:
|
||||
pattern = self._drum_pattern
|
||||
beat_pos = self._clock_count / 24.0
|
||||
pattern_beat = beat_pos % pattern.beats
|
||||
beat_resolution = 1.0 / 24.0
|
||||
for hit in pattern.hits:
|
||||
if abs(hit.position - pattern_beat) < beat_resolution / 2:
|
||||
self._drum_channel.note_on(hit.sound.value, hit.velocity)
|
||||
|
||||
self._clock_count += 1
|
||||
|
||||
def _all_notes_off(self):
|
||||
"""Kill all sounding voices on all channels."""
|
||||
for channel in self.channels.values():
|
||||
with channel._lock:
|
||||
channel.voices.clear()
|
||||
if self._drum_channel:
|
||||
with self._drum_channel._lock:
|
||||
self._drum_channel.voices.clear()
|
||||
|
||||
def _midi_callback(self, event, data=None):
|
||||
"""Handle incoming MIDI messages."""
|
||||
msg, _ = event
|
||||
if len(msg) == 0:
|
||||
return
|
||||
|
||||
# System realtime messages (1 byte)
|
||||
if msg[0] == 0xF8: # Clock - 24 ppqn
|
||||
self._on_clock()
|
||||
return
|
||||
elif msg[0] == 0xFA: # Start
|
||||
print(" > Start")
|
||||
self._playing = True
|
||||
self._clock_count = 0
|
||||
return
|
||||
elif msg[0] == 0xFC: # Stop
|
||||
print(" [] Stop")
|
||||
self._playing = False
|
||||
self._all_notes_off()
|
||||
return
|
||||
elif msg[0] == 0xFB: # Continue
|
||||
print(" > Continue")
|
||||
self._playing = True
|
||||
return
|
||||
|
||||
if len(msg) < 3:
|
||||
return
|
||||
|
||||
status = msg[0]
|
||||
ch = (status & 0x0F) + 1
|
||||
msg_type = status & 0xF0
|
||||
|
||||
if ch not in self.channels:
|
||||
return
|
||||
|
||||
channel = self.channels[ch]
|
||||
note = msg[1]
|
||||
velocity = msg[2]
|
||||
|
||||
if msg_type == 0x90 and velocity > 0:
|
||||
channel.note_on(note, velocity)
|
||||
if self._recording:
|
||||
import time as _t
|
||||
self._record_events.append(
|
||||
(_t.perf_counter() - self._record_start, ch, note, velocity, True))
|
||||
elif msg_type == 0x80 or (msg_type == 0x90 and velocity == 0):
|
||||
channel.note_off(note)
|
||||
if self._recording:
|
||||
import time as _t
|
||||
self._record_events.append(
|
||||
(_t.perf_counter() - self._record_start, ch, note, 0, False))
|
||||
elif msg_type == 0xB0:
|
||||
self._apply_cc(ch, note, velocity)
|
||||
elif msg_type == 0xE0:
|
||||
bend_raw = (msg[2] << 7) | msg[1]
|
||||
bend_semitones = (bend_raw - 8192) / 8192.0 * 2.0
|
||||
ratio = 2.0 ** (bend_semitones / 12.0)
|
||||
with channel._lock:
|
||||
for v in channel.voices:
|
||||
if v.active:
|
||||
v.pitch_ratio = ratio
|
||||
|
||||
def _audio_callback(self, outdata, frames, time_info, status):
|
||||
"""sounddevice callback - mix all channels to stereo."""
|
||||
stereo = numpy.zeros((frames, 2), dtype=numpy.float32)
|
||||
for channel in self.channels.values():
|
||||
stereo += channel.render_stereo(frames)
|
||||
if self._drum_channel:
|
||||
stereo += self._drum_channel.render_stereo(frames)
|
||||
|
||||
# Soft clip per channel
|
||||
stereo[:, 0] = numpy.tanh(stereo[:, 0])
|
||||
stereo[:, 1] = numpy.tanh(stereo[:, 1])
|
||||
outdata[:] = stereo
|
||||
|
||||
def list_ports(self):
|
||||
"""List available MIDI input ports."""
|
||||
if rtmidi is None:
|
||||
raise ImportError("python-rtmidi required. Install with: pip install pytheory[live]")
|
||||
midi_in = rtmidi.MidiIn()
|
||||
ports = midi_in.get_ports()
|
||||
for i, name in enumerate(ports):
|
||||
print(f" {i}: {name}")
|
||||
midi_in.delete()
|
||||
return ports
|
||||
|
||||
def start(self, port=None):
|
||||
"""Start the engine - opens MIDI input and audio output.
|
||||
|
||||
Args:
|
||||
port: MIDI port index or name. None = first available.
|
||||
|
||||
Blocks until Ctrl-C or stop() is called.
|
||||
"""
|
||||
if rtmidi is None:
|
||||
raise ImportError(
|
||||
"python-rtmidi is required for live MIDI. "
|
||||
"Install it with: pip install pytheory[live]"
|
||||
)
|
||||
|
||||
if not self.channels:
|
||||
self.channel(1, instrument="electric_piano")
|
||||
|
||||
# Pre-compute wavetables
|
||||
print(" Pre-rendering wavetables...")
|
||||
n_samples = SAMPLE_RATE * 3
|
||||
for _, channel in self.channels.items():
|
||||
if channel.is_drums:
|
||||
continue
|
||||
for midi_note in range(36, 97):
|
||||
channel._get_wave(midi_note, n_samples)
|
||||
print(f" Cached {sum(len(c._cache) for c in self.channels.values())} wavetables.")
|
||||
print()
|
||||
|
||||
# Open MIDI
|
||||
self._midi_in = rtmidi.MidiIn()
|
||||
ports = self._midi_in.get_ports()
|
||||
|
||||
if not ports:
|
||||
print(" No MIDI input ports found. (Keyboard mode still works)")
|
||||
self._midi_in.delete()
|
||||
self._midi_in = None
|
||||
# Don't return — still start audio for keyboard mode
|
||||
|
||||
port_name = "none"
|
||||
if self._midi_in and ports:
|
||||
if port is None:
|
||||
port = 0
|
||||
elif isinstance(port, str):
|
||||
matched = False
|
||||
for i, name in enumerate(ports):
|
||||
if port.lower() in name.lower():
|
||||
port = i
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
self._midi_in.delete()
|
||||
self._midi_in = None
|
||||
print(f" MIDI port not found: {port!r}, continuing without MIDI")
|
||||
port = None
|
||||
|
||||
if self._midi_in and port is not None:
|
||||
try:
|
||||
self._midi_in.open_port(port)
|
||||
self._midi_in.ignore_types(sysex=True, timing=False, active_sense=True)
|
||||
self._midi_in.set_callback(self._midi_callback)
|
||||
port_name = ports[port]
|
||||
except Exception:
|
||||
self._midi_in.delete()
|
||||
self._midi_in = None
|
||||
print(" Failed to open MIDI port, continuing without MIDI")
|
||||
|
||||
print(f" PyTheory Live Engine")
|
||||
print(f" MIDI: {port_name}")
|
||||
print(f" Buffer: {self.buffer_size} samples ({self.buffer_size/self.sample_rate*1000:.1f}ms)")
|
||||
print(f" Channels:")
|
||||
for ch, channel in sorted(self.channels.items()):
|
||||
kind = "drums" if channel.is_drums else channel.synth_name
|
||||
print(f" {ch:2d}: {kind} (vol={channel.volume})")
|
||||
if self._drum_pattern:
|
||||
print(f" Drums: {self._drum_pattern.name} (synced to MIDI clock)")
|
||||
print()
|
||||
print(" Playing... (Ctrl-C to stop)")
|
||||
print()
|
||||
|
||||
self._stream = sd.OutputStream(
|
||||
samplerate=self.sample_rate,
|
||||
blocksize=self.buffer_size,
|
||||
channels=2,
|
||||
dtype='float32',
|
||||
callback=self._audio_callback,
|
||||
)
|
||||
|
||||
try:
|
||||
self._stream.start()
|
||||
self._stop_event.clear()
|
||||
self._stop_event.wait()
|
||||
except KeyboardInterrupt:
|
||||
print("\n Stopped.")
|
||||
finally:
|
||||
if self._stream:
|
||||
self._stream.stop()
|
||||
self._stream.close()
|
||||
self._stream = None
|
||||
if self._midi_in:
|
||||
self._midi_in.close_port()
|
||||
self._midi_in.delete()
|
||||
self._midi_in = None
|
||||
|
||||
def keyboard_play(self, ch=1):
|
||||
"""Enable computer keyboard as MIDI input on a channel."""
|
||||
self._keyboard_channel = ch
|
||||
return self
|
||||
|
||||
def keyboard_note(self, key, on=True):
|
||||
"""Translate a keyboard key to a MIDI note and play it.
|
||||
|
||||
QWERTY layout: ZSXDCVGBHNJM = C through B (lower octave)
|
||||
Q2W3ER5T6Y7U = C through B (upper octave)
|
||||
"""
|
||||
# Chromatic layout across the full keyboard
|
||||
# Bottom two rows = lower octave range
|
||||
# Top two rows = upper octave range (+1 octave)
|
||||
# Black keys on the row above their white keys
|
||||
#
|
||||
# Row 3 (ZXCVBNM,./): white keys C D E F G A B C D E
|
||||
# Row 2 (ASDFGHJKL;'): black keys + extras
|
||||
# Row 1 (QWERTYUIOP[]): white keys C D E F G A B C D E F G
|
||||
# Row 0 (1234567890-=): black keys + extras
|
||||
lower = {
|
||||
# White keys: Z X C V B N M , . /
|
||||
'z': 0, 'x': 2, 'c': 4, 'v': 5, 'b': 7, 'n': 9, 'm': 11,
|
||||
',': 12, '.': 14, '/': 16,
|
||||
# Black keys: S D G H J L ;
|
||||
's': 1, 'd': 3, 'g': 6, 'h': 8, 'j': 10,
|
||||
'l': 13, ';': 15,
|
||||
# Extras
|
||||
'a': 0, 'f': 4, 'k': 11, "'": 17,
|
||||
}
|
||||
upper = {
|
||||
# White keys: Q W E R T Y U I O P [ ]
|
||||
'q': 0, 'w': 2, 'e': 4, 'r': 5, 't': 7, 'y': 9, 'u': 11,
|
||||
'i': 12, 'o': 14, 'p': 16, '[': 17, ']': 19,
|
||||
# Black keys: 2 3 5 6 7 9 0
|
||||
'2': 1, '3': 3, '5': 6, '6': 8, '7': 10,
|
||||
'9': 13, '0': 15,
|
||||
# Extras
|
||||
'1': 0, '4': 4, '8': 11, '-': 18, '=': 19,
|
||||
}
|
||||
|
||||
if self._keyboard_channel is None:
|
||||
return False
|
||||
|
||||
ch = self._keyboard_channel
|
||||
if ch not in self.channels:
|
||||
return False
|
||||
|
||||
midi_note = None
|
||||
if key in lower:
|
||||
midi_note = (self._keyboard_octave + 1) * 12 + lower[key]
|
||||
elif key in upper:
|
||||
midi_note = (self._keyboard_octave + 2) * 12 + upper[key]
|
||||
|
||||
if midi_note is not None:
|
||||
channel = self.channels[ch]
|
||||
if on:
|
||||
channel.note_on(midi_note, 100)
|
||||
if self._recording:
|
||||
import time as _t
|
||||
self._record_events.append(
|
||||
(_t.perf_counter() - self._record_start,
|
||||
ch, midi_note, 100, True))
|
||||
else:
|
||||
channel.note_off(midi_note)
|
||||
if self._recording:
|
||||
import time as _t
|
||||
self._record_events.append(
|
||||
(_t.perf_counter() - self._record_start,
|
||||
ch, midi_note, 0, False))
|
||||
return True
|
||||
return False
|
||||
|
||||
def start_recording(self):
|
||||
"""Start recording MIDI events."""
|
||||
import time as _t
|
||||
self._record_events = []
|
||||
self._record_start = _t.perf_counter()
|
||||
self._recording = True
|
||||
|
||||
def stop_recording(self):
|
||||
"""Stop recording."""
|
||||
self._recording = False
|
||||
|
||||
def export_recording(self, filename="recording.mid", bpm=None):
|
||||
"""Export recorded events to a MIDI file.
|
||||
|
||||
Returns a pytheory Score if no filename given.
|
||||
"""
|
||||
if not self._record_events:
|
||||
return None
|
||||
|
||||
use_bpm = bpm or (self._bpm if self._bpm > 10 else 120)
|
||||
|
||||
from .rhythm import Score, Duration
|
||||
|
||||
score = Score("4/4", bpm=int(use_bpm))
|
||||
|
||||
# Group events by channel
|
||||
by_channel = {}
|
||||
for ts, ch, note, vel, is_on in self._record_events:
|
||||
if ch not in by_channel:
|
||||
by_channel[ch] = []
|
||||
by_channel[ch].append((ts, note, vel, is_on))
|
||||
|
||||
# Build parts
|
||||
for ch, events in sorted(by_channel.items()):
|
||||
inst = self.picks[ch - 1] if 1 <= ch <= 8 else "piano"
|
||||
part = score.part(f"ch{ch}", instrument=inst)
|
||||
|
||||
# Convert to note-on/off pairs
|
||||
active = {}
|
||||
notes = []
|
||||
for ts, note, vel, is_on in events:
|
||||
if is_on:
|
||||
active[note] = (ts, vel)
|
||||
elif note in active:
|
||||
start_ts, start_vel = active.pop(note)
|
||||
dur_sec = ts - start_ts
|
||||
dur_beats = dur_sec * use_bpm / 60.0
|
||||
notes.append((start_ts, note, max(0.125, dur_beats), start_vel))
|
||||
|
||||
notes.sort(key=lambda x: x[0])
|
||||
|
||||
beat_pos = 0.0
|
||||
for ts, midi_note, dur, vel in notes:
|
||||
note_beat = ts * use_bpm / 60.0
|
||||
if note_beat > beat_pos:
|
||||
part.rest(note_beat - beat_pos)
|
||||
# Convert MIDI note to name
|
||||
name = NOTE_NAMES[midi_note % 12]
|
||||
octave = midi_note // 12 - 1
|
||||
part.add(f"{name}{octave}", dur, velocity=vel)
|
||||
beat_pos = note_beat + dur
|
||||
|
||||
if filename:
|
||||
score.save_midi(filename)
|
||||
|
||||
return score
|
||||
|
||||
def save_config(self, filename):
|
||||
"""Save current configuration to JSON."""
|
||||
import json
|
||||
config = {
|
||||
"seed": self.seed if hasattr(self, 'seed') else None,
|
||||
"buffer_size": self.buffer_size,
|
||||
"drums": getattr(self, '_drum_pattern_name', None),
|
||||
"channels": {},
|
||||
}
|
||||
for ch, channel in self.channels.items():
|
||||
config["channels"][str(ch)] = {
|
||||
"synth": channel.synth_name,
|
||||
"envelope": channel.envelope_name,
|
||||
"volume": channel.volume,
|
||||
"pan": channel.pan,
|
||||
"reverb": channel.reverb,
|
||||
"lowpass": channel.lowpass,
|
||||
"chorus": channel.chorus,
|
||||
"distortion": channel.distortion,
|
||||
"is_drums": channel.is_drums,
|
||||
}
|
||||
with open(filename, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
|
||||
def load_config(self, filename):
|
||||
"""Load configuration from JSON."""
|
||||
import json
|
||||
with open(filename) as f:
|
||||
config = json.load(f)
|
||||
for ch_str, ch_cfg in config.get("channels", {}).items():
|
||||
ch = int(ch_str)
|
||||
self.channel(ch,
|
||||
synth=ch_cfg.get("synth"),
|
||||
envelope=ch_cfg.get("envelope"),
|
||||
drums=ch_cfg.get("is_drums", False),
|
||||
volume=ch_cfg.get("volume", 0.5),
|
||||
pan=ch_cfg.get("pan", 0.0),
|
||||
reverb=ch_cfg.get("reverb", 0),
|
||||
lowpass=ch_cfg.get("lowpass", 0),
|
||||
chorus=ch_cfg.get("chorus", 0),
|
||||
distortion=ch_cfg.get("distortion", 0))
|
||||
if config.get("drums"):
|
||||
self.drums(config["drums"])
|
||||
|
||||
def stop(self):
|
||||
"""Stop the engine."""
|
||||
self._stop_event.set()
|
||||
if self._stream:
|
||||
self._stream.stop()
|
||||
if self._midi_in:
|
||||
self._midi_in.close_port()
|
||||
self._midi_in.delete()
|
||||
self._midi_in = None
|
||||
@@ -0,0 +1,819 @@
|
||||
"""PyTheory Live — interactive MIDI synthesizer with TUI."""
|
||||
|
||||
import curses
|
||||
import random
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import os
|
||||
|
||||
from pytheory.live import LiveEngine
|
||||
from pytheory.rhythm import INSTRUMENTS, Pattern
|
||||
|
||||
|
||||
NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
|
||||
|
||||
|
||||
def note_name(midi):
|
||||
return f"{NOTE_NAMES[midi % 12]}{midi // 12 - 1}"
|
||||
|
||||
|
||||
class LiveTUI:
|
||||
def __init__(self, seed=None, port="OP-XY", n_channels=8,
|
||||
drum_pattern="rock", buffer_size=128):
|
||||
self.seed = seed or random.randint(0, 9999)
|
||||
self.port = port
|
||||
self.n_channels = n_channels
|
||||
self.buffer_size = buffer_size
|
||||
self.engine = None
|
||||
self.log_lines = []
|
||||
self.max_log = 500
|
||||
self.running = True
|
||||
self.instruments = sorted([k for k in INSTRUMENTS.keys()
|
||||
if k not in ("808_bass",)])
|
||||
self.drum_patterns = sorted(Pattern.list_presets())
|
||||
self.current_drum = drum_pattern
|
||||
self.picks = []
|
||||
self.bpm = "—"
|
||||
self.status = "Init"
|
||||
self.status_color = 3
|
||||
self._build_engine()
|
||||
|
||||
def _build_engine(self):
|
||||
rng = random.Random(self.seed)
|
||||
self.picks = rng.sample(self.instruments, min(self.n_channels, len(self.instruments)))
|
||||
self.engine = LiveEngine(buffer_size=self.buffer_size)
|
||||
for i, inst in enumerate(self.picks, 1):
|
||||
self.engine.channel(i, instrument=inst, reverb=0.3)
|
||||
if self.current_drum and self.current_drum not in ("none", "-"):
|
||||
self.engine.drums(self.current_drum, volume=0.5)
|
||||
self.engine.cc(0, "lowpass", min_val=300, max_val=8000)
|
||||
|
||||
def log(self, msg, color=0):
|
||||
self.log_lines.append((time.time(), msg, color))
|
||||
if len(self.log_lines) > self.max_log:
|
||||
self.log_lines = self.log_lines[-self.max_log:]
|
||||
|
||||
def _patch_engine_logging(self):
|
||||
original_cb = self.engine._midi_callback
|
||||
|
||||
def logging_cb(event, data=None):
|
||||
msg, _ = event
|
||||
if len(msg) == 0:
|
||||
return
|
||||
if msg[0] == 0xF8:
|
||||
if self.engine._bpm > 10:
|
||||
self.bpm = f"{self.engine._bpm:.0f}"
|
||||
elif msg[0] == 0xFA:
|
||||
self.log("▶ Start", 5)
|
||||
self.status = "Playing"
|
||||
self.status_color = 1
|
||||
elif msg[0] == 0xFC:
|
||||
self.log("■ Stop", 4)
|
||||
self.status = "Stopped"
|
||||
self.status_color = 4
|
||||
elif msg[0] == 0xFB:
|
||||
self.log("▶ Continue", 5)
|
||||
self.status = "Playing"
|
||||
self.status_color = 1
|
||||
elif len(msg) >= 3:
|
||||
status = msg[0]
|
||||
ch = (status & 0x0F) + 1
|
||||
msg_type = status & 0xF0
|
||||
if msg_type == 0x90 and msg[2] > 0:
|
||||
inst = self.picks[ch - 1] if 1 <= ch <= 8 else "?"
|
||||
self.log(f"♪ {ch}:{inst} {note_name(msg[1])} v={msg[2]}", 1)
|
||||
elif msg_type == 0xB0:
|
||||
self.log(f"⚙ CC{msg[1]}={msg[2]}", 3)
|
||||
elif msg_type == 0xE0:
|
||||
bend = ((msg[2] << 7) | msg[1]) - 8192
|
||||
self.log(f"↕ Bend ch{ch} {bend:+d}", 3)
|
||||
original_cb(event, data)
|
||||
|
||||
self.engine._midi_callback = logging_cb
|
||||
|
||||
def run(self, stdscr):
|
||||
curses.curs_set(0)
|
||||
curses.use_default_colors()
|
||||
curses.init_pair(1, curses.COLOR_GREEN, -1)
|
||||
curses.init_pair(2, curses.COLOR_CYAN, -1)
|
||||
curses.init_pair(3, curses.COLOR_YELLOW, -1)
|
||||
curses.init_pair(4, curses.COLOR_RED, -1)
|
||||
curses.init_pair(5, curses.COLOR_MAGENTA, -1)
|
||||
curses.init_pair(6, curses.COLOR_BLACK, curses.COLOR_BLUE)
|
||||
curses.init_pair(7, curses.COLOR_BLACK, curses.COLOR_GREEN)
|
||||
curses.init_pair(8, curses.COLOR_BLACK, curses.COLOR_YELLOW)
|
||||
curses.init_pair(9, curses.COLOR_BLACK, curses.COLOR_RED)
|
||||
stdscr.nodelay(True)
|
||||
stdscr.timeout(60)
|
||||
|
||||
self._patch_engine_logging()
|
||||
|
||||
# Pre-render with progress
|
||||
self.status = "Rendering"
|
||||
self.status_color = 3
|
||||
stdscr.erase()
|
||||
stdscr.addstr(1, 2, "PyTheory Live", curses.A_BOLD)
|
||||
stdscr.addstr(2, 2, "Pre-rendering wavetables...", curses.color_pair(3))
|
||||
stdscr.refresh()
|
||||
|
||||
n_samples = 44100 * 3
|
||||
count = 0
|
||||
for _, channel in self.engine.channels.items():
|
||||
if channel.is_drums:
|
||||
continue
|
||||
for midi_note in range(36, 97):
|
||||
channel._get_wave(midi_note, n_samples)
|
||||
count += 1
|
||||
if count % 50 == 0:
|
||||
stdscr.addstr(3, 2, f" {count} wavetables...", curses.color_pair(2))
|
||||
stdscr.refresh()
|
||||
|
||||
self.log(f"Cached {count} wavetables", 1)
|
||||
self.log("Starting engine...", 3)
|
||||
self.status = "Starting"
|
||||
self.status_color = 3
|
||||
|
||||
# Start engine
|
||||
def run_engine():
|
||||
import io
|
||||
capture = io.StringIO()
|
||||
old = sys.stdout
|
||||
sys.stdout = capture
|
||||
try:
|
||||
self.engine.start(port=self.port)
|
||||
except Exception as e:
|
||||
self.log(f"Engine error: {e}", 4)
|
||||
finally:
|
||||
sys.stdout = old
|
||||
output = capture.getvalue()
|
||||
if output.strip():
|
||||
for line in output.strip().split('\n'):
|
||||
self.log(line.strip(), 2)
|
||||
|
||||
engine_thread = threading.Thread(target=run_engine, daemon=True)
|
||||
engine_thread.start()
|
||||
|
||||
cmd_buf = ""
|
||||
cursor_pos = 0
|
||||
cmd_history = []
|
||||
history_idx = -1
|
||||
tab_matches = []
|
||||
tab_idx = -1
|
||||
tab_prefix = ""
|
||||
self.kbd_active = False
|
||||
self._kbd_held = {} # key → last_press_time
|
||||
self._picker = None # {"channel": int, "index": int, "scroll": int, "filter": str}
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
# Update status from engine state
|
||||
if (self.engine._stream and self.engine._stream.active
|
||||
and self.status == "Starting"):
|
||||
self.status = "Listening"
|
||||
self.status_color = 2
|
||||
self.log("Audio stream active", 1)
|
||||
|
||||
h, w = stdscr.getmaxyx()
|
||||
if h < 10 or w < 40:
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
stdscr.erase()
|
||||
|
||||
div = max(30, w * 3 // 5)
|
||||
cfg_x = div + 2
|
||||
|
||||
# ═══ HEADER BAR ═══
|
||||
header = f" PyTheory Live "
|
||||
stdscr.addstr(0, 0, header, curses.color_pair(6) | curses.A_BOLD)
|
||||
|
||||
# Status badge
|
||||
badge_colors = {1: 7, 2: 6, 3: 8, 4: 9}
|
||||
badge_cp = badge_colors.get(self.status_color, 6)
|
||||
badge = f" {self.status} "
|
||||
x = len(header)
|
||||
stdscr.addstr(0, x, badge, curses.color_pair(badge_cp) | curses.A_BOLD)
|
||||
x += len(badge)
|
||||
|
||||
kbd_mode = ""
|
||||
if self.engine._keyboard_channel:
|
||||
kbd_mode = f" KBD:ch{self.engine._keyboard_channel}"
|
||||
rec_mode = " ●REC" if self.engine._recording else ""
|
||||
info = f" BPM:{self.bpm} drums:{self.current_drum} seed:{self.seed}{kbd_mode}{rec_mode}"
|
||||
try:
|
||||
stdscr.addstr(0, x, info[:w - x], curses.color_pair(6))
|
||||
# Fill rest of header
|
||||
remaining = w - x - len(info)
|
||||
if remaining > 0:
|
||||
stdscr.addstr(0, x + len(info), " " * remaining, curses.color_pair(6))
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# ═══ DIVIDER ═══
|
||||
for y in range(1, h - 2):
|
||||
try:
|
||||
stdscr.addch(y, div, '│', curses.color_pair(2) | curses.A_DIM)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# ═══ LEFT: EVENTS ═══
|
||||
try:
|
||||
stdscr.addstr(1, 1, " Events ", curses.color_pair(2) | curses.A_BOLD)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
log_h = h - 5
|
||||
visible = self.log_lines[-log_h:] if log_h > 0 else []
|
||||
for i, (ts, msg, color) in enumerate(visible):
|
||||
ly = 2 + i
|
||||
if ly >= h - 3:
|
||||
break
|
||||
# Fade old messages
|
||||
age = time.time() - ts
|
||||
attr = curses.A_DIM if age > 8 else 0
|
||||
try:
|
||||
stdscr.addstr(ly, 1, msg[:div - 2],
|
||||
curses.color_pair(color) | attr)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# ═══ RIGHT: CONFIG ═══
|
||||
try:
|
||||
stdscr.addstr(1, cfg_x, " Config ",
|
||||
curses.color_pair(2) | curses.A_BOLD)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
y = 3
|
||||
rw = w - cfg_x - 1
|
||||
for i, inst in enumerate(self.picks, 1):
|
||||
try:
|
||||
stdscr.addstr(y, cfg_x, f"{i}", curses.A_BOLD)
|
||||
stdscr.addstr(y, cfg_x + 1, ":", curses.A_DIM)
|
||||
stdscr.addstr(y, cfg_x + 2, inst[:rw - 2],
|
||||
curses.color_pair(1))
|
||||
except curses.error:
|
||||
pass
|
||||
y += 1
|
||||
|
||||
y += 1
|
||||
# VU meters per channel
|
||||
y += 1
|
||||
try:
|
||||
stdscr.addstr(y, cfg_x, "Levels:", curses.A_BOLD | curses.A_DIM)
|
||||
except curses.error:
|
||||
pass
|
||||
y += 1
|
||||
for i, inst in enumerate(self.picks, 1):
|
||||
if i in self.engine.channels:
|
||||
lv = self.engine.channels[i].level
|
||||
bars = int(min(lv, 1.0) * 16)
|
||||
meter = "|" * bars + "-" * (16 - bars)
|
||||
color = 1 if bars < 15 else 3 if bars < 18 else 4
|
||||
try:
|
||||
stdscr.addstr(y, cfg_x, f"{i}", curses.A_BOLD)
|
||||
stdscr.addstr(y, cfg_x + 1, f" {meter}", curses.color_pair(color))
|
||||
except curses.error:
|
||||
pass
|
||||
y += 1
|
||||
|
||||
y += 1
|
||||
rec_indicator = " ● REC " if self.engine._recording else ""
|
||||
kbd_indicator = f" kbd:ch{self.engine._keyboard_channel} oct{self.engine._keyboard_octave}" if self.engine._keyboard_channel else ""
|
||||
|
||||
pairs = [
|
||||
("Drums", self.current_drum, 5),
|
||||
("BPM", str(self.bpm), 0),
|
||||
("Latency", f"{self.engine.buffer_size / 44100 * 1000:.1f}ms", 0),
|
||||
("MIDI", self.port, 0),
|
||||
("Seed", str(self.seed), 2),
|
||||
]
|
||||
if rec_indicator:
|
||||
pairs.append(("", rec_indicator, 4))
|
||||
if kbd_indicator:
|
||||
pairs.append(("", kbd_indicator, 2))
|
||||
for label, val, cp in pairs:
|
||||
try:
|
||||
stdscr.addstr(y, cfg_x, f"{label}:", curses.A_BOLD)
|
||||
stdscr.addstr(y, cfg_x + len(label) + 1, f" {val}"[:rw],
|
||||
curses.color_pair(cp))
|
||||
except curses.error:
|
||||
pass
|
||||
y += 1
|
||||
|
||||
y += 1
|
||||
cmds = [
|
||||
"ch <n> [inst]",
|
||||
"fx <n> <param> <val>",
|
||||
"pan <n> <-1..1>",
|
||||
"drums [pattern|-]",
|
||||
"kbd [ch] [oct]",
|
||||
"rec / stop / export",
|
||||
"save/load <file>",
|
||||
"seed [n] / list",
|
||||
"exit",
|
||||
]
|
||||
try:
|
||||
stdscr.addstr(y, cfg_x, "Commands:", curses.color_pair(3))
|
||||
except curses.error:
|
||||
pass
|
||||
for i, c in enumerate(cmds):
|
||||
try:
|
||||
stdscr.addstr(y + 1 + i, cfg_x + 1, c[:rw],
|
||||
curses.color_pair(2) | curses.A_DIM)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# ═══ INPUT BAR ═══
|
||||
iy = h - 2
|
||||
try:
|
||||
stdscr.addstr(iy - 1, 0, "─" * (w - 1), curses.A_DIM)
|
||||
if self.kbd_active:
|
||||
stdscr.addstr(iy, 0, " KEYBOARD ",
|
||||
curses.color_pair(7) | curses.A_BOLD)
|
||||
stdscr.addstr(iy, 10, " Esc=exit Up/Down=octave ",
|
||||
curses.color_pair(3))
|
||||
else:
|
||||
stdscr.addstr(iy, 0, " $ ",
|
||||
curses.color_pair(1) | curses.A_BOLD)
|
||||
stdscr.addstr(iy, 3, cmd_buf[:w - 5])
|
||||
# Cursor at position
|
||||
cx = 3 + cursor_pos
|
||||
if cx < w - 1:
|
||||
# Show character under cursor inverted, or block at end
|
||||
if cursor_pos < len(cmd_buf):
|
||||
stdscr.addstr(iy, cx, cmd_buf[cursor_pos],
|
||||
curses.A_REVERSE)
|
||||
else:
|
||||
stdscr.addstr(iy, cx, " ", curses.A_REVERSE)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# ═══ PICKER OVERLAY ═══
|
||||
if self._picker is not None:
|
||||
filt = self._picker["filter"]
|
||||
items = [i for i in self.instruments if filt in i] if filt else self.instruments
|
||||
idx = self._picker["index"]
|
||||
pw = min(32, w - 4)
|
||||
ph = min(len(items) + 2, h - 4)
|
||||
px = (w - pw) // 2
|
||||
py = (h - ph) // 2
|
||||
# Border
|
||||
title = f" Ch {self._picker['channel']} "
|
||||
try:
|
||||
stdscr.addstr(py, px, "┌" + title + "─" * (pw - 2 - len(title)) + "┐", curses.color_pair(2) | curses.A_BOLD)
|
||||
for ri in range(1, ph - 1):
|
||||
stdscr.addstr(py + ri, px, "│" + " " * (pw - 2) + "│", curses.color_pair(2))
|
||||
stdscr.addstr(py + ph - 1, px, "└" + "─" * (pw - 2) + "┘", curses.color_pair(2))
|
||||
except curses.error:
|
||||
pass
|
||||
# Items
|
||||
vis_h = ph - 2
|
||||
scroll = self._picker["scroll"]
|
||||
if idx < scroll:
|
||||
scroll = idx
|
||||
elif idx >= scroll + vis_h:
|
||||
scroll = idx - vis_h + 1
|
||||
self._picker["scroll"] = scroll
|
||||
for ri in range(vis_h):
|
||||
li = scroll + ri
|
||||
if li >= len(items):
|
||||
break
|
||||
name = items[li][:pw - 4]
|
||||
attr = curses.A_REVERSE | curses.color_pair(1) if li == idx else curses.color_pair(0)
|
||||
try:
|
||||
padded = f" {name}" + " " * (pw - 3 - len(name))
|
||||
stdscr.addstr(py + 1 + ri, px + 1, padded, attr)
|
||||
except curses.error:
|
||||
pass
|
||||
# Filter hint
|
||||
hint = f"/{filt}" if filt else "type to filter"
|
||||
try:
|
||||
stdscr.addstr(py + ph - 1, px + 2, hint[:pw - 4], curses.color_pair(3) | curses.A_DIM)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
stdscr.refresh()
|
||||
|
||||
# ═══ INPUT ═══
|
||||
ch = stdscr.getch()
|
||||
if ch == -1:
|
||||
continue
|
||||
|
||||
# KEYBOARD MODE: all keys go to MIDI
|
||||
if self.kbd_active:
|
||||
if ch == 27: # Escape exits keyboard mode
|
||||
# Release all held notes
|
||||
for k in list(self._kbd_held):
|
||||
self.engine.keyboard_note(k, on=False)
|
||||
self._kbd_held.clear()
|
||||
self.kbd_active = False
|
||||
self.engine._keyboard_channel = None
|
||||
self.log("Keyboard off (Esc)", 3)
|
||||
elif ch == curses.KEY_UP:
|
||||
self.engine._keyboard_octave = min(8, self.engine._keyboard_octave + 1)
|
||||
self.log(f"Octave ↑ {self.engine._keyboard_octave}", 2)
|
||||
elif ch == curses.KEY_DOWN:
|
||||
self.engine._keyboard_octave = max(0, self.engine._keyboard_octave - 1)
|
||||
self.log(f"Octave ↓ {self.engine._keyboard_octave}", 2)
|
||||
elif 32 <= ch < 127:
|
||||
key = chr(ch).lower()
|
||||
now = time.time()
|
||||
if key in self._kbd_held:
|
||||
# Key repeat — just refresh the timer
|
||||
self._kbd_held[key] = now
|
||||
else:
|
||||
# New key press
|
||||
played = self.engine.keyboard_note(key, on=True)
|
||||
if played:
|
||||
self._kbd_held[key] = now
|
||||
|
||||
# Release keys that haven't been pressed for 150ms
|
||||
now = time.time()
|
||||
expired = [k for k, t in self._kbd_held.items()
|
||||
if now - t > 0.15]
|
||||
for k in expired:
|
||||
self.engine.keyboard_note(k, on=False)
|
||||
del self._kbd_held[k]
|
||||
|
||||
continue
|
||||
|
||||
# PICKER MODE
|
||||
if self._picker is not None:
|
||||
filt = self._picker["filter"]
|
||||
items = [i for i in self.instruments if filt in i] if filt else self.instruments
|
||||
if ch == 27: # Escape cancels
|
||||
self._picker = None
|
||||
elif ch == curses.KEY_UP:
|
||||
self._picker["index"] = max(0, self._picker["index"] - 1)
|
||||
elif ch == curses.KEY_DOWN:
|
||||
self._picker["index"] = min(len(items) - 1, self._picker["index"] + 1)
|
||||
elif ch == 10 or ch == 13: # Enter selects
|
||||
if items:
|
||||
inst = items[self._picker["index"]]
|
||||
n = self._picker["channel"]
|
||||
self.picks[n - 1] = inst
|
||||
self.engine.channel(n, instrument=inst, reverb=0.3)
|
||||
self.log(f"Ch {n} → {inst}", 1)
|
||||
self._picker = None
|
||||
elif ch == curses.KEY_BACKSPACE or ch == 127:
|
||||
if filt:
|
||||
self._picker["filter"] = filt[:-1]
|
||||
self._picker["index"] = 0
|
||||
self._picker["scroll"] = 0
|
||||
elif 32 <= ch < 127:
|
||||
self._picker["filter"] = filt + chr(ch)
|
||||
self._picker["index"] = 0
|
||||
self._picker["scroll"] = 0
|
||||
continue
|
||||
|
||||
if ch == 10 or ch == 13:
|
||||
if cmd_buf.strip():
|
||||
cmd_history.append(cmd_buf)
|
||||
self._handle_command(cmd_buf.strip())
|
||||
cmd_buf = ""
|
||||
cursor_pos = 0
|
||||
history_idx = -1
|
||||
elif ch == 27:
|
||||
if self.engine._keyboard_channel and not cmd_buf:
|
||||
# Escape exits keyboard mode
|
||||
self.engine._keyboard_channel = None
|
||||
self.log("Keyboard off (Esc)", 3)
|
||||
else:
|
||||
cmd_buf = ""
|
||||
cursor_pos = 0
|
||||
elif ch == curses.KEY_BACKSPACE or ch == 127:
|
||||
if cursor_pos > 0:
|
||||
cmd_buf = cmd_buf[:cursor_pos - 1] + cmd_buf[cursor_pos:]
|
||||
cursor_pos -= 1
|
||||
elif ch == curses.KEY_LEFT:
|
||||
cursor_pos = max(0, cursor_pos - 1)
|
||||
elif ch == curses.KEY_RIGHT:
|
||||
cursor_pos = min(len(cmd_buf), cursor_pos + 1)
|
||||
elif ch == curses.KEY_HOME or ch == 1: # Ctrl-A
|
||||
cursor_pos = 0
|
||||
elif ch == curses.KEY_END or ch == 5: # Ctrl-E
|
||||
cursor_pos = len(cmd_buf)
|
||||
elif ch == curses.KEY_UP:
|
||||
if cmd_history and history_idx < len(cmd_history) - 1:
|
||||
history_idx += 1
|
||||
cmd_buf = cmd_history[-(history_idx + 1)]
|
||||
cursor_pos = len(cmd_buf)
|
||||
elif ch == curses.KEY_DOWN:
|
||||
if history_idx > 0:
|
||||
history_idx -= 1
|
||||
cmd_buf = cmd_history[-(history_idx + 1)]
|
||||
cursor_pos = len(cmd_buf)
|
||||
else:
|
||||
history_idx = -1
|
||||
cmd_buf = ""
|
||||
cursor_pos = 0
|
||||
elif ch == 9: # Tab
|
||||
if tab_matches and tab_prefix == cmd_buf:
|
||||
tab_idx = (tab_idx + 1) % len(tab_matches)
|
||||
cmd_buf = tab_matches[tab_idx]
|
||||
else:
|
||||
tab_matches = self._complete(cmd_buf)
|
||||
tab_prefix = cmd_buf
|
||||
if len(tab_matches) == 1:
|
||||
cmd_buf = tab_matches[0]
|
||||
tab_matches = []
|
||||
elif tab_matches:
|
||||
tab_idx = 0
|
||||
cmd_buf = tab_matches[0]
|
||||
else:
|
||||
tab_matches = []
|
||||
cursor_pos = len(cmd_buf)
|
||||
elif 32 <= ch < 127:
|
||||
cmd_buf = cmd_buf[:cursor_pos] + chr(ch) + cmd_buf[cursor_pos:]
|
||||
cursor_pos += 1
|
||||
tab_matches = []
|
||||
tab_idx = -1
|
||||
|
||||
except KeyboardInterrupt:
|
||||
self.running = False
|
||||
|
||||
self.engine.stop()
|
||||
|
||||
def _complete(self, text):
|
||||
"""Return list of completions for current input."""
|
||||
parts = text.split()
|
||||
commands = ["ch", "fx", "pan", "drums", "kbd", "rec", "stop", "export",
|
||||
"save", "load", "seed", "list", "patterns", "octave", "help", "exit"]
|
||||
fx_params = ["volume", "pan", "lowpass", "lowpass_q", "reverb", "chorus",
|
||||
"detune", "spread", "analog", "distortion", "delay",
|
||||
"tremolo_depth", "saturation", "phaser", "sub_osc", "noise_mix"]
|
||||
|
||||
if not parts:
|
||||
return [c + " " for c in commands]
|
||||
|
||||
# Completing first word
|
||||
if len(parts) == 1 and not text.endswith(" "):
|
||||
prefix = parts[0].lower()
|
||||
return [c + " " for c in commands if c.startswith(prefix)]
|
||||
|
||||
verb = parts[0].lower()
|
||||
|
||||
# ch <n> <instrument>
|
||||
if verb == "ch" and len(parts) == 3 and not text.endswith(" "):
|
||||
prefix = parts[2].lower()
|
||||
return [f"ch {parts[1]} {i} " for i in self.instruments
|
||||
if i.startswith(prefix)]
|
||||
if verb == "ch" and len(parts) == 2 and text.endswith(" "):
|
||||
return [f"ch {parts[1]} {i} " for i in self.instruments]
|
||||
|
||||
# drums <pattern>
|
||||
if verb == "drums" and len(parts) == 2 and not text.endswith(" "):
|
||||
prefix = parts[1].lower()
|
||||
matches = [p for p in self.drum_patterns if p.startswith(prefix)]
|
||||
return [f"drums {m} " for m in matches]
|
||||
if verb == "drums" and len(parts) == 1 and text.endswith(" "):
|
||||
return [f"drums {p} " for p in self.drum_patterns]
|
||||
|
||||
# fx <n> <param> <val>
|
||||
if verb == "fx" and len(parts) == 3 and not text.endswith(" "):
|
||||
prefix = parts[2].lower()
|
||||
return [f"fx {parts[1]} {p} " for p in fx_params
|
||||
if p.startswith(prefix)]
|
||||
if verb == "fx" and len(parts) == 2 and text.endswith(" "):
|
||||
return [f"fx {parts[1]} {p} " for p in fx_params]
|
||||
|
||||
return []
|
||||
|
||||
def _handle_command(self, cmd):
|
||||
parts = cmd.split()
|
||||
if not parts:
|
||||
return
|
||||
verb = parts[0].lower()
|
||||
|
||||
if verb in ("quit", "q", "exit"):
|
||||
self.running = False
|
||||
elif verb in ("help", "h"):
|
||||
self.log("ch <n> [inst] | fx <n> <param> <val> | drums [pat|-]", 2)
|
||||
self.log("seed [n] | list | patterns | exit", 2)
|
||||
elif verb == "ch" and len(parts) == 2:
|
||||
try:
|
||||
n = int(parts[1])
|
||||
if 1 <= n <= len(self.picks):
|
||||
# Open instrument picker with current instrument pre-selected
|
||||
current = self.picks[n - 1]
|
||||
idx = self.instruments.index(current) if current in self.instruments else 0
|
||||
self._picker = {"channel": n, "index": idx, "scroll": max(0, idx - 5), "filter": ""}
|
||||
else:
|
||||
self.log(f"Channel 1-{len(self.picks)}", 4)
|
||||
except ValueError:
|
||||
self.log("ch <1-8> [instrument]", 4)
|
||||
elif verb == "ch" and len(parts) >= 3:
|
||||
try:
|
||||
n = int(parts[1])
|
||||
inst = parts[2]
|
||||
if inst not in INSTRUMENTS:
|
||||
self.log(f"Unknown: {inst}", 4)
|
||||
return
|
||||
if not (1 <= n <= len(self.picks)):
|
||||
self.log(f"Channel 1-{len(self.picks)}", 4)
|
||||
return
|
||||
self.picks[n - 1] = inst
|
||||
self.engine.channel(n, instrument=inst, reverb=0.3)
|
||||
self.log(f"Ch {n} → {inst}", 1)
|
||||
except (ValueError, IndexError):
|
||||
self.log("ch <1-8> <instrument>", 4)
|
||||
elif verb == "fx" and len(parts) >= 4:
|
||||
try:
|
||||
n = int(parts[1])
|
||||
param = parts[2]
|
||||
val = float(parts[3])
|
||||
if not (1 <= n <= len(self.picks)):
|
||||
self.log(f"Channel 1-{len(self.picks)}", 4)
|
||||
return
|
||||
if n not in self.engine.channels:
|
||||
self.log(f"Channel {n} not active", 4)
|
||||
return
|
||||
channel = self.engine.channels[n]
|
||||
if param == "reverb":
|
||||
channel.reverb = val
|
||||
channel._cache.clear()
|
||||
elif param == "lowpass":
|
||||
channel.lowpass = val
|
||||
channel._cache.clear()
|
||||
elif param == "volume":
|
||||
channel.volume = val
|
||||
elif hasattr(channel, param):
|
||||
setattr(channel, param, val)
|
||||
channel._cache.clear()
|
||||
else:
|
||||
self.log(f"Unknown param: {param}", 4)
|
||||
return
|
||||
self.log(f"Ch {n} {param}={val}", 1)
|
||||
except (ValueError, IndexError):
|
||||
self.log("fx <1-8> <param> <value>", 4)
|
||||
elif verb == "fx" and len(parts) == 2:
|
||||
try:
|
||||
n = int(parts[1])
|
||||
if n in self.engine.channels:
|
||||
ch = self.engine.channels[n]
|
||||
self.log(f"Ch {n}: vol={ch.volume} lp={ch.lowpass} rev={ch.reverb}", 2)
|
||||
else:
|
||||
self.log(f"Channel {n} not active", 4)
|
||||
except ValueError:
|
||||
self.log("fx <ch> <param> <value>", 4)
|
||||
elif verb == "fx" and len(parts) <= 1:
|
||||
self.log("Volume/Mix:", 3)
|
||||
self.log(" volume pan reverb delay", 2)
|
||||
self.log("Filter:", 3)
|
||||
self.log(" lowpass lowpass_q", 2)
|
||||
self.log("Modulation:", 3)
|
||||
self.log(" chorus detune spread tremolo_depth", 2)
|
||||
self.log(" phaser analog", 2)
|
||||
self.log("Drive:", 3)
|
||||
self.log(" distortion saturation", 2)
|
||||
self.log("Synth:", 3)
|
||||
self.log(" sub_osc noise_mix", 2)
|
||||
self.log("", 0)
|
||||
self.log("fx <ch> <param> <value>", 2)
|
||||
elif verb == "drums" and len(parts) == 1:
|
||||
self.log(f"Current: {self.current_drum}", 5)
|
||||
for i in range(0, len(self.drum_patterns), 4):
|
||||
row = " ".join(f"{x:17s}" for x in self.drum_patterns[i:i+4])
|
||||
self.log(f" {row}", 2)
|
||||
elif verb == "drums" and len(parts) >= 2:
|
||||
pat = " ".join(parts[1:])
|
||||
if pat in ("none", "off", "mute", "-"):
|
||||
self.current_drum = "none"
|
||||
self.engine._drum_pattern = None
|
||||
self.engine._drum_channel = None
|
||||
self.log("Drums off", 4)
|
||||
else:
|
||||
try:
|
||||
self.current_drum = pat
|
||||
self.engine.drums(pat, volume=0.5)
|
||||
self.log(f"Drums → {pat}", 5)
|
||||
except Exception as e:
|
||||
self.log(f"Error: {e}", 4)
|
||||
elif verb == "seed" and len(parts) == 1:
|
||||
self.log(f"Seed: {self.seed}", 2)
|
||||
elif verb == "seed" and len(parts) >= 2:
|
||||
try:
|
||||
self.seed = int(parts[1])
|
||||
rng = random.Random(self.seed)
|
||||
self.picks = rng.sample(self.instruments, 8)
|
||||
for i, inst in enumerate(self.picks, 1):
|
||||
self.engine.channel(i, instrument=inst, reverb=0.3)
|
||||
self.log(f"Seed → {self.seed}", 1)
|
||||
except ValueError:
|
||||
self.log("seed <number>", 4)
|
||||
elif verb == "list":
|
||||
for i in range(0, len(self.instruments), 3):
|
||||
row = " ".join(f"{x:22s}" for x in self.instruments[i:i+3])
|
||||
self.log(f" {row}", 2)
|
||||
elif verb == "patterns":
|
||||
for i in range(0, len(self.drum_patterns), 4):
|
||||
row = " ".join(f"{x:17s}" for x in self.drum_patterns[i:i+4])
|
||||
self.log(f" {row}", 2)
|
||||
elif verb == "pan" and len(parts) >= 3:
|
||||
try:
|
||||
n = int(parts[1])
|
||||
val = float(parts[2])
|
||||
val = max(-1.0, min(1.0, val))
|
||||
if n in self.engine.channels:
|
||||
self.engine.channels[n].pan = val
|
||||
self.log(f"Ch {n} pan={val:+.1f}", 1)
|
||||
else:
|
||||
self.log(f"Channel {n} not active", 4)
|
||||
except ValueError:
|
||||
self.log("pan <1-8> <-1..1>", 4)
|
||||
elif verb == "kbd":
|
||||
if len(parts) >= 2:
|
||||
try:
|
||||
ch_num = int(parts[1])
|
||||
self.engine._keyboard_channel = ch_num
|
||||
if len(parts) >= 3:
|
||||
self.engine._keyboard_octave = int(parts[2])
|
||||
except ValueError:
|
||||
self.log("kbd <channel> [octave]", 4)
|
||||
return
|
||||
else:
|
||||
self.engine._keyboard_channel = self.engine._keyboard_channel or 1
|
||||
self.kbd_active = True
|
||||
# Make sure wavetables are cached for this channel
|
||||
ch_num = self.engine._keyboard_channel
|
||||
if ch_num in self.engine.channels:
|
||||
channel = self.engine.channels[ch_num]
|
||||
if not channel._cache:
|
||||
self.log("Rendering wavetables...", 3)
|
||||
for midi_note in range(36, 97):
|
||||
channel._get_wave(midi_note, 44100 * 3)
|
||||
self.log(f"♪ Keyboard ON ch{ch_num} oct{self.engine._keyboard_octave} (Esc=exit, ↑↓=octave)", 1)
|
||||
elif verb == "rec":
|
||||
self.engine.start_recording()
|
||||
self.log("● Recording...", 4)
|
||||
elif verb == "stop" and self.engine._recording:
|
||||
self.engine.stop_recording()
|
||||
n = len(self.engine._record_events)
|
||||
self.log(f"■ Stopped ({n} events)", 3)
|
||||
elif verb == "export":
|
||||
fname = parts[1] if len(parts) > 1 else "recording.mid"
|
||||
score = self.engine.export_recording(fname)
|
||||
if score:
|
||||
self.log(f"Exported → {fname}", 1)
|
||||
else:
|
||||
self.log("Nothing to export", 4)
|
||||
elif verb == "save" and len(parts) >= 2:
|
||||
fname = parts[1]
|
||||
if not fname.endswith(".json"):
|
||||
fname += ".json"
|
||||
self.engine.seed = self.seed
|
||||
self.engine.save_config(fname)
|
||||
self.log(f"Saved → {fname}", 1)
|
||||
elif verb == "load" and len(parts) >= 2:
|
||||
fname = parts[1]
|
||||
if not fname.endswith(".json"):
|
||||
fname += ".json"
|
||||
try:
|
||||
self.engine.load_config(fname)
|
||||
self.log(f"Loaded ← {fname}", 1)
|
||||
# Update picks from channels
|
||||
for ch, channel in self.engine.channels.items():
|
||||
if 1 <= ch <= 8:
|
||||
self.picks[ch - 1] = channel.synth_name
|
||||
except Exception as e:
|
||||
self.log(f"Error: {e}", 4)
|
||||
elif verb == "octave" and len(parts) >= 2:
|
||||
try:
|
||||
self.engine._keyboard_octave = int(parts[1])
|
||||
self.log(f"Keyboard octave → {self.engine._keyboard_octave}", 2)
|
||||
except ValueError:
|
||||
self.log("octave <0-8>", 4)
|
||||
else:
|
||||
self.log(f"? {cmd}", 4)
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="PyTheory Live — real-time MIDI synthesizer")
|
||||
parser.add_argument("seed", nargs="?", type=int, default=None, help="Random seed for instruments")
|
||||
parser.add_argument("--port", "-p", default="OP-XY", help="MIDI port name (default: OP-XY)")
|
||||
parser.add_argument("--channels", "-c", type=int, default=8, help="Number of channels (default: 8)")
|
||||
parser.add_argument("--drums", "-d", default="rock", help="Drum pattern (default: rock, 'none' to disable)")
|
||||
parser.add_argument("--buffer", "-b", type=int, default=128, help="Audio buffer size (default: 128)")
|
||||
args = parser.parse_args()
|
||||
|
||||
tui = LiveTUI(seed=args.seed, port=args.port, n_channels=args.channels,
|
||||
drum_pattern=args.drums, buffer_size=args.buffer)
|
||||
curses.wrapper(tui.run)
|
||||
|
||||
# Print resume command on exit
|
||||
cmd_parts = ["pytheory-live", str(tui.seed)]
|
||||
if tui.port != "OP-XY":
|
||||
cmd_parts += ["--port", tui.port]
|
||||
if tui.n_channels != 8:
|
||||
cmd_parts += ["--channels", str(tui.n_channels)]
|
||||
if tui.current_drum != "rock":
|
||||
cmd_parts += ["--drums", tui.current_drum]
|
||||
if tui.buffer_size != 128:
|
||||
cmd_parts += ["--buffer", str(tui.buffer_size)]
|
||||
print(f"\nResume this session with:\n {' '.join(cmd_parts)}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+447
-63
@@ -279,32 +279,32 @@ def strings_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||||
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
|
||||
# Vectorized harmonic synthesis with body resonance
|
||||
harmonics = numpy.arange(1, n_harmonics + 1, dtype=numpy.float64)
|
||||
freqs = hz * harmonics
|
||||
valid = freqs < nyquist
|
||||
harmonics = harmonics[valid]
|
||||
freqs = freqs[valid]
|
||||
n_valid = len(harmonics)
|
||||
|
||||
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)
|
||||
# Body resonance — vectorized
|
||||
air_f = max(200, min(400, hz * 1.5))
|
||||
body = (1.0
|
||||
+ 0.6 * numpy.exp(-((freqs - air_f) / 100) ** 2)
|
||||
+ 0.4 * numpy.exp(-((freqs - 1000) / 300) ** 2)
|
||||
+ 0.3 * numpy.exp(-((freqs - 2500) / 500) ** 2))
|
||||
|
||||
# Amplitude: 1/n with body shaping, even harmonics weaker
|
||||
amps = (1.0 / harmonics) * body
|
||||
amps[1::2] *= 0.85 # even harmonics (index 1,3,5... = harmonic 2,4,6...)
|
||||
|
||||
# Random phases
|
||||
phases = rng.uniform(0, 2 * numpy.pi, n_valid)
|
||||
|
||||
# Process in small batches to stay cache-friendly
|
||||
for i in range(n_valid):
|
||||
phase = 2 * numpy.pi * (freqs[i] * t + vibrato * harmonics[i] / hz) + phases[i]
|
||||
wave += amps[i] * numpy.sin(phase)
|
||||
|
||||
# Normalize
|
||||
max_val = numpy.abs(wave).max()
|
||||
@@ -478,6 +478,246 @@ def rhodes_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||||
return (peak * wave).astype(numpy.int16)
|
||||
|
||||
|
||||
def wurlitzer_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||||
"""Wurlitzer electric piano — vibrating steel reed over a pickup.
|
||||
|
||||
Unlike the Rhodes (tine + tonebar), the Wurlitzer uses a flat
|
||||
steel reed that vibrates near an electrostatic pickup. The result
|
||||
is more nasal, reedy, and biting — especially when driven hard.
|
||||
Think Supertramp, Ray Charles, early Billy Joel. It barks and
|
||||
growls in a way the Rhodes never does.
|
||||
"""
|
||||
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
|
||||
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
|
||||
|
||||
# Faster decay than Rhodes — reeds don't sustain like tines
|
||||
decay = numpy.where(t < 0.1,
|
||||
numpy.exp(-5.0 * t),
|
||||
numpy.exp(-5.0 * 0.1) * numpy.exp(-2.0 * (t - 0.1)))
|
||||
|
||||
wave = numpy.zeros(n_samples, dtype=numpy.float64)
|
||||
brightness = numpy.clip((hz - 65) / 800, 0.0, 1.0)
|
||||
|
||||
# Reed harmonics — more odd harmonics than Rhodes (nasal character)
|
||||
reed_harmonics = [
|
||||
(1, 1.0),
|
||||
(2, 0.4), # less 2nd than Rhodes
|
||||
(3, 0.5 + 0.15 * brightness), # strong 3rd — the nasal quality
|
||||
(4, 0.15),
|
||||
(5, 0.25 + 0.1 * brightness), # strong odd harmonics
|
||||
(6, 0.08),
|
||||
(7, 0.12), # 7th present — reed buzz
|
||||
]
|
||||
|
||||
for n, amp in reed_harmonics:
|
||||
f_n = hz * n
|
||||
if f_n >= SAMPLE_RATE / 2:
|
||||
break
|
||||
h_decay = decay * numpy.exp(-(1.0 + 0.4 * brightness) * (n - 1) * t)
|
||||
phase = rng.uniform(0, 2 * numpy.pi)
|
||||
wave += amp * numpy.sin(2 * numpy.pi * f_n * t + phase) * h_decay
|
||||
|
||||
# Reed buzz — slight asymmetric distortion at attack
|
||||
# This is the "bark" when you hit hard
|
||||
attack_len = min(int(SAMPLE_RATE * 0.03), n_samples)
|
||||
attack_env = numpy.zeros(n_samples, dtype=numpy.float64)
|
||||
attack_env[:attack_len] = numpy.exp(-numpy.linspace(0, 6, attack_len))
|
||||
wave += numpy.tanh(wave * 3.0 * attack_env) * 0.15
|
||||
|
||||
# Electrostatic pickup character — slightly compressed/nasal
|
||||
wave = numpy.tanh(wave * 1.1) / 1.1
|
||||
|
||||
mx = numpy.abs(wave).max()
|
||||
if mx > 0:
|
||||
wave /= mx
|
||||
|
||||
return (peak * wave).astype(numpy.int16)
|
||||
|
||||
|
||||
def vibraphone_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||||
"""Vibraphone — struck aluminum bars with motor-driven tremolo.
|
||||
|
||||
Metal bars hit with soft mallets, resonator tubes underneath,
|
||||
and a spinning disc (motor) that modulates the sound creating
|
||||
the signature vibraphone shimmer/tremolo.
|
||||
"""
|
||||
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
|
||||
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
|
||||
|
||||
# Long sustain — bars ring for seconds
|
||||
decay = numpy.exp(-0.8 * t)
|
||||
|
||||
wave = numpy.zeros(n_samples, dtype=numpy.float64)
|
||||
|
||||
# Metal bar modes — slightly inharmonic
|
||||
bar_modes = [
|
||||
(1.0, 1.0), # fundamental
|
||||
(2.76, 0.3), # first overtone (not 2x — bars are inharmonic)
|
||||
(5.4, 0.12), # second overtone
|
||||
(8.93, 0.04), # third
|
||||
]
|
||||
|
||||
for ratio, amp in bar_modes:
|
||||
f = hz * ratio
|
||||
if f >= SAMPLE_RATE / 2:
|
||||
break
|
||||
mode_decay = decay * numpy.exp(-0.5 * (ratio - 1) * t)
|
||||
phase = rng.uniform(0, 2 * numpy.pi)
|
||||
wave += amp * numpy.sin(2 * numpy.pi * f * t + phase) * mode_decay
|
||||
|
||||
# Motor tremolo — spinning disc modulates amplitude at ~5-7 Hz
|
||||
motor_rate = 5.5
|
||||
motor_depth = 0.35
|
||||
# Motor takes a moment to spin up
|
||||
motor_env = 1.0 - numpy.exp(-2.0 * t)
|
||||
tremolo = 1.0 - motor_depth * motor_env * (0.5 + 0.5 * numpy.sin(2 * numpy.pi * motor_rate * t))
|
||||
wave *= tremolo
|
||||
|
||||
# Soft mallet attack
|
||||
mallet_len = min(int(SAMPLE_RATE * 0.005), n_samples)
|
||||
mallet = rng.uniform(-0.15, 0.15, mallet_len).astype(numpy.float64)
|
||||
mallet *= numpy.exp(-numpy.linspace(0, 12, mallet_len))
|
||||
wave[:mallet_len] += mallet
|
||||
|
||||
mx = numpy.abs(wave).max()
|
||||
if mx > 0:
|
||||
wave /= mx
|
||||
|
||||
return (peak * wave).astype(numpy.int16)
|
||||
|
||||
|
||||
def pipe_organ_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||||
"""Pipe organ — air through ranks of pipes, multiple stops.
|
||||
|
||||
The pipe organ is additive synthesis incarnate — each stop adds
|
||||
a rank of pipes at a specific harmonic. We model a classic
|
||||
registration: principal 8', octave 4', fifteenth 2', mixture.
|
||||
Constant air pressure means no dynamics — always full and sustained.
|
||||
"""
|
||||
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
|
||||
|
||||
wave = numpy.zeros(n_samples, dtype=numpy.float64)
|
||||
|
||||
# Principal 8' — the fundamental organ tone
|
||||
# Pipe harmonics with subtle wind noise
|
||||
for n in range(1, 12):
|
||||
f_n = hz * n
|
||||
if f_n >= SAMPLE_RATE / 2:
|
||||
break
|
||||
# Pipe spectral shape — principalish
|
||||
if n == 1:
|
||||
amp = 1.0
|
||||
elif n == 2:
|
||||
amp = 0.6
|
||||
elif n == 3:
|
||||
amp = 0.4
|
||||
elif n <= 6:
|
||||
amp = 0.2 / n
|
||||
else:
|
||||
amp = 0.08 / n
|
||||
wave += amp * numpy.sin(2 * numpy.pi * f_n * t)
|
||||
|
||||
# Octave 4' stop — one octave up
|
||||
for n in range(1, 8):
|
||||
f_n = hz * 2 * n
|
||||
if f_n >= SAMPLE_RATE / 2:
|
||||
break
|
||||
amp = (0.4 if n == 1 else 0.15 / n)
|
||||
wave += amp * numpy.sin(2 * numpy.pi * f_n * t)
|
||||
|
||||
# Fifteenth 2' — two octaves up, brightness
|
||||
wave += 0.2 * numpy.sin(2 * numpy.pi * hz * 4 * t)
|
||||
wave += 0.08 * numpy.sin(2 * numpy.pi * hz * 5 * t)
|
||||
|
||||
# Subtle wind/chiff noise at attack
|
||||
chiff_len = min(int(SAMPLE_RATE * 0.04), n_samples)
|
||||
chiff = _noise(chiff_len).astype(numpy.float64) * 0.08
|
||||
chiff *= numpy.exp(-numpy.linspace(0, 10, chiff_len))
|
||||
wave[:chiff_len] += chiff
|
||||
|
||||
# Constant amplitude — organ doesn't decay
|
||||
# Just a tiny fade-in to avoid click
|
||||
fadein = min(int(SAMPLE_RATE * 0.01), n_samples)
|
||||
wave[:fadein] *= numpy.linspace(0, 1, fadein)
|
||||
|
||||
mx = numpy.abs(wave).max()
|
||||
if mx > 0:
|
||||
wave /= mx
|
||||
|
||||
return (peak * wave).astype(numpy.int16)
|
||||
|
||||
|
||||
def choir_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE, lyric="ah"):
|
||||
"""Choir — voices singing vowels shaped by strong formant filters.
|
||||
|
||||
The key to vocal sound is FORMANTS — resonant peaks from the
|
||||
vocal tract shape. We generate a rich glottal source then filter
|
||||
it hard through formant bandpass filters. The formants are what
|
||||
make "ah" sound different from "oo".
|
||||
"""
|
||||
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
|
||||
|
||||
# Vowel formant frequencies + bandwidths (Hz) — F1, F2, F3, F4
|
||||
_FORMANTS = {
|
||||
"ah": [(730, 90), (1090, 110), (2440, 170), (3400, 250)],
|
||||
"ee": [(270, 60), (2290, 200), (3010, 300), (3500, 250)],
|
||||
"oh": [(570, 80), (840, 100), (2410, 170), (3400, 250)],
|
||||
"oo": [(300, 50), (870, 90), (2240, 170), (3400, 250)],
|
||||
"eh": [(530, 70), (1840, 150), (2480, 200), (3400, 250)],
|
||||
}
|
||||
formants = _FORMANTS.get(lyric, _FORMANTS["ah"])
|
||||
|
||||
# Glottal source — rich buzz with all harmonics
|
||||
n_harmonics = min(25, int((SAMPLE_RATE / 2) / hz))
|
||||
|
||||
# No per-harmonic vibrato — it causes amplitude wobble through formants.
|
||||
# Choir vibrato comes from the ensemble= parameter instead (natural
|
||||
# pitch variation between voices).
|
||||
source = numpy.zeros(n_samples, dtype=numpy.float64)
|
||||
for n in range(1, n_harmonics + 1):
|
||||
f_n = hz * n
|
||||
if f_n >= SAMPLE_RATE / 2:
|
||||
break
|
||||
# Glottal slope: -12dB/octave
|
||||
amp = 1.0 / (n * n) * 4.0
|
||||
source += amp * numpy.sin(2 * numpy.pi * f_n * t)
|
||||
|
||||
# Filter through formants — this is where the voice happens
|
||||
wave = numpy.zeros(n_samples, dtype=numpy.float64)
|
||||
for fc, bw in formants:
|
||||
lo = max(20, fc - bw)
|
||||
hi = min(SAMPLE_RATE // 2 - 1, fc + bw)
|
||||
if lo < hi:
|
||||
bp, ap = scipy.signal.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
|
||||
filtered = scipy.signal.lfilter(bp, ap, source)
|
||||
# Boost formants proportionally
|
||||
gain = 1.0 if fc < 1000 else 0.7
|
||||
wave += filtered * gain
|
||||
|
||||
# Breathy onset — air before phonation
|
||||
breath_len = min(int(SAMPLE_RATE * 0.08), n_samples)
|
||||
breath = _noise(breath_len).astype(numpy.float64) * 0.04
|
||||
# Filter breath through formants too
|
||||
for fc, bw in formants[:2]:
|
||||
lo = max(20, fc - bw * 2)
|
||||
hi = min(SAMPLE_RATE // 2 - 1, fc + bw * 2)
|
||||
if lo < hi:
|
||||
bp, ap = scipy.signal.butter(1, [lo, hi], btype='band', fs=SAMPLE_RATE)
|
||||
breath = scipy.signal.lfilter(bp, ap, numpy.pad(breath, (0, max(0, n_samples - breath_len))))[:breath_len]
|
||||
breath *= numpy.exp(-numpy.linspace(0, 5, breath_len))
|
||||
wave[:breath_len] += breath
|
||||
|
||||
# Gentle attack
|
||||
attack_len = min(int(SAMPLE_RATE * 0.06), n_samples)
|
||||
wave[:attack_len] *= numpy.linspace(0, 1, attack_len)
|
||||
|
||||
mx = numpy.abs(wave).max()
|
||||
if mx > 0:
|
||||
wave /= mx
|
||||
|
||||
return (peak * wave).astype(numpy.int16)
|
||||
|
||||
|
||||
def bass_guitar_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||||
"""Bass guitar — plucked thick string with magnetic pickup.
|
||||
|
||||
@@ -1808,6 +2048,107 @@ def sitar_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||||
return (peak * out).astype(numpy.int16)
|
||||
|
||||
|
||||
def singing_bowl_strike_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||||
"""Singing bowl strike — mallet hit that excites all modes at once.
|
||||
|
||||
The initial hit produces a bright chirp as the higher partials
|
||||
ring momentarily, then the sound settles into the fundamental
|
||||
with slow beating. Higher modes decay fast, the fundamental
|
||||
rings for seconds.
|
||||
"""
|
||||
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
|
||||
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
|
||||
|
||||
wave = numpy.zeros(n_samples, dtype=numpy.float64)
|
||||
|
||||
# Bowl modal ratios — measured from real Himalayan bowls.
|
||||
# (ratio, amplitude, decay_rate, beat_hz)
|
||||
# The beat_hz is the frequency split between near-degenerate
|
||||
# mode pairs — this is what makes the bowl shimmer.
|
||||
bowl_modes = [
|
||||
(1.0, 1.0, 0.4, 0.3), # fundamental — very slow beat, long ring
|
||||
(2.71, 0.45, 0.8, 0.6), # second partial
|
||||
(5.12, 0.22, 1.6, 0.9), # third — prominent in the chirp
|
||||
(8.26, 0.12, 3.5, 1.2), # fourth — fast decay, bright
|
||||
(12.1, 0.06, 6.0, 1.5), # fifth — just a flash
|
||||
]
|
||||
|
||||
for ratio, amp, decay_rate, beat_hz in bowl_modes:
|
||||
f = hz * ratio
|
||||
if f >= SAMPLE_RATE / 2:
|
||||
break
|
||||
|
||||
phase1 = rng.uniform(0, 2 * numpy.pi)
|
||||
phase2 = rng.uniform(0, 2 * numpy.pi)
|
||||
|
||||
f_split = beat_hz / 2.0
|
||||
mode_decay = numpy.exp(-decay_rate * t)
|
||||
tone1 = numpy.sin(2 * numpy.pi * (f - f_split) * t + phase1)
|
||||
tone2 = numpy.sin(2 * numpy.pi * (f + f_split) * t + phase2)
|
||||
wave += amp * (tone1 + tone2) * 0.5 * mode_decay
|
||||
|
||||
# Strike chirp — higher partials ring briefly on impact
|
||||
chirp_len = min(int(SAMPLE_RATE * 0.04), n_samples)
|
||||
chirp_t = numpy.linspace(0, 1, chirp_len)
|
||||
chirp_freq = hz * (3.0 - 2.0 * chirp_t)
|
||||
chirp_phase = numpy.cumsum(chirp_freq) / SAMPLE_RATE * 2 * numpy.pi
|
||||
chirp = 0.6 * numpy.sin(chirp_phase) * numpy.exp(-chirp_t * 8)
|
||||
wave[:chirp_len] += chirp
|
||||
|
||||
mx = numpy.abs(wave).max()
|
||||
if mx > 0:
|
||||
wave /= mx
|
||||
|
||||
return (peak * 0.8 * wave).astype(numpy.int16)
|
||||
|
||||
|
||||
def singing_bowl_ring_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||||
"""Singing bowl ring — sustained rubbing around the rim with a mallet.
|
||||
|
||||
When you rub the rim, the bowl builds up slowly as the mallet
|
||||
continuously feeds energy into the resonance. The fundamental
|
||||
dominates with strong beating. Higher partials come and go as
|
||||
the mallet catches different modes. The sound has a pulsing,
|
||||
breathing quality from the slow amplitude modulation.
|
||||
"""
|
||||
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
|
||||
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
|
||||
|
||||
wave = numpy.zeros(n_samples, dtype=numpy.float64)
|
||||
|
||||
# Rim rubbing excites the fundamental most, but upper modes
|
||||
# shimmer in as the mallet catches them
|
||||
bowl_modes = [
|
||||
(1.0, 1.0, 0.15, 0.4), # fundamental — very slow decay, gentle beat
|
||||
(2.71, 0.35, 0.35, 0.7), # second — stronger presence
|
||||
(5.12, 0.18, 0.7, 1.0), # third — audible shimmer
|
||||
(8.26, 0.08, 1.2, 1.3), # fourth — bright ring
|
||||
]
|
||||
|
||||
for ratio, amp, decay_rate, beat_hz in bowl_modes:
|
||||
f = hz * ratio
|
||||
if f >= SAMPLE_RATE / 2:
|
||||
break
|
||||
|
||||
phase1 = rng.uniform(0, 2 * numpy.pi)
|
||||
phase2 = rng.uniform(0, 2 * numpy.pi)
|
||||
|
||||
f_split = beat_hz / 2.0
|
||||
# Slow build-up envelope — rim rubbing takes time to excite
|
||||
buildup = 1.0 - numpy.exp(-2.0 * t / (ratio * 0.5))
|
||||
# Gentle decay after the buildup
|
||||
sustain_env = buildup * numpy.exp(-decay_rate * numpy.maximum(0, t - 0.5))
|
||||
tone1 = numpy.sin(2 * numpy.pi * (f - f_split) * t + phase1)
|
||||
tone2 = numpy.sin(2 * numpy.pi * (f + f_split) * t + phase2)
|
||||
wave += amp * (tone1 + tone2) * 0.5 * sustain_env
|
||||
|
||||
mx = numpy.abs(wave).max()
|
||||
if mx > 0:
|
||||
wave /= mx
|
||||
|
||||
return (peak * 0.8 * 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.
|
||||
|
||||
@@ -1946,6 +2287,8 @@ class Synth(Enum):
|
||||
ACOUSTIC_GUITAR = "acoustic_guitar_synth"
|
||||
SITAR = "sitar_synth"
|
||||
ELECTRIC_GUITAR = "electric_guitar_synth"
|
||||
SINGING_BOWL_STRIKE = "singing_bowl_strike_synth"
|
||||
SINGING_BOWL_RING = "singing_bowl_ring_synth"
|
||||
|
||||
def __call__(self, hz, **kwargs):
|
||||
"""Make Synth members callable — dispatches to the wave function."""
|
||||
@@ -1959,6 +2302,8 @@ _SYNTH_FUNCTIONS = {
|
||||
"pwm_slow": pwm_slow_wave, "pwm_fast": pwm_fast_wave,
|
||||
"pluck_synth": pluck_wave, "organ_synth": organ_wave,
|
||||
"strings_synth": strings_wave, "piano_synth": piano_wave, "rhodes_synth": rhodes_wave,
|
||||
"wurlitzer_synth": wurlitzer_wave, "vibraphone_synth": vibraphone_wave,
|
||||
"pipe_organ_synth": pipe_organ_wave, "choir_synth": choir_wave,
|
||||
"bass_guitar_synth": bass_guitar_wave, "flute_synth": flute_wave,
|
||||
"trumpet_synth": trumpet_wave, "clarinet_synth": clarinet_wave,
|
||||
"marimba_synth": marimba_wave, "oboe_synth": oboe_wave,
|
||||
@@ -1974,6 +2319,8 @@ _SYNTH_FUNCTIONS = {
|
||||
"ukulele_synth": ukulele_wave,
|
||||
"acoustic_guitar_synth": acoustic_guitar_wave,
|
||||
"sitar_synth": sitar_wave, "electric_guitar_synth": electric_guitar_wave,
|
||||
"singing_bowl_strike_synth": singing_bowl_strike_wave,
|
||||
"singing_bowl_ring_synth": singing_bowl_ring_wave,
|
||||
}
|
||||
|
||||
|
||||
@@ -3275,7 +3622,7 @@ def _render_drum_hit(sound_value, n_samples):
|
||||
DrumSound.TABLA_DHA.value: lambda n: _synth_tabla_dha(n),
|
||||
DrumSound.TABLA_TIT.value: lambda n: _synth_tabla_tit(n),
|
||||
DrumSound.TABLA_KE.value: lambda n: _synth_tabla_ke(n),
|
||||
DrumSound.TABLA_GE_BEND.value: lambda n: _synth_tabla_ge_bend(n),
|
||||
DrumSound.TABLA_GE_BEND.value: _synth_tabla_ge_bend,
|
||||
# Dhol
|
||||
DrumSound.DHOL_DAGGA.value: lambda n: _synth_dhol_dagga(n),
|
||||
DrumSound.DHOL_TILLI.value: lambda n: _synth_dhol_tilli(n),
|
||||
@@ -3326,6 +3673,9 @@ def _render_drum_hit(sound_value, n_samples):
|
||||
|
||||
renderer = _dispatch.get(sound_value, lambda n: _synth_clave(n))
|
||||
result = renderer(n_samples)
|
||||
# Override for ge_bend — dispatch closure has stale reference
|
||||
if sound_value == 108:
|
||||
result = _synth_tabla_ge_bend(n_samples)
|
||||
return result
|
||||
|
||||
|
||||
@@ -4451,6 +4801,7 @@ def _render_notes_to_buf(notes, buf, samples_per_beat, total_samples,
|
||||
|
||||
a, d, s, r = envelope_tuple
|
||||
_skw = synth_kwargs or {}
|
||||
_synth_cache = {} # (hz, n_samples) → waveform, avoids resynthesizing same note
|
||||
beat_pos = 0.0
|
||||
for note_index, note in enumerate(notes):
|
||||
if note.tone is not None:
|
||||
@@ -4566,9 +4917,16 @@ def _render_notes_to_buf(notes, buf, samples_per_beat, total_samples,
|
||||
note_lyric = getattr(note, 'lyric', '')
|
||||
if note_lyric:
|
||||
note_skw['lyric'] = note_lyric
|
||||
# Render oscillators
|
||||
waves = [synth_fn(hz, n_samples=n_samples, **note_skw)
|
||||
for hz in pitches]
|
||||
# Render oscillators (cached per hz+n_samples)
|
||||
waves = []
|
||||
for hz in pitches:
|
||||
cache_key = (hz, n_samples, tuple(sorted(note_skw.items())))
|
||||
if cache_key in _synth_cache:
|
||||
waves.append(_synth_cache[cache_key].copy())
|
||||
else:
|
||||
w = synth_fn(hz, n_samples=n_samples, **note_skw)
|
||||
_synth_cache[cache_key] = w
|
||||
waves.append(w)
|
||||
# Sub-oscillator: octave-below sine
|
||||
if sub_osc > 0:
|
||||
for hz in pitches:
|
||||
@@ -4816,37 +5174,23 @@ def render_score(score):
|
||||
|
||||
n_ensemble = max(1, getattr(part, 'ensemble', 1))
|
||||
|
||||
for _ens_i in range(n_ensemble):
|
||||
# Each ensemble voice gets its own buffer
|
||||
ens_buf = part_buf if n_ensemble == 1 else numpy.zeros(total_samples, dtype=numpy.float32)
|
||||
# Ensemble voices get micro-variations
|
||||
ens_humanize = part.humanize
|
||||
ens_analog = part.analog
|
||||
if n_ensemble > 1:
|
||||
import random as _ens_rnd
|
||||
_ens_rnd.seed(42 + _ens_i * 7)
|
||||
# Hybrid approach:
|
||||
# 1. Consistent player tendency (rush/drag) — seeded per player
|
||||
_player_tendency = _ens_rnd.gauss(0, 0.018)
|
||||
# 2. Tiny per-note wobble on top
|
||||
ens_humanize = max(part.humanize, 0.012)
|
||||
# Each player's drum tuned slightly different
|
||||
ens_analog = max(part.analog, 0.06 + _ens_rnd.uniform(0, 0.08))
|
||||
|
||||
if n_ensemble > 1:
|
||||
# FAST ENSEMBLE: render once, duplicate with time shifts
|
||||
# Render the "reference" voice with light humanize
|
||||
if part.legato:
|
||||
_render_legato_to_buf(
|
||||
part.notes, ens_buf, samples_per_beat, total_samples,
|
||||
part.notes, part_buf, samples_per_beat, total_samples,
|
||||
synth_fn, env_tuple, part.volume, score.bpm,
|
||||
glide_time=part.glide, swing=effective_swing,
|
||||
tempo_map=tempo_map if has_tempo_changes else None,
|
||||
temperament=_temperament, reference_pitch=_ref_pitch)
|
||||
else:
|
||||
_render_notes_to_buf(
|
||||
part.notes, ens_buf, samples_per_beat, total_samples,
|
||||
part.notes, part_buf, samples_per_beat, total_samples,
|
||||
synth_fn, env_tuple, part.volume, score.bpm,
|
||||
swing=effective_swing,
|
||||
tempo_map=tempo_map if has_tempo_changes else None,
|
||||
humanize=ens_humanize,
|
||||
humanize=part.humanize,
|
||||
detune=part.detune,
|
||||
spread=part.spread,
|
||||
stereo_buf=stereo_buf,
|
||||
@@ -4861,23 +5205,63 @@ def render_score(score):
|
||||
synth_kwargs=synth_kwargs,
|
||||
temperament=_temperament,
|
||||
reference_pitch=_ref_pitch,
|
||||
analog=ens_analog)
|
||||
analog=part.analog)
|
||||
|
||||
if n_ensemble > 1:
|
||||
# Shift the whole voice by the player's consistent tendency
|
||||
# (some players rush, some drag — this is fixed per player)
|
||||
# Now duplicate with per-player offsets (cheap buffer ops)
|
||||
import random as _ens_rnd
|
||||
ref_buf = part_buf.copy()
|
||||
part_buf *= 1.0 / n_ensemble # scale down the reference voice
|
||||
|
||||
for _ens_i in range(1, n_ensemble):
|
||||
_ens_rnd.seed(42 + _ens_i * 7)
|
||||
_player_tendency = _ens_rnd.gauss(0, 0.018)
|
||||
shift_samples = int(_player_tendency * samples_per_beat)
|
||||
|
||||
voice = ref_buf.copy()
|
||||
|
||||
# Time shift — player rushes or drags
|
||||
if shift_samples > 0 and shift_samples < total_samples:
|
||||
# Player drags — shift right
|
||||
shifted = numpy.zeros_like(ens_buf)
|
||||
shifted[shift_samples:] = ens_buf[:-shift_samples]
|
||||
ens_buf = shifted
|
||||
voice[shift_samples:] = voice[:-shift_samples].copy()
|
||||
voice[:shift_samples] = 0
|
||||
elif shift_samples < 0 and abs(shift_samples) < total_samples:
|
||||
# Player rushes — shift left
|
||||
shifted = numpy.zeros_like(ens_buf)
|
||||
shifted[:shift_samples] = ens_buf[-shift_samples:]
|
||||
ens_buf = shifted
|
||||
part_buf += ens_buf / n_ensemble
|
||||
voice[:shift_samples] = voice[-shift_samples:].copy()
|
||||
voice[shift_samples:] = 0
|
||||
|
||||
# Slight velocity variation per voice
|
||||
vel_var = 1.0 + _ens_rnd.gauss(0, 0.04)
|
||||
voice *= vel_var
|
||||
|
||||
part_buf += voice / n_ensemble
|
||||
else:
|
||||
if part.legato:
|
||||
_render_legato_to_buf(
|
||||
part.notes, part_buf, samples_per_beat, total_samples,
|
||||
synth_fn, env_tuple, part.volume, score.bpm,
|
||||
glide_time=part.glide, swing=effective_swing,
|
||||
tempo_map=tempo_map if has_tempo_changes else None,
|
||||
temperament=_temperament, reference_pitch=_ref_pitch)
|
||||
else:
|
||||
_render_notes_to_buf(
|
||||
part.notes, part_buf, samples_per_beat, total_samples,
|
||||
synth_fn, env_tuple, part.volume, score.bpm,
|
||||
swing=effective_swing,
|
||||
tempo_map=tempo_map if has_tempo_changes else None,
|
||||
humanize=part.humanize,
|
||||
detune=part.detune,
|
||||
spread=part.spread,
|
||||
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,
|
||||
temperament=_temperament,
|
||||
reference_pitch=_ref_pitch,
|
||||
analog=part.analog)
|
||||
|
||||
# Apply effects — segmented if automation exists
|
||||
auto_points = part._get_automation_points()
|
||||
@@ -5145,7 +5529,7 @@ def render_score(score):
|
||||
buzz *= _exp_decay(buzz_len, 25)
|
||||
wave[:buzz_len] = wave[:buzz_len] + buzz.astype(numpy.float32)
|
||||
|
||||
mono_hit = wave * vel_scale * 0.7
|
||||
mono_hit = wave * vel_scale * 0.7 * drum_part.volume
|
||||
# Sidechain trigger — kick only
|
||||
if hit.sound.value == DrumSound.KICK.value:
|
||||
drum_buf[start:start + hit_len] += mono_hit
|
||||
|
||||
+22
-6
@@ -23,6 +23,15 @@ INSTRUMENTS = {
|
||||
"tremolo_depth": 0.12, "tremolo_rate": 4.5,
|
||||
"analog": 0.15,
|
||||
},
|
||||
"wurlitzer": {
|
||||
"synth": "wurlitzer_synth", "envelope": "none",
|
||||
"tremolo_depth": 0.18, "tremolo_rate": 5.0,
|
||||
"analog": 0.2,
|
||||
},
|
||||
"pipe_organ": {
|
||||
"synth": "pipe_organ_synth", "envelope": "none",
|
||||
"reverb": 0.5, "reverb_type": "cathedral",
|
||||
},
|
||||
"organ": {
|
||||
"synth": "organ_synth", "envelope": "organ",
|
||||
"chorus": 0.2, "chorus_rate": 5.5,
|
||||
@@ -256,6 +265,16 @@ INSTRUMENTS = {
|
||||
"lowpass": 4500,
|
||||
"humanize": 0.2,
|
||||
},
|
||||
"singing_bowl": {
|
||||
"synth": "singing_bowl_strike_synth", "envelope": "none",
|
||||
"reverb": 0.5,
|
||||
"humanize": 0.2,
|
||||
},
|
||||
"singing_bowl_ring": {
|
||||
"synth": "singing_bowl_ring_synth", "envelope": "none",
|
||||
"reverb": 0.5,
|
||||
"humanize": 0.2,
|
||||
},
|
||||
|
||||
# ── Synth presets ──
|
||||
"synth_lead": {
|
||||
@@ -303,8 +322,8 @@ INSTRUMENTS = {
|
||||
"humanize": 0.15,
|
||||
},
|
||||
"choir": {
|
||||
"synth": "vocal_synth", "envelope": "pad",
|
||||
"detune": 8, "spread": 0.4,
|
||||
"synth": "choir_synth", "envelope": "none",
|
||||
"detune": 6, "spread": 0.3, "ensemble": 6,
|
||||
"reverb": 0.45, "reverb_type": "cathedral",
|
||||
},
|
||||
"granular_texture": {
|
||||
@@ -321,10 +340,7 @@ INSTRUMENTS = {
|
||||
|
||||
# ── 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,
|
||||
"synth": "vibraphone_synth", "envelope": "none",
|
||||
"reverb": 0.3, "reverb_type": "plate",
|
||||
},
|
||||
"marimba": {
|
||||
|
||||
+730
@@ -0,0 +1,730 @@
|
||||
"""PyTheory Live — interactive MIDI synthesizer with TUI."""
|
||||
|
||||
import curses
|
||||
import random
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import os
|
||||
|
||||
from pytheory.live import LiveEngine
|
||||
from pytheory.rhythm import INSTRUMENTS, Pattern
|
||||
|
||||
|
||||
NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
|
||||
|
||||
|
||||
def note_name(midi):
|
||||
return f"{NOTE_NAMES[midi % 12]}{midi // 12 - 1}"
|
||||
|
||||
|
||||
class LiveTUI:
|
||||
def __init__(self, seed=None, port="OP-XY", n_channels=8,
|
||||
drum_pattern="rock", buffer_size=128):
|
||||
self.seed = seed or random.randint(0, 9999)
|
||||
self.port = port
|
||||
self.n_channels = n_channels
|
||||
self.buffer_size = buffer_size
|
||||
self.engine = None
|
||||
self.log_lines = []
|
||||
self.max_log = 500
|
||||
self.running = True
|
||||
self.instruments = sorted([k for k in INSTRUMENTS.keys()
|
||||
if k not in ("808_bass",)])
|
||||
self.drum_patterns = sorted(Pattern.list_presets())
|
||||
self.current_drum = drum_pattern
|
||||
self.picks = []
|
||||
self.bpm = "—"
|
||||
self.status = "Init"
|
||||
self.status_color = 3
|
||||
self._build_engine()
|
||||
|
||||
def _build_engine(self):
|
||||
rng = random.Random(self.seed)
|
||||
self.picks = rng.sample(self.instruments, min(self.n_channels, len(self.instruments)))
|
||||
self.engine = LiveEngine(buffer_size=self.buffer_size)
|
||||
for i, inst in enumerate(self.picks, 1):
|
||||
self.engine.channel(i, instrument=inst, reverb=0.3)
|
||||
if self.current_drum and self.current_drum not in ("none", "-"):
|
||||
self.engine.drums(self.current_drum, volume=0.5)
|
||||
self.engine.cc(0, "lowpass", min_val=300, max_val=8000)
|
||||
|
||||
def log(self, msg, color=0):
|
||||
self.log_lines.append((time.time(), msg, color))
|
||||
if len(self.log_lines) > self.max_log:
|
||||
self.log_lines = self.log_lines[-self.max_log:]
|
||||
|
||||
def _patch_engine_logging(self):
|
||||
original_cb = self.engine._midi_callback
|
||||
|
||||
def logging_cb(event, data=None):
|
||||
msg, _ = event
|
||||
if len(msg) == 0:
|
||||
return
|
||||
if msg[0] == 0xF8:
|
||||
if self.engine._bpm > 10:
|
||||
self.bpm = f"{self.engine._bpm:.0f}"
|
||||
elif msg[0] == 0xFA:
|
||||
self.log("▶ Start", 5)
|
||||
self.status = "Playing"
|
||||
self.status_color = 1
|
||||
elif msg[0] == 0xFC:
|
||||
self.log("■ Stop", 4)
|
||||
self.status = "Stopped"
|
||||
self.status_color = 4
|
||||
elif msg[0] == 0xFB:
|
||||
self.log("▶ Continue", 5)
|
||||
self.status = "Playing"
|
||||
self.status_color = 1
|
||||
elif len(msg) >= 3:
|
||||
status = msg[0]
|
||||
ch = (status & 0x0F) + 1
|
||||
msg_type = status & 0xF0
|
||||
if msg_type == 0x90 and msg[2] > 0:
|
||||
inst = self.picks[ch - 1] if 1 <= ch <= 8 else "?"
|
||||
self.log(f"♪ {ch}:{inst} {note_name(msg[1])} v={msg[2]}", 1)
|
||||
elif msg_type == 0xB0:
|
||||
self.log(f"⚙ CC{msg[1]}={msg[2]}", 3)
|
||||
elif msg_type == 0xE0:
|
||||
bend = ((msg[2] << 7) | msg[1]) - 8192
|
||||
self.log(f"↕ Bend ch{ch} {bend:+d}", 3)
|
||||
original_cb(event, data)
|
||||
|
||||
self.engine._midi_callback = logging_cb
|
||||
|
||||
def run(self, stdscr):
|
||||
curses.curs_set(0)
|
||||
curses.use_default_colors()
|
||||
curses.init_pair(1, curses.COLOR_GREEN, -1)
|
||||
curses.init_pair(2, curses.COLOR_CYAN, -1)
|
||||
curses.init_pair(3, curses.COLOR_YELLOW, -1)
|
||||
curses.init_pair(4, curses.COLOR_RED, -1)
|
||||
curses.init_pair(5, curses.COLOR_MAGENTA, -1)
|
||||
curses.init_pair(6, curses.COLOR_BLACK, curses.COLOR_BLUE)
|
||||
curses.init_pair(7, curses.COLOR_BLACK, curses.COLOR_GREEN)
|
||||
curses.init_pair(8, curses.COLOR_BLACK, curses.COLOR_YELLOW)
|
||||
curses.init_pair(9, curses.COLOR_BLACK, curses.COLOR_RED)
|
||||
stdscr.nodelay(True)
|
||||
stdscr.timeout(60)
|
||||
|
||||
self._patch_engine_logging()
|
||||
|
||||
# Pre-render with progress
|
||||
self.status = "Rendering"
|
||||
self.status_color = 3
|
||||
stdscr.erase()
|
||||
stdscr.addstr(1, 2, "PyTheory Live", curses.A_BOLD)
|
||||
stdscr.addstr(2, 2, "Pre-rendering wavetables...", curses.color_pair(3))
|
||||
stdscr.refresh()
|
||||
|
||||
n_samples = 44100 * 3
|
||||
count = 0
|
||||
for _, channel in self.engine.channels.items():
|
||||
if channel.is_drums:
|
||||
continue
|
||||
for midi_note in range(36, 97):
|
||||
channel._get_wave(midi_note, n_samples)
|
||||
count += 1
|
||||
if count % 50 == 0:
|
||||
stdscr.addstr(3, 2, f" {count} wavetables...", curses.color_pair(2))
|
||||
stdscr.refresh()
|
||||
|
||||
self.log(f"Cached {count} wavetables", 1)
|
||||
self.log("Starting engine...", 3)
|
||||
self.status = "Starting"
|
||||
self.status_color = 3
|
||||
|
||||
# Start engine
|
||||
def run_engine():
|
||||
import io
|
||||
capture = io.StringIO()
|
||||
old = sys.stdout
|
||||
sys.stdout = capture
|
||||
try:
|
||||
self.engine.start(port=self.port)
|
||||
except Exception as e:
|
||||
self.log(f"Engine error: {e}", 4)
|
||||
finally:
|
||||
sys.stdout = old
|
||||
output = capture.getvalue()
|
||||
if output.strip():
|
||||
for line in output.strip().split('\n'):
|
||||
self.log(line.strip(), 2)
|
||||
|
||||
engine_thread = threading.Thread(target=run_engine, daemon=True)
|
||||
engine_thread.start()
|
||||
|
||||
cmd_buf = ""
|
||||
cursor_pos = 0
|
||||
cmd_history = []
|
||||
history_idx = -1
|
||||
tab_matches = []
|
||||
tab_idx = -1
|
||||
tab_prefix = ""
|
||||
self.kbd_active = False
|
||||
self._kbd_held = {} # key → last_press_time
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
# Update status from engine state
|
||||
if (self.engine._stream and self.engine._stream.active
|
||||
and self.status == "Starting"):
|
||||
self.status = "Listening"
|
||||
self.status_color = 2
|
||||
self.log("Audio stream active", 1)
|
||||
|
||||
h, w = stdscr.getmaxyx()
|
||||
if h < 10 or w < 40:
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
stdscr.erase()
|
||||
|
||||
div = max(30, w * 3 // 5)
|
||||
cfg_x = div + 2
|
||||
|
||||
# ═══ HEADER BAR ═══
|
||||
header = f" PyTheory Live "
|
||||
stdscr.addstr(0, 0, header, curses.color_pair(6) | curses.A_BOLD)
|
||||
|
||||
# Status badge
|
||||
badge_colors = {1: 7, 2: 6, 3: 8, 4: 9}
|
||||
badge_cp = badge_colors.get(self.status_color, 6)
|
||||
badge = f" {self.status} "
|
||||
x = len(header)
|
||||
stdscr.addstr(0, x, badge, curses.color_pair(badge_cp) | curses.A_BOLD)
|
||||
x += len(badge)
|
||||
|
||||
kbd_mode = ""
|
||||
if self.engine._keyboard_channel:
|
||||
kbd_mode = f" KBD:ch{self.engine._keyboard_channel}"
|
||||
rec_mode = " ●REC" if self.engine._recording else ""
|
||||
info = f" BPM:{self.bpm} drums:{self.current_drum} seed:{self.seed}{kbd_mode}{rec_mode}"
|
||||
try:
|
||||
stdscr.addstr(0, x, info[:w - x], curses.color_pair(6))
|
||||
# Fill rest of header
|
||||
remaining = w - x - len(info)
|
||||
if remaining > 0:
|
||||
stdscr.addstr(0, x + len(info), " " * remaining, curses.color_pair(6))
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# ═══ DIVIDER ═══
|
||||
for y in range(1, h - 2):
|
||||
try:
|
||||
stdscr.addch(y, div, '│', curses.color_pair(2) | curses.A_DIM)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# ═══ LEFT: EVENTS ═══
|
||||
try:
|
||||
stdscr.addstr(1, 1, " Events ", curses.color_pair(2) | curses.A_BOLD)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
log_h = h - 5
|
||||
visible = self.log_lines[-log_h:] if log_h > 0 else []
|
||||
for i, (ts, msg, color) in enumerate(visible):
|
||||
ly = 2 + i
|
||||
if ly >= h - 3:
|
||||
break
|
||||
# Fade old messages
|
||||
age = time.time() - ts
|
||||
attr = curses.A_DIM if age > 8 else 0
|
||||
try:
|
||||
stdscr.addstr(ly, 1, msg[:div - 2],
|
||||
curses.color_pair(color) | attr)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# ═══ RIGHT: CONFIG ═══
|
||||
try:
|
||||
stdscr.addstr(1, cfg_x, " Config ",
|
||||
curses.color_pair(2) | curses.A_BOLD)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
y = 3
|
||||
rw = w - cfg_x - 1
|
||||
for i, inst in enumerate(self.picks, 1):
|
||||
try:
|
||||
stdscr.addstr(y, cfg_x, f"{i}", curses.A_BOLD)
|
||||
stdscr.addstr(y, cfg_x + 1, ":", curses.A_DIM)
|
||||
stdscr.addstr(y, cfg_x + 2, inst[:rw - 2],
|
||||
curses.color_pair(1))
|
||||
except curses.error:
|
||||
pass
|
||||
y += 1
|
||||
|
||||
y += 1
|
||||
# VU meters per channel
|
||||
y += 1
|
||||
try:
|
||||
stdscr.addstr(y, cfg_x, "Levels:", curses.A_BOLD | curses.A_DIM)
|
||||
except curses.error:
|
||||
pass
|
||||
y += 1
|
||||
for i, inst in enumerate(self.picks, 1):
|
||||
if i in self.engine.channels:
|
||||
lv = self.engine.channels[i].level
|
||||
bars = int(min(lv, 1.0) * 16)
|
||||
meter = "|" * bars + "-" * (16 - bars)
|
||||
color = 1 if bars < 15 else 3 if bars < 18 else 4
|
||||
try:
|
||||
stdscr.addstr(y, cfg_x, f"{i}", curses.A_BOLD)
|
||||
stdscr.addstr(y, cfg_x + 1, f" {meter}", curses.color_pair(color))
|
||||
except curses.error:
|
||||
pass
|
||||
y += 1
|
||||
|
||||
y += 1
|
||||
rec_indicator = " ● REC " if self.engine._recording else ""
|
||||
kbd_indicator = f" kbd:ch{self.engine._keyboard_channel} oct{self.engine._keyboard_octave}" if self.engine._keyboard_channel else ""
|
||||
|
||||
pairs = [
|
||||
("Drums", self.current_drum, 5),
|
||||
("BPM", str(self.bpm), 0),
|
||||
("Latency", f"{self.engine.buffer_size / 44100 * 1000:.1f}ms", 0),
|
||||
("MIDI", self.port, 0),
|
||||
("Seed", str(self.seed), 2),
|
||||
]
|
||||
if rec_indicator:
|
||||
pairs.append(("", rec_indicator, 4))
|
||||
if kbd_indicator:
|
||||
pairs.append(("", kbd_indicator, 2))
|
||||
for label, val, cp in pairs:
|
||||
try:
|
||||
stdscr.addstr(y, cfg_x, f"{label}:", curses.A_BOLD)
|
||||
stdscr.addstr(y, cfg_x + len(label) + 1, f" {val}"[:rw],
|
||||
curses.color_pair(cp))
|
||||
except curses.error:
|
||||
pass
|
||||
y += 1
|
||||
|
||||
y += 1
|
||||
cmds = [
|
||||
"ch <n> [inst]",
|
||||
"fx <n> <param> <val>",
|
||||
"pan <n> <-1..1>",
|
||||
"drums [pattern|-]",
|
||||
"kbd [ch] [oct]",
|
||||
"rec / stop / export",
|
||||
"save/load <file>",
|
||||
"seed [n] / list",
|
||||
"exit",
|
||||
]
|
||||
try:
|
||||
stdscr.addstr(y, cfg_x, "Commands:", curses.color_pair(3))
|
||||
except curses.error:
|
||||
pass
|
||||
for i, c in enumerate(cmds):
|
||||
try:
|
||||
stdscr.addstr(y + 1 + i, cfg_x + 1, c[:rw],
|
||||
curses.color_pair(2) | curses.A_DIM)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# ═══ INPUT BAR ═══
|
||||
iy = h - 2
|
||||
try:
|
||||
stdscr.addstr(iy - 1, 0, "─" * (w - 1), curses.A_DIM)
|
||||
if self.kbd_active:
|
||||
stdscr.addstr(iy, 0, " KEYBOARD ",
|
||||
curses.color_pair(7) | curses.A_BOLD)
|
||||
stdscr.addstr(iy, 10, " Esc=exit Up/Down=octave ",
|
||||
curses.color_pair(3))
|
||||
else:
|
||||
stdscr.addstr(iy, 0, " $ ",
|
||||
curses.color_pair(1) | curses.A_BOLD)
|
||||
stdscr.addstr(iy, 3, cmd_buf[:w - 5])
|
||||
# Cursor at position
|
||||
cx = 3 + cursor_pos
|
||||
if cx < w - 1:
|
||||
# Show character under cursor inverted, or block at end
|
||||
if cursor_pos < len(cmd_buf):
|
||||
stdscr.addstr(iy, cx, cmd_buf[cursor_pos],
|
||||
curses.A_REVERSE)
|
||||
else:
|
||||
stdscr.addstr(iy, cx, " ", curses.A_REVERSE)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
stdscr.refresh()
|
||||
|
||||
# ═══ INPUT ═══
|
||||
ch = stdscr.getch()
|
||||
if ch == -1:
|
||||
continue
|
||||
|
||||
# KEYBOARD MODE: all keys go to MIDI
|
||||
if self.kbd_active:
|
||||
if ch == 27: # Escape exits keyboard mode
|
||||
# Release all held notes
|
||||
for k in list(self._kbd_held):
|
||||
self.engine.keyboard_note(k, on=False)
|
||||
self._kbd_held.clear()
|
||||
self.kbd_active = False
|
||||
self.engine._keyboard_channel = None
|
||||
self.log("Keyboard off (Esc)", 3)
|
||||
elif ch == curses.KEY_UP:
|
||||
self.engine._keyboard_octave = min(8, self.engine._keyboard_octave + 1)
|
||||
self.log(f"Octave ↑ {self.engine._keyboard_octave}", 2)
|
||||
elif ch == curses.KEY_DOWN:
|
||||
self.engine._keyboard_octave = max(0, self.engine._keyboard_octave - 1)
|
||||
self.log(f"Octave ↓ {self.engine._keyboard_octave}", 2)
|
||||
elif 32 <= ch < 127:
|
||||
key = chr(ch).lower()
|
||||
now = time.time()
|
||||
if key in self._kbd_held:
|
||||
# Key repeat — just refresh the timer
|
||||
self._kbd_held[key] = now
|
||||
else:
|
||||
# New key press
|
||||
played = self.engine.keyboard_note(key, on=True)
|
||||
if played:
|
||||
self._kbd_held[key] = now
|
||||
|
||||
# Release keys that haven't been pressed for 150ms
|
||||
now = time.time()
|
||||
expired = [k for k, t in self._kbd_held.items()
|
||||
if now - t > 0.15]
|
||||
for k in expired:
|
||||
self.engine.keyboard_note(k, on=False)
|
||||
del self._kbd_held[k]
|
||||
|
||||
continue
|
||||
|
||||
if ch == 10 or ch == 13:
|
||||
if cmd_buf.strip():
|
||||
cmd_history.append(cmd_buf)
|
||||
self._handle_command(cmd_buf.strip())
|
||||
cmd_buf = ""
|
||||
cursor_pos = 0
|
||||
history_idx = -1
|
||||
elif ch == 27:
|
||||
if self.engine._keyboard_channel and not cmd_buf:
|
||||
# Escape exits keyboard mode
|
||||
self.engine._keyboard_channel = None
|
||||
self.log("Keyboard off (Esc)", 3)
|
||||
else:
|
||||
cmd_buf = ""
|
||||
cursor_pos = 0
|
||||
elif ch == curses.KEY_BACKSPACE or ch == 127:
|
||||
if cursor_pos > 0:
|
||||
cmd_buf = cmd_buf[:cursor_pos - 1] + cmd_buf[cursor_pos:]
|
||||
cursor_pos -= 1
|
||||
elif ch == curses.KEY_LEFT:
|
||||
cursor_pos = max(0, cursor_pos - 1)
|
||||
elif ch == curses.KEY_RIGHT:
|
||||
cursor_pos = min(len(cmd_buf), cursor_pos + 1)
|
||||
elif ch == curses.KEY_HOME or ch == 1: # Ctrl-A
|
||||
cursor_pos = 0
|
||||
elif ch == curses.KEY_END or ch == 5: # Ctrl-E
|
||||
cursor_pos = len(cmd_buf)
|
||||
elif ch == curses.KEY_UP:
|
||||
if cmd_history and history_idx < len(cmd_history) - 1:
|
||||
history_idx += 1
|
||||
cmd_buf = cmd_history[-(history_idx + 1)]
|
||||
cursor_pos = len(cmd_buf)
|
||||
elif ch == curses.KEY_DOWN:
|
||||
if history_idx > 0:
|
||||
history_idx -= 1
|
||||
cmd_buf = cmd_history[-(history_idx + 1)]
|
||||
cursor_pos = len(cmd_buf)
|
||||
else:
|
||||
history_idx = -1
|
||||
cmd_buf = ""
|
||||
cursor_pos = 0
|
||||
elif ch == 9: # Tab
|
||||
if tab_matches and tab_prefix == cmd_buf:
|
||||
tab_idx = (tab_idx + 1) % len(tab_matches)
|
||||
cmd_buf = tab_matches[tab_idx]
|
||||
else:
|
||||
tab_matches = self._complete(cmd_buf)
|
||||
tab_prefix = cmd_buf
|
||||
if len(tab_matches) == 1:
|
||||
cmd_buf = tab_matches[0]
|
||||
tab_matches = []
|
||||
elif tab_matches:
|
||||
tab_idx = 0
|
||||
cmd_buf = tab_matches[0]
|
||||
else:
|
||||
tab_matches = []
|
||||
cursor_pos = len(cmd_buf)
|
||||
elif 32 <= ch < 127:
|
||||
cmd_buf = cmd_buf[:cursor_pos] + chr(ch) + cmd_buf[cursor_pos:]
|
||||
cursor_pos += 1
|
||||
tab_matches = []
|
||||
tab_idx = -1
|
||||
|
||||
except KeyboardInterrupt:
|
||||
self.running = False
|
||||
|
||||
self.engine.stop()
|
||||
|
||||
def _complete(self, text):
|
||||
"""Return list of completions for current input."""
|
||||
parts = text.split()
|
||||
commands = ["ch", "fx", "pan", "drums", "kbd", "rec", "stop", "export",
|
||||
"save", "load", "seed", "list", "patterns", "octave", "help", "exit"]
|
||||
fx_params = ["volume", "pan", "lowpass", "lowpass_q", "reverb", "chorus",
|
||||
"detune", "spread", "analog", "distortion", "delay",
|
||||
"tremolo_depth", "saturation", "phaser", "sub_osc", "noise_mix"]
|
||||
|
||||
if not parts:
|
||||
return [c + " " for c in commands]
|
||||
|
||||
# Completing first word
|
||||
if len(parts) == 1 and not text.endswith(" "):
|
||||
prefix = parts[0].lower()
|
||||
return [c + " " for c in commands if c.startswith(prefix)]
|
||||
|
||||
verb = parts[0].lower()
|
||||
|
||||
# ch <n> <instrument>
|
||||
if verb == "ch" and len(parts) == 3 and not text.endswith(" "):
|
||||
prefix = parts[2].lower()
|
||||
return [f"ch {parts[1]} {i} " for i in self.instruments
|
||||
if i.startswith(prefix)]
|
||||
if verb == "ch" and len(parts) == 2 and text.endswith(" "):
|
||||
return [f"ch {parts[1]} {i} " for i in self.instruments]
|
||||
|
||||
# drums <pattern>
|
||||
if verb == "drums" and len(parts) == 2 and not text.endswith(" "):
|
||||
prefix = parts[1].lower()
|
||||
matches = [p for p in self.drum_patterns if p.startswith(prefix)]
|
||||
return [f"drums {m} " for m in matches]
|
||||
if verb == "drums" and len(parts) == 1 and text.endswith(" "):
|
||||
return [f"drums {p} " for p in self.drum_patterns]
|
||||
|
||||
# fx <n> <param> <val>
|
||||
if verb == "fx" and len(parts) == 3 and not text.endswith(" "):
|
||||
prefix = parts[2].lower()
|
||||
return [f"fx {parts[1]} {p} " for p in fx_params
|
||||
if p.startswith(prefix)]
|
||||
if verb == "fx" and len(parts) == 2 and text.endswith(" "):
|
||||
return [f"fx {parts[1]} {p} " for p in fx_params]
|
||||
|
||||
return []
|
||||
|
||||
def _handle_command(self, cmd):
|
||||
parts = cmd.split()
|
||||
if not parts:
|
||||
return
|
||||
verb = parts[0].lower()
|
||||
|
||||
if verb in ("quit", "q", "exit"):
|
||||
self.running = False
|
||||
elif verb in ("help", "h"):
|
||||
self.log("ch <n> [inst] | fx <n> <param> <val> | drums [pat|-]", 2)
|
||||
self.log("seed [n] | list | patterns | exit", 2)
|
||||
elif verb == "ch" and len(parts) == 2:
|
||||
try:
|
||||
n = int(parts[1])
|
||||
if 1 <= n <= len(self.picks):
|
||||
self.log(f"Ch {n}: {self.picks[n-1]}", 2)
|
||||
else:
|
||||
self.log(f"Channel 1-{len(self.picks)}", 4)
|
||||
except ValueError:
|
||||
self.log("ch <1-8> [instrument]", 4)
|
||||
elif verb == "ch" and len(parts) >= 3:
|
||||
try:
|
||||
n = int(parts[1])
|
||||
inst = parts[2]
|
||||
if inst not in INSTRUMENTS:
|
||||
self.log(f"Unknown: {inst}", 4)
|
||||
return
|
||||
if not (1 <= n <= len(self.picks)):
|
||||
self.log(f"Channel 1-{len(self.picks)}", 4)
|
||||
return
|
||||
self.picks[n - 1] = inst
|
||||
self.engine.channel(n, instrument=inst, reverb=0.3)
|
||||
self.log(f"Ch {n} → {inst}", 1)
|
||||
except (ValueError, IndexError):
|
||||
self.log("ch <1-8> <instrument>", 4)
|
||||
elif verb == "fx" and len(parts) >= 4:
|
||||
try:
|
||||
n = int(parts[1])
|
||||
param = parts[2]
|
||||
val = float(parts[3])
|
||||
if not (1 <= n <= len(self.picks)):
|
||||
self.log(f"Channel 1-{len(self.picks)}", 4)
|
||||
return
|
||||
if n not in self.engine.channels:
|
||||
self.log(f"Channel {n} not active", 4)
|
||||
return
|
||||
channel = self.engine.channels[n]
|
||||
if param == "reverb":
|
||||
channel.reverb = val
|
||||
channel._cache.clear()
|
||||
elif param == "lowpass":
|
||||
channel.lowpass = val
|
||||
channel._cache.clear()
|
||||
elif param == "volume":
|
||||
channel.volume = val
|
||||
elif hasattr(channel, param):
|
||||
setattr(channel, param, val)
|
||||
channel._cache.clear()
|
||||
else:
|
||||
self.log(f"Unknown param: {param}", 4)
|
||||
return
|
||||
self.log(f"Ch {n} {param}={val}", 1)
|
||||
except (ValueError, IndexError):
|
||||
self.log("fx <1-8> <param> <value>", 4)
|
||||
elif verb == "fx" and len(parts) == 2:
|
||||
try:
|
||||
n = int(parts[1])
|
||||
if n in self.engine.channels:
|
||||
ch = self.engine.channels[n]
|
||||
self.log(f"Ch {n}: vol={ch.volume} lp={ch.lowpass} rev={ch.reverb}", 2)
|
||||
else:
|
||||
self.log(f"Channel {n} not active", 4)
|
||||
except ValueError:
|
||||
self.log("fx <ch> <param> <value>", 4)
|
||||
elif verb == "fx" and len(parts) <= 1:
|
||||
self.log("Volume/Mix:", 3)
|
||||
self.log(" volume pan reverb delay", 2)
|
||||
self.log("Filter:", 3)
|
||||
self.log(" lowpass lowpass_q", 2)
|
||||
self.log("Modulation:", 3)
|
||||
self.log(" chorus detune spread tremolo_depth", 2)
|
||||
self.log(" phaser analog", 2)
|
||||
self.log("Drive:", 3)
|
||||
self.log(" distortion saturation", 2)
|
||||
self.log("Synth:", 3)
|
||||
self.log(" sub_osc noise_mix", 2)
|
||||
self.log("", 0)
|
||||
self.log("fx <ch> <param> <value>", 2)
|
||||
elif verb == "drums" and len(parts) == 1:
|
||||
self.log(f"Current: {self.current_drum}", 5)
|
||||
for i in range(0, len(self.drum_patterns), 4):
|
||||
row = " ".join(f"{x:17s}" for x in self.drum_patterns[i:i+4])
|
||||
self.log(f" {row}", 2)
|
||||
elif verb == "drums" and len(parts) >= 2:
|
||||
pat = " ".join(parts[1:])
|
||||
if pat in ("none", "off", "mute", "-"):
|
||||
self.current_drum = "none"
|
||||
self.engine._drum_pattern = None
|
||||
self.engine._drum_channel = None
|
||||
self.log("Drums off", 4)
|
||||
else:
|
||||
try:
|
||||
self.current_drum = pat
|
||||
self.engine.drums(pat, volume=0.5)
|
||||
self.log(f"Drums → {pat}", 5)
|
||||
except Exception as e:
|
||||
self.log(f"Error: {e}", 4)
|
||||
elif verb == "seed" and len(parts) == 1:
|
||||
self.log(f"Seed: {self.seed}", 2)
|
||||
elif verb == "seed" and len(parts) >= 2:
|
||||
try:
|
||||
self.seed = int(parts[1])
|
||||
rng = random.Random(self.seed)
|
||||
self.picks = rng.sample(self.instruments, 8)
|
||||
for i, inst in enumerate(self.picks, 1):
|
||||
self.engine.channel(i, instrument=inst, reverb=0.3)
|
||||
self.log(f"Seed → {self.seed}", 1)
|
||||
except ValueError:
|
||||
self.log("seed <number>", 4)
|
||||
elif verb == "list":
|
||||
for i in range(0, len(self.instruments), 3):
|
||||
row = " ".join(f"{x:22s}" for x in self.instruments[i:i+3])
|
||||
self.log(f" {row}", 2)
|
||||
elif verb == "patterns":
|
||||
for i in range(0, len(self.drum_patterns), 4):
|
||||
row = " ".join(f"{x:17s}" for x in self.drum_patterns[i:i+4])
|
||||
self.log(f" {row}", 2)
|
||||
elif verb == "pan" and len(parts) >= 3:
|
||||
try:
|
||||
n = int(parts[1])
|
||||
val = float(parts[2])
|
||||
val = max(-1.0, min(1.0, val))
|
||||
if n in self.engine.channels:
|
||||
self.engine.channels[n].pan = val
|
||||
self.log(f"Ch {n} pan={val:+.1f}", 1)
|
||||
else:
|
||||
self.log(f"Channel {n} not active", 4)
|
||||
except ValueError:
|
||||
self.log("pan <1-8> <-1..1>", 4)
|
||||
elif verb == "kbd":
|
||||
if len(parts) >= 2:
|
||||
try:
|
||||
ch_num = int(parts[1])
|
||||
self.engine._keyboard_channel = ch_num
|
||||
if len(parts) >= 3:
|
||||
self.engine._keyboard_octave = int(parts[2])
|
||||
except ValueError:
|
||||
self.log("kbd <channel> [octave]", 4)
|
||||
return
|
||||
else:
|
||||
self.engine._keyboard_channel = self.engine._keyboard_channel or 1
|
||||
self.kbd_active = True
|
||||
# Make sure wavetables are cached for this channel
|
||||
ch_num = self.engine._keyboard_channel
|
||||
if ch_num in self.engine.channels:
|
||||
channel = self.engine.channels[ch_num]
|
||||
if not channel._cache:
|
||||
self.log("Rendering wavetables...", 3)
|
||||
for midi_note in range(36, 97):
|
||||
channel._get_wave(midi_note, 44100 * 3)
|
||||
self.log(f"♪ Keyboard ON ch{ch_num} oct{self.engine._keyboard_octave} (Esc=exit, ↑↓=octave)", 1)
|
||||
elif verb == "rec":
|
||||
self.engine.start_recording()
|
||||
self.log("● Recording...", 4)
|
||||
elif verb == "stop" and self.engine._recording:
|
||||
self.engine.stop_recording()
|
||||
n = len(self.engine._record_events)
|
||||
self.log(f"■ Stopped ({n} events)", 3)
|
||||
elif verb == "export":
|
||||
fname = parts[1] if len(parts) > 1 else "recording.mid"
|
||||
score = self.engine.export_recording(fname)
|
||||
if score:
|
||||
self.log(f"Exported → {fname}", 1)
|
||||
else:
|
||||
self.log("Nothing to export", 4)
|
||||
elif verb == "save" and len(parts) >= 2:
|
||||
fname = parts[1]
|
||||
if not fname.endswith(".json"):
|
||||
fname += ".json"
|
||||
self.engine.seed = self.seed
|
||||
self.engine.save_config(fname)
|
||||
self.log(f"Saved → {fname}", 1)
|
||||
elif verb == "load" and len(parts) >= 2:
|
||||
fname = parts[1]
|
||||
if not fname.endswith(".json"):
|
||||
fname += ".json"
|
||||
try:
|
||||
self.engine.load_config(fname)
|
||||
self.log(f"Loaded ← {fname}", 1)
|
||||
# Update picks from channels
|
||||
for ch, channel in self.engine.channels.items():
|
||||
if 1 <= ch <= 8:
|
||||
self.picks[ch - 1] = channel.synth_name
|
||||
except Exception as e:
|
||||
self.log(f"Error: {e}", 4)
|
||||
elif verb == "octave" and len(parts) >= 2:
|
||||
try:
|
||||
self.engine._keyboard_octave = int(parts[1])
|
||||
self.log(f"Keyboard octave → {self.engine._keyboard_octave}", 2)
|
||||
except ValueError:
|
||||
self.log("octave <0-8>", 4)
|
||||
else:
|
||||
self.log(f"? {cmd}", 4)
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="PyTheory Live — real-time MIDI synthesizer")
|
||||
parser.add_argument("seed", nargs="?", type=int, default=None, help="Random seed for instruments")
|
||||
parser.add_argument("--port", "-p", default="OP-XY", help="MIDI port name (default: OP-XY)")
|
||||
parser.add_argument("--channels", "-c", type=int, default=8, help="Number of channels (default: 8)")
|
||||
parser.add_argument("--drums", "-d", default="rock", help="Drum pattern (default: rock, 'none' to disable)")
|
||||
parser.add_argument("--buffer", "-b", type=int, default=128, help="Audio buffer size (default: 128)")
|
||||
args = parser.parse_args()
|
||||
|
||||
tui = LiveTUI(seed=args.seed, port=args.port, n_channels=args.channels,
|
||||
drum_pattern=args.drums, buffer_size=args.buffer)
|
||||
curses.wrapper(tui.run)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -690,9 +690,10 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytheory"
|
||||
version = "0.40.0"
|
||||
version = "0.40.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "rich" },
|
||||
{ name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "sounddevice" },
|
||||
@@ -712,6 +713,7 @@ docs = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "rich", specifier = ">=14.3.3" },
|
||||
{ name = "scipy" },
|
||||
{ name = "sounddevice" },
|
||||
]
|
||||
@@ -802,6 +804,20 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "14.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "roman-numerals"
|
||||
version = "4.1.0"
|
||||
|
||||
Reference in New Issue
Block a user