Fundamentals
Export Targets
Code Export
Patcher UI
Special Topics
RNBO Raspberry Pi OSCQuery Runner
Monophonic Legato modes
Create custom monophonic legato voice stealing modes with codebox.
For monophonic synth patchers - even though only one note is sounding at once, there are many ways we can handle the incoming note stream using polyphonic logic. Perhaps we want create some extended features for legato note behavior and add some classic portamento glide and glissando effects. This is a good example of a feature set where the duties are split between the notecontroller and the synth patcher. The following guide will show you how to create these features in rnbo by extending the notecontroller above to buffer the input while notes are held.
Firstly we define our constants and state variables as before. This time the array we create to buffer held notes will store the voice index as a value in the array - instead of using the array order as the index.
// Even though only one voice is sounding at once
const POLYPHONY = 1;
// we can buffer the input to create customised legato modes.
const NOTEBUFFERSIZE = 128;
const MIDINoteStateOff = 0;
const MIDINoteStateOn = 1;
const MIDINoteOffMask = 0x80;
const MIDINoteOnMask = 0x90;
// ----- noteBuffer
// 0 ... voice index (>= 1 ... active, 0 ... muted)
// 1 ... note number (MIDI pitch)
// 2 ... velocity
// 3 ... last note on time
// 4 ... MIDI channel
@state noteBuffer = new FixedNumberArray(NOTEBUFFERSIZE, 5);
@state parser = new midiparse();
@state mode = 0;
We create a function find the index of a midi note in the notebuffer by pitch and channel.
function noteBufferIndex(pitch, channel) {
for(let i = 0; i < NOTEBUFFERSIZE; i++) {
if (noteBuffer[i][1] == pitch && noteBuffer[i][4] == channel) {
return i;
}
}
return -1;
}
Next are the functions for adding and removing notes to/from the buffer.
function addBufferedNote(pitch, velocity, channel) {
let existingNote = noteBufferIndex(pitch, channel);
if (existingNote > -1) {
// update the velocity of the existing note
post("updating existing note", pitch, velocity, channel);
noteBuffer[existingNote][2] = velocity;
return -1;
}
for(let i = 0; i < NOTEBUFFERSIZE; i++) {
if (noteBuffer[i][1] == 0) {
post("adding buffered note", pitch, velocity, channel);
noteBuffer[i][0] = assignVoice(i);
noteBuffer[i][1] = pitch;
noteBuffer[i][2] = velocity;
noteBuffer[i][3] = currenttime();
noteBuffer[i][4] = channel;
return i;
}
}
}
When we remove a note from the buffer, we check if the note was assigned to a voice. If it was - we send a noteoff and clear the voice from the buffer. We then check if there are held notes waiting and if there are we initiate a noteon using the next suitable buffered note.
function removeBufferedNote(pitch, channel) {
post("removing buffered note", pitch, channel);
for(let i = 0; i < NOTEBUFFERSIZE; i++) {
if (noteBuffer[i][1] == pitch &&
noteBuffer[i][4] == channel) {
let voiceIndex = noteBuffer[i][0];
let notePitch = noteBuffer[i][1];
let noteChannel = noteBuffer[i][4];
// remove note from the notebuffer
noteBuffer[i][0] = 0;
noteBuffer[i][1] = 0;
noteBuffer[i][2] = 0;
noteBuffer[i][3] = 0;
noteBuffer[i][4] = 0;
// if this note was assigned to a voice
// we need to send the noteoff and clear the voice
if (voiceIndex > 0) {
sendNoteOff(voiceIndex, notePitch, noteChannel);
let nextNote = findNextSuitableBufferedNote();
if(nextNote > -1) {
let nextVoiceIdx = assignVoice(nextNote);
noteBuffer[nextNote][0] = nextVoiceIdx;
sendNoteOn(nextVoiceIdx, noteBuffer[nextNote][1], noteBuffer[nextNote][2], noteBuffer[nextNote][4]);
}
}
return i;
}
}
return -1;
}
We find the next suitable buffered note according to the mode. This is an extension of the logic used in the first example.
function findNextSuitableBufferedNote() {
let targetValue = 0;
let bufferTarget = 0 - 1;
// modes 0,1 check against pitch, 2,3 check against time
let targetValueIndex = (mode >= 2) ? 3 : 1;
for(let i = 0; i < NOTEBUFFERSIZE; i++) {
if (noteBuffer[i][0] == 0 && noteBuffer[i][targetValueIndex] > 0) {
let candidateValue = noteBuffer[i][targetValueIndex];
if (targetValue == 0) {
targetValue = candidateValue;
bufferTarget = i;
continue;
}
if (stealMode(candidateValue, targetValue)) {
targetValue = candidateValue;
bufferTarget = i;
continue;
}
}
}
return bufferTarget;
}
We again create noteon and noteoff functions that we can call programatically.
function sendNoteOn(voiceTarget, pitch, velocity, channel) {
// send mute state
listout2 = [ voiceTarget, 0 ];
// set target
out3 = voiceTarget;
// send midi values out
out1 = MIDINoteOnMask | channel;
out1 = pitch;
out1 = velocity;
}
function sendNoteOff(voiceTarget, pitch, channel) {
// set target
out3 = voiceTarget;
// send out MIDI
out1 = MIDI_NoteOffMask | channel;
out1 = pitch;
out1 = 64; // default note off velocity
}
We simplify our original stealMode function slightly, but retain the same logic.
function stealMode(candidateValue, targetValue) {
let suitable;
switch (mode) {
case 0: // higher note steals
suitable = candidateValue > targetValue;
break;
case 1: // lower note steals
suitable = candidateValue < targetValue;
break;
case 2: // FIFO
suitable = candidateValue < targetValue;
break;
case 3: // LIFO
suitable = candidateValue > targetValue;
break;
default: // pass
suitable = false;
}
return suitable;
}
We extend our assignVoice function to utilise the note buffer, clearing a voice if needed. This time we return the assigned voice index instead of carrying out the noteon logic. Note - we also don't completely remove an entry from the notebuffer when we steal it - we just set the voiceState to muted. This allows us to return to the note later if the note we just assigned is released.
function assignVoice(bufferedNoteIndex) {
let voiceTarget = 1;
let bufferTarget = 0 - 1;
// modes 0,1 check against pitch, 2,3 check against time
let targetValue = 0;
let targetValueIndex = (mode >= 2) ? 3 : 1;
// find the next available voice by determining if there are available voices
// or clearing the most suitable note (by mode)
for(let i = 0; i < POLYPHONY; i++) {
let candidateVoiceIndex = i + 1;
for(let j = 0; j < NOTEBUFFERSIZE; j++) {
if (noteBuffer[j][0] == candidateVoiceIndex) {
if (targetValue == 0) {
targetValue = noteBuffer[j][targetValueIndex];
bufferTarget = j;
continue;
}
let candidateValue = noteBuffer[j][targetValueIndex];
if (stealMode(candidateValue, targetValue)) {
voiceTarget = candidateVoiceIndex;
targetValue = candidateValue;
bufferTarget = j;
continue;
}
}
}
}
// if there is a voiceTarget to clear, clear it unless stealmode == off
if (bufferTarget > -1) {
if (mode != 4) {
sendNoteOff(voiceTarget, noteBuffer[bufferTarget][1], noteBuffer[bufferTarget][4]);
// set the voiceState to zero but keep it in the noteBuffer
// it will be removed completely when there is a noteoff from the controller
noteBuffer[bufferTarget][0] = 0;
} else {
voiceTarget = 0;
}
}
return voiceTarget;
}
Our main notecontroller logic is very simple, extending our original implementation to use the new noteBuffer.
let midistate = parser.next(in1);
let messageType = midistate[0];
mode = in2;
let status = midistate[midistate.length - 3];
let byte1 = midistate[midistate.length - 2];
let byte2 = midistate[midistate.length - 1];
switch (messageType) {
case -1:
// nothing detected
break;
case 0:
{
// note on or off
let pitch = midistate[1];
let velocity = midistate[2];
let channel = midistate[3];
let isNoteOn = velocity != 0;
if (isNoteOn) {
let bufferedNote = addBufferedNote(pitch, velocity, channel);
if (bufferedNote > -1) {
let voiceTarget = noteBuffer[bufferedNote][0];
if (voiceTarget > 0) {
sendNoteOn(voiceTarget, pitch, velocity, channel);
}
}
} else {
removeBufferedNote(pitch, channel);
}
break;
}
default:
// set target
out3 = 0;
// default behavior is just forwarding MIDI to everyone
out1 = status;
out1 = byte1;
out1 = byte2;
}
We'll now look at the synth patcher implementation for the portamento, glissando and glide features. The MIDI standard uses CC 5, 37 and 65 for portamento - so by using these we'll make our rnbo patcher naturally compliant with any MIDI controllers that are using the standard on any target export platform. We can set the course portamento time using CC 5 - optionally using CC 37 for fine tuning. CC 65 toggles portamento on and off. This scales a hardcoded max glide time value of 2000ms. On a synthesiser, you would usually alter this value by accessing it as a parameter in a menu. You could modify this patcher to do this using the param object if you liked.
There are two common implementations of glide time in classic synthesizers. We scale the value set using the MIDI input above according to which type of glide is selected via the glidemode param.
Linear Constant Time (LCT) uses the glide time value as the time taken to glide between each successive note. The time is constant irrespective of pitch distance.
Linear Constant Rate (LCR) uses the glide time value as the time taken to glide one octave - closer pitch values will have shorter glide times and vice versa.
This gives us a value that we set as the time for the curve~ object.
To create a glissando effect, we use the ftom~ object with the @round true
option to create the stepping effect - which naturally rounds the processed input to the nearest semitone and convert it back to the frequency to drive the oscillator via mtof~.
Finally we use latch and voice to ensure we don't create a slide for the first note. This element of the logic could perhaps be seen to be more within the scope of the notecontroller - so if you like, consider how you might implement this in the notecontroller instead of the patcher and adapt it.
There are many other ways to create your own notecontrollers for @voicemode user
- it depends entirely on the kind of expression you'd like to create for your synthesizer patch.
Happy patching!
Materials in this article