Frequency Modulation – Phase Modulation – Delay Modulation (FM / PM / DM)

Thinking again about these basic synthesis strategies, I wanted to see how to write them in equivalent ways in SC. Phase modulation is roughly the same as frequency modulation: it produces sidebands in the same way, and this is also the case for delay modulation. Delay modulation is practical because it enables FM-like modulation without knowing the source’s frequencies.

Definitions:

carr: carrier frequency
mod: modulator frequency
dev: deviation, maximum change of carrier

index = dev / mod

Let phi(t) be the phase, with a time-dependent frequency, the phase of the sine is the derivation of the frequency – and the phase is the integrated frequency change.

FM written as function f(t):

f(t) = sin(2pi carr t + 2pi dev INT(0, t, sin(2pi mod x) dx))

phi(t) = 2pi dev INT(0, t, sin(2pi mod x) dx)

Resolving the integral leads to

phi(t) = (1 - sin(2pi mod t + pi/2)) * index

Therefore, FM and PM with sine oscillators can equivalently be written like this. Due to a restriction of SinOsc’s phase argument (it has to be between -8pi and +8pi), we apply a modulo operation to phi.

(
// evaluate several times
{
	var carr = Rand(100, 1000).poll(0, \carr);
	var mod = Rand(100, 1000).poll(0, \mod);
	var index = Rand(0.5, 5).poll(0, \index);

	var dev = mod * index;

	var fm = SinOsc.ar(carr + SinOsc.ar(mod, 0, dev));
	var phi = ((1 - SinOsc.ar(mod, pi/2)) * index);
	var pm = SinOsc.ar(carr, phi.mod(2pi));

	[fm, pm] * 0.1
}.plot
)

With delay modulation, t is replaced by t - delay(t), delay(t) >= 0. To equal phase modulation, we must have

sin(2pi carr (t - delay(t))) = sin(2pi carr t + phi(t))

With 0 < phi(t) < 2pi, this holds for

delay(t) = (1 - phi(t)/2pi) / carr > 0

Implementing this with DelayL or DelayC works straightly – however, a snippet at the start is missing. This can be related to the fact that in this example we start with a raising modulator frequency (for delay(t) > t the state is unknown).

(
// evaluate several times
{
	var carr = Rand(200, 1000).poll(0, \carr);
	var mod = Rand(50, 300).poll(0, \mod);
	var index = Rand(0.5, 5).poll(0, \index);

	var dev = mod * index;

	var fm = SinOsc.ar(carr + SinOsc.ar(mod, 0, dev));
	var phi = ((1 - SinOsc.ar(mod, pi/2)) * index).mod(2pi);
	var pm = SinOsc.ar(carr, phi);
	// DelayC might be more accurate than DelayL
	var dm = DelayC.ar(SinOsc.ar(carr), 1, (phi / -2pi + 1) / carr);

	[fm, pm, dm] * 0.1
}.plot
)

The init section issue (if it is such) improves by starting with a decreasing carrier (a modulator phase shift of 180 degrees). In most practical use cases, the introduced complication probably doesn’t pay as we would start with an envelope anyway.

There is a slight numerical difference between the FM, PM, and DM, but this is not audible in most cases.

(
// comparison test
x = { |type = 0|
	var carr = Rand(200, 1000).poll(0, \carr);
	var mod = Rand(30, 500).poll(0, \mod);
	var index = Rand(1.5, 3).poll(0, \index);

	var dev = mod * index;

	var fm = SinOsc.ar(carr + SinOsc.ar(mod, 0, dev));
	var phi = ((1 - SinOsc.ar(mod, pi/2)) * index).mod(2pi);
	var pm = SinOsc.ar(carr, phi);
	var dm = DelayC.ar(SinOsc.ar(carr), 1, (phi / -2pi + 1) / carr);

	type.poll(1, \type);
	Select.ar(type, [fm, pm, dm]) * 0.1 ! 2;
}.play
)

s.scope

s.freqscope

// change type of modulation, no audible difference

x.set(\type, 1)   // PM

x.set(\type, 2)   // DM

x.set(\type, 0)   // FM


x.release

With unknown input for delay modulation, some rough estimations can help. Here the index works like in the SinOsc case for a 200 Hz carrier, maximum delay 5 ms.


(
// control sideband strength with mouse
x = {
	var mod = 200;
	var index = MouseX.kr(0, 3).poll(5, \index);

	var dev = mod * index;

	var src = LFTri.ar(LFDNoise3.ar(0.2!2).range(50, [700, 700]));
	var phi = ((1 - SinOsc.ar(mod, pi/2)) * index).mod(2pi);
	var dm = DelayC.ar(src, 0.2, ((phi / -2pi + 1) / 200).poll(5, \delay));

	Splay.ar(dm, 0.7) * 0.1;
}.play
)

s.freqscope

x.release

However, the basic meaning of the index in the sine case is lost. Sidebands are not of equal strength in all registers:

(
// control sideband strength with mouse
// observe different strengths of low and high sin esp. with low index values

x = {
	var mod = 200;
	var fund = 500;
	var index = MouseX.kr(0, 2).poll(5, \index);

	var dev = mod * index;

	var src = SinOsc.ar([1, 3] * fund);
	var phi = ((1 - SinOsc.ar(mod, pi/2)) * index).mod(2pi);

	var dm = DelayC.ar(src, 1, ((phi / -2pi + 1) / fund).poll(5, \delay));
	// this would compensate
	// var dm = DelayC.ar(src, 1, ((phi / -2pi + 1) / fund / [1, 3]).poll(5, \delay));

	dm ! 2 * 0.05;
}.play
)

s.freqscope

x.release

Here a comparison with buffer playback modulation of a sine wave. With BufRd, you have the option to do FM (by modulating the phasor rate) and PM (by modulating the phasor signal). The SC implementation is more practical with Sweep than with Phasor. With sine sources, this is just a proof of concept. Certainly, plain sine wave modulation with BufRd and PlayBuf is not a good idea as other issues arise (numerical stability). However, BufRd and PlayBuf are practical to apply FM-like effects to buffered sources.

(
u = Signal.sineFill(512, [1]);
b = Buffer.loadCollection(s, u, 1);
)

b.plot

(
// evaluate several times
{
	var carr = Rand(500, 1000).poll(0, \carr);
	var mod = Rand(100, 400).poll(0, \mod);
	var index = Rand(0.5, 3).poll(0, \index);

	var dev = mod * index;

	var fm_1 = SinOsc.ar(carr + SinOsc.ar(mod, 0, dev));
	var phi = ((1 - SinOsc.ar(mod, pi/2)) * index).mod(2pi);
	var pm_1 = SinOsc.ar(carr, phi);

	var bufFrames = BufFrames.ir(b);
	var fm_2 = BufRd.ar(
		1,
		b,
		Sweep.ar(
			0,
			SinOsc.ar(mod, 0, dev, carr) * bufFrames * BufRateScale.ir(b),
			// carr + SinOsc.ar(mod, 0, dev) * bufFrames * BufRateScale.ir(b),
		).mod(bufFrames)
	);
	var pm_2 = BufRd.ar(
		1,
		b,
		(Sweep.ar(
			0,
			carr * bufFrames * BufRateScale.ir(b),
		) + (phi * bufFrames * BufRateScale.ir(b) / 2pi)).mod(bufFrames)
	);

	var fm_3 = PlayBuf.ar(
		1,
		b,
		carr + SinOsc.ar(mod, 0, dev) * bufFrames / SampleRate.ir,
		loop: 1
	);
	var dm_1 = DelayC.ar(SinOsc.ar(carr), 1, (phi / -2pi + 1) / carr);
	var dm_2 = DelayC.ar(
		PlayBuf.ar(1, b, carr * bufFrames / SampleRate.ir, loop: 1),
		1,
		(phi / -2pi + 1) / carr
	);

	[fm_1, pm_1, fm_2, pm_2, fm_3, dm_1, dm_2] * 0.1
}.plot
)
7 Likes