Additive Synthesis

hey, i would like to share this additive synthesis approach with you, using an additive comb filter (its an additive-subtractive approach similiar to razor or harmor) with control over comb density, comb offset, comb skew and comb peak. Some of its initial ideas especially the function for detuning the partials (based on the bank piano model for detuning strings) are by @nathan. But i have reworked the additive comb filter which is now based on a raised cosine window and all params are nicely normalised between 0 and 1.
I have additionally added a spectral limiter, so you can modulate the fundamental frequency without getting any aliasing. Beside these amplitude transformers, i have added an additional frequency tranformer called warpFrequencies, based on a transfer function (a ramp with a kink at 0.5, think classic phase distortion) to compress the partials in the lower or upper part of the spectrum. Its a bit heavy on CPU if you use 128 partials, but if you set them on 64 its okay.
The modulation is just a proof of concept, you can adjust it to your liking.



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

var makeStretchedHarmonicSeries = { |chain, inharmonicity|
	chain[\freqs] = chain[\freq] * chain[\ratios] * (1 + (inharmonicity * chain[\ratios] * chain[\ratios])).sqrt;
    chain;
};

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

var warpFrequencies = { |chain, warpPoint|
    var normalizedFreqs = (chain[\freqs] / chain[\freqs][chain[\numPartials] - 1]).clip(0, 1);
    var warpedFreqs = transferFunc.(normalizedFreqs, 1 - warpPoint);
    chain[\freqs] = warpedFreqs * chain[\freqs][chain[\numPartials] - 1];
    chain;
};

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

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

var addCombFilter = { |chain, combOffset, combDensity, combSkew, combPeak|
	var phase, warpedPhase;
	phase = chain[\freqs].log2 - chain[\freq].log2;
	phase = (phase * combDensity - combOffset).wrap(0, 1);
	warpedPhase = transferFunc.(phase, combSkew);
	chain[\amps] = chain[\amps] * raisedCos.(warpedPhase, combPeak);
    chain;
};

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

SynthDef(\additiveComb, {

	var numPartials = 128;

	var lfos, combOffset, combDensity, combPeak, combSkew, warpSpectrum, inharmonicity, freq, chain, sig;

	lfos = 6.collect{ |i|
		SinOsc.kr(\modMF.kr(0.5, spec: ControlSpec(0.1, 3)), Rand(0, 2pi));
	};

	combOffset = \combOffset.kr(0, spec: ControlSpec(0, 1));
	combOffset = combOffset * (2 ** (lfos[0] * \combOffsetMD.kr(0, spec: ControlSpec(0, 2))));

	combDensity = \combDensity.kr(0, spec: ControlSpec(0, 1));
	combDensity = combDensity * (2 ** (lfos[1] * \combDensityMD.kr(0, spec: ControlSpec(0, 2))));

	combPeak = \combPeak.kr(5, spec: ControlSpec(1, 10));
	combPeak = combPeak * (2 ** (lfos[2] * \combPeakMD.kr(0, spec: ControlSpec(0, 2))));

	combSkew = \combSkew.kr(0.5, spec: ControlSpec(0.01, 0.99));
	combSkew = combSkew * (2 ** (lfos[3] * \combSkewMD.kr(0, spec: ControlSpec(0, 2))));

	warpSpectrum = \warpSpec.kr(0.5, spec: ControlSpec(0, 1));
	warpSpectrum = warpSpectrum * (2 ** (lfos[4] * \warpSpecMD.kr(0, spec: ControlSpec(0, 2))));

	freq = \freq.kr(60, spec: ControlSpec(20, 500));
	freq = freq * (2 ** (lfos[5] * \freqMD.kr(0, spec: ControlSpec(0, 3))));

	inharmonicity = \inharmonicity.kr(0, spec: ControlSpec(0, 0.1));

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

	chain = initChain.(numPartials, freq);
	chain = makeStretchedHarmonicSeries.(chain, inharmonicity);
	chain = warpFrequencies.(chain, warpSpectrum);
	chain = addLimiter.(chain);
	chain = addSpectralTilt.(chain, \tiltPerOctaveDb.kr(-3, spec: ControlSpec(-3, -12)));
	chain = addCombFilter.(chain, combOffset, combDensity, combSkew, combPeak);

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

	sig = sig[0,2..].sum + ([-1,1] * sig[1,3..].sum);

	sig = sig * -15.dbamp;

	sig = sig * \amp.kr(-5, spec: ControlSpec(-5, -25, \lin, 1)).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;
)

(
Routine({

	s.bind {

		Synth(\additiveComb, [

			\freq, 110,
			\freqMD, 0,

			\inharmonicity, 0,

			\modMF, 0.5,

			\combOffset, 0,
			\combOffsetMD, 0,

			\combDensity, 0.5,
			\combDensityMD, 1,

			\combSkew, 0.5,
			\combSkewMD, 0,

			\combPeak, 5,
			\combPeakMD, 0,

			\warpSpec, 0.5,
			\warpSpecMD, 0,

			\tiltPerOctaveDb, -3.00,

			\amp, -15,
			\out, 0,
		]);

	};

}).play;
)
14 Likes

Ive also implemented that in gen~. The implementation is based on a buffer (OscBank) where data is written to if a param has changed (for 128 partials, just 3% CPU on my computer) , everything audio rate. If someone has decent knowledge in C++ and would be interested in helping me out to get that RNBO export to SC running, i could export this and some other things i have developed over the last months to SC. There is already a thread on sc synth Max inside SuperCollider? - #27 by Spacechild1 . Still working through the C++ Primer.

2 Likes

Hey there! I had a go at adding some functions, mainly changed the generator function vaguely based on PadSynth, as well as some easy low hanging stuff like: even and odd harmonic levels, frequency drift per partial, and zeroing any partials above the nyquist sample rate.

I think it’d be cool to port some of the ideas from RAZOR next, as well as distributing the partials in the stereo field, and amp envelope per partial control :slight_smile:

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

    var makeStretchedHarmonicSeries = { |chain, inharmonicity|
        chain[\freqs] = chain[\freq] * chain[\ratios] * (1 + (inharmonicity * chain[\ratios] * chain[\ratios])).sqrt;
        chain;
    };

    var makeStretchedHarmonicSeries2 = { |chain, harmonicRatio, ampScale, ampSkew, stretch|
		//get harmonic integers
        var amps;
		var powN = pow(chain[\ratios], harmonicRatio / 2);
		var relF = (powN * ((1.0 + (powN - 1)) * stretch));
		//harmonic frequency
		chain[\freqs] = (relF * chain[\freq]) ;
		//scale amp and skew
        amps = ((pow(2, ampScale) - ampSkew) * pow(relF, ampSkew));
        chain[\amps] = amps;
    };

    var evenOddMask = {|chain, oddLevel, evenLevel|
        chain[\amps] = chain[\amps].collect { |item, i| if(i.odd){ item * oddLevel; } { item * evenLevel; } };
        chain;
    };

    var removeNyquistPartials = {|chain|
        chain[\amps] = chain[\amps] * (chain[\freqs] <= (s.sampleRate * 0.5));
        chain;
    };
    
    var transferFunc = { |phase, skew|
        Select.kr(phase > skew, [
            0.5 * phase / skew,
            0.5 * (1 + ((phase - skew) / (1 - skew)))
        ]);
    };
    
    var warpFrequencies = { |chain, warpPoint|
        var normalizedFreqs = (chain[\freqs] / chain[\freqs][chain[\numPartials] - 1]).clip(0, 1);
        var warpedFreqs = transferFunc.(normalizedFreqs, 1 - warpPoint);
        chain[\freqs] = warpedFreqs * chain[\freqs][chain[\numPartials] - 1];
        chain;
    };
    
    var addSpectralTilt = { |chain, tiltPerOctave|
        chain[\amps] = chain[\amps] * (chain[\ratios].log2 * tiltPerOctave).dbamp;
        chain;
    };
    
    var raisedCos = { |phase, index|
        var cosine = cos(phase * 2pi);
        var raised = exp(index.abs * (cosine - 1));
        var hanning = 0.5 * (1 + cos(phase * 2pi));
        raised * hanning;
    };
    
    var addCombFilter = { |chain, combOffset, combDensity, combSkew, combPeak|
        var phase, warpedPhase;
        phase = chain[\freqs].log2 - chain[\freq].log2;
        phase = (phase * combDensity - combOffset).wrap(0, 1);
        warpedPhase = transferFunc.(phase, combSkew);
        chain[\amps] = chain[\amps] * raisedCos.(warpedPhase, combPeak);
        chain;
    };
    
    var addLimiter = { |chain|
        var nyquist = SampleRate.ir / 2 - 2000;
        var fade = nyquist - 1000;
        var limiter = 1 - ((chain[\freqs].clip(fade, nyquist) - fade) * 0.001);
        chain[\amps] = chain[\amps] * limiter;
        chain;
    };
    
    SynthDef(\additiveComb, {
    
        var numPartials = 128;
    
        var lfos, combOffset, combDensity, combPeak, combSkew, warpSpectrum, inharmonicity, freq, chain, sig;
        var harmonicRatio, ampScale, ampSkew, stretch, oddLevel, evenLevel;
        var partialDrift, partialDriftFreq, partialDriftMD;

        lfos = 6.collect{ |i|
            SinOsc.kr(\modMF.kr(0.5, spec: ControlSpec(0.1, 3)), Rand(0, 2pi));
        };
    
        combOffset = \combOffset.kr(0, spec: ControlSpec(0, 1));
        combOffset = combOffset * (2 ** (lfos[0] * \combOffsetMD.kr(0, spec: ControlSpec(0, 2))));
    
        combDensity = \combDensity.kr(0, spec: ControlSpec(0, 1));
        combDensity = combDensity * (2 ** (lfos[1] * \combDensityMD.kr(0, spec: ControlSpec(0, 2))));
    
        combPeak = \combPeak.kr(5, spec: ControlSpec(1, 10));
        combPeak = combPeak * (2 ** (lfos[2] * \combPeakMD.kr(0, spec: ControlSpec(0, 2))));
    
        combSkew = \combSkew.kr(0.5, spec: ControlSpec(0.01, 0.99));
        combSkew = combSkew * (2 ** (lfos[3] * \combSkewMD.kr(0, spec: ControlSpec(0, 2))));
    
        warpSpectrum = \warpSpec.kr(0.5, spec: ControlSpec(0, 1));
        warpSpectrum = warpSpectrum * (2 ** (lfos[4] * \warpSpecMD.kr(0, spec: ControlSpec(0, 2))));
    
        freq = \freq.kr(60, spec: ControlSpec(20, 500));
        freq = freq * (2 ** (lfos[5] * \freqMD.kr(0, spec: ControlSpec(0, 3))));

        harmonicRatio = \harmonicRatio.kr(1);
        ampScale = \ampScale.kr(1);
        ampSkew = \ampSkew.kr(0);
        stretch = \stretch.kr(1);

        oddLevel = \oddLevel.kr(1);
        evenLevel = \evenLevel.kr(1);

        partialDriftFreq = \partialDriftFreq.kr(1.5);
        partialDriftMD = \partialDriftMD.kr(0);

        /////////////////////////////////////////////////////////////////////////////
    
        chain = initChain.(numPartials, freq);
        chain = makeStretchedHarmonicSeries2.(chain, harmonicRatio, ampScale, ampSkew, stretch);
        chain = evenOddMask.(chain, oddLevel, evenLevel);
        chain = warpFrequencies.(chain, warpSpectrum);
        chain = addLimiter.(chain);
        chain = addSpectralTilt.(chain, \tiltPerOctaveDb.kr(-3, spec: ControlSpec(-3, -12)));
        chain = addCombFilter.(chain, combOffset, combDensity, combSkew, combPeak);
        chain = removeNyquistPartials.(chain);
        
        partialDrift = LFNoise2.ar(partialDriftFreq ! chain[\numPartials]) * partialDriftMD;

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

        sig = sig[0,2..].sum + ([-1,1] * sig[1,3..].sum);
    
        sig = sig * -15.dbamp;
    
        sig = sig * \amp.kr(-5, spec: ControlSpec(-5, -25, \lin, 1)).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;

)
    
(
Routine({

    s.bind {

        Synth(\additiveComb, [

            \freq, 60,
            \freqMD, 0,

            \partialDriftFreq, 100,
            \partialDriftMD, 0,

            \harmonicRatio, 2,
            \ampSkew, 0,
            \ampScale, 1,
            \stretch, 2,

            \oddLevel, 1,
            \evenLevel, 1,

			\modMF, 0.5,

			\combOffset, 0,
			\combOffsetMD, 0,

			\combDensity, 0.5,
			\combDensityMD, 1,

			\combSkew, 0.5,
			\combSkewMD, 0,

			\combPeak, 5,
			\combPeakMD, 1,

			\warpSpec, 0.5,
			\warpSpecMD, 0,

			\tiltPerOctaveDb, 3.00,

			\amp, -15,
			\out, 0,
        ]);

    };

}).play;
)
1 Like

Hey, thanks for your contributions :slight_smile: The comb filter should create a square wave type of spectrum by filtering out every other partial when setting combDensity to 1, the idea comes from the Xaoc Odessa. I find this more elegant then adding a separate control for odd and Even.
There is already a spectral limiter which does a smooth fade out of partials above a threshold near nyquist, so no need to add another one. Fading should also sound better then a hard brickwall.

Thanks for adding the other stretchedHarmonic series, will check that out!
I also have nth-order butterworth implementations of additive LPF, HPF, BPF, formant and notch filters but i think the comb filter does present the additive approach in the most unique way.

I have taken a bit of time to think about the modulation and have grouped the params in groups of two and have offset the phase for the second member of each group.
The groups are:

  • position modulation (spacing and offset of notches)
  • shape modulation (depth and shape of notches)
  • spectrum modulation (stretch and cluster)

Most of the params are neatly normalized between 0 and 1, so instead of fine tuning the modulation ranges you could scale the modulation between 0 and 1 for linear modulation using this modScale function for bipolar modulators and keep exponential modulation via 2 ** (mod * index) for combPeak and freq.

var modScale = { |mod, amount|
    (0.5 * amount * (mod - 1) + 1);
};

// Position modulation group (spacing and offset of notches)
posMF = \posMF.kr(0.3, spec: ControlSpec(0.1, 2));
posSource = { |phase|
	SinOsc.kr(posMF, phase * pi + Rand(0, pi))
};

combDensity = \combDensity.kr(0, spec: ControlSpec(0, 1));
combDensity = combDensity * modScale.(posSource.(0), \combDensityMD.kr(0, spec: ControlSpec(0, 1)));

combOffset = \combOffset.kr(0, spec: ControlSpec(0, 1));
combOffset = combOffset * modScale.(posSource.(0.5), \combOffsetMD.kr(0, spec: ControlSpec(0, 1)));

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

// Shape modulation group (depth and shape of notches)
shapeMF = \shapeMF.kr(0.3, spec: ControlSpec(0.1, 2));
shapeSource = { |phase|
	SinOsc.kr(shapeMF, phase * pi + Rand(0, pi))
};

combSkew = \combSkew.kr(0.5, spec: ControlSpec(0.01, 0.99));
combSkew = combSkew * modScale.(shapeSource.(0), \combSkewMD.kr(0, spec: ControlSpec(0, 1)));

combPeak = \combPeak.kr(1, spec: ControlSpec(1, 5));
combPeak = combPeak * (2 ** (shapeSource.(0.5) * \combPeakMD.kr(0, spec: ControlSpec(0, 2))));

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

// Spectrum modulation group (cluster and stretch)
specMF = \specMF.kr(0.3, spec: ControlSpec(0.1, 2));
specSource = { |phase|
	SinOsc.kr(specMF, phase * pi + Rand(0, pi))
};

harmonicStretch = \harmonicStretch.kr(0, spec: ControlSpec(0, 0.1));
harmonicStretch = harmonicStretch * modScale.(specSource.(0), \harmonicStretchMD.kr(0, spec: ControlSpec(0, 1)));

harmonicCluster = \harmonicCluster.kr(0.5, spec: ControlSpec(0, 1));
harmonicCluster = harmonicCluster * modScale.(specSource.(0.5), \harmonicClusterMD.kr(0, spec: ControlSpec(0, 1)));

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

// Frequency modulation group
freqMF = \freqMF.kr(0.3, spec: ControlSpec(0.1, 2));
freqSource = { |phase|
	SinOsc.kr(specMF, phase * pi + Rand(0, pi))
};

freq = \freq.kr(60, spec: ControlSpec(20, 300));
freq = freq * (2 ** (freqSource.(0) * \freqMD.kr(0, spec: ControlSpec(0, 2))));
2 Likes

sig = sig[0,2..].sum + ([-1,1] * sig[1,3..].sum); this does distribute the odd partials center and the even partials on the sides (ratios going from 1…numPartials)

There is already an envelope per partial, the raised cosine window with index and skew control.

1 Like

A bit offtopic: But ive looked at the source code of the knob studio max4live device to implement a more versatile LFO then basic SinOsc. Its overlapping basic functions to create more complex shapes which evolve over time (could be a great addition to the additive synth), would need some help here (maybe we could start a new thread):

(
var drawSin = { |j, in_, out_, cycles = 1, phase = 0, amp = 1|
    Select.ar((j >= in_) * (j <= out_), [
        K2A.ar(0),
        1 - cos((j - in_) / (out_ - in_) * cycles * 2pi + phase * 2pi) / 2 * amp
    ]);
};

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

var drawTriangle = { |j, in_, out_, cycles, phase, amp|
    Select.ar((j >= in_) * (j <= out_), [
        K2A.ar(0),
        transferFunc.(((j - in_) / ((out_ - in_)) * cycles + phase).wrap(0, 1), 0.5) * amp
    ]);
};

var join = { |j, in0, out0, in1, out1|
    var overlap = ((j >= in0) * (j <= out0) * (j >= in1) * (j <= out1));
    var crossfade = (j - max(in0, in1)) / (min(out0, out1) - max(in0, in1));
    [
        Select.ar(overlap, [K2A.ar(1), 1 - crossfade]),
        Select.ar(overlap, [K2A.ar(1), crossfade])
    ]
};

{
    var phase = Phasor.ar(0, 50 * SampleDur.ir);
    var overlap = 0.1;
    var sine = drawSin.(phase, 0, 0.5 + overlap, 1, 0, 1);
    var triangle = drawTriangle.(phase, 0.5 - overlap, 1, 3, 0, 1);
    var weights = join.(phase, 0, 0.5 + overlap, 0.5 - overlap, 1);
    (sine * weights[0]) + (triangle * weights[1]);
}.plot(0.021);
)
2 Likes

I have reworked the Limiter, now its a bit clearer and more easy to understand:


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;
};
1 Like

another quick addition:

var addExpCombFilter = { |chain, combOffset, combDensity, combSkew, combPeak|
	var phase = ((chain[\freqs].log2 - chain[\freq].log2) * combDensity - combOffset).wrap(0, 1);
	var warpedPhase = transferFunc.(phase, combSkew);
    chain[\amps] = chain[\amps] * raisedCos.(warpedPhase, combPeak);
    chain;
};

var addLinCombFilter = { |chain, combOffset, combDensity, combSkew, combPeak|
	var phase = (chain[\ratios] * 0.5 * combDensity - combOffset).wrap(0, 1);
    var warpedPhase = transferFunc.(phase, combSkew);
    chain[\amps] = chain[\amps] * raisedCos.(warpedPhase, combPeak);
    chain;
};

or if you want that the harmonic stretch and cluster is taken into account, you should instead of using chain[\ratios] use the ratios by chain[\freqs] / chain[\freq], currently dont know whats better, probably the second one.

var addLinCombFilter = { |chain, combOffset, combDensity, combSkew, combPeak|
	var phase = ((chain[\freqs] / chain[\freq]) * 0.5 * combDensity - combOffset).wrap(0, 1);
    var warpedPhase = transferFunc.(phase, combSkew);
    chain[\amps] = chain[\amps] * raisedCos.(warpedPhase, combPeak);
    chain;
};

only the linear comb filter will cancel out every second harmonic when combDensity = 1. Will experiment more with the warping of the linear comb response to shape it like this (from the manual of the xaoc odessa):


This would mean the combDensity could be nicely normalized between 0 and 1, where 1 means meaningfully to filter out every other partial and you could additionally shape the comb response with a combWarp param to be clustered around the low frequencies or the high frequencies (similiar to the exponential comb filter). I think you could get this behaviour by shaping the phase of the linear comb filter with a quintic easing function where shape 0.5 means linear phase, shape 0 means logarithmic phase and 1 means exponential phase:

// exponential to linear morph

(
var coreQuintic = { |x|
	x * x * x * x * x;
};

var outQuintic = { |x|
	1 - coreQuintic.(1 - x);
};

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

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

var expToLinearMorph = { |x, shape|
	Select.ar(shape > 0.5, [
		quinticOutToLinear.(x, shape),
		linearToQuinticIn.(x, shape)
	]);
};

{
	var phase = Phasor.ar(0, 50 * SampleDur.ir);
	expToLinearMorph.(phase, \shape.kr(0));
}.plot(0.02);
)

Maybe we could add some addtional panning ideas, currently looking at SplayAz.

1 Like

Ok I had quite a cool idea: This is essentially the PadSynth algorithm, except instead of filling a buffer of harmonics for a IFFT at every sample value, you can just generate N sidebands around the frequency at each harmonic, where the partial’s amplitude is scaled to some window function.
Sounds surpisingly good (code probably sucks but it works)

(
    var initHarmonicsChain = { |numPartials=10, sidebands=5, freq=80|
        (
            freq: freq,
            //here num partials is number of harmonic nodes
            numPartials: numPartials,
            ratios: (1..numPartials),
            amps: 1 ! numPartials,
            //should be odd but works with whatever
            sidebands: sidebands
        )
    };

    var removeNyquistPartials = {|chain|
        var nyquestIdx = chain[\freqs].selectIndices({|item, i| (item >= (s.sampleRate)) || (item <= 0.0)});
        chain[\amps].putEach(nyquestIdx, 0);
        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;
    };

    //exponential/gaussian-ish function bounded 0-1
    var hprofile = {|fi, bwi|
        x = fi/bwi;
        x = x * x;
        x = exp((x.neg) / bwi);
        x;
    };

    var padSynthDistribution = { |chain, harmonicRatio=1, bw=1, bwScale=1, bwSkew=1, stretch=1|
		//get harmonic integers
        var amps;
		var powN = pow(chain[\ratios], harmonicRatio / 2);
		var relF = (powN * ((1.0 + (powN - 1)) * stretch));
        var bw_Hz, bwi, fi;
        
        //loop vars
        var sidebands = chain[\sidebands];
        var idxOffset = (sidebands / 2).floor;
        var newPartials = List.new();
        var newAmps = List.new();
        var newSize;
        
		//harmonic frequency
		chain[\freqs] = (relF * chain[\freq]);
        bw_Hz = (pow(2, (bw / 1200) - bwSkew)) * chain[\freq] * pow(relF, bwScale);
        bwi = 1 / (chain[\ratios]);

        //for each harmonic, create n-1 sidebands with amplitudes of each sideband on gaussianish distribution
        chain[\numPartials].do({|i|
            sidebands.do({ |j|
                var partialIdx = j - idxOffset;
                var freqOffset = bw_Hz[i] / sidebands;
                var subPartialFreq = chain[\freqs][i] + (partialIdx * freqOffset);
                var subPartialAmp = hprofile.(partialIdx.abs.linlin(0, idxOffset, 0, 1), bwi[i]);
                //debug
                ['harmonic group ' ++ i, subPartialFreq, subPartialAmp].postln;

                newPartials.add(subPartialFreq);
                newAmps.add(subPartialAmp);
            });
        });

        //update dict with new partial
        newSize = newPartials.size;
        chain[\numPartials] = newSize;
        chain[\freqs] = newPartials.asArray;
        chain[\ratios] = (1..newSize);
        chain[\amps] = newAmps.asArray;
        chain;
    };

    {
        var chain, sig, partialDrift;
        // 10 harmonics 4 sidebands per harmonic
        chain = initHarmonicsChain.(numPartials: 10, sidebands: 5, freq: 160);
        // 5 harmonics 14 sidebands per harmonic
        // chain = initHarmonicsChain.(numPartials: 5, sidebands: 15, freq: 160);
        //etc
        // chain = initHarmonicsChain.(20, 7, 160);

        //try changing params
        chain = padSynthDistribution.(
            chain, 
            harmonicRatio: 1, 
            bw: 1, 
            bwScale: 2, 
            bwSkew: 0,
            stretch: 1,
        );

        chain = addLimiter.(chain);
        chain = removeNyquistPartials.(chain);
        
        //add partial drift
        partialDrift = LFNoise2.ar(4 ! chain[\numPartials]) * 1;

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

        sig = sig[0,2..].sum + ([-1,1] * sig[1,3..].sum);
        sig = sig * -25.dbamp;

    }.play;
)
1 Like

cool here is a gaussian plotted against the raised cosine window:

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

var gaussian = { |phase, index|
	var sine = sin(phase * pi) * index;
	exp(sine * sine.neg);
};

{
	var index = 5;
	var phase = Phasor.ar(0, 50 * SampleDur.ir);
	var a = raisedCos.(phase, index);
	var b = gaussian.(phase, index);
	[a, b];
}.plot(0.02);
)

what would you use that for here?

i just stumbled across the hprofile function :slight_smile: i guess this attempt is similiar to formant synthesis either by crossfading partials with the modFM approach or by using single sideband modulation:

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

var singleSideBandPM = { |freq, modRatio, index|
	var carrPhase = Phasor.ar(DC.ar(0), freq * SampleDur.ir);
	var modPhase = Phasor.ar(DC.ar(0), freq * modRatio * SampleDur.ir);
	var raisedCosWindow = raisedCos.(modPhase, index);
	var mod = sin(modPhase * 2pi);
	var carr = sin(carrPhase * 2pi + (mod * index));
	carr * raisedCosWindow;
};

SynthDef(\singleSidebandPM, {

	var freq, lfos, carrPhase, modPhase, index, sig, phase;

	freq = \freq.kr(110);

	phase = (Phasor.ar(0, 0.3 * SampleDur.ir) + [0.0, 0.5]).wrap(0, 1);
	lfos = IEnvGen.ar(Env([0, 1, 0], [0.5, 0.5], \sin), phase);
	lfos = lfos * 2 - 1;

	index = [
		\indexA.kr(8) * (2 ** (lfos[1] * 3)),
		\indexB.kr(16) * (2 ** (lfos[0] * 1)),
	];

	sig = singleSideBandPM.(freq, \modRatio.kr(1), index).sum;

	sig = Pan2.ar(sig, \pan.kr(0));

	sig = sig * \amp.kr(-25.dbamp);

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

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

(
Routine({

	var ratios = [1.00, 1.04];
	var pan = [-1.0, 1.0];

	s.bind {
		ratios.collect{ |ratio, i|
			var freq = 125;

			Synth(\singleSidebandPM, [

				\freq, freq * ratio,
				\modRatio, 1,

				// amp & outs
				\amp, -25.dbamp,
				\pan, pan[i],
				\out, 0,

			]);
		};

	};

}).play;
)

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

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

var crossfade_formants = { |phase, harm|
	var harmEven = harm.round(2);
	var harmOdd = ((harm + 1).round(2) - 1);
	var sigEven = sin(phase * 2pi * harmEven);
	var sigOdd = sin(phase * 2pi * harmOdd);
	LinXFade2.ar(sigEven, sigOdd, harm.fold(0, 1) * 2 - 1);
};

var modFM = { |freq, harm, index|
	var phase = Phasor.ar(DC.ar(0), freq * SampleDur.ir);
	var raisedCosWindow = raisedCos.(phase, index);
	var formants = crossfade_formants.(phase, harm);
	formants * raisedCosWindow;
};

SynthDef(\formant, {

	var sig, phase, lfos, harmonics;

	phase = (Phasor.ar(0, 0.3 * SampleDur.ir) + [0.0, 0.5]).wrap(0, 1);
	lfos = IEnvGen.ar(Env([0, 1, 0], [0.5, 0.5], \sin), phase);
	lfos = lfos * 2 - 1;

	harmonics = [
		\harmA.kr(8) * (2 ** (lfos[1] * 3)),
		\harmB.kr(16) * (2 ** (lfos[0] * 1))
	];

	sig = modFM.(\freq.kr(440), harmonics, \index.kr(1)).sum;

	sig = Pan2.ar(sig, \pan.kr(0));

	sig = sig * \amp.kr(-25.dbamp);

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

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

(
Routine({

	var ratios = [1.00, 1.04];
	var pan = [-1.0, 1.0];

	s.bind {
		ratios.collect{ |ratio, i|
			var freq = 125;

			Synth(\formant, [

				\freq, freq * ratio,
				\index, 16,

				\amp, -25.dbamp,
				\pan, pan[i],
				\out, 0,

			]);

		};
	};

}).play;
)

the expToLinearMorph function, described in my former post, can be used instead of the transferFunc to shape the entire spectrum (a bit of a different behaviour):

var coreQuintic = { |x|
	x * x * x * x * x;
};

var outQuintic = { |x|
	1 - coreQuintic.(1 - x);
};

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

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

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

var addHarmonicClustering = { |chain, clusterPoint|
    var normalizedFreqs = (chain[\freqs] / chain[\freqs][chain[\numPartials] - 1]).clip(0, 1);
    var clusteredFreqs = expToLinearMorph.(normalizedFreqs, clusterPoint);
    chain[\freqs] = chain[\freqs][chain[\numPartials] - 1] * clusteredFreqs;
    chain;
};

1 Like

That is so cool! Yeah I guess it is similar to pm/formant synthesis, but using a generator function instead - I wanted to make it work with the chain functions you already made. You can get some cool unison effects by messing around w the bwSkew parameter

I’m going to try and refactor what I made to allow for modulation but maybe your work covers it already :slight_smile:

for comparision, thats the transferFunction (with kink at 0.5) from before for warping the spectrum:

really cool, i was just guessing that you could combine the 2 examples of either modFM or singleSidebandPM from above with the additive chain functions. These have a really well behaving spectrum, but havent tested that. Then you can create complex spectra with just a few partials (CPU will love that)

Refactored without the do loop. Not sure if this is more efficient or the same. I want to look at porting the NI RAZOR ‘reverbs’ next.

(
var initHarmonicsChain = { |harmonics=10, sidebands=5, freq=80|
    var numPartials = harmonics * sidebands;
    (
        freq: freq,
        numPartials: numPartials,
        ratios: (1..numPartials),
        amps: 1 ! numPartials,

        sidebands: sidebands,
        harmonicIdx: (1..harmonics).dupEach(sidebands),
        sidebandIdx: (0..sidebands - 1).wrapExtend(numPartials)
    )
};

var hprofile = {|fi, bwi, windowSkew=0.5|
    var x = abs(fi - windowSkew) * 2;
    x = x / bwi;
    x = x * x;
    x = exp(x.neg);
    x;
};

var padSynthDistribution = { |chain, harmonicRatio=1, bw=1, bwScale=1, bwSkew=1, stretch=1, windowSkew=0.5|
    //get harmonic integers
    var amps;
    var powN = pow(chain[\harmonicIdx], harmonicRatio / 2);
    var relF = (powN * ((1.0 + (powN - 1)) * stretch));
    var bw_Hz, bwi, fi;
    var idxOffset = (chain[\sidebands] / 2).floor;
    var partialIdx, freqOffset, subPartialFreq, subPartialAmp;
    
    // //harmonic frequency
    chain[\freqs] = (relF * chain[\freq]);
    bw_Hz = (pow(2, (bw / 1200) - bwSkew)) * chain[\freq] * pow(relF, bwScale);
    bwi = 1 / (chain[\harmonicIdx]);
    //for each harmonic, create n-1 sidebands with amplitudes of each sideband on gaussianish distribution
    freqOffset = bw_Hz / chain[\sidebands];
    partialIdx = chain[\sidebandIdx] - idxOffset;
    chain[\freqs] = chain[\freqs] + (partialIdx * freqOffset);
    chain[\amps] = hprofile.(chain[\sidebandIdx].linlin(0, chain[\sidebands]-1, 0, 1), bwi, windowSkew);
};

var removeNyquistPartials = {|chain|
    var nyquestIdx = chain[\freqs].selectIndices({|item, i| (item >= (s.sampleRate)) || (item <= 0.0)});
    chain[\amps].putEach(nyquestIdx, 0);
    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 evenOddMask = {|chain, oddLevel, evenLevel|
    chain[\amps] = chain[\amps].collect { |item, i| if(i.odd){ item * oddLevel; } { item * evenLevel; } };
    chain;
};

var evenOddHarmonicMask = {|chain, oddLevel, evenLevel|
    chain[\amps] = chain[\amps].collect { |item, i| if(i.odd){ item * oddLevel; } { item * evenLevel; } };
    chain;
};

{
var chain, sig, lfos;

lfos = 6.collect{ |i|
    SinOsc.kr(\modMF.kr(0.25, spec: ControlSpec(0.1, 3)), Rand(0, 2pi));
};

chain = initHarmonicsChain.(harmonics: 10, sidebands: 9, freq: 440);

chain = padSynthDistribution.(
    chain, 
    harmonicRatio: 2, 
    bw: 1000,
    bwScale: 2, 
    bwSkew: 0,
    stretch: 1,
    windowSkew: lfos[0]
);
chain = evenOddMask.(chain, 1, 1);
chain = addLimiter.(chain);

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

sig = sig[0,2..].sum + ([-1,1] * sig[1,3..].sum);
sig = sig * -25.dbamp;
}.play;
)

cool, one thing to keep in mind with the padSynth implementation ist that you wont be able to change the harmonics and the sidebands dynamically after SynthDef evaluation. These will be fixed. If its important to you to change the sidebands dynamically, then you could have a look at the two versions of formant synthesis (singleSideBand modulation and modFM) i have shared above.
In the initial additive example i have shared, the harmonics are also fixed with SynthDef evaluation, but the whole process is based on manipulating this fixed set of harmonics either by frequency transformers or amplitude transformers instead of updating this set of harmonics from the language.

When layering different formants (either with singleSideBand modulation or with modFM), this has some additive quality to it. See example below:

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

var crossfade_formants = { |phase, harm|
	var harmEven = harm.round(2);
	var harmOdd = ((harm + 1).round(2) - 1);
	var sigEven = sin(phase * 2pi * harmEven);
	var sigOdd = sin(phase * 2pi * harmOdd);
	LinXFade2.ar(sigEven, sigOdd, harm.fold(0, 1) * 2 - 1);
};

var modFM = { |freq, harm, index|
	var phase = Phasor.ar(DC.ar(0), freq * SampleDur.ir);
	var raisedCosWindow = raisedCos.(phase, index);
	var formants = crossfade_formants.(phase, harm);
	formants * raisedCosWindow;
};

var ampMod = { |modFreq, modAmount|
	(0.5 * modAmount * (SinOsc.ar(modFreq) - 1) + 1);
};

SynthDef(\formant, {

	var trig, sustain, gainEnv, harmEnv, harmonics, sig;

	trig = \trig.tr(1);
	sustain = \sustain.kr(1);

	gainEnv = EnvGen.ar(Env(
		[0, 1, 0],
		[\atk.kr(0.01), \rel.kr(0.99)],
		[\atkCurve.kr(45.0), \relCurve.kr(-24.0)]
	), trig, timeScale: sustain);

	harmEnv = EnvGen.ar(Env(
		[0, 1, 0],
		[\fAtk.kr(0.01), \fRel.kr(0.99)],
		[\fAtkCurve.kr(45.0), \fRelCurve.kr(-24.0)]
	), trig, timeScale: sustain);

	harmonics = harmEnv.linlin(0, 1, 1, \harmEnvAmount.kr(50));

	sig = modFM.(\freq.kr(103.826), harmonics, \index.kr(1));

	sig = sig * gainEnv;

	sig = Pan2.ar(sig, \pan.kr(0));

	sig = sig * ampMod.(\ampMF.kr(5), \ampMD.kr(0));

	sig = sig * \amp.kr(-15.dbamp);

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

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

(
Pdef(\laser,
	Pmono(\formant,

		\trig, 1,
		\legato, 0.8,
		\dur, 8,

		\atk, 0.005,
		\rel, 0.995,
		\atkCurve, 45.0,
		\relCurve, -24.0,

		\fAtk, 0.001,
		\fRel, 0.999,
		\fAtkCurve, 45.0,
		\fRelCurve, -24.0,

		\time, Pfunc { |ev| ev.use { ~sustain.value } / thisThread.clock.tempo },

		\freq, [44, 54, 60].midicps,

		\harmEnvAmount, 50,
		\index, 2,

		\ampMF, 5,
		\ampMD, 0,

		\amp, -15.dbamp,
		\out, 0,
	);
).play;
)