Simple fm synthesis question

hey, i was messing around with fm synthesis (just one carrier, one modulator) to achieve some squeaking sounds and discovered that i have to put either the cRatio really high or the mRatio really low and ajusting index & iScale to achieve this kind of sound which made me suspicious if it could be achieved maybe with an additional frequency envelope for the modulator / carrier or something else to set mRatio and cRatio to moderate amounts. atm mRatio at 0.32 and cRatio at 32 feels kind of odd to me. and i dont want to set the frequency for the modualator to another variable then freq.
Im also not sure if it makes any difference to have an array of detuned frequencies for the modulator. it seems that the modulator is just higher in amplitude then and its not so much or nothing at all changing the sound when you adjust the other parameters accordingly. any ideas?
EDIT: should it be always the same for freq and mul. i mean freqs * mRatio for the frequency and freqs * mRatio * iEnv for mul? or whats about freqs * mRatio for freq and freq * mRatio * iEnv for mul when you deal with an array of detuned frequencies?


(
{

	var trig = \trig.tr(1);
	var freq = \freq.kr(20);

	var index = \index.kr(3);
	var iScale = \iScale.kr(6);
	var mRatio = \mRatio.kr(0.32);
	var cRatio = \cRatio.kr(32);

	var sig, fmod, freqs, gainEnv, iEnv;

	iEnv = EnvGen.ar(Env([index, index * iScale, index], [\iAtk.kr(0.34), \iRel.kr(0.01)], [\cAtk.kr(0), \cRel.kr(0)]), trig);
	gainEnv = EnvGen.ar(Env([0, 1, 1, 0], [\atk.kr(0.075), \dec.kr(0.25), \rel.kr(0.025)]), trig, doneAction: Done.freeSelf);
	
	freqs = freq * Array.fill(6, { \detun.kr(1.008) ** Rand(-1.0, 1.0) });

	fmod = SinOsc.ar(freqs * mRatio, mul: (freqs * mRatio * iEnv)).sum;
	sig = Saw.ar(freqs * cRatio + fmod);
	
	sig = sig * gainEnv * \amp.kr(0.1);

	sig = Splay.ar(sig);
	sig = LeakDC.ar(sig);
}.play;
)

I often write FM as carrier = WhateverOscillator.ar(freq * (1 + (index * modulator))). I find this avoids a lot of confusion about what needs to be multiplied where.

hjh

2 Likes

thank you.
ive implemented it like this and was playing around with the number of unison voices for the modulator and tried to compensate the higher amplitudes with fmod * numVoices.reciprocal.

one question is: when i put in other values for \mRatio.kr(0.125) for example 0.10 or 0.20 i get really hard clicks for some values. not depending if its higher or lower than 0.125. why is that?
I thought that its because the frequency is negativ at some point and polled some values but i m not sure if thats the reason. i would like to avoid that while doing fm with low frequencies and high amplitudes for the modulator. any ideas?
EDIT: if you for example put in .wrap(0, 4pi) the sound is really different.

(
{
	var freq = \freq.kr(20.midicps);
	var numVoices = 2;

	var sig, fmod, freqs, gainEnv, iEnv;

	iEnv = EnvGen.ar(Env([0, 1, 0], [\iAtk.kr(0.34), \iRel.kr(0.01)], [\cAtk.kr(4.0), \cRel.kr(-4.0)]));
	gainEnv = EnvGen.ar(Env([0, 1, 1, 0], [\atk.kr(0.075), \dec.kr(0.25), \rel.kr(0.025)]), doneAction: Done.freeSelf);
	
	iEnv = \index.kr(3) + iEnv.linlin(0, 1, 0, \iEnvAmount.kr(12));
	
	freqs = freq * Array.fill(numVoices, { \detun.kr(1.008) ** Rand(-1.0, 1.0) });

	fmod = SinOsc.ar(freqs * \mRatio.kr(0.125)).sum * numVoices.reciprocal;
	sig = Saw.ar((freqs * \cRatio.kr(5) * (1 + (iEnv * fmod))).poll);
	
	sig = sig * gainEnv * \amp.kr(0.25);

	sig = Splay.ar(sig);
	sig = LeakDC.ar(sig);
}.play;
)

you are making a DC offset somehow:

(
{
	var freq = \freq.kr(20.midicps);
	var numVoices = 2;

	var sig, fmod, freqs, gainEnv, iEnv;

	iEnv = EnvGen.ar(Env([0, 1, 0], [\iAtk.kr(0.34), \iRel.kr(0.01)], [\cAtk.kr(4.0), \cRel.kr(-4.0)]));
	gainEnv = EnvGen.ar(Env([0, 1, 1, 0], [\atk.kr(0.075), \dec.kr(0.25), \rel.kr(0.025)]), doneAction: Done.freeSelf).poll;
	
	iEnv = \index.kr(3) + iEnv.linlin(0, 1, 0, \iEnvAmount.kr(12));
	
	freqs = freq * Array.fill(numVoices, { \detun.kr(1.008) ** Rand(-1.0, 1.0) });

	fmod = SinOsc.ar(freqs * \mRatio.kr(0.1)).sum * numVoices.reciprocal;
	sig = Saw.ar(freqs * \cRatio.kr(5) * (1 + (iEnv * fmod)));
	
	sig = sig * gainEnv * \amp.kr(0.25);

	sig = Splay.ar(sig);
	sig = LeakDC.ar(sig);
}.plot(0.5);
)

if you put the LeakDC before the gainEnv, it goes away:

(
{
	var freq = \freq.kr(20.midicps);
	var numVoices = 2;

	var sig, fmod, freqs, gainEnv, iEnv;

	iEnv = EnvGen.ar(Env([0, 1, 0], [\iAtk.kr(0.34), \iRel.kr(0.01)], [\cAtk.kr(4.0), \cRel.kr(-4.0)]));
	gainEnv = EnvGen.ar(Env([0, 1, 1, 0], [\atk.kr(0.075), \dec.kr(0.25), \rel.kr(0.025)]), doneAction: Done.freeSelf).poll;
	
	iEnv = \index.kr(3) + iEnv.linlin(0, 1, 0, \iEnvAmount.kr(12));
	
	freqs = freq * Array.fill(numVoices, { \detun.kr(1.008) ** Rand(-1.0, 1.0) });

	fmod = SinOsc.ar(freqs * \mRatio.kr(0.1)).sum * numVoices.reciprocal;
	sig = Saw.ar(freqs * \cRatio.kr(5) * (1 + (iEnv * fmod)));
	
	sig = LeakDC.ar(sig);
	
	sig = Splay.ar(sig);
	
	sig = sig * gainEnv * \amp.kr(0.25);

	
}.play;
)
1 Like

“Any ideas” would be, treat the signal ranges algebraically.

The carrier frequency is freq * (1 + (index * mod)).

Mod is a sinusoid between -1 and +1.

Hence index * mod ranges -index to +index.

And 1+ that ranges 1 - index to 1 + index. Let’s call this m.

Frequency gets multiplied by m. So, if m is negative, then the carrier frequency will be negative.

How could the lower bound be negative? If 1 - index < 0 → add index to both sides → 1 < index so any index > 1 will produce negative frequencies.

So, according to the classic fm formula, you can’t have high modulator amplitudes and avoid negative frequencies. (Incidentally Risset sometimes called fm synthesis “negative increment” synthesis, so negative frequencies are just part of the technique.)

You could distort the modulator (wrap), but then the center of m might no longer be 1 and you would hear that as an offset for the pitch.

Also, a wavetable oscillator loaded with a sawtooth cycle will be more stable under rapid frequency modulation than Saw. A basic formula for a band-limited sawtooth is b = Buffer.alloc(s, 2048, 1, { |buf| buf.sine1Msg((1..n).reciprocal) }); where n is the number of partials.

hjh

1 Like

Interesting. @jamshark70 do you remember the source where you read about Risset ? Thank you

It was a lecture by John Chowning that I heard at a conference, quite some years ago.

hjh

1 Like

thank you @Sam_Pluta @jamshark70 for the explanations. i have been adjusting the LeakDC and will also test out using a wavetable osc instead of Saw.

the Squine Ugen seems to be the most solid for FM with high amplitudes and Squine or SinOsc as a modulator. ive compared it with Saw, SawDPW DPW4Saw and Osc with a band limited Saw.

However i like the detune characteristics of this Roland JP-8000 Clone SuperSaw (Roland JP-8000 and JP-8080) in SuperCollider · GitHub when using unison Saws.
and was trying to implement it with a more stable Saw wave then LFSaw for modulation and tried out all the Saw Ugens and also Squine (which is kind of a compromise). the setup is the most stable with high index values but if i get rid of the sig = sig.tanh; the amplitude is way to high.
any ideas for further adjustments? i think this is really on the edge of possible dsp.


(
var superSaw;

superSaw = { |freq, mix, detune|
	var detuneCurve = { |x|
		(10028.7312891634 * x.pow(11)) -
		(50818.8652045924 * x.pow(10)) +
		(111363.4808729368 * x.pow(9)) -
		(138150.6761080548 * x.pow(8)) +
		(106649.6679158292 * x.pow(7)) -
		(53046.9642751875 * x.pow(6)) +
		(17019.9518580080 * x.pow(5)) -
		(3425.0836591318 * x.pow(4)) +
		(404.2703938388 * x.pow(3)) -
		(24.1878824391 * x.pow(2)) +
		(0.6717417634 * x) +
		0.0030115596
	};
	var centerGain = { |x| (-0.55366 * x) + 0.99785 };
	var sideGain = { |x| (-0.73764 * x.pow(2)) + (1.2841 * x) + 0.044372 };
	//var center = LFSaw.ar(freq, Rand());
	var center = Squine.ar(freq, clip: 0, skew: 1, iminsweep: 4, initphase: Rand());
	var freqs = [
		(freq - (freq * (detuneCurve.(detune)) * 0.11002313)),
		(freq - (freq * (detuneCurve.(detune)) * 0.06288439)),
		(freq - (freq * (detuneCurve.(detune)) * 0.01952356)),
		// (f + (f*(~detuneCurve.(detune))*0)),
		(freq + (freq * (detuneCurve.(detune)) * 0.01991221)),
		(freq + (freq * (detuneCurve.(detune)) * 0.06216538)),
		(freq + (freq * (detuneCurve.(detune)) * 0.10745242))
	];
	var side = Mix.fill(6, { |n|
		//LFSaw.ar(freqs[n], Rand(0, 2))
		Squine.ar(freqs[n], clip:0, skew: 1, iminsweep: 4, initphase: Rand(0, 1))
	});
	var sig = (center * centerGain.(mix)) + (side * sideGain.(mix));
	sig = HPF.ar(sig, freq);
	sig;
};

SynthDef(\elephant, {

	var freq, modEnv, detuneEnv, gainEnv, iEnv;
	var sig, fmod;

	gainEnv = EnvGen.ar(Env([0, 1, 1, 0], [\atk.kr(0.075), \dec.kr(0.25), \rel.kr(0.025)]), doneAction: Done.freeSelf);
	iEnv = EnvGen.ar(Env([0, 1, 0], [\iAtk.kr(0.34), \iRel.kr(0.01)], [\iAtkC.kr(4.0), \iRelC.kr(-4.0)]));
	modEnv = EnvGen.ar(Env([0, 1, 0], [\mAtk.kr(0.34), \mRel.kr(0.01)], [\mAtkC.kr(-8.0), \mRelC.kr(-8.0)]));

	iEnv = \index.kr(1) + iEnv.linlin(0, 1, 0, \iEnvAmount.kr(12));
	detuneEnv = \detun.kr(0.35) + modEnv.linlin(0, 1, 0, \detuneEnvAmount.kr(0.35));

	freq = \freq.kr(311.479) + modEnv.linlin(0, 1, 0, \freqEnvAmount.kr(-103.827));

	fmod = SinOsc.ar(5);

	sig = superSaw.((freq * (1 + (fmod * iEnv))), \mix.kr(0.7), detuneEnv);

	sig = sig.tanh;

	sig = LeakDC.ar(sig);

	sig = Splay.ar(sig);

	sig = sig * gainEnv * \amp.kr(0.1);

	//sig = SafetyLimiter.ar(sig);
	Out.ar(\out.kr(0), sig);
}).play;
)

One suggestion here:

	var detuneFactor = freq * detuneCurve.(detune);
	var freqs = [
		(freq - (detuneFactor * 0.11002313)),
		(freq - (detuneFactor * 0.06288439)),
		(freq - (detuneFactor * 0.01952356)),
		(freq + (detuneFactor * 0.01991221)),
		(freq + (detuneFactor * 0.06216538)),
		(freq + (detuneFactor * 0.10745242))
	];

detuneCurve produces 43 UGens.

var detuneCurve = { |x|
	(10028.7312891634 * x.pow(11)) -
	(50818.8652045924 * x.pow(10)) +
	(111363.4808729368 * x.pow(9)) -
	(138150.6761080548 * x.pow(8)) +
	(106649.6679158292 * x.pow(7)) -
	(53046.9642751875 * x.pow(6)) +
	(17019.9518580080 * x.pow(5)) -
	(3425.0836591318 * x.pow(4)) +
	(404.2703938388 * x.pow(3)) -
	(24.1878824391 * x.pow(2)) +
	(0.6717417634 * x) +
	0.0030115596
};

a = detuneCurve.(DC.kr(1));

(
c = 0;

f = { |ugen|
	c = c + 1;  // for this UGen
	ugen.inputs.do { |input|
		if(input.isUGen) { f.(input) };
	};
	c
};

f.(a);
)

-> 43

When you call it six times, you get 43*6 = 258 UGens.

When you call it six times with the same input value, you still get 258 UGens except 215 of them are simply duplicating the work done by the first 43. Math ops are relatively fast but caching the one detuneValue result will cut out 215 UGens and that can’t be a bad thing. (Edit: Actually a few more than 215 because I re-edited my post to eliminate the redundant freq * as well.)

SC’s SynthDef builder is not smart enough to detect that these subchains are identical. It’s up to you to cache calculations that you will use repeatedly.

hjh

thank you very much this is great :slight_smile:
nearly half of the Ugens now with the current example.
How much partials would you suggest for the Osc?

one additonal observation: the HPF which has the freq argument as the cutoff frequency in the end of the supersaw function is causing trouble when modulating the freq. In the initial design of the roland JP-8000 its added to prevent unpleasant frequency content caused by aliasing below the fundamental frequency.