Learn Monophonic Legato modes

Getting Started

Welcome to RNBO

Quickstart

RNBO Basics

Key Differences

Why We Made RNBO

Fundamentals

Audio IO

Messages to rnbo~

Using Parameters

MIDI in RNBO

Messages and Ports

Polyphony and Voice Control

Audio Files in RNBO

Using Buffers

Using the FFT

Export Targets

Export Targets Overview

VST/AudioUnit
Max External Target
Raspberry Pi Target
The Web Export Target
The C++ Source Code Target

Code Export

Working with JavaScript
Working with C++

Codebox

Getting Started with Codebox

Understanding Storage: let, const, @state, @param

Building a Note Controller with Codebox

Building a Note Controller with Codebox

Voice Stealing modes

Monophonic Legato modes

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.

Building-a-Note-Controller-With-Codebox-02.png

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;
}

Building-a-Note-Controller-With-Codebox-03.png

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.

Building-a-Note-Controller-With-Codebox-04.png

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.

Building-a-Note-Controller-With-Codebox-05.png

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

Building-a-Note-Controller-With-Codebox-06.png

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.

Building-a-Note-Controller-With-Codebox-07.png

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

  • mono-legato-modes.maxpat