Additive Synthesis

This is the ticket right here! I would buy this album on Bandcamp.

Thanks for sharing, this is really cool stuff! Here it is running at 1024 partials with a GUI showing the amplitudes:

4 Likes

Thanks @Herman !

Would it be possible for you to share your code?

the gui looks really cool, how do you create the visualisation of the spectrum? :slight_smile:
I have experimented lately with setting it up as a dual chain where the modulation for each chain has a global offset to get some nice stereo modulation effects. You can get a quick GUI for testing with .gui or preferable with .gui2 (where the chains are nicely grouped together).

(
var initChain = { |numPartials, freq|
    (
        freq: freq,
        numPartials: numPartials,
        ratios: (1..numPartials),
        amps: 1 ! numPartials
    )
};

var addHarmonicStretching = { |chain, inharmonicity|
	var inharmonicityScaled = inharmonicity.clip(0, 1) * 0.1;
    var stretchedHarmonics = (1 + (inharmonicityScaled * chain[\ratios] * chain[\ratios])).sqrt;
    chain[\freqs] = chain[\freq] * chain[\ratios] * stretchedHarmonics;
};

var pseudoLog2In = { |x, coef = 10|
	1 - (log2((1 - x) * (2 ** coef - 1) + 1) / coef);
};

var pseudoLog2Out = { |x|
    1 - pseudoLog2In.(1 - x);
};

var log2OutToLinear = { |x, shape|
	var mix = shape * 2;
	var easeOut = pseudoLog2Out.(x);
	easeOut * (1 - mix) + (x * mix);
};

var linearToLog2In = { |x, shape|
	var mix = (shape - 0.5) * 2;
	var easeIn = pseudoLog2In.(x);
	x * (1 - mix) + (easeIn * mix);
};

var expToLinearMorph = { |x, shape|
	Select.kr(shape > 0.5, [
		log2OutToLinear.(x, shape),
		linearToLog2In.(x, shape)
	]);
};

var raisedCos = { |phase, index|
    var cosine = cos(phase * 2pi);
    var raised = exp(index.abs * (cosine - 1));
	var hanning = 0.5 * (cosine + 1);
    raised * hanning;
};

var addCombFilter = { |chain, combOffset, combDensity, combWarp, combSync, combPeak|
    var ratios = chain[\freqs] / chain[\freq];
    var normalizedRatios = (ratios / ratios[chain[\numPartials] - 1]).clip(0, 1);
    var warpedRatios = expToLinearMorph.(normalizedRatios, combWarp);
    var rescaledRatios = warpedRatios * ratios[chain[\numPartials] - 1];
    var offsetRatios = rescaledRatios * (0.5 * combDensity) + (combOffset * combDensity.sign);
	var syncedRatios = offsetRatios * (1 - combSync);
    var phases = syncedRatios.wrap(0, 1);
    chain[\amps] = chain[\amps] * raisedCos.(phases, combPeak);
    chain;
};

var addLimiter = { |chain|
    var nyquist = SampleRate.ir * 0.5;
    var fadeStart = nyquist - 2000;
	var limiter = (1 - (chain[\freqs] - fadeStart) / 1000).clip(0, 1);
    chain[\amps] = chain[\amps] * limiter;
    chain;
};

var addSpectralTilt = { |chain, tiltPerOctave|
	chain[\amps] = chain[\amps] * (chain[\ratios].log2 * tiltPerOctave).dbamp;
	chain;
};

var modScale = { |modulator, value, amount, mode = \bipolar, direction = \center|

	// Convert bipolar to unipolar if needed
	var mod = if(mode == \bipolar) { (modulator + 1) * 0.5 } { modulator };

	case
	// Full range modulation
	{ direction == \center } {
		value * (1 - amount) + (mod * amount);
	}
	// Upward only modulation
	{ direction == \ceil } {
		value + (mod * (1 - value) * amount);
	}
	// Downward only modulation
	{ direction == \floor } {
		value - (mod * value * amount);
	};

};

var modScaleBipolar = { |modulator, value, amount, direction = \center|
	modScale.(modulator, value, amount, \bipolar, direction);
};


var modScaleUnipolar = { |modulator, value, amount, direction = \center|
	modScale.(modulator, value, amount, \unipolar, direction);
};

var setupChain = { |chainID, phaseOffset = 0, numPartials = 64|

	var combPosMF, combPosMod, combDensity, combOffset, combWarp, combSync;
	var combPeakMF, combPeakMod, combPeak;
	var stretchMF, stretchMod, stretch;
	var freqMF, freqMod, freq;
	var panMF, panMod, pan;
	var chain, partials;

	// Function to create named controls with chain-specific name
	var param = { |name, default, spec|
		var paramName = "%_%".format(chainID, name).asSymbol;
		NamedControl.kr(paramName, default, spec: spec);
	};

	///////////////////////////////////////////////////////////////////////////////////

	// position of notches
	combPosMF = param.(\combPosMF, 0.1, ControlSpec(0.01, 1));
	combPosMod = { |phase|
		SinOsc.kr(combPosMF, (phase + phaseOffset) * pi)
	};

	combOffset = param.(\combOffset, 0, ControlSpec(0, 1));
	combOffset = combOffset + (combPosMod.(0.5) * param.(\combOffsetMD, 0, ControlSpec(0, 4)));

	combDensity = modScaleBipolar.(
		modulator: combPosMod.(1.5),
		value: param.(\combDensity, 0, ControlSpec(0, 1)),
		amount: param.(\combDensityMD, 0, ControlSpec(0, 1)),
		direction: \ceil
	);

	combWarp = modScaleBipolar.(
		modulator: combPosMod.(1.5),
		value: param.(\combWarp, 0.5, ControlSpec(0, 1)),
		amount: param.(\combWarpMD, 0, ControlSpec(0, 1)),
		direction: \center
	);

	combSync = modScaleBipolar.(
		modulator: combPosMod.(0.5),
		value: param.(\combSync, 0.5, ControlSpec(0, 1)),
		amount: param.(\combSyncMD, 0, ControlSpec(0, 1)),
		direction: \ceil
	);

	///////////////////////////////////////////////////////////////////////////////////

	// shape of notches
	combPeakMF = param.(\combPeakMF, 0.1, ControlSpec(0.01, 1));
	combPeakMod = { |phase|
		SinOsc.kr(combPeakMF, (phase + Rand(0, 1)) * pi)
	};

	combPeak = param.(\combPeak, 0, ControlSpec(0, 5));
	combPeak = combPeak * (2 ** (combPeakMod.(0) * param.(\combPeakMD, 0, ControlSpec(0, 2))));

	///////////////////////////////////////////////////////////////////////////////////

	// inharmonicity
	stretchMF = param.(\stretchMF, 0.1, ControlSpec(0.01, 1));
	stretchMod = { |phase|
		SinOsc.kr(stretchMF, (phase + Rand(0, 1)) * pi)
	};

	stretch = modScaleBipolar.(
		modulator: stretchMod.(0),
		value: param.(\stretch, 0, ControlSpec(0, 1)),
		amount: param.(\stretchMD, 0, ControlSpec(0, 1)),
		direction: \ceil
	);

	///////////////////////////////////////////////////////////////////////////////////

	// frequency
	freqMF = param.(\freqMF, 0.1, ControlSpec(0.01, 1));
	freqMod = { |phase|
		SinOsc.kr(freqMF, (phase + Rand(0, 1)) * pi)
	};

	freq = param.(\freq, 110, ControlSpec(60, 1000));
	freq = freq * (2 ** (freqMod.(0) * param.(\freqMD, 0, ControlSpec(0, 2))));

	///////////////////////////////////////////////////////////////////////////////////

	chain = initChain.(numPartials, freq);
	chain = addHarmonicStretching.(chain, stretch);
	chain = addLimiter.(chain);
	chain = addCombFilter.(chain, combOffset, combDensity, combWarp, combSync, combPeak);
	chain = addSpectralTilt.(chain, param.(\tilt, -3, ControlSpec(-3, -12)));

	partials = SinOsc.ar(
		freq: chain[\freqs],
		phase: { Rand(0, 2pi) } ! numPartials,
		mul: chain[\amps]
	);

	partials = SelectX.ar(param.(\oddEven, 0.5, ControlSpec(0, 1)), [
		partials[1, 3..],
		partials[0, 2..]
	]);

	partials = partials.sum;

	///////////////////////////////////////////////////////////////////////////////////

	// panning
	panMF = param.(\panMF, 0.1, ControlSpec(0.01, 1));
	panMod = { |phase|
		SinOsc.ar(panMF, (phase + phaseOffset) * pi)
	};

	pan = modScaleBipolar.(
		modulator: panMod.(0.5),
		value: param.(\pan, 0.5, ControlSpec(0, 1)),
		amount: param.(\panMD, 0, ControlSpec(0, 1)),
		direction: \center
	);

	PanAz.ar(2, partials, pan.linlin(0, 1, -0.5, 0.5));

};

SynthDef(\additiveComb, {

	var numPartials = 64;
	var sig;

	sig = XFade2.ar(
		setupChain.(\one, 0, numPartials),
		setupChain.(\two, 1, numPartials),
		\all_chainMix.kr(0.5, spec: ControlSpec(0, 1)) * 2 - 1
	);

	sig = sig * \all_amp.kr(-15, spec: ControlSpec(-35, -5)).dbamp;

    sig = sig * Env.asr(0.001, 1, 0.001).ar(Done.freeSelf, \gate.kr(1));

    sig = Limiter.ar(sig);
    sig = LeakDC.ar(sig);
    Out.ar(\out.kr(0), sig);
}).add;
)

(
~getNodeProxy = { |synthDefName, initArgs = #[], numChannels = 2|
	var synthDef = try{ SynthDescLib.global[synthDefName].def } ?? {
		Error("SynthDef '%' not found".format(synthDefName)).throw
	};
	var nodeProxy = NodeProxy.audio(s, numChannels);
	nodeProxy.prime(synthDef).set(*initArgs);
};
)

x = ~getNodeProxy.(\additiveComb);
x.gui2;

Its using a MultiFloatArray/View from LNX studio with OSCFunc.newMatching and SendReply, which sends the whole chain[\amplitudes] array back to sclang. i am surprised it works so smoothly with high partials count and refreshing 8 times per second.

what im trying to figure out is a way to add another non-linearity (tanh, clip etc.), here its scaled by the combOffset and linearly mixed via the combShape param. When combShape = 1, you get a nice intermediate pausing / cadence effect because all phases are set to 0 momentarily, if combOffset is bigger then the scaled ratios. But im still searching for an implementation which has the same effect but applied after the wrapping to work with normalized phases. I will keep you in the loop :slight_smile: PS. you should multiply your combOffset by combDensity.sign, so combOffset has no effect when combDensity is 0 (no combs)).

var addCombFilter = { |chain, combOffset, combDensity, combWarp, combShape, combPeak|
    var ratios = chain[\freqs] / chain[\freq];
    var normalizedRatios = (ratios / ratios[chain[\numPartials] - 1]).clip(0, 1);
    var warpedRatios = log2ToLinearMorph.(normalizedRatios, combWarp);
    var scaledRatios = warpedRatios * ratios[chain[\numPartials] - 1];
    var offsetRatios = scaledRatios * (0.5 * combDensity) + (combOffset * combDensity.sign);
	var shapedRatios = offsetRatios * (1 - combShape) + (offsetRatios.tanh * combShape);
    var phases = shapedRatios.wrap(0, 1);
    chain[\amps] = chain[\amps] * raisedCos.(phases, combPeak);
    chain;
};

The way i have set this up always gives me future dancehall vibes haha riddim 1 or riddim 2

i have additionally tried to come up with different warping functions (before we had quintic easing), here is a pseudo log2 easing function with linear blend, which sounds pretty sharp.

var pseudoLog2In = { |x, coef = 10|
	1 - (log2((1 - x) * (2 ** coef - 1) + 1) / coef);
};

var pseudoLog2Out = { |x|
    1 - pseudoLog2In.(1 - x);
};

var log2OutToLinear = { |x, shape|
	var mix = shape * 2;
	var easeOut = pseudoLog2Out.(x);
	easeOut * (1 - mix) + (x * mix);
};

var linearToLog2In = { |x, shape|
	var mix = (shape - 0.5) * 2;
	var easeIn = pseudoLog2In.(x);
	x * (1 - mix) + (easeIn * mix);
};

var log2ToLinearMorph = { |x, shape|
	Select.kr(shape > 0.5, [
		log2OutToLinear.(x, shape),
		linearToLog2In.(x, shape)
	]);
};

very cool, do you think you could help me to implement that in SC, im not very familiar with loading into float array and using OSC messages? Im normally using the approach of creating a SynthDef and then use .prime to prepare a NodeProxy and then use NodeProxyGui2 (see dual chain example). Would be awesome to add the visualized spectrum and place it on the same window as NodeProxyGui2.

sure, here is a version in plain supercollider.

(
var view, array, oscFunc, numPartials=128;

array = DoubleArray.fill(numPartials, 0);
oscFunc=OSCFunc.newMatching({|msg|
	array = msg[3..];
	{view.refresh}.defer }, '/amplitudes', s.addr);

view = UserView.new(bounds: Rect(50, 50, 480, 120))
.drawFunc={|me|
	var size=1, sw=1, sw2=1, sh=1, w=me.bounds.width, h=me.bounds.height;

	size = array.size;
	sw   = ((w-2)/size).clip(1,inf);
	sw2  = (sw).clip(1,inf);
	Pen.smoothing_(false);
	Color.black.set;
	Pen.fillRect(Rect(0,0,w,h));
	Color.gray.set;
	size.do{|i|
		var value = array[i]; 
		if ((value>1)||(value<0)) { value = value.wrap(0,1) }; // wrap for offset
		value=value.curvelin(curve:7); // added by me to better see

		Pen.fillRect( Rect(i*sw+1, h-1, sw2, value.neg*(h-2) ) );
	};
};

view.front;
)

and in your SynthDef add this line:

SendReply.kr(Impulse.kr(8), '/amplitudes', chain[\amps]);

thanks for sharing your work, really cool sounds with this! This View/drawFunc is from lnx studio by @neilcosgrove

1 Like

Thank You very much, I will spend some time with This in the upcoming Days and try to figure out how to implement that in a gui.

thank, thats working fine :slight_smile:
But the representation of the amplitudes seems to be incorrect if you either shift the fundamental frequency or adjust the inharmonicity / stretching of the harmonics, compare that with the representation on the freqscope.

Iā€™m not sure if i understand correctly. This shows the amplitudes of the partials, but it puts them next to another in fixed distance, not positioned according to frequency. So this is not a spectrum, but some sort of qualitative view of what the chain is doing. You could send back the frequencies as well and get a more spectrally accurate representation, making the x axis represent frequency instead of an index into an array.

1 Like