Husserl tutorial series (12). Custom voice allocation
Ernest
11月 19 2021 | 4:36 午後
This is the last tutorial before the Husserl3 release, which I anticipate to be in 3-5 weeks. Prior tutorials in this series:
Designing a good LFO in gen~ Codebox: https://cycling74.com/forums/gen~-codebox-tutorial-oscillators-part-one
Resampling: when Average is Better: https://cycling74.com/forums/gen~-codebox-tutorial-oscillators-part-2
Wavetables and Wavesets: https://cycling74.com/forums/gen~-codebox-tutorial-oscillators-part-3
Anti-Aliasing Oscillators: https://cycling74.com/forums/husserl-tutorial-series-part-4-anti-aliasing-oscillators
Implementing Multiphony in Max: https://cycling74.com/forums/implementing-multiphony-in-max
Envelope Followers, Limiters, and Compressors: https://cycling74.com/forums/husserl-tutorial-series-part-6-envelope-followers-limiting-and-compression
Repeating ADSR Envelope in gen~: https://cycling74.com/forums/husserl-tutorials-part-7-repeating-adsr-envelope-in-gen~
JavaScript: the Oddest Programming Language: https://cycling74.com/forums/husserl-tutorial-series-javascript-part-one
JavaScript for the UI, and JSUI:<a href="https://cycling74.com/forums/husserl-tutorial-9-javascript-for-the-ui-and-jsui"> https://cycling74.com/forums/husserl-tutorial-9-javascript-for-the-ui-and-jsui
Programming pattrstorage with JavaScript: https://cycling74.com/forums/husserl-tutorial-series-programming-pattrstorage-with-javascript
Applying gen to MIDI and real-world cases. https://cycling74.com/forums/husserl-tutorial-series-11-applying-gen-to-midi-and-real-world-cases
Benefits of Custom Voice Allocation
The original voice allocator in Max4 was a very simple design, allocating voices in a round-robin fashion (1>2.3>4>1>2...). That is not ideal, because note-off events can free earlier voices, meaning that the allocator assigns a voice that is already playing to a new voice while others were already freed. Cycling74 has since updated its voice allocator, and I'm led to believe the new version allocates voices on a least-recently-used basis, which is much better. However I've not really experimented with it because I'd already written a voice allocator for gen, and it already provided information and capabilities that is not available in Cycling74's prebuilt object. It had three buffers to store each voice's pitch, velocity, and age that were accessible to other objects for processing and display. It also turned off notes on the kslider object if there was voice overflow, and provided the last played voice to gen~ for it to use for updating panel indicators. The voice age and velocity data was already useful for showing a 'dancing display' in multislider of playing voices.
Multiphonic Voice Allocator
When I got to multiphonic design in Max, it was a pretty simple matter of adding one buffer to remember the channel for each voice, and to add channel search to the note-on/off routines. Meanwhile the subpatch for voice allocation has got pretty complicated, because the same gen object now also provides velocity shaping and polyphonic glide. I'll be describing how all these capabilities work below. Here's a picture of the top-level patch, and a download of it if you want to see it in Max.
As well as the support objects for the gen voice allocator, the patch contains velocity-shaping support, channel layering, and MIDI note I/O. The patch contains a little logic to invert the velocities from the kslider object, as it generates 0 values from the bottom edge rather than the top edge. It also stops MIDI note input from being echoed to the MIDI note output. The gen allocator sends MIDI note events to the appropriate channel, and plays notes from a kslider object for the currently displayed channel on the presentation panel.
Note: the 'setvalue' messages issued by the join objects at the bottom of this patch are understood by both poly~ and mc.gen~ in Max 8+, so it doesn't matter which is connected to the custom voice allocator.
Layering Voices
At first blush, one might think 16 channels and 32 voices are far more than most people ever want. However, an important feature of multiphonic instruments is that one can layer additional voices over any one channel from other channels, with different sound settings for each channel, simply by sending note events from one channel to others.
One could go whole hog and trigger all 16 channels with one note to create a massively fat sound with 32 detuned voices, but the technique kind of maxes out at eight oscillators, after which most people find the sound 'too fat.' Mostly people prefer to play four channels with up to four layers each. Even so 32 voices can run out, so I am thinking of increasing the design to 48 or 64 voices before final release, but people will need a top-notch computer to play that many, so I'm still not sure.
Even with 32 voices, a wealth of layer choices are possible with only one 16-entry 'layers' buffer, simply by setting multiple channels to trigger from one 'master' channel, and/or daisy-chaining them together. For example, suppose channel 2 is triggered by notes from channel 1, and channels 3 and 4 are triggered by notes from channel 2. Then:
Playing channel 1 plays channels 1&2
Playing channel 2 plays channels 2, 3 &4
Channels 3 and 4 play by themselves.
So one 16-entry buffer already provides many possibilities. For each channel, setting its entry in the buffer to zero means the channel plays by itself. Setting it to another channel causes the channel to receive notes from it. JavaScript stores and recalls the 'layers' buffer values in multi presets, as described in the previous tutorial. And a simple menu allows setting the layer for each channel; if zero, the channel plays by itself; values between 1 and 16 set the source channel for MIDI notes.
But there's a problem. gen cannot create more than one event from any event it receives. So the extra note-on events needed to come from outside gen. Therefore I added a subpatch with an uzi that scans the buffer on every note event and echoes the note event to other channels.
Besides getting caught yet again by the different 0-based and 1-based channel indexing for Max and gen respectively, this mostly worked fine at first attempt. There is a minor quirk. If a note was triggered by another channel, and the layer source is turned off, then the note continues to play until it's turned off manually. So I might add some additional functionality for that, but I'm not sure it's an undesirable behavior. At least it doesn't seem enough to worry about for a first multiphonic release. That aside, what an amazingly powerful control for such simple design additions!
Initializing the allocator
At the top of the voice allocator's gen code is one function to calculate velocity shaping, then declarations of all the needed buffers, local data arrays, history variables, and dynamic variables.
calcBreakpoint(vel, x, y){ // velocity shaping
//if (x==127) return vel * y;
if (vel <= x) return vel * (y /x) *.007874;
else return (y + (vel -y) /(127-x)) *.007874;
}
Buffer programs(), pch(), vel(), age(), chn(); // INIT
Data gtimes(43);
History numv(0), vcnt(32), lastv(32), gcnt(1), srate;
u, v, x, y = 0;
glidet = in5;
srate = in6;
bpm = in7;
multi = in8;
lastp = 0;
Upon receiving a reset signal, all the buffers reset to zero values, except the age buffer, which needs to be initialized with a number sequence (1~32 for 32 voices).
Note: I didn't want to use a hot attribute or parameter polling, because they both increase CPU usage, so I used a little design trick for resets. The notes sent into the allocator are actually triggered by the last parameter, pitch, on the first input, which is always above zero. So I send the gen~ object negative numbers for reset signals, which I do an awful lot, in fact, during design lol.
In the below fragment of the panel, the 'dancing voices' display is in the top left, and the velocity shaper controls in the bottom right. Changes to the velocity shaper from the panel trigger another uzi in the patch containing the gen object that causes all the velocities for voices on the same channel to be changed dynamically. Dynamic velocity shaping from MIDI on channels other than the displayed channel is currently not implemented, but the channel for velocity shaping is already on a separate output from gen, so it's simply a matter of connecting the velocity-shaper uzi to the gen object that parses MIDI input.
After the declarations, a setup routine populates a data() array with glide times upon reset signal triggering, as well as resetting the buffers when the maximum voice count changes.
if (in6 != srate){ // SET UP GLIDE TIMES
srate = in6;
for (i = 1; i < 43; i +=1){
u = selector(i,
60 *32 / srate , //0, 1/32
60 *16 / srate , //1, 1/16
60 *8 / srate , //2, 1/8
60 *4 / srate , //3, 1/4
60 *3 / srate , //4, 1/3
//... etc.
60 / (srate *12), //38, 12
60 / (srate *16), //39, 16
-1, //40, take from LFO1
-2 //41, take from LFO2
);
gtimes.poke(u, i -1);
}
}
if (in1< 0){ // RESET
vcnt = neg(in1);
for(i=1;i<=32; i+=1){
chn.poke(0, i);
vel.poke(0, i);
pch.poke(0, i);
age.poke(i, i);
numv = 0;
lastv = 1;
out5 = lastv;
}
Additionally, I have another routine that changes all the velocities if the velocity shaper panel control is altered. The uzi for dynamic velocity shaping sends in a negative number on the first input, in a different range, to trigger the velocity changes. This provides dynamic control of all playing voices on a channel.
}else if (in1 > 100){ // RESHAPE ALL VELOCITIES
u = in1 - 100; //voice
v = vel.peek(u);
if(chn.peek(u)==in4 && v>0){
x = programs.peek(33 +multi, in4); // e1v
y = programs.peek(34 +multi, in4);
out9 = calcBreakpoint(v, x, y);
x = programs.peek(126 +multi, in4); //e2v
y = programs.peek(127 +multi, in4);
out10 = calcBreakpoint(v, x, y);
out8 = u;
}else{
out8 = 0;
out5 = 0;
out1 = 0;
}
Note-On Routine
This routine selects which voice to turn on, triggered by any positive value on the velocity input when a positive pitch is received on input 1. The routine looks through the age buffer to find the oldest voice and makes it the newest voice. If the oldest voice was on, it turns the voice off on the kslider; but it doesn't need to turn the voice off in gen~, because it's about to send it a new note-on event. gen~ can't distinguish between events with identical values, so as described in Husserl's envelope tutorial, a gate counter increments on every note-on event and its value sent to the new note's voice.
The velocity value for the two velocity shapers, and the note distance from the last note-on event on the same channel is calculated, and from the latter, the increment size for a cycle-based ramp to the new pitch is calculated too. Then all that data is sent to the appropriate voice (together with its channel assignment, so the voice knows which channel of the control data buffer to use for the new note). Finally, the new note is written to the appropriate voice index in the velocity, channel, and pitch buffers; the age of the new note is set to 1, and the age of all other voices incremented by one.
} else if (in2 >0) { // NOTE ON
for(i=1;i<=vcnt; i+=1){
x = age.peek(i);
if(x == vcnt){
if(numv==vcnt){
//kb overflow update
if(vel.peek(i)>0) out4 = pch.peek(i);
}else{
// voice available
numv +=1;
out4= 0;
}
v, y = 0;
out12 = 0;
// start on glide calcs
while (v < vcnt){
v +=1;
y = pch.peek(v);
if (y > 0 && chn.peek(v) == in4){
//lastp
out12 = y;
v = vcnt;
}
}
u = gtimes.peek(in5);
if (in5 >0){ // if glide not set by lfo
if(in5 > 19){
//fixed glide time
// (60/(measure*bpm)/srate
out11 = u/in7;
} else {
// divide above by octave difference
out11=out11/floor((lastp-in1)/12);
}
}
// update buffers
age.poke(1, i);
chn.poke(in3, i);
vel.poke(in2, i);
pch.poke(in1, i);
// e1v x breakpoint
x = programs.peek(33 +multi, in3);
// e1v y breakpoint
y = programs.peek(34 +multi, in3);
// e1v velocity
out9 = calcBreakpoint(in2, x, y);
// e2v x breakpoint
x = programs.peek(126 +multi, in3);
// e2v y breakpoint
y = programs.peek(127 +multi, in3);
// e2v velocity
out10 = calcBreakpoint(in2, x, y);
out8 = i; // voice for e1/2v
out7 = in2; // midi velocity
out6 = in3; // chan for noteon
if (in4 == in3){ // if note chan == display chan
// lastv = current voice
lastv = i;
}else {
// else lastv = spare voice
lastv = 33;
}
out5 = lastv; // lastv out
out3 = gcnt; //gate out 0-1
gcnt += 1;
out2 = in1; //midi pitch out
out1 = i; // voice out
}else{
age.poke(x+1,i);
}
}
p0 = in1;
Note-Off Routine
Note-off is triggered by the remaining condition, a zero value on the velocity input when a positive pitch is received on input 1. As there is no indication to which MIDI note-on event a MIDI note-off event is attached, the routine searches for the voice index of the oldest playing note on the same channel with the same pitch. The voice may have been used for another note already, but if it's found, the age of all older playing voices is decremented, moving them up in the age queue, and the age of the voice that has just been turned off is set to the age of the prior oldest playing voice. That means any voices which are not playing have higher indices in the age queue, while all notes that were off are still older, giving all previously played notes as much time as possible to finish their release phases. The velocity buffer is zeroed for the voice that has been turned off, but a record of the channel and pitch is kept, in case they're needed for the multiphonic portamento time calc. Finally the note-off message is sent to the appropriate voice instance of gen~.
} else{ //NOTE OFF
for(i=1;i<=vcnt; i+=1){
if(pch.peek(i)==in1
&& vel.peek(i)>0
&& chn.peek(i)== in3){
y = age.peek(i);
vel.poke(0, i);
for(j=1;j<=vcnt; j+=1){
x = age.peek(j);
if(x>y && x<=numv){
age.poke(x-1, j);
}
}
age.poke(numv, i);
numv -=1;
out11 = -3;
out6 = in3; //chan
out2 = in1; //pitch
out1 = i; //voice
break;
}
}
}
Planned Enhancements in Husserl 4
For Husserl4, the voice allocator will be extended to recognize note events from sequencers, such that it knows exactly which voice to turn off, because the sequencers will send a numeric index that increments with each note together with pitch and velocity data.
Note: some synthesizers also provide other option, such as using the most recently played note, which works for fast-playing sequences of short notes. However in this design, if gen~ reaches the end of an envelope cycle without the note being turned off, it sends a note-off event itself to the voice allocator via javascript. This means that fast playing short notes do not use up all the allocation slots, and so other modes are not needed except for high/low-note priority on releasing one of several notes during monophonic play, which is planned for support in a future release.
And that's the end of the last tutorial for Husserl3. Now I'm back to final touches and making some demo presets. Happy patching, folks )
Tikoda
10月 28 2023 | 4:06 午後
I've just finished reading all of these and I'm incredibly grateful for all the work you've put in and shared! Did Husserl3 ever come into existence?
Ernest
10月 29 2023 | 12:55 午前
Thank you for asking. unfortunately I had a stroke leaving me totally deaf, so I was not able to complete the design. Maybe you will )
Tikoda
11月 18 2023 | 3:09 午後
Oh I'm sorry to hear that, and simultaneously happy that you are still with us. Definitely curious about where your focus(es) are now, but no pressure on answering that hah.
My mind is definitely not so code-savvy but I'm designing a small multi-channel synthesiser (i think?) it keeps changing and I keep getting distracted (: