shorepine / amy

AMY - the Additive Music synthesizer librarY

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Patches, wire messages, and oscillator-relative configuration

dpwe opened this issue · comments

AMY specified low-level control over individual oscillators. Useful synthesizer tones generally involve multiple oscillators configured to act together. In analog synthesizers, these are oscillators slightly detuned or in octaves, to add harmonic complexity. In FM, there is a very specific use of one oscillator as the the (phase modulation) input to later oscillators. But in all cases, the overall "note design" and note events involve multiple, co-ordinated oscillators.

There's a need, then, to be able to configure multiple oscillators together, yet AMY commands typically affect only a single oscillator. The concept of a "patch", however, is a set of parameter settings with an overall, global result. We use built-in patches to configure FM voices to provide complete configurations inherited from the DX7 using the wave=ALGO, patch=<n> Python-interface idiom. They effectively set a lot of parameters of 6 or 9 individual oscillators.

We are also introducing presets to emulate the subtractive-synthesis Juno synthesizer, which also involve a lot of parameters for 4 or 5 individual oscillators. Unlike the FM/DX7, we have not yet 'baked' these presets into the AMY engine, but it would be nice to have them immediately available.

One way to do this is to package up the oscillator configuration, for instance by recording and storing the wire messages. This is a neat, agnostic way to capture any broad set of lower-level AMY parameters. However, for a polyphonic synthesizer, you need to configure multiple blocks of AMY oscillators, one block for each simultaneously-sounding voice. An exact recording of a particular set of wire-level configuration commands will only describe the setting of a single set of oscillators. If we wanted to provide 6 simultaneous voices, we'd have to store distinct config sets for each voice.

Messages (other than for global effects like chorus and EQ) start with an oscillator specification (osc=<n> in the Python wrapper, or v<n> in the wire protocol). A complete patch, however, may include interdependencies between oscs: There is the modulation oscillator source mod_osc=<n>, the secondary oscillators used in an FM patch algo_source=a,b,c,..., and the new chained_osc concept that propagates note-on events along a chain of oscs.

One possibility is that we freeze patches that set up co-ordinated groups of oscillators in a fixed oscillator-address range, e.g. oscs 0 to 9. We then support a different command (perhaps clone_osc will suffice?) that can then duplicate this group to a different set of oscillators, including fixing up all the references to other oscillators. Essentially, this just means allowing clone_osc to clone mod_osc, algo_source, and chained_osc, but to always interpret them as relative to osc 0.

(See https://github.com/bwhitman/amy/tree/junopatches branch for our latest stabs at this) I've added a load_patch AMY param that does the work of loading the AMY strings from ROM and have updated amy_patches.py to generate these for DX7 and Juno for now. The patch loader uses the osc you loaded the patch on as base_osc and sets the oscillators relative to that osc. This doesn't yet do anything about polyphony.

As of right now, in the junopatches branch, you can do (python, but C structs from e.g. Arduino work too)

import amy; amy.live()
amy.send(osc=0, load_patch=1) # setup patch 1 (a juno patch) to start at osc 0
amy.send(osc=0, vel=1, note=50) # play a note on that patch
amy.send(osc=5, load_patch=1) # load the same patch on osc 5 -- because I know that juno patches are 5 oscs
amy.send(osc=10, load_patch=1) # load the same patch on osc 10 -- ....

# play a chord 
amy.send(osc=0, vel=1, note=50)
amy.send(osc=5, vel=1, note=54)
amy.send(osc=10, vel=1, note=58)

This seems to work well. "patches" 0-127 are Juno patches and 128-255 are DX7 patches. But this presumes the user knows that Juno patches take X space, dx7 takes Y (9), other types of future patches will be unknown.

@dpwe's proposal of a clone command would help a lot. We could do, for ex:

amy.send(load_patch=1, osc=0, clone=4) # set starting osc to 0 but make 4 copies, inherently knowing how many oscs each patch takes

But then, how do we send note ons, parameter changes, etc, to individual "voices" ?

(I should also point out the new capabilities for "recording" patches, in Python, used by amy_headers.py to generate patches.h for Juno and DX7:

amy.log_patch() # starting logging to a patch
amy.send(...) # do whatever
patch = amy.retrieve_patch() # returns a patch string

A proposal for voices vs oscillators is simply to have this clone become a new parameter called voice, which is a comma separated string of integers. Let's say there are 32 of them total. We keep a map of base_osc <-> voice_idx in the AMY kernel and update it when they're instantiated via patches. Only patches can make a voice. If you want a simple sine wave to be a voice, make it a patch. (Maybe we can later allow for loading of patches over AMY runtime instead of forcing them to be baked in.) So,

amy.send(load_patch=1, voices="0,1,2,3") # load juno patch into 4 voices. internally figure out which oscs to use and update our internal map of oscs <-> voices
amy.send(load_patch=130, voices="4,5") # load dx7 patch into 2 voices.
amy.send(voices="0,1", vel=1, note=40) # send this note on to voices 0 and 1
amy.send(voices="2",vel=1, note=44) # different note on differnet voice
amy.send(voices="4", vel=1, note=50) # dx7 note on

Still TBD:

  • If i use voices can i still use raw oscillator addressing?
  • How can I e.g. change the resonance of a filter on a Juno voice?

This is great. voice is a logical mapping to a block of oscillators, distinct from osc indexing, and dynamic. You can send Amy commands with both voice and osc parameters, so amy.send(voice=4, osc=1, filter_freq="440,0,0,0,5") means to set the filter frequency of the second (i.e, index of 1) oscillator in the set of oscillators assigned to voice 4, which is maybe a Juno patch. You have to know which oscillator does what within a particular patch, but that's what documentation is for.

Also, I love the way you said kernel.

I'm working on prototyping voices now in the junobranches branch. It may get annoying. e.g.

amy.send(voices="0,1",load_patch=1) # loads juno patch 1 (5 oscs each) into voices 0 and 1 (oscs 0-4 are voice 0, oscs 5-9 are voice 1)
amy.send(voices="2", load_patch=130) # loads DX7 patch 2 (9 oscs each) into voice 2 - oscs 10-18 are voice 2
amy.send(voices="0", load_patch=131) # load dx7 patch 3 into voice 0, but at 9 oscs it will eat into the oscs used for voice 1

or

amy.send(voices="0,1",load_patch=1) # loads juno patch 1 (5 oscs each) into voices 0 and 1.
amy.send(voices="3", load_patch=130) # loads DX7 patch 2 into voice .. 3? how do I know which oscs to use?

A way to get around this for now is to force a full amy.reset() if you do anything with voices that is non monotonically increasing and sequential. that is, make the user know ahead of time how they want to map out voices and if they go back and change it they'll have to start over again.

But the real way is to keep the oscs set up as non-consecutive blocks that any voice can borrow from and we keep a map of (used/unused) per oscillator. And when I need to grab, say 7 oscillators, I scan through that list and piece it together. The voice map is more like a linked list of oscillator indices , not base_osc and count. Then we'll need to run DOS defrag on it every so often, so....

image

A middle ground is to maintain a map of available oscillators, but to require allocations to be contiguous (like malloc() works). With fragmentation, you might be unable to allocate a new voice before you completely run out of oscillators, but that's not the end of the world - if you've been allocating and deallocating voices, maybe you want to do a reset() now and then.

OK, that's how it's working now, will only alloc contiguously.

e.g.

amy.send(voices="0,1", load_patch=0) # load juno patch on voice 0&1, using oscs 0-4 and 5-9
amy.send(voices="2", load_patch=129) # loads dx7 patch on voice 2, will use osc 10-19
amy.send(voices="0", load_patch=1) # will replace the voice 0 with another juno patch. since same osc count reuses osc 0-4
amy.send(voices="0", load_patch=130) # can't fit 9 in osc-0-4, so uses 20-29. osc 0-4 are now free though

This is all working AFAICT in junopatches.

I feel like this is in a good spot for a version 1. The load_patch happening at message receive time and not scheduled time is a known issue but may just be something we live with. We can create new issues as we see them.

Just to say, the Juno patches are still somewhat in flux (changes to the interpretation of midi values in juno.py). This is about achieving ear-equivalence with recordings of a real juno-106. I know this doesn't matter for most users.