Husserl tutorial series (12). Custom voice allocation

Ernest's icon

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:

  1. Designing a good LFO in gen~ Codebox: https://cycling74.com/forums/gen~-codebox-tutorial-oscillators-part-one

  2. Resampling: when Average is Better: https://cycling74.com/forums/gen~-codebox-tutorial-oscillators-part-2

  3. Wavetables and Wavesets: https://cycling74.com/forums/gen~-codebox-tutorial-oscillators-part-3

  4. Anti-Aliasing Oscillators: https://cycling74.com/forums/husserl-tutorial-series-part-4-anti-aliasing-oscillators

  5. Implementing Multiphony in Max: https://cycling74.com/forums/implementing-multiphony-in-max

  6. Envelope Followers, Limiters, and Compressors: https://cycling74.com/forums/husserl-tutorial-series-part-6-envelope-followers-limiting-and-compression

  7. Repeating ADSR Envelope in gen~: https://cycling74.com/forums/husserl-tutorials-part-7-repeating-adsr-envelope-in-gen~

  8. JavaScript: the Oddest Programming Language: https://cycling74.com/forums/husserl-tutorial-series-javascript-part-one

  9. 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

  10. Programming pattrstorage with JavaScript: https://cycling74.com/forums/husserl-tutorial-series-programming-pattrstorage-with-javascript

  11. 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.

Max Patch
Copy patch and select New From Clipboard 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's icon

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's icon

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's icon

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 (: