Cabbage Logo
Back to Cabbage Site

MIDI sustain/damper pedal

Hi,

I am looking for feedback on implementing a keyboard sustain/damper pedal in Cabbage/Csound. Basically something that mimics a pedal on an acoustic piano. The instrument should listen to MIDI notes, while a MIDI mapped slider/expression pedal is used to dynamically control sustain. I imagine there might be a more elegant and efficient way of doing this compared to my solution below. I haven’t found a solution in literature nor in examples. An example of such implementation would be very useful though. I hope you can help me learn something here.

Since I want to control the sustain/damping of notes (instruments) dynamically, it seems that the standard Csound solution employing envelopes with release segments is not feasible.

My solution involves triggering instruments with indefinite durations, turning them off within the instrument instances and substituting envelope opcodes for attack and release with k-rate multiplications. I don’t need the full ADSR envelope so it was sufficient with an attack and release. A global sustain variable (gkSustain), which can be MIDI mapped in a DAW, is used to “open” and “close” the keyboard and instruments are turned off when the volume envelope reaches a very small threshold and the key (note) is released.

When hitting a note repeatedly, I have experienced clicks, and I guess this is not a problem specific to my implementation? It happens because the instrument and its volume envelope get re-triggered. To resolve this, I’m not re-triggering any instruments/notes that are still playing but only re-trigger the envelope, however, not starting from zero but from the current value (in release phase). To enable this, I have used a rather brute force approach and keep track of volume envelopes for all notes using a table (gkNoteVolume[]).

I’m curious what is your opinion, am I doing something stupid, missing something? Could this be implemented more efficiently?

<Cabbage>
form caption("test sustain/damper pedal") size(400, 300), guiMode("queue"), pluginId("def1")
keyboard bounds(8, 158, 381, 95)

rslider bounds(40, 34, 60, 60) channel("Attack") range(0.001, 5, .01, .2, 1e-6) colour(255, 120, 0, 255) trackerColour(255, 120, 0, 255) markerColour(0, 0, 0, 255) popupText("0")
label   bounds(40, 94, 60, 12) text("ATT") fontColour(255, 120, 0, 255) channel("AttackLabel")


rslider bounds(140, 34, 60, 60) channel("Sustain") range(0, .99, .86, 5, 1e-6) colour(150, 255, 0, 255) trackerColour(150, 255, 0, 255) markerColour(0, 0, 0, 255) popupText("0")
label   bounds(140, 94, 60, 12) text("SUS") fontColour(150, 255, 0, 255) channel("SustainLabel")

</Cabbage>
<CsoundSynthesizer>
<CsOptions>
-n -d -m0d -+rtmidi=NULL -M0

</CsOptions>
<CsInstruments>
ksmps = 32
nchnls = 2
0dbfs = 1

gkNoteVolume[] init 128 ; track volumes of all notes 

instr 1
gaL init 0
gaR init 0

gkSustain = .98 + .02*cabbageGetValue:k("Sustain")
kAttack cabbageGetValue "Attack"
massign 0,0 ; disable automatic triggering of instruments from MIDI note events

gkstatus,kchan,gkdata1,gkdata2 midiin ; monitor MIDI input

insno_Envelope nstrnum "PlayEnvelope" ; assign instrument name to instrument number 
insno_Sound nstrnum "PlaySound"
;maxalloc "PlayEnvelope", 64
;maxalloc "PlaySound", 64

;ka active insno_Envelope
;ka active insno_Sound
;printk2 ka
		
if gkstatus == 144 then // Note ON (or Note OFF when polyphonic aftertouch)
    if gkdata2 > 0 then // velocity or aftertouch is not zero
    // trigger instrument with a held note, p4 = Note number, p5 = velocity, p6 = attack, p7 = start volume 
    // the automatic release mechanism cannot be used if a dynamic release via a global sustain/damper pedal is to be applied
       
        // trigger volume envelope - this is needed to prevent clicks when re-triggering the same note before it was turned off 
        event "i",insno_Envelope+(gkdata1*0.001), 0, -1, gkdata1, gkdata2/127, kAttack, gkNoteVolume[gkdata1]

        // trigger oscillator only if its volume is zero (else the volume envelope will be applied to the already running oscillator)
        if gkNoteVolume[gkdata1] == 0 then  
           event "i",insno_Sound+(gkdata1*0.001), 0, -1, gkdata1
        endif
     endif
endif


endin



instr PlayEnvelope
iVel = p5
iAttack = p6
iStartVol = p7

kON init 1
kAttackEnv init iStartVol
kReleaseEnv init 1

// catch NOTE OFF
if (gkstatus == 128) || (gkstatus == 144 && gkdata2 == 0) then
    kON = 0
endif

if kON == 1 then
    // linseg doesn't seem to work here
    if kAttackEnv <= iVel then
        kAttackEnv += 1/(kr*iAttack)
    endif    
else
    if kReleaseEnv > 1e-3 then ; to allow for dynamic damping, can't use expseg (or opcodes with release segment) 
        kReleaseEnv *= gkSustain 
    else
        kReleaseEnv *= 0.999 ; gentle final release
    endif
    
    // set volume to 0 and turn OFF intrument when volume is low enough
    if gkNoteVolume[p4] < 1e-5 then
        gkNoteVolume[p4] = 0
        turnoff
    endif

endif

// update global array of volumes
gkNoteVolume[p4] = kAttackEnv*kReleaseEnv

endin


instr PlaySound
iFrq = cpsmidinn(p4)

aVol = tone(a(gkNoteVolume[p4]),33)
aOut poscil aVol, iFrq;, -1, rnd(1)

gaL += aOut
gaR += aOut

if gkNoteVolume[p4] == 0 then
    turnoff
endif

endin

instr 999	;OUTPUT (ALWAYS ON)
    outs gaL, gaR	;SEND AUDIO FROM THE FINAL ACTIVE EFFECT TO THE OUTPUTS
    clear gaL, gaR
endin

</CsInstruments>
<CsScore>
;causes Csound to run for about 7000 years...
f0 z
i 1 0 -1 ; IN
i 999 0 -1 ; OUT
</CsScore>
</CsoundSynthesizer>

I don’t think it’s an easy thing to solve, and it’s certainly nothing I’ve attempted to do before. I imagine it is something @iainmccurdy may have tackled in some of his implementations. Either way, you seem to have come pretty close to nailing it. I don’t have a sustain pedal to test this out, but one thing that does strike me is ksmps=32, which means your envelopes will only update every 32 samples. I’d be inclined to put this logic into a UDO with setksmps 1 to minimise unwanted zipper noise.

I’m not a fan of global variables, but the only way to avoid it in this instance is to place your amplitudes into a table instead of an array, but I think it would just make the code harder to read, so I think gkNoteVolume[] is a keeper :slight_smile:

Great to get your feedback Rory, thanks! And good to know that I might come close to nailing it. I was surprised that I couldn’t find info on this kind of use since it such a fundamental part of expression on a piano and keyboard.

You can test this with a mouse. Just trigger some notes with long sustain (use the SUS slider) and turn the sustain slider down soon after to shorten their sustain.

I’m handling the zippers with this line aVol = tone(a(gkNoteVolume[p4]),33). I am often using the tone opcode rather than the linseg and portk combo for such purposes, which I probably picked up from you on this forum. Would the UDO solution be advantageous for efficiency? I’d probably want to smooth any jitter from a MIDI controller anyway and I think the UDO probably wouldn’t handle that well?

Why not? Curious to learn.

How would table vs array compare in terms of efficiency (CPU)? Would there be any other advantages of table vs array, I don’t know, maybe read/write stability, less likely to crash Csound etc?

I took my own attempt at this, replicating your functionality and changing a few things that I thought could be done differently:

  • still using Csound’s default MIDI triggering which can still be useful in some ways
  • reducing the amount of code
  • encapsulate the code within a single instrument

The main points are that:

  • xtratim is used to extend note durations beyond the note release
  • turnoff2 to is used to override the xtratim and force a note to quit once the release stage has been completed
  • incrementing is used instead of linsegs etc. (as you are doing) for attack and release scalars so that these can be changed dynamically during a note. A difference here is that these are indices that read attack and release scaling values from tables. This gives you a bit more flexibility regarding the shape of attack and release envelopes.
  • fractional note values are used to give each note played a unique identification (needed to ensure that turnoff2 only turns off the correct note)

Maybe some of this is useful in your project.

DynamicEnvelopes.csd (2.3 KB)

This is super cool! Thank you very much for sharing Iain!
There are quite a few things/concepts for me to learn from your example and experiment with it.

The use of the table seems really useful. It is a nice way of smoothing any jumps via interpolation. And I guess this is more efficient than using the filter from my example?
Using the release flag is also elegant. It is novel to me the idea of triggering instances from within the same instrument, and using cpsmidi() like that. I’ll have to wrap my head around the abs(rnd31(1,0)) trick. Does this guaranty that we are not getting the same instance number?

One thing that I’m missing is the handling of repeated notes. Your magic envelopes avoid clicks, but I guess due to destructive phases the volumes of repeated notes are jittery. This might actually be a somewhat realistic model. But would you have a good trick to mimic my solution for that, where I used an array to track envelopes in their release phase? Maybe by tracking the timers? But would still require a table or an array? What is your opinion on tables vs arrays (question from my previous post)?

abs(rnd31(1,0)) generates a 31-bit fraction between zero and 1 that is seeded from the system clock, pretty much guaranteeing a unique value.

You are right that if the same note is played repeatedly and the release time is long, these notes pile on top of one another. In practice this may not be a problem but this is not how an acoustic piano works. It can also cause a CPU overhead if the release time is long and the CPU-overhead of the sound synthesiser part is high. If you want there to be only one note-per-key polyphony, while still preventing clicks as old notes are turned off, attached is a suggestion.

This time a function table stores the number of active instances of each note, including notes in their release stage (this is done this way because the active opcode cannot discriminate fractional p1 values). If a new note is played, existing notes with the same note number in their release stage are quickly turned off after ramping them down to zero amplitude.

DynamicEnvelopes2.csd (3.0 KB)

In answer to your earlier question, the main reason global variables are avoided is that they create variable name pollution in the global (instr 0) space, which is a problem if you shunt together blocks of code that use the same global variable name but that want to use it independently. It is fine to use global variables if you do not intend to do this sort of thing and the scope of usage of your code if known and limited. I use global variables but I know it makes Rory sad.

The useful thing about function tables over arrays is that they provide handy built-in mechanisms for generating curves etc. These mechanisms could be replicated for arrays, but that adds some additional steps. An advantage of arrays is their multidimensionality option. GEN02 is essentially single-dimension array.

1 Like

Your answers and examples are precious. Thanks!

The ramping down the volume of active instances nicely prevents clicks, but it is not what I intended. Imagine pumping up the energy into a capacitor or a pipe (by blowing air), so when a new and the same note comes, the volume of that note increases from its current level (as in my implementation) and does not go silent first, turns off, and then ramps up again. This would require that notes are not re-triggered if they are active. Do you think this could be achieved by adapting your solution?

I couldn’t agree more. :slight_smile:

Here’s another suggestion. Sound is produced by instr 2 which is triggered by instr 1 if a new note is played and a previous note is not releasing. Envelopes are generated in instr 1 and are mixed into a zak channel corresponding to the note number thereby implementing the ‘pumping energy into a system’ idea. Instr 2 reads its envelope from the zak channel corresponding to its note number. Instr 2 also senses whether all the instr 1 notes corresponding to its note number, including ones in their release stages, have ceased and if so, turns itself off.

I guess few people use the zak system anymore as the newer arrays replicate their functionality, but their mixing and clearing opcodes are convenient and I thought I’d introduce them here.

DynamicEnvelopes3.csd (3.5 KB)

1 Like

Another precious example. Thanks a lot!
It seems this is getting close to what I had in my implementation with arrays.

Interesting to learn about the zak system.
A few quick questions.
It seems only one zak array can be defined, correct?

The mixing into zak array seems to just add signals, so 1+1=2. I was expecting it to mix in with proportions, e.g. 50/50 old/new signal or some other ratio. So in your implementation the magnitude can grow above unity. Are there any elegant ways of achieving the same as in my implementation, where the envelope never goes above 1?

A puzzle for me is the line turnoff2 p1, 5, 0. The imode = 4 doesn’t work properly and the imode = 5 (as you use) is not documented. Could you shed some light on this please?

FWIW, the imode = 5 is documented:
https://csound.com/docs/manual/turnoff2.html

It’s the “sum” of imode = 4 and imode = 1

That’s right, mode = 4 would turn off the the currently held note as well as the one in its release stage. By adding mode = 1 - ‘oldest note only’ - only the note in its release stage is turned off and the held note is left alone.

Yes, zakm simply mixes into the zak channel without scaling. If you wanted it to scale the local envelope 50/50 before mixing you could explicitly scale it:
zkwm (kRel*kAtt)*0.5, notnum()

Just one zak array per orchestra. Well, two actually, one for k/i variables and one for a-rate variables.

You can also scale the amplitude of an instrument based on the number of instances of that instrument using the active opcode:
kamp/active:k(p1)
However, this is unlikely to provide the perfect result firstly because there will be a sudden jump in amplitude in held notes as new notes are added (some sort of compression-like smoothing would have to be applied), and secondly because this division of amplitude will tend to result in a perceived drop in loudness as more and more notes are added - a more evolved scaling will be needed and it will be slightly dependent on the nature of the sounds being mixed.

If you want to limit the envelope in instr 2 to a maximum of 1, you can use the limit opcode.
kEnv limit kEnv, 0, 1
You should probably scale the contributing envelopes down to a maximum slightly below 1 to prevent an obvious dynamic flattening.