Skip to content

Commit

Permalink
Merge pull request #382 from shorepine/dont_touch_my_synths
Browse files Browse the repository at this point in the history
midi.py, music.md, ..: Add midi.config.get_synth(), rename polyphony
  • Loading branch information
bwhitman authored Sep 12, 2024
2 parents e9385c8 + 2bf7780 commit 89fc29f
Show file tree
Hide file tree
Showing 4 changed files with 50 additions and 56 deletions.
78 changes: 35 additions & 43 deletions docs/music.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ If you're using [Tulip Desktop](tulip_desktop.md) instead of a real Tulip, thing

When you start up your Tulip, it is configured to receive MIDI messages from the MIDI in port. You can plug in any MIDI device that sends MIDI out, like a MIDI keyboard or your computer running a sequencer.

Try to just play notes once you've turned on Tulip, By default, MIDI channel 1 plays a Juno-6 patch. Notes on channel 10 will play PCM patches.
Try to just play notes once you've turned on Tulip, By default, MIDI channel 1 plays a Juno-6 patch. Notes on channel 10 will play PCM patches, roughly aligned with General MIDI drums.

You can adjust patch assignments per channel, or change patches, using our built in `voices` app. You can type `run('voices')` or tap the bottom right launcher menu and tap `Voices`.

Expand Down Expand Up @@ -67,7 +67,7 @@ First, let's grab the synth we'll be playing. Let's just use the default playing
```python
import music, random
chord = music.Chord("F:min7").midinotes()
synth = midi.config.synth_per_channel[1]
synth = midi.config.get_synth(channel=1)
```

The first `import music, random` tells Tulip that we'll be using those libraries. Some (like `tulip, amy, midi`) are already included on bootup, but it's a good habit to get into when writing programs.
Expand Down Expand Up @@ -114,35 +114,30 @@ tulip.seq_bpm(120)

## Making new Synths

We're using `midi.config.synth_per_channel[1]` to "borrow" the synth booted with Tulip. But if you're going to want to share your ideas with others, you should make your own `Synth` that doesn't conflict with anything already running on Tulip. That's easy, you can just run:
We're using `midi.config.get_synth(channel=1)` to "borrow" the synth booted with Tulip. But if you're going to want to share your ideas with others, you should make your own `Synth` that doesn't conflict with anything already running on Tulip. That's easy, you can just run:

```python
synth = midi.Synth(2) # two note polyphony
synth.program_change(143)
synth = midi.Synth(num_voices=2, patch_number=143) # two note polyphony, patch 143 is DX7 BASS 2
```

And if you want to play multimbral tones, like a Juno-6 bass alongside a DX7 pad:

```python
synth1 = midi.Synth(1)
synth2 = midi.Synth(1)
synth1.program_change(0) # Juno
synth2.program_change(128) # DX7
synth1.note_on(50,1)
synth2.note_on(50,0.5)
synth1 = midi.Synth(num_voices=1, patch_number=0) # Juno
synth2 = midi.Synth(num_voices=1, patch_number=128) # DX7
synth1.note_on(50, 1)
synth2.note_on(50, 0.5)
```

You can also "schedule" notes in the near future (up to 20 seconds ahead). This is useful for sequencing fast parameter changes or keeping in time with the sequencer. `Synth`s accept a `time` parameter, and it's in milliseconds. For example:

```python
# play a chord all at once
import music, midi, tulip
synth4 = midi.Synth(4)
synth.program_change(1)
synth4 = midi.Synth(num_voices=4, patch_number=1)
chord = music.Chord("F:min7").midinotes()
tic = tulip.ticks_ms() + 1000 # 1 seconds from right now
for i,note in enumerate(chord):
synth4.note_on(note, 0.5, time=tulip.ticks_ms()+(i*1000))
synth4.note_on(note, 0.5, time=tulip.ticks_ms() + (i * 1000)) # time is i seconds from now
# each note on will play precisely one second after the last
```

Expand All @@ -165,10 +160,10 @@ As you learn more about AMY (the underlying synth engine) you may be interested

You may want to programatically change the MIDI to synth mapping. One example would be to lower the polyphony of the booted by default 6-note synth on channel 1, so that notes coming in through MIDI don't impact the performance or polyphony of your app. Or if you want to set up your music app to receive different patches on different MIDI channels.

You can change the parameters of channel synths like:
You can change the parameters of channel synths like this:

```python
midi.config.add_synth(channel, patch, polyphony)
midi.config.add_synth(channel=c, patch_number=p, num_voices=n)
```

Note that `add_synth` will stop any running Synth on that channel and boot a new one in its place.
Expand All @@ -191,8 +186,7 @@ Type `edit('jam.py')` (or whatever you want to call it.) You'll see a black scre
import tulip, midi, music, random

chord = music.Chord("F:min7").midinotes()
synth = midi.Synth(1)
synth.program_change(143) # DX7 BASS 2
synth = midi.Synth(num_voices=1, patch_number=143) # DX7 BASS 2
slot = None

def note(t):
Expand Down Expand Up @@ -246,8 +240,7 @@ def run(screen):
app = screen
app.slot = None
app.chord = music.Chord("F:min7").midinotes()
app.synth = midi.Synth(1)
app.synth.program_change(143) # DX7 BASS 2
app.synth = midi.Synth(num_voices=1, patch_number=143) # DX7 BASS 2
app.present()
app.quit_callback = stop
start(app)
Expand All @@ -263,8 +256,7 @@ def run(screen):
app = screen
app.slot = None
app.chord = music.Chord("F:min7").midinotes()
app.synth = midi.config.synth_per_channel[1]
app.synth.program_change(143) # DX7 BASS 2
app.synth = midi.Synth(num_voices=1, patch_number=143) # DX7 BASS 2
bpm_slider = tulip.UISlider(tulip.seq_bpm()/2.4, w=300, h=25,
callback=bpm_change, bar_color=123, handle_color=23)
app.add(bpm_slider, x=300,y=200)
Expand All @@ -287,29 +279,29 @@ Now quit the `jam2` app if it was already running and re-`run` it. You should se

## Sampler, OscSynth

The drum machine in Tulip uses a slightly different `Synth` called `OscSynth`. You can use AMY directly with `OscSynth`, with one oscillator per voice of polyphony. Like this simple sine wave synth:
Tulip defines a few different `Synth` classes, including `OscSynth` which directly uses one oscillator per voice of polyphony, as in this simple sine wave synth:

```python
s = midi.OscSynth(wave=amy.SINE)
s.note_on(60,1)
s.note_off(60)
```

Let's try it as a sampler. There are 29 samples of drum-like and some instrument sounds in Tulip, and it can adjust the pitch and pan and loop of each one. You can try it out by just
Let's try it as a sampler. There are 29 samples of drum-like and instrument sounds in Tulip, and it can adjust the pitch and pan and loop of each one. You can try it out with:

```python
# You can pass any AMY arguments to the setup of OscSynth
s = midi.OscSynth(wave=amy.PCM, patch=10) # PCM wave type, patch=10
s = midi.OscSynth(wave=amy.PCM, patch=10) # PCM wave type, patch=10 (808 Cowbell)

s.note_on(50, 1.0)
s.note_on(40, 1.0) # different pitch

s.update_oscs(pan=0) # different pan
s.note_on(40, 1.0)

s.update_oscs(feedback=1, patch=23) # feedback=1 loops the sound
s.update_oscs(feedback=1, patch=23) # patch 23 is Koto, feedback=1 loops the sound
s.note_on(40, 1.0)
s.note_off(40) # note_off for looped instruments
s.note_off(40) # looped instruments require a note_off to stop
```

You can load your own samples into Tulip. Take any .wav file and [load it onto Tulip.](getting_started.md#transfer-files-between-tulip-and-your-computer) Now, load it in as a `CUSTOM` PCM patch:
Expand All @@ -323,8 +315,8 @@ s.note_on(60, 1.0)
You can also load PCM patches with looped segments if you have their loopstart and loopend parameters (these are often stored in the WAVE metadata. If the .wav file has this metadata, we'll parse it. The example file `/sys/ex/vlng3.wav` has it. You can also provide the metadata directly.) To indicate looping, use `feedback=1`.

```python
patch = tulip.load_sample("/sys/ex/vlng3.wav") # loads wave looping metadata
s = midi.OscSynth(wave=amy.CUSTOM, patch=patch, feedback=1,num_voices=1)
patch = tulip.load_sample("/sys/ex/vlng3.wav") # loads wave looping metadata
s = midi.OscSynth(wave=amy.CUSTOM, patch=patch, feedback=1, num_voices=1)
s.note_on(60, 1.0) # loops
s.note_on(55, 1.0) # loops
s.note_off(55) # stops
Expand Down Expand Up @@ -361,8 +353,8 @@ amy.reset() # reset every AMY oscillator
Here's how to make an 808-style bass drum tone in pure AMY oscillators:

```python
amy.send(osc=31,wave=amy.SINE,amp=0.5, freq=0.25, phase=0.5)
amy.send(osc=32,wave=amy.SINE,bp0="0,1,500,0,0,0",freq="261.63,1,0,0,0,1",mod_source=31,vel=1)
amy.send(osc=31, wave=amy.SINE, amp=0.5, freq=0.25, phase=0.5)
amy.send(osc=32, wave=amy.SINE, bp0="0,1,500,0,0,0", freq="261.63,1,0,0,0,1", mod_source=31, vel=1)
```

If you're interested in going deeper on all that AMY can do, [check out AMY's README](https://github.com/shorepine/amy/blob/main/README.md).
Expand All @@ -375,9 +367,9 @@ You can write functions that respond to MIDI inputs easily on Tulip. Let's say y
```python
import midi, amy
def sine(m):
if(m[0]==144): # MIDI message byte 0 note on
if m[0] == 144: # MIDI message byte 0 note on
# send a sine wave to osc 30, with midi note and velocity
amy.send(osc=30,wave=amy.SINE,note=m[1],vel=m[2]/127.0)
amy.send(osc=30, wave=amy.SINE, note=m[1], vel=m[2] / 127.0)

# Stop the default MIDI callback that plays e.g. Juno notes, so we can hear ours
midi.stop_default_callback()
Expand All @@ -386,7 +378,7 @@ midi.stop_default_callback()
midi.add_callback(sine)

# Now play a MIDI note into Tulip. If you don't have a KB attached, use midi_local to send the message:
tulip.midi_local((144,40,100))
tulip.midi_local((144, 40, 100))
# You should hear a sine wave

midi.start_default_callback()
Expand Down Expand Up @@ -449,17 +441,17 @@ Now, send the AMY setup commands for your patch. Make sure your patch is consecu
The WOOD PIANO patch is four operators, each with an envelope and different modulation amplitude.

```python
amy.send(osc=1,bp0="0,1,5300,0,0,0",phase=0.25,ratio=1,amp="0.3,0,0,1,0,0")
amy.send(osc=2,bp0="0,1,3400,0,0,0",phase=0.25,ratio=0.5,amp="1.68,0,0,1,0,0")
amy.send(osc=3,bp0="0,1,6700,0,0,0",phase=0.25,ratio=1,amp="0.23,0,0,1,0,0")
amy.send(osc=4,bp0="0,1,3400,0,0,0",phase=0.25,ratio=0.5,amp="1.68,0,0,1,0,0")
amy.send(osc=1, bp0="0,1,5300,0,0,0", phase=0.25, ratio=1, amp="0.3,0,0,1,0,0")
amy.send(osc=2, bp0="0,1,3400,0,0,0", phase=0.25, ratio=0.5, amp="1.68,0,0,1,0,0")
amy.send(osc=3, bp0="0,1,6700,0,0,0", phase=0.25, ratio=1, amp="0.23,0,0,1,0,0")
amy.send(osc=4, bp0="0,1,3400,0,0,0", phase=0.25, ratio=0.5, amp="1.68,0,0,1,0,0")
```

Then, send the "root" oscillator instructions. This is the one you'd send note-ons to. The root oscillator gets an "algorithm", which indicates how to modulate the operators. See the [AMY documentation](https://github.com/shorepine/amy) for more detail. It also gets its own amplitude and pitch envelopes (`bp0` and `bp1`).


```python
amy.send(osc=0,wave=amy.ALGO,algorithm=5,algo_source="1,2,3,4",bp0="0,1,147,0",bp1="0,1,179,1",freq="0,1,0,0,1,1")
amy.send(osc=0, wave=amy.ALGO, algorithm=5, algo_source="1,2,3,4", bp0="0,1,147,0", bp1="0,1,179,1", freq="0,1,0,0,1,1")
```

Now, tell AMY to stop logging the patch and store it to a custom patch number.
Expand All @@ -473,9 +465,9 @@ Now, you're free to use this patch number like all the Juno and DX7 ones. For a
```python
s = midi.Synth(5)
s.program_change(1024)
s.note_on(50,1)
s.note_on(50,1)
s.note_on(55,1)
s.note_on(50, 1)
s.note_on(50, 1)
s.note_on(55, 1)
```

Try saving these setup commands (including the `store_patch`, which gets cleared on power / boot) to a python file, like `woodpiano.py`, and `execfile("woodpiano.py")` on reboot to set it up for you!
Expand Down
2 changes: 1 addition & 1 deletion docs/tulip_api.md
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,7 @@ By default, Tulip boots into a live MIDI synthesizer mode. Any note-ons, note-of

By default, MIDI notes on channel 1 will map to Juno-6 patch 0. And MIDI notes on channel 10 will play the PCM samples (like a drum machine).

You can adjust which voices are sent with `midi.config.add_synth(channel, patch, polyphony)`. For example, you can have Tulip play DX7 patch 129 on channel 2 with `midi.config.add_synth(channel=2, patch=129, polyphony=1)`. The `2`, channel, is a MIDI channel (we use 1-16 indexing), the patch `129` is an AMY patch number, `1` is the number of voices (polyphony) you want to support for that channel and patch.
You can adjust which voices are sent with `midi.config.add_synth(channel, patch_number, num_voices)`. For example, you can have Tulip play DX7 patch 129 on channel 2 with `midi.config.add_synth(channel=2, patch_number=129, num_voices=1)`. `channel=2` is a MIDI channel (we use 1-16 indexing), `patch=129` is an AMY patch number, `num_voices=1` is the number of voices (polyphony) you want to support for that channel and patch.

(A good rule of thumb is Tulip CC can support about 6 simultaneous total voices for Juno-6, 8-10 for DX7, and 20-30 total voices for PCM and more for other simpler oscillator patches.)

Expand Down
24 changes: 13 additions & 11 deletions tulip/shared/py/midi.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ def __init__(self, voices_per_channel, patch_per_channel, show_warnings=True):
self.show_warnings = show_warnings
self.synth_per_channel = dict()
self.arpeggiator_per_channel = {}
for channel, polyphony in voices_per_channel.items():
for channel, num_voices in voices_per_channel.items():
patch = patch_per_channel[channel] if channel in patch_per_channel else None
self.add_synth(channel, patch, polyphony)
self.add_synth(channel, patch, num_voices)

def add_synth_object(self, channel, synth_object):
if channel in self.synth_per_channel:
Expand All @@ -30,13 +30,11 @@ def add_synth_object(self, channel, synth_object):
if channel in self.arpeggiator_per_channel:
self.arpeggiator_per_channel[channel].synth = synth_object

def add_synth(self, channel, patch, polyphony):
def add_synth(self, channel=1, patch_number=0, num_voices=6):
if channel == 10:
synth_object = DrumSynth(num_voices=polyphony)
synth_object = DrumSynth(num_voices=num_voices)
else:
synth_object = Synth(num_voices=polyphony)
if patch is not None:
synth_object.program_change(patch)
synth_object = Synth(num_voices=num_voices, patch_number=patch_number)
self.add_synth_object(channel, synth_object)

def insert_arpeggiator(self, channel, arpeggiator):
Expand All @@ -49,14 +47,18 @@ def remove_arpeggiator(self, channel):
self.arpeggiator_per_channel.synth = None
del self.arpeggiator_per_channel[channel]

def program_change(self, channel, patch):
def program_change(self, channel, patch_number):
# update the map
self.synth_per_channel[channel].program_change(patch)
self.synth_per_channel[channel].program_change(patch_number)

def get_active_channels(self):
"""Return numbers of MIDI channels with allocated synths."""
return list(self.synth_per_channel.keys())

def get_synth(self, channel):
"""Return the Synth associated with a given channel."""
return self.synth_per_channel[channel] if channel in self.synth_per_channel else None

def channel_info(self, channel):
"""Report the current patch_num and list of amy_voices for this channel."""
if channel not in self.synth_per_channel:
Expand Down Expand Up @@ -300,7 +302,7 @@ def all_notes_off(self):
self._voice_off(voice)


def note_on(self, note, velocity, time=None):
def note_on(self, note, velocity=1, time=None):
if not self.amy_voice_nums:
# Note on after synth.release()?
raise ValueError('Synth note on with no voices - synth has been released?')
Expand Down Expand Up @@ -377,7 +379,7 @@ def __init__(self, num_voices=6, first_osc=None):
def _note_on_with_osc(self, osc, note, velocity, time):
raise NotImplementedError

def note_on(self, note, velocity, time=None):
def note_on(self, note, velocity=1, time=None):
osc = self.oscs[self.next_osc]
self.next_osc = (self.next_osc + 1) % len(self.oscs)
# Update mapping of note to osc. If notes are repeated, this will lose track.
Expand Down
2 changes: 1 addition & 1 deletion tulip/shared/py/voices.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ def update_map():
channel_patch, amy_voices = midi.config.channel_info(channel)
channel_polyphony = 0 if amy_voices is None else len(amy_voices)
if (channel_patch, channel_polyphony) != (patch_no, polyphony):
midi.config.add_synth(channel=channel, patch=patch_no, polyphony=polyphony)
midi.config.add_synth(channel=channel, patch_number=patch_no, num_voices=polyphony)


# populate the patches dialog from patches.py
Expand Down

0 comments on commit 89fc29f

Please sign in to comment.