Two SuperCollider FM Synthesis Projects

I just posted my second SuperCollider FM Synthesis project to GitHub, a pedagogical extension of a great DX7 “clone” for the SuperCollider language. The project allows users to enter DX7 presets, by number, and generate usable SynthDefs (SuperCollider synthesis instrument definitions) and analysis that provides insight into the nature of the instrument configuration.

My previous SuperCollider FM Synthesis project was somewhat similar in scope and design. That project allowed the user to generate and audition any number of FM synths (sounds) of different configurations using randomness. Why randomness? Because if you don’t know what you’re doing, and even if you do, you can use randomness to “suggest” sounds that you *might* just like and, with a little editing, could be really great.

The primary motivation behind both projects is pedagogical. As my students will attest, FM synthesis is hard, both conceptually and practically. Management of all of the components and connections of a compelling FM instrument requires a lot of careful coordination. There are many moving parts beyond the primary considerations of carrier and modulator frequency ratio and index of modulation. Those parts include the generation of amplitude envelopes, arrangement of CM pairs if multi-CM arrangements are being used, feedback (if applicable), and so on. And it gets worse. In Musimathics Chapter 9, Gareth Loy writes of FM Synthesis:

Since FM synthesis is a nonlinear synthesis technique, there is no formal analysis method that
would allow us to exactly resynthesize an arbitrary timbre with FM. Using FM to model instrumental timbres is strictly an art.

Loy, Musimathics Vol 2, pg. 402

This is the real kicker. No one can give anyone else the “recipe” for making x sound using FM synthesis. So, the real power of a technosystem like the DX7 is two-fold. First, it has the ability to, in one “algorithm”, generate all components of a complex, time-varying sound — the transient, the steady state, and the release — where the perceived loudness (brightness) of the instrument is controllable via one parameter. Second, presets for the instrument abound. Those presets represent the collective work of musicians the world over to create life-like, compelling sounds, some of which model actual instruments. (And some of which actually do so very well!) With access to this wealth of data we have power. We can collect, analyze, and generalize about the algorithms that have been “artistically” created by preset designers.

How does it work?

The original DX7 project linked to above provides those presets in a form usable within SuperCollider. What that project does not do is make the resulting realization of those presets easily usable beyond its own context. That’s what the DX7-P project aims to do. Like the FMGen project, the DX7-P project exists to format generated data in a useful way. The code allows the user to enter a note, amplitude, and preset number and generate usable SynthDefs and analysis information. The SynthDefs can be used in any SC3 music-making context, and the analysis allows the user to look at, and generalize about, the nature of the FM algorithm used in the instrument. Here is an example of the DX7-P project in use.

The code in action: results

Below is a trumpet tone generated using MIDI note 55 (G3), a velocity of 95 (range 0-127), and preset number 386.

The SynthDefs (Synth definitions — i.e. sound recipe) generated for this sound are these:

SynthDef( 'InfEfx_386' , { arg outBus=0, gate = nil , lfoGet1 = 0, lfoGet2 = 1,
lfo_speed = 5.412716 , lfo_wave = 9 ,lfo_phase = 1.5707963267949 ,
lfo_delay = 1.5509090909091 ,lfo_amd = 0.0 ,pitchCons = 0.054569212121212 
, envTrig = 1 ,tameC = 0 , tameM = 1 ;
var lfo, pitchenv, output, randomlfo, multiPitch, lfoAmp;

lfo = Osc.ar(lfo_wave,lfo_speed, lfo_phase, 0.5 * lfoGet2, 0.5 * lfoGet2) + LFNoise0.ar(lfo_speed, 0.5 * lfoGet1, 0.5 * lfoGet1);
lfo = lfo * EnvGen.ar(Env.asr(lfo_delay,releaseTime:0.01, curve: -5),envTrig);
multiPitch = LinLin.ar(lfo, 0, 1, (pitchCons.neg.midiratio * tameM) + tameC, pitchCons.midiratio);
lfoAmp = LinLin.ar(lfo, 0, 1, lfo_amd * (-3/4), 0);
FreeSelf.kr(gate);

Out.ar(~busMePitch, multiPitch);
Out.ar(~busMeAmp, lfoAmp)
}).add;

The above is a pitch and amplitude LFO synth required by the below instrument.

SynthDef( 'DX7_386' ,{
arg outBus=0, pitch= 195.99771799087 , gate = 1, envPL0= 0.75 , envPL1= -0.375 , envPL2= 0.375 ,envPL3= 0.0 ,
envPL4= 0.75 , envPR0= 0.0093636119714172 ,envPR1= 0.021808432874493 ,
envPR2= 0.0036494817163351 ,envPR3= 0.028672481197527 ,coars1= 1 ,
fine_1= 1.0 ,coars2= 2 ,fine_2= 1.05 ,
coars3= 1 ,fine_3= 1.0 ,coars4= 1 ,
fine_4= 1.0 ,coars5= 6 ,fine_5= 1.04 ,
coars6= 7 ,fine_6= 1.21 ,env1L0= 75.121734187804 ,
env1L1= 0.87173400217933 ,env1L2= 3.8717340096793 ,env1L3= 35.371734088429 ,
env1L4= 75.121734187804 ,env1R0= 0.0104437877 ,env1R1= 0.4781546410553 ,
env1R2= 8.7414136343802 ,env1R3= 0.26884232689383 ,env1C0= 3 ,
env1C1= -3 ,env1C2= -3 ,env1C3= -3 ,
env2L0= 116.27553479069 ,env2L1= 52.525534631314 ,env2L2= 116.27553479069 ,
env2L3= 116.27553479069 ,env2L4= 116.27553479069 ,env2R0= 2.281185e-05 ,
env2R1= 25.30272643166 ,env2R2= 0.0 ,env2R3= 0.0 ,
env2C0= 3 ,env2C1= -3 ,env2C2= -3 ,
env2C3= -3 ,env3L0= 88.40380072101 ,env3L1= 14.153800535385 ,
env3L2= 17.153800542885 ,env3L3= 17.153800542885 ,env3L4= 88.40380072101 ,
env3R0= 0.07228205433 ,env3R1= 0.93015895450116 ,env3R2= 0.0 ,
env3R3= 0.35438954554401 ,env3C0= 3 ,env3C1= -3 ,
env3C2= -3 ,env3C3= -3 ,env4L0= 93.871734234679 ,
env4L1= 19.621734049054 ,env4L2= 48.121734120304 ,env4L3= 48.121734120304 ,
env4L4= 93.871734234679 ,env4R0= 0.00344916923 ,env4R1= 0.0073069076928915 ,
env4R2= 0.0 ,env4R3= 0.21245576568883 ,env4C0= 3 ,
env4C1= -3 ,env4C2= -3 ,env4C3= -3 ,
env5L0= 111.0000002775 ,env5L1= 36.750000091875 ,env5L2= 65.250000163125 ,
env5L3= 65.250000163125 ,env5L4= 111.0000002775 ,env5R0= 0.00867938 ,
env5R1= 8.836510067761 ,env5R2= 0.0 ,env5R3= 0.13395264129583 ,
env5C0= 3 ,env5C1= -3 ,env5C2= -3 ,
env5C3= -3 ,env6L0= 74.90380068726 ,env6L1= 0.6538005016345 ,
env6L2= 74.90380068726 ,env6L3= 74.90380068726 ,env6L4= 74.90380068726 ,
env6R0= 0.06835313667 ,env6R1= 0.11772806426007 ,env6R2= 0.0 ,
env6R3= 0.0 ,env6C0= 3 ,env6C1= -3 ,
env6C2= -3 ,env6C3= -3 ,noteBlok1= 1 ,
noteBlok2= 1 ,noteBlok3= 1 ,
noteBlok4= 1 ,noteBlok5= 1 ,
noteBlok6= 1 ,
dn0= 0 ,dn1= 12.566370614359 ,dn2= 12.566370614359 ,dn3= 12.566370614359 ,
dn4= 0 ,dn5= 0 ,dn6= 0 ,dn7= 0 ,
dn8= 0 ,dn9= 0 ,dn10= 0 ,dn11= 0 ,
dn12= 0 ,dn13= 0 ,dn14= 5.5417694409324 ,dn15= 0 ,
dn16= 0 ,dn17= 0 ,dn18= 0 ,dn19= 0 ,
dn20= 0 ,dn21= 0 ,dn22= 12.566370614359 ,dn23= 0 ,
dn24= 0 ,dn25= 0 ,dn26= 0 ,dn27= 0 ,
dn28= 0 ,dn29= 12.566370614359 ,dn30= 0 ,dn31= 0 ,
dn32= 0 ,dn33= 0 ,dn34= 0 ,dn35= 0 ,
dn36= 1 ,dn37= 0 ,dn38= 0 ,dn39= 0 ,
dn40= 0 ,dn41= 0 ,detun1= 12 ,
detun2= 0 ,detun3= 7 ,detun4= 7 ,
detun5= 6 ,detun6= 7 ,modSens1= 0 ,
modSens2= 0 ,modSens3= 0 ,modSens4= 0 ,
modSens5= 0 ,modSens6= 0 ,outMult= 1 ,
osc_sync= 0 ,transpose= nil ,gate1=1,gate1Rel = 1,
amp=0.1,totVol= nil ;

var ctls, mods, chans, out, kilnod,
envAmp1, envEnv1, envAmp2, envEnv2, envAmp3, envEnv3 ,envAmp4, envEnv4, envAmp5, envEnv5, envAmp6, envEnv6, dca, envAmpP, envEnvP;

envEnvP = Env.new([ envPL0, envPL1, envPL2, envPL3, envPL4], [envPR0,envPR1,envPR2,envPR3], 0, 3);
envAmpP = EnvGen.kr(envEnvP, gate, doneAction:0);
envEnv1 = Env.new([(-1 * env1L0).dbamp ,(-1 * env1L1).dbamp, (-1 * env1L2).dbamp, (-1 * env1L3).dbamp, (-1 * env1L4).dbamp], [env1R0,env1R1,env1R2,env1R3], [env1C0,env1C1,env1C2,env1C3], 3);
envAmp1 = EnvGen.kr(envEnv1, gate, doneAction:0 );
envEnv2 = Env.new([(-1 * env2L0).dbamp ,(-1 * env2L1).dbamp, (-1 * env2L2).dbamp, (-1 * env2L3).dbamp, (-1 * env2L4).dbamp], [env2R0,env2R1,env2R2,env2R3], [env2C0,env2C1,env2C2,env2C3], 3);
envAmp2 = EnvGen.kr(envEnv2, gate, doneAction:0 );
envEnv3 = Env.new([(-1 * env3L0).dbamp ,(-1 * env3L1).dbamp, (-1 * env3L2).dbamp, (-1 * env3L3).dbamp, (-1 * env3L4).dbamp], [env3R0,env3R1,env3R2,env3R3], [env3C0,env3C1,env3C2,env3C3], 3);
envAmp3 = EnvGen.kr(envEnv3, gate, doneAction:0 );
envEnv4 = Env.new([(-1 * env4L0).dbamp ,(-1 * env4L1).dbamp, (-1 * env4L2).dbamp, (-1 * env4L3).dbamp, (-1 * env4L4).dbamp], [env4R0,env4R1,env4R2,env4R3], [env4C0,env4C1,env4C2,env4C3], 3);
envAmp4 = EnvGen.kr(envEnv4, gate, doneAction:0 );
envEnv5 = Env.new([(-1 * env5L0).dbamp ,(-1 * env5L1).dbamp, (-1 * env5L2).dbamp, (-1 * env5L3).dbamp, (-1 * env5L4).dbamp], [env5R0,env5R1,env5R2,env5R3], [env5C0,env5C1,env5C2,env5C3], 3);
envAmp5 = EnvGen.kr(envEnv5, gate, doneAction:0 );
envEnv6 = Env.new([(-1 * env6L0).dbamp ,(-1 * env6L1).dbamp, (-1 * env6L2).dbamp, (-1 * env6L3).dbamp, (-1 * env6L4).dbamp], [env6R0,env6R1,env6R2,env6R3], [env6C0,env6C1,env6C2,env6C3], 3);
envAmp6 = EnvGen.kr(envEnv6, gate, doneAction:0 );

ctls = [
[coars1 * fine_1 * ((pitch * noteBlok1) + ((detun1-7)/32)) * (envAmpP.midiratio) * Lag2.ar(In.ar(~busMePitch),0.01),  Rand(0,2pi) * osc_sync, envAmp1 * (Lag2.ar(In.ar(~busMeAmp),0.01) * (modSens1/3)).dbamp],
[coars2 * fine_2 * ((pitch  * noteBlok2) + ((detun2-7)/32)) * (envAmpP.midiratio) * Lag2.ar(In.ar(~busMePitch),0.01),  Rand(0,2pi) * osc_sync, envAmp2 * (Lag2.ar(In.ar(~busMeAmp),0.01) * (modSens2/3)).dbamp],
[coars3 * fine_3 * ((pitch * noteBlok3) + ((detun3-7)/32)) * (envAmpP.midiratio) * Lag2.ar(In.ar(~busMePitch),0.01),  Rand(0,2pi) * osc_sync, envAmp3 * (Lag2.ar(In.ar(~busMeAmp),0.01) * (modSens3/3)).dbamp],
[coars4 * fine_4 * ((pitch  * noteBlok4) + ((detun4-7)/32)) * (envAmpP.midiratio) * Lag2.ar(In.ar(~busMePitch),0.01),  Rand(0,2pi) * osc_sync, envAmp4 * (Lag2.ar(In.ar(~busMeAmp),0.01) * (modSens4/3)).dbamp],
[coars5 * fine_5 * ((pitch  * noteBlok5) + ((detun5-7)/32)) * (envAmpP.midiratio) * Lag2.ar(In.ar(~busMePitch),0.01),  Rand(0,2pi) * osc_sync, envAmp5 * (Lag2.ar(In.ar(~busMeAmp),0.01) * (modSens5/3)).dbamp],
[coars6 * fine_6 * ((pitch  * noteBlok6) + ((detun6-7)/32)) * (envAmpP.midiratio) * Lag2.ar(In.ar(~busMePitch),0.01),  Rand(0,2pi) * osc_sync, envAmp6 * (Lag2.ar(In.ar(~busMeAmp),0.01) * (modSens6/3)).dbamp]
];

mods = [
[dn0, dn1, dn2, dn3, dn4, dn5],
[dn6, dn7, dn8, dn9, dn10, dn11],
[dn12, dn13, dn14, dn15, dn16, dn17],
[dn18, dn19, dn20, dn21, dn22, dn23],
[dn24, dn25, dn26, dn27, dn28, dn29],
[dn30, dn31, dn32, dn33, dn34, dn35]
];

chans = [0, 1, 2, 3, 4, 5];
out = FM7.ar(ctls, mods).slice(chans) * -12.dbamp;
out = Mix.new([ // this is how the synthdef mutes channels - all are summed, but some are multiplied by zeros
(out[0] * 1 * dn36),
(out[1] * 1 * dn37),
(out[2] * 1 * dn38),
(out[3] * 1 * dn39),
(out[4] * 1 * dn40),
(out[5] * 1 * dn41),
]);

FreeSelfWhenDone.kr(Line.kr(0, 1, 10));
kilnod = DetectSilence.ar(out, 0.01, 0.2, doneAction:2);
out = out / outMult;
Out.ar(outBus, out.dup);
}).add

The above is the “trumpet” instrument SynthDef proper. Note that without the FM7 Ugen (plugin) the code would be even more complex as the multi-carrier, multi-modulator FM arrangement would have to be coded by hand using an additional ~15 Ugens minimum.

That analysis though

The envelopes used in the synth are below, plotted courtesy of the analysis functionality.

With the analysis provided by the program we can ascertain the following information about this synthesis program.

The arrangement of oscillators in the multi-modulator FM arrangement is Algorithm 18 in DX7 parlance:

                     +-+        
                     |6|        
                     +-+        
                      |         
                     +-+        
                     |5|        
                     +-+        
                 |--  |         
           +-+  +-+| +-+        
           |2|  |3|| |4|        
           +-+  +-+| +-+        
            -----|-----         
                +-+             
                |1|             
                +-+             

In this arrangement, the first oscillator is the only carrier (the oscillator whose output we actually hear). Oscillators 2, 3, and 4 feed into the carrier in a parallel multi-modulator arrangement. Oscillator 4 also has oscillator 5 and 6 modulating it in series. Oscillator 3 has a feedback path where the output is fed back into it’s index.

The CM ratios for the oscillators are as follows:

OSC 1: 1, OSC 2: ~2, OSC 3: ~1, OSC 4: ~1, OSC 5: ~6.2, and OSC 6: ~8.5

Here we see a heavy emphasis on even harmonic relationships: 1:2:4:6:8, albeit with anharmonic components.

We also note that the feedback path on OSC 3 will have a smoothing effect on the resulting sidebands.

All of this, along with the information we have from the envelope plot, shows that the resulting tone will have a strong even-harmonic content where the steady-state tone is a parallel multi-modulator FM arrangement (2 and 3 feeding into 1) where oscillator 4 and its series modulators 5 and 6 are responsible for the bright, transient attack.

Easy peasy.

Now we only need to compare this analysis with other trumpet presets in the collection that we like to generalize about what an FM trumpet needs to do to be sonically successful.

I hope this peaks your interest in SuperCollider FM Synthesis and DX7 instrument algorithms just a little bit. If so, please download the project from GitHub and run it yourself. Remember you will need a working SuperCollider installation and the FM7 plugin you can get by installing the SuperCollider Plugins. Try a bunch of different presets until you find something you really like and then run the code with the analysis flag to learn why it works.

If you like this article and want to know when I post the next one, please hit subscribe and enter your email address. Your address will only be used to send notifications of new posts.