FM Synthesis algorithms

I’m interested in creating my own operators and creating a few FM synthesis algorithms. I’ve searched the forum, and found a similar question from a few years ago (this one). The OP was directed to a DX7 emulation someone had written, as well as the FM7 UGen. Both of those are far more complicated than I need, though. I’m also not looking to emulate the DX7, but simply gain a better understanding of how FM synthesis work by creating something relatively simple (4 operators and a few algorithms).

Like the OP of the post I linked to, I’m basing my code on Eli Fieldsteel’s FN synthesis tutorials. Below are the SynthDefs I have so far. Note that I’m using Supriya, and not sclang.

This is Eli’s SynthDef (in Python):

@synthdef()
def frequency_modulation(
    attack_release=(0.01, 3.0),
    curve_attack_release=(4, -4),
    frequency=500, 
    modulator_ratio=1, 
    carrier_ratio=1, 
    index_scale=(1, 5),
):
    amp = 0.2
    attack = attack_release[0]
    release = attack_release[1]
    pan = 0.0
    curve_attack = curve_attack_release[0]
    curve_release = curve_attack_release[1]
    
    index = index_scale[0]
    scaled_index = index_scale[0] * index_scale[1]

    index_envelope = EnvGen.kr(
        envelope=Envelope(
            amplitudes=[index, scaled_index, index],
            durations=[attack, release],
            curves=[curve_attack, curve_release],
        )
    )

    env = EnvGen.kr(
        envelope=Envelope.percussive(
            attack_time=attack, 
            release_time=release,
            curve=curve_release,
        ),
        done_action=2,
    )
    modulator = SinOsc.ar(frequency=frequency * modulator_ratio) * (frequency * modulator_ratio) * index_envelope
    carrier = SinOsc.ar(frequency=frequency * carrier_ratio + modulator) * env * amp
    signal = Pan2.ar(source=carrier, position=pan)
    Out.ar(bus=0, source=signal)

My question is how to handle input and output signals in the SynthDefs based on some of the algorithms. For example, algorithm 5:

I have working code where I split the carrier and modulator into their own SynthDefs. However, that creates complications because looking at something like algorithm 5, it seems that some operators will need more than one In and Out UGen. For example. I’ve read that carriers (like operators 1 and 3 in algorithm 5) are summed before being output. So how to do that, and how to implement the feedback in operator 4, is where I’m running to problems. Any suggestions?

You want to use local in and out for feedback… however sc does not support single sample feedback only block size feedback (this will hopefully be changed in a few versions by @Spacechild1), therefore you can’t really do what you want :upside_down_face:

Honestly I would look into FM7. Yes it seems complicated, but it really isn’t as complicated as it looks. It has all the algorithms built in.

2 Likes

You could write a function for a single pm operator, the onpole filter and an operator stack.
The Yamaha DX7 Patent uses a different kind of averaging filter, but the tracking OnePole filter is even better.
The index of your modulator is naturally scaled to radians, so you want to make sure to divide it by 2pi. If you are dealing with SinOscs only, you could skip that step and just add the modulation after multiplying the phase by 2pi. But if you want to exchange your carrier for a Wavetable Osc, you have to first divide by 2pi to normalize the modulation and afterwards multiply by BufFrames otherwise your index is not correctly scaled.
Additionally you could write a function for the self modulation operator.
You also want to make sure that your Oscillators have a phase reset. This doesnt matter if you use event based scheduling with Pbind or Routines, but it does matter if you use Pmono or a different method of event scheduling.

here are two parallel stacks:

(
var onepoleLP = { |sig, freq|
	var nyquist = SampleRate.ir / 2;
	var freqClipped = freq.clip(nyquist.neg, nyquist);
	var slope = freqClipped.abs * SampleDur.ir;
	OnePole.ar(sig, exp(-2pi * slope));
};

var operatorPM = { |reset, freq, mod = 0|
	var phase = Phasor.ar(reset, freq * SampleDur.ir);
	SinOsc.ar(DC.ar(0), phase + mod * 2pi);
};

var operatorStack = { |reset, carrFreq, modRatio, pmIndex|
	var modFreq = carrFreq * modRatio;
	var modulator = operatorPM.(reset, modFreq) / 2pi * pmIndex;
	modulator = onepoleLP.(modulator, modFreq);
	operatorPM.(reset, carrFreq, modulator);
};

{
	var reset;
	var parallel, stack_A, stack_B;
	reset = \reset.tr(0);
	stack_A = operatorStack.(reset, \carrFreq_A.kr(100), \modRatio_A.kr(1.5), \pmIndex_A.kr(2.5));
	stack_B = operatorStack.(reset, \carrFreq_B.kr(200), \modRatio_B.kr(2.0), \pmIndex_B.kr(1.0));
	parallel = [stack_A, stack_B].sum;
}.plot(0.021);
)

here is an operator with feedback:

s.options.blockSize = 1;

(
var onepoleLP = { |sig, freq|
	var nyquist = SampleRate.ir / 2;
	var freqClipped = freq.clip(nyquist.neg, nyquist);
	var slope = freqClipped.abs * SampleDur.ir;
	OnePole.ar(sig, exp(-2pi * slope));
};

var operatorPM = { |reset, freq, mod = 0|
	var phase = Phasor.ar(reset, freq * SampleDur.ir);
	SinOsc.ar(DC.ar(0), phase + mod * 2pi);
};

var operatorFb = { |reset, freq, fbIndex|
	var fbIn, sig, mod;
	fbIn = LocalIn.ar(1);
	mod = fbIn * fbIndex;
	mod = onepoleLP.(mod, freq);
	sig = operatorPM.(reset, freq, mod);
	LocalOut.ar(sig);
	sig;
};

{
	var reset = \reset.tr(0);
	operatorFb.(reset, \carrFreq_A.kr(100), \fbIndex.kr(1));
}.plot(0.021);
)

for self modulation you expect the SinOsc to get a Saw shape (make sure to boot with blocksize = 1)

3 Likes

If you dont want to reduce the blocksize to 1, but leave it on default you could consider to implement your self modulating operator with phase shaping / phase increment distortion for classic PD instead. This also gives you the saw like shape:

phase shaping:

(
var kink = { |phase, skew|
	Select.ar(phase > skew, [
		0.5 * (phase / skew),
		0.5 * (1 + ((phase - skew) / (1 - skew)))
	]);
};

{
	var skew = \skew.kr(0.125);
	var phase = Phasor.ar(0, 50 * SampleDur.ir);
	var warpedPhase = kink.(phase, skew);
	cos(warpedPhase * 2pi).neg;
}.plot(0.02);
)

phase increment distortion:

(
var triangle = { |phase, skew|
	Select.ar(phase > skew, [
		phase / skew,
		1 - ((phase - skew) / (1 - skew))
	]);
};

{
	var skew = \skew.kr(0.125);
	var phase = Phasor.ar(0, 50 * SampleDur.ir);
	var warpedPhase = triangle.(phase, skew);
	cos(phase + (warpedPhase * (0.5 - skew)) * 2pi).neg;
}.plot(0.02);
)

3 Likes

It may also be worth pointing out that the concerns here about blocksize = 1 for feedback only matter if you want to get as close as possible to Yamaha FM behavior.

Another valid creative option is simply not to care about that, and use the sound that comes out of your synth, as it is.

High-index feedback produces noise, where differences in coloration between blocksize = 1 vs blocksize = 64 may not be aurally significant. Or, they might be significant… but that’s an artistic decision – e.g. at index = 20, I can’t tell the difference between blocksizes, but at index = 5, I can.

Lower-index feedback, such as dietcv’s index = 1 example where the result should be sawtooth-ish – with a larger block size, it’s very different from a sawtooth, but you do get a clean harmonic tone, and if it works in your project, then it works in the project.

hjh

1 Like

Instead of recreating the DX7 directly, i would suggest to use Wavetables for your operators anyway. Then you are free to load a sine to saw wavetable into one of your operators. For example by using the awesome OscOS Ugen from Oversampling Oscillators.

The self modulating feedback is a very cool synthesis approach and can go beyond what was implemented in the Yamaha Synthesizers in the 80s. Have a look here: Fors Superberry

load a sine to saw wavetable into one of your operators. For example by using the awesome OscOS Ugen

Oh yeah! or a full 4 wavetable sine/tri/sq/saw.

Also in OversamplingOscillators are 2 4x4 feedback modulating oscillators - FM7aOS and FM7bOS that are basically the FM7 with 3 differences: only 4 oscillators (which is maybe what you want), ability to switch between all 4 oscillator shapes, oversampling, so less aliasing.

They don’t quite replicate the DX7 algorithms though.

Sam

1 Like

Thanks to everyone for their feedback!

I’ve been trying to build this algoithm:

I decided it was easiest, for now, to keep the carrier and all modulating operators in the same SynthDef. So the two SynthDefs below represent the left and right “stacks” of operators in the above image.

@synthdef()
def operators_1_and_2(
    amplitude = 0.2,
    attack=(0.01),
    carrier_ratio=1, 
    curve=(-4),
    decay=(0.3),
    frequency=500, 
    gate=1,
    modulator_ratio=1, 
    out_bus=0,
    release=(3.0),
    sustain=(0.5),
):
    envelope = EnvGen.kr(
        envelope=Envelope.adsr(
            attack_time=attack[0],
            decay_time=decay[0], 
            sustain=sustain[0],
            release_time=release[0],
            curve=curve[0],
        ),
        done_action=2,
        gate=gate,
    )

    modulator = SinOsc.ar(frequency=frequency * modulator_ratio) * (frequency * modulator_ratio) * envelope
    signal = SinOsc.kr(frequency=frequency * carrier_ratio + modulator) * envelope * amplitude
    Out.kr(bus=out_bus, source=signal)

@synthdef()
def operators_3_and_4(
    amplitude = 0.2,
    attack=(0.01),
    carrier_ratio=1, 
    curve=(-4),
    decay=(0.3),
    frequency=500, 
    gate=1,
    in_bus=2,
    modulator_ratio=1, 
    release=(3.0),
    sustain=(0.5),
):
    envelope = EnvGen.kr(
        envelope=Envelope.adsr(
            attack_time=attack[0],
            decay_time=decay[0], 
            sustain=sustain[0],
            release_time=release[0],
            curve=curve[0],
        ),
        done_action=2,
        gate=gate,
    )

    feedback = LocalIn.ar(channel_count=1)
    modulator = SinOsc.ar(frequency=frequency * modulator_ratio) * (frequency * modulator_ratio) * envelope
    modulator += feedback
    modulator *= 1.05
    LocalOut.ar(source=modulator)
    carrier = SinOsc.ar(frequency=frequency * carrier_ratio + modulator) * envelope * amplitude
    carrier_1_signal = In.kr(bus=in_bus, channel_count=1)
    output = carrier + carrier_1_signal
    Out.ar(bus=0, source=output)

I simplified the operators by having each SynthDef share an ADSR envelope, instead of the carrier and modulator having separate envelopes like they do in the original example I posted. I used a bus to send the signal from operators 1 and 2 to operator 3 so it could be summed with output of operators 3 and 4. That’s all working as expected. I’m not sure I did the feedback correctly, though. This is just the feedback related code:

    feedback = LocalIn.ar(channel_count=1)
    modulator = SinOsc.ar(frequency=frequency * modulator_ratio) * (frequency * modulator_ratio) * envelope
    modulator += feedback
    modulator *= 1.05 #  The hard-coded 1.05 is meant to be like a feedback index
    LocalOut.ar(source=modulator)

I get some interesting results when playing around the 1.05 multiplier, but I’m not sure this is the correct way to do it. Suggestions?

Im not familiar with Supriya, but there is no need to split the operator stacks into different SynthDefs and setup busses.
I would additionally recommend if you are dealing with operator stacks and especially feedback to use Phase Modulation instead of Frequency Modulation (like its done in the DX7 and in my example above :wink: ). The Bessel Functions in FM will produce amplitudes at DC which detune the frequency of your overall output. The tracking OnePole filter is additionally useful for lowpass filtering (PM) or highpass filtering (FM), especially for the feedback case (also used in the DX7).

I’ll put it all in the same SynthDef. You’re right, there’s no reason to have 2.

I don’t know what a lot of the math in your examples are doing. I’m also not familiar with some of the UGens you use. Since FM synthesis as a whole is rather new to me, I’d prefer to start out working with something I understand better as a base, and then change it to a better implementation later. First, I’d like to get the feedback working correctly, then start thinking about everything else.

I’m a little confused by what this is doing \fbIndex.kr(1) in your example here:

{
	var reset = \reset.tr(0);
	operatorFb.(reset, \carrFreq_A.kr(100), \fbIndex.kr(1));
}.plot(0.021);

fbIndex hasn’t been declared previously, but you’e calling .kr(1) on it. What’s happening there?

These are NamedControls. Its an alternative syntax for argument style SynthDef arguments. This comes up quite often, you can search here on the forum and find some good explanations.
You can have a look here for some explanations about FM vs. PM and the use of OnePole filters.

Awesome. I’ve seen them before but haven’t seen an explanation of how they work. Thanks.

2 things:

  • I think the feedback is supposed to be added to the frequency of the modulator but you have added it to the output of the modulator.
  • The 1.05 feedback coefficient should be multiplied by the feedback, but you have multiplied it by the output of the modulator. So it should probably look something like this (not tested):
feedback = LocalIn.ar(channel_count=1) * 1.05
modulator = SinOsc.ar(frequency=frequency * modulator_ratio + feedback) * (frequency * modulator_ratio) * envelope
LocalOut.ar(source=modulator)

But by all means, do whatever sounds good to you!

I was pretty sure I had the feedback wrong, which is why I asked about it. I updated my code after looking again at dietcv’s example. I’m going to experiment with it more tomorrow. I’ve been on a ton of pain killers since my surgery last week, and I think they’re affecting me more than I realized :laughing:

I went through and implemented eight four-operator algorithms today. These are the first three.

@synthdef()
def algorithm_1(
    amplitude = 0.2,
    attack=(0.01),
    carrier_ratio=1, 
    curve=(-4),
    decay=(0.3),
    feedback_index=1.0,
    frequency=500, 
    gate=1,
    modulator_ratio=1, 
    release=(3.0),
    sustain=(0.5),
) -> None:
    envelope = EnvGen.kr(
        envelope=Envelope.adsr(
            attack_time=attack[0],
            decay_time=decay[0], 
            sustain=sustain[0],
            release_time=release[0],
            curve=curve[0],
        ),
        done_action=2,
        gate=gate,
    )

    feedback = LocalIn.ar(channel_count=1)
    feedback *= feedback_index
    modulator_4 = SinOsc.ar(frequency=frequency * modulator_ratio + feedback) * (frequency * modulator_ratio) * envelope
    LocalOut.ar(source=modulator_4)
    
    modulator_3 = SinOsc.kr(frequency=frequency * modulator_ratio + modulator_4) * (frequency * modulator_ratio) * envelope
    
    modulator_2 = SinOsc.ar(frequency=frequency * modulator_ratio) * (frequency * modulator_ratio) * envelope
    modulator_2 += modulator_3
    
    carrier = SinOsc.ar(frequency=frequency * carrier_ratio + modulator_2) * envelope
    
    pan = Pan2.ar(source=carrier, position=0.0, level=amplitude)
    Out.ar(bus=0, source=pan)

@synthdef()
def algorithm_2(
    amplitude = 0.2,
    attack=(0.01),
    # Increases the centering of harmonics on the overtone series
    carrier_ratio=1, 
    curve=(-4),
    decay=(0.3),
    feedback_index=1.0,
    frequency=500, 
    gate=1,
    # Increases the spacing of the sidebands, 
    # so get different combinations of specific overtones
    modulator_ratio=1, 
    release=(3.0),
    sustain=(0.5),
) -> None:
    envelope = EnvGen.kr(
        envelope=Envelope.adsr(
            attack_time=attack[0],
            decay_time=decay[0], 
            sustain=sustain[0],
            release_time=release[0],
            curve=curve[0],
        ),
        done_action=2,
        gate=gate,
    )
    
    modulator_4 = SinOsc.ar(frequency=frequency * modulator_ratio) * (frequency * modulator_ratio) * envelope
    
    feedback = LocalIn.ar(channel_count=1)
    feedback *= feedback_index
    modulator_3 = SinOsc.ar(frequency=frequency * modulator_ratio + feedback) * (frequency * modulator_ratio) * envelope
    LocalOut.ar(source=modulator_3)

    modulator_3 += modulator_4

    modulator_2 = SinOsc.ar(frequency=frequency * modulator_ratio + modulator_3) * (frequency * modulator_ratio) * envelope
    modulator_2 += modulator_4

    carrier = SinOsc.ar(frequency=frequency * carrier_ratio + modulator_2) * envelope
    pan = Pan2.ar(source=carrier, position=0.0, level=amplitude)
    Out.ar(bus=0, source=pan)

@synthdef()
def algorithm_3(
    amplitude = 0.2,
    attack=(0.01),
    carrier_ratio=1, 
    curve=(-4),
    decay=(0.3),
    feedback_index=1.0,
    frequency=500, 
    gate=1,
    modulator_ratio=1, 
    release=(3.0),
    sustain=(0.5),
) -> None:
    envelope = EnvGen.kr(
        envelope=Envelope.adsr(
            attack_time=attack[0],
            decay_time=decay[0], 
            sustain=sustain[0],
            release_time=release[0],
            curve=curve[0],
        ),
        done_action=2,
        gate=gate,
    )
    
    modulator_3 = SinOsc.ar(frequency=frequency * modulator_ratio) * (frequency * modulator_ratio) * envelope
    modulator_2 = SinOsc.ar(frequency=frequency * modulator_ratio + modulator_3) * (frequency * modulator_ratio) * envelope

    feedback = LocalIn.ar(channel_count=1)
    feedback *= feedback_index
    modulator_4 = SinOsc.ar(frequency=frequency * modulator_ratio + feedback) * (frequency * modulator_ratio) * envelope
    LocalOut.ar(source=modulator_4)

    carrier = SinOsc.ar(frequency=frequency * carrier_ratio + modulator_2) * envelope
    carrier += modulator_4
    pan = Pan2.ar(source=carrier, position=0.0, level=amplitude)
    Out.ar(bus=0, source=pan)

I’m not hearing any change in the sound when I change the value of the feedback index, though. I’m also not sure I have the order of operations correct. For example, in algorithm 3, should I add the output of modulators 3 and 2 to the frequency of carrier 1, and then add the output of all of that to modulator 4? For easy reference:

As @cian suggested, I’d go for FM7 because it does exactly what you need. You don’t need to use all 6 operators, you can use only 4.

If you want a SynthDef that implements it, you can find one here.

Then, to create your algorithm 5 you would simply do:

x = Synth(\fmx, [mod12: 1, mod34: 1, mod44: 1, amp1: 1, amp3: 1]);

For modulation, the syntax is: \modXY where X is carrier and Y modulator. If X == Y you get feedback. The value (1 in the example above) is the modulation amount.

Carrier level is set with \ampX where X is the operator index and the value is the output amount.

Only operators that are set will be used, so if you only set operators 1-4 you get a 4-op FM synth.

If you want to use envelopes, they are implemented by operator, with 4-stage level and time envelopes:

  • \atkl1 for attack level on operator 1
  • \atkt1 for attack time on operator 1
  • \decl1 for decay level on operator 1
  • etc

going on with \dect1, \susl1, \sust1, \rell1, \relt1; \atkl2, \atkt2, …

There’s also a general ADSR envelope (\atk, \dec, \sus and \rel);

I’m not familiar with Supriya, so you’ll have to translate it yourself, but I’m sure it would be pretty easy.

I haven’t heard about the OversamplingOscillators @Sam_Pluta recommends, but they sure sound interesting and worth checking out (I will :)).

FM7 hasn’t been added to Supriya’s API, and it’s highly unlikely that Josehpine will add it herself. She’s been sticking to the core UGens and functionality. I don’t have the time myself to do it, either.