FM Synthesis algorithms

yes, something like:

modulator_3 = SinOsc.ar(frequency=frequency * modulator_ratio_3) * (frequency * modulator_ratio_3) * (envelope_3 * index_3)
modulator_2 = SinOsc.ar(frequency=frequency * modulator_ratio_2 + modulator_3) * (frequency * modulator_ratio_2) * (envelope_2 * index_2)

feedback = LocalIn.ar(channel_count=1)
feedback *= (frequency * modulator_ratio_4 * index_4)
modulator_4 = SinOsc.ar(frequency=frequency * modulator_ratio_4 + feedback)
LocalOut.ar(source=modulator_4)
    
carrier = SinOsc.ar(frequency=frequency * carrier_ratio + modulator_2 + modulator_4) * carrier_envelope
    
pan = Pan2.ar(source=carrier, position=0.0, level=amplitude)
Out.ar(bus=0, source=pan)

Awesome. Thanks.

I’ve been wondering about how many operator-specific parameters I should have. You think each operator should have its own envelope, modulation ratio, and modulation index?

Thats a specific problem you will also find in other synthesis approaches like additive synthesis, where its not obvious how to create an expressive control interface. For that reason you will find a subtractive-additive approach in all modern additive synthesizers (razor, harmor, pigment etc.) which have function based macro controls instead of individually setting amplitudes and phases of partials. The same is true for these DX7 like operators. When setting numbers by hand you will very unlikely have an expressive instrument. One way of dealing with that is to use the MLPRegressor from the FluCoMa toolkit to interpolate between different presets, which could be created randomly and then you mostly care about parameter ranges and not so much about specific parameter values, which arent self explanatory anyway because of their non-linear nature. You will find alot of literature where this behaviour is even described as favourable over linear a to b mapping, lose control gain influence. For example in the PhD thesis by Tom Mudd Nonlinear Dynamics In Musical Interactions

You could start from both ends of the spectrum by all operators having their own operator-specific parameters and all operators sharing the same parameters and then look how to get and shape the sounds you are interested in a way which suits you best.

1 Like

I finished a demo showing how to create operators and algorithms using Supriya. It can be found here. I made sure to include a shoutout to all of you here who helped me. Please take a look at the demo, if you have time.

Thanks so much for all of you help!

By the way, I’ve started rewriting @dietcv’s operators in Supriya. I’m hoping to implement at least some of the algorithms in the demo using phase modulation like dietcv did.

1 Like

There are three nice tutorials on FM synthesis in gen~ via music hackspace (they are not for free, 10€ each), but i find them helpful. But more importantly they are from last month. Alot of information you can get is just too old, to be informed about the current state of things.

They are also based on the book you mentioned in your reddit FM Theory and Applications: By Musicians for Musicians, by Chowning and Bristow and touch on some ways to advance FM synthesis beyond the DX7 world from the 80s.

Hi,

I’ve made versions of the phase modulation operators, with and without feedback, in Supriya. I added a few things that weren’t in your examples, so I’d like your feedback. I’ve already played some patterns with both operators, but haven’t tried building an algorithm with them yet. Anyway, they both sound lovely so far. Here they are:

@synthdef()
def phase_modulation_operator(
    amplitude=0.2,
    carrier_adsr=(0.01, 0.3, 0.5, 3.0),
    carrier_curve=(-4),
    carrier_frequency=440,
    carrier_phase_index=1.0,
    carrier_ratio=1,
    gate=1,
    modulator_adsr=(0.01, 0.3, 0.5, 3.0),
    modulator_curve=(-4),
    modulator_ratio=1,
    modulator_phase_index=1.0,
):
    carrier_envelope = EnvGen.kr(
        envelope=Envelope.adsr(
            attack_time=carrier_adsr[0],
            decay_time=carrier_adsr[1], 
            sustain=carrier_adsr[2],
            release_time=carrier_adsr[3],
            curve=carrier_curve[0],
        ),
        done_action=2,
        gate=gate,
    )

    modulator_envelope = EnvGen.kr(
        envelope=Envelope.adsr(
            attack_time=modulator_adsr[0],
            decay_time=modulator_adsr[1], 
            sustain=modulator_adsr[2],
            release_time=modulator_adsr[3],
            curve=modulator_curve[0],
        ),
        done_action=2,
        gate=gate,
    )
    ratio = IRand.ir(minimum=1, maximum=3)
    carrier_ratio = ratio
    modulator_ratio = ratio * 2

    modulator_frequency = carrier_frequency * modulator_ratio
    carrier_frequency *= carrier_ratio

    # Modulator
    phase = Phasor.ar(trigger=0, rate=modulator_frequency * SampleDur.ir())
    modulator = SinOsc.ar(frequency=DC.ar(source=0), phase=phase) / (TWO_PI * modulator_phase_index) * modulator_envelope

    nyquist = SampleRate.ir() / 2
    clipped_frequency = modulator_frequency.clip(-nyquist, nyquist)
    slope = abs(clipped_frequency) * SampleDur.ir()
    modulator = OnePole.ar(source=modulator, coefficient=(-TWO_PI * slope).exponential())
    
    # carrier
    phase = Phasor.ar(trigger=0, rate=carrier_frequency * SampleDur.ir())
    carrier = SinOsc.ar(frequency=DC.ar(source=0), phase=(phase + modulator) * TWO_PI * carrier_phase_index) * carrier_envelope

    pan = Pan2.ar(source=carrier, position=0.0, level=amplitude)
    Out.ar(bus=0, source=pan)

@synthdef()
def feedback_phase_modulation_operator(
    adsr=(0.01, 0.3, 0.5, 3.0),
    amplitude=0.2,
    curve=(-4),
    frequency=440,
    feedback_index=1.0,
    gate=1,
):
    envelope = EnvGen.kr(
        envelope=Envelope.adsr(
            attack_time=adsr[0],
            decay_time=adsr[1], 
            sustain=adsr[2],
            release_time=adsr[3],
            curve=curve[0],
        ),
        done_action=2,
        gate=gate,
    )

    feedback = LocalIn.ar(channel_count=1) * feedback_index

    # OnePole
    nyquist = SampleRate.ir() / 2
    clipped_frequency = frequency.clip(-nyquist, nyquist)
    slope = abs(clipped_frequency) * SampleDur.ir()
    modulator = OnePole.ar(source=feedback, coefficient=(-TWO_PI * slope).exponential())

    # Operator PM
    phase = Phasor.ar(trigger=0, rate=frequency * SampleDur.ir())
    signal = SinOsc.ar(frequency=DC.ar(source=0), phase=(phase + modulator) * TWO_PI)
    LocalOut.ar(source=signal)

    signal *= envelope

    pan = Pan2.ar(source=signal, position=0.0, level=amplitude)
    Out.ar(bus=0, source=pan)

looks good! for the feedback case you also want to divide by 2pi,
feedback = LocalIn.ar(channel_count=1) / TWO_Pi * feedback_index
or add the modulator after scaling your phase phase=phase * TWO_PI + modulator
I think its better to have this redundant calculation (first divide and then multiply by 2pi), because then you better understand what to do when you want to exchange for a wavetable Osc, where you are dividing by 2pi first and then scaling by frames in the buffer as i said earlier.

Awesome. Thanks! Just to clarify, this is what you think is best for the operator with feedback?

@synthdef()
def feedback_phase_modulation_operator(...):
    feedback = LocalIn.ar(channel_count=1) / TWO_PI * feedback_index
    ...
    carrier = SinOsc.ar(frequency=DC.ar(source=0), phase=phase * TWO_PI + modulator)

Should I leave the operator without feedback the way it is? Like this:

carrier = SinOsc.ar(frequency=DC.ar(source=0), phase=(phase + modulator) * TWO_PI * carrier_phase_index) * carrier_envelope

or also change it?

carrier = SinOsc.ar(frequency=DC.ar(source=0), phase=(phase * TWO_PI + modulator) * carrier_phase_index) * carrier_envelope

hey, this expression (phase + modulator) * TWO_PI multiplies the carrier phase and the modulator by 2pi. Your modulator is only correctly scaled if you have divided it by 2pi before, when you use this expression. In the case of SinOscs you can also use phase * 2pi + modulator and dont have to divide your modulator by 2pi. First dividing and then multiplying your modulator by 2pi is not necessary, but it has some educational value. If you would like to use your normalized carrier phase between 0 and 1 to drive a buffered single cycle waveform, you would like to scale that by BufFrames and not by 2pi, so you would first divide your modulator by 2pi and then scale your phase + modulator by bufframes. Just using SinOscs is an edge case here.

I think my confusion partly stems from the fact that you don’t divide the result of operatorPM by 2pi in your original example of phase modulation with feedback. So I was wondering if there was a reason for that, or if it was a mistake?

I learned how to incorporate UGens in Python functions (as opposed to exclusively using them in a SynthDef) in Supriya. So I was able to refactor my code to more closely reflect your sclang example. I’m dividing the result of phase_modulation_operator by TWO_PI in both cases. Here it is:

def one_pole_filter(
        frequency: UGenRecursiveInput,
        modulator: UGenRecursiveInput, 
) -> UGenOperable:
    nyquist = SampleRate.ir() / 2
    clipped_frequency = frequency.clip(-nyquist, nyquist)
    slope = abs(clipped_frequency) * SampleDur.ir()
    return OnePole.ar(source=modulator, coefficient=(-TWO_PI * slope).exponential())

def phase_modulation_operator(
    frequency: UGenRecursiveInput, 
    modulator: UGenRecursiveInput=0,
) -> UGenOperable:
    phase = Phasor.ar(trigger=0, rate=frequency * SampleDur.ir())
    return SinOsc.ar(frequency=DC.ar(source=0), phase=(phase + modulator) * TWO_PI)


@synthdef()
def phase_modulation(
    amplitude=0.2,
    carrier_adsr=(0.01, 0.3, 0.5, 3.0),
    carrier_curve=(-4),
    carrier_frequency=440,
    carrier_phase_index=1.0,
    carrier_ratio=1,
    gate=1,
    modulator_adsr=(0.01, 0.3, 0.5, 3.0),
    modulator_curve=(-4),
    modulator_ratio=1,
    modulator_phase_index=1.0,
) -> None:
    carrier_envelope = EnvGen.kr(
        envelope=Envelope.adsr(
            attack_time=carrier_adsr[0],
            decay_time=carrier_adsr[1], 
            sustain=carrier_adsr[2],
            release_time=carrier_adsr[3],
            curve=carrier_curve[0],
        ),
        done_action=2,
        gate=gate,
    )

    modulator_envelope = EnvGen.kr(
        envelope=Envelope.adsr(
            attack_time=modulator_adsr[0],
            decay_time=modulator_adsr[1], 
            sustain=modulator_adsr[2],
            release_time=modulator_adsr[3],
            curve=modulator_curve[0],
        ),
        done_action=2,
        gate=gate,
    )
    ratio = IRand.ir(minimum=1, maximum=3)
    carrier_ratio = ratio
    modulator_ratio = ratio * 2

    modulator_frequency = carrier_frequency * modulator_ratio
    carrier_frequency *= carrier_ratio

    modulator = phase_modulation_operator(frequency=modulator_frequency) / (TWO_PI * modulator_phase_index) * modulator_envelope
    modulator = one_pole_filter(frequency=modulator_frequency, modulator=modulator)
    
    carrier = phase_modulation_operator(frequency=modulator_frequency, modulator=modulator) / (TWO_PI * carrier_phase_index) * carrier_envelope

    pan = Pan2.ar(source=carrier, position=0.0, level=amplitude)
    Out.ar(bus=0, source=pan)

@synthdef()
def feedback_phase_modulation(
    adsr=(0.01, 0.3, 0.5, 3.0),
    amplitude=0.2,
    curve=(-4),
    frequency=440,
    feedback_index=1.0,
    gate=1,
) -> None:
    ratio = IRand.ir(minimum=1, maximum=3)
    carrier_ratio = ratio

    envelope = EnvGen.kr(
        envelope=Envelope.adsr(
            attack_time=adsr[0],
            decay_time=adsr[1], 
            sustain=adsr[2],
            release_time=adsr[3],
            curve=curve[0],
        ),
        done_action=2,
        gate=gate,
    )

    feedback = LocalIn.ar(channel_count=1) / TWO_PI * feedback_index
    modulator = one_pole_filter(frequency=frequency, modulator=feedback)

    frequency *= carrier_ratio
    
    carrier = phase_modulation_operator(frequency=frequency, modulator=modulator) / TWO_PI
    LocalOut.ar(source=carrier)

    carrier *= envelope

    pan = Pan2.ar(source=carrier, position=0.0, level=amplitude)
    Out.ar(bus=0, source=pan)

Hey there is no need to divide the Carrier by 2pi in Both examples. I would Suggest to make some Plots and listen to some of the results to get a feeling How the Index Value interacts with the Overall Sound. Unfortunately I cant explain that differently than I have already done.

Oh, right. There’s no need to do divide the output of the carriers by 2pi because that output isn’t being used to modulate anything. We do need to divide the modulator’s output, though, because it needs to be scaled appropriately when modulating the phase of the carrier.