Simple Wavetable Synthesis


#1

As part of exploring good ways to implement oscillator hard sync, I got distracted by wavetable synthesis. So here’s some code for creating wavetable oscillators for saw, square and triangle waves which are band limited, and where the lower frequencies have more harmonics (e.g. you don’t get weedy bass oscillators).

You can also use VOsc for wavetable synthesis pretty easily (easier than what I’ve done here). If I get a moment I’ll knock something up using the adventure kid stuff.

I’m going to throw up some alternative code that uses PlayBuf at some point, but in general I recommend using VOsc if you can as it’s a little bit more efficient and gives you some nice stuff for free.


#2
(
/* Each octave requires a separate buffer. Each buffer has fewer harmonics so we avoid problems with nyquist. 

This function takes a base frequency (which will be the lowest note on the piano in this 
code because I'm a traditionalist) and uses that to identify the first buffer, and then 
keeps creating buffers until it reaches the last octave than we can implement for the 
current sample rate.

This isn't super efficient, but it suffices and nothing is hard-coded.
*/
var makeBuf = {|baseFreq, fn|   // fn is the function that defines the harmonics for each buffer
  var maxFreq = Server.local.sampleRate;
  var freqs = Array.series(20, baseFreq, 12).midicps.select({|item| item < maxFreq});
  var bufCount = freqs.size;
  // Need consecutive buffers if we're going to use vosc
  Buffer.allocConsecutive(bufCount, s, 4096, 1, {|buf, i|
    var harms = maxFreq.div(freqs[i]);
    fn.(buf, harms);
  });
};

/* This function takes the baseFreq that we defined when creating our buffers, the base
buffer and the number of buffers that were created. 

It uses these to create a new function which takes frequency and phase and returns a VOsc 
that knows about our buffers. This function can then be used in SynthDefs similarly to how 
we might use UGens. We will use it below to create square waves, triangle, etc.
*/
var makeOsc = {|baseFreq, baseBuf, bufCount|
  {|freq, phase|
    var bufPos = ((freq-baseFreq).cpsmidi / 12).clip(0, bufCount) + baseBuf-1;
    VOsc.ar(bufPos, freq, phase);
  }
};

var baseFreq = 21;

/* All the creation of buffers, harmonics, etc happens inside a function, that we then
call. This ensures that our buffers are only accessible by our new Saw Wave pseudo ugen */
var wavSaw = {
  var makeSaw = {|buf, harms|
    buf.sine3Msg((1..harms),  // This defines the harmonics for our saw wave.
      (1..harms).reciprocal,  // This defines the amplitudes for our saw wave.
      (1..harms).collect({|x| if(x.odd){0}{pi}})); // This defines the phases
  };

  var bufs = makeBuf.(baseFreq, makeSaw);  // make the buffers using our makeSaw function
  makeOsc.(baseFreq, bufs[0].bufnum, bufs.size); // create an oscillator function.
}.();

var wavTri = {
  var makeTri = {|buf, harms|
    buf.sine3Msg((1..harms).select(_.odd),
      (1..harms).select(_.odd).collect(_.squared.reciprocal), 
      (1..harms).select(_.odd).collect({|x| if(x.odd){0}{pi}}));
  };

  var bufs = makeBuf.(baseFreq, makeTri);  
  makeOsc.(baseFreq, bufs[0].bufnum, bufs.size);
}.();

var wavSqr = {
  var makeSqr = {|buf, harms, duty|
  buf.sine2Msg((1..harms).select(_.odd),
    (1..harms).select(_.odd).collect(_.reciprocal))
  };
  
  var bufs = makeBuf.(baseFreq, makeSqr);  
  makeOsc.(baseFreq, bufs[0].bufnum, bufs.size);
}.();
    
SynthDef(\WavSaw, {
  Out.ar(0, wavSaw.(\freq.kr(220), \phase.kr(pi))); // we call wavSaw and this handles creating the ugen graph thingy ma job.
}).add;

SynthDef(\WavTri, {
  Out.ar(0, wavTri.(\freq.kr(220), \phase.kr(pi)));
}).add;

SynthDef(\WavSqr, {
  Out.ar(0, wavTri.(\freq.kr(220), \phase.kr(pi)));
}).add;

)

Synth(\WavSaw)
Synth(\WavTri)
Synth(\WavSqr)

#3

I think on a different post somebody asked how you could build up blocks of code like you can in PureData. The method used here is one approach that I think works, unless I’ve missed something.


#4

yup I do like the above method of building functions with functions to eventually slot into a synthdef.

but things like scheduling and calling other synths still can’t go into synthdef, which I think sort of acts as an obstruction to continuous, ongoing abstraction.

I created a topic for discussions regarding how to approach this problem:


#5

I’ve been working for a while on this and didn’t realise it was already a thing. Oops. Thanks for sharing!

I made something similar (using WaveTerrain.ar), which is probably nowhere near as efficient… here’s mine:

var genTerrain = {
arg formula = \sin,
	directory = "~/Music/SuperCollider/anti alias/rendered",
	samples = 16384,
	buffers = 128,
	bufferFundamentalFirst = 0,
	bufferFundamentalLast = 127,
	cutoffStartHz = 17500,
	cutoffEndHz = 20750;

var cutoffStart = cutoffStartHz.cpsmidi;
var cutoffEnd = cutoffEndHz.cpsmidi;
var harmonics = trunc((cutoffEndHz) / (bufferFundamentalFirst.midicps));
var bufferRange = bufferFundamentalLast - bufferFundamentalFirst;
var cutoffRange = cutoffEnd - cutoffStart;
var path = format("%/%_%x%_%-%_%Hz-%Hz.wav", directory, formula, samples, buffers, bufferFundamentalFirst, bufferFundamentalLast, cutoffStartHz, cutoffEndHz).standardizePath;

var currentHarmonic = Array.fill(samples, 0);
var highestBufferFilledPrevious = buffers - 1;
var highestBufferFillHypothetical = buffers - 1;
var highestBufferFillCurrent = buffers - 1;
var highestBufferTouch = buffers - 1;

var terrain = Array.fill(buffers, {Array.fill(samples, 0)});
var bufferArray = Array.fill(buffers * samples, {0});

format("starting % wave render", formula).postln;

for(1, harmonics, { arg harmonic;
	format("adding harmonic % of %" , harmonic, harmonics).postln;
	highestBufferFilledPrevious = highestBufferFillCurrent;
	highestBufferFillHypothetical = trunc((((cutoffStartHz / harmonic).cpsmidi) - bufferFundamentalFirst) * (buffers - 1) / bufferRange);
	highestBufferFillCurrent = if(highestBufferFillHypothetical < buffers,
		if(highestBufferFillHypothetical < 0, 0, highestBufferFillHypothetical), buffers - 1);
	highestBufferTouch = trunc((((cutoffEndHz / harmonic).cpsmidi) - bufferFundamentalFirst) * (buffers - 1) / bufferRange);
	forBy(highestBufferFilledPrevious - 1, highestBufferFillCurrent, -1, {
		arg space; terrain.put(space, terrain[highestBufferFilledPrevious]);
	});
	currentHarmonic = Array.fill(samples, {
		arg sample, phase = 0, mul = 1;
		switch(formula,
			\sin, {
				phase = 0;
				mul = if(harmonic == 1, 1, 0);
				((harmonic * sample * 2 * pi / samples) - phase).sin * mul;
			},
			\tri, {
				phase = ((harmonic % 4) - 1) * pi / 2;
				mul = if(harmonic % 2 == 0, 0, 8 / (pi ** 2) / (harmonic ** 2));
				((harmonic * sample * 2 * pi / samples) - phase).sin * mul;
			},
			\triNoFund, {
				phase = ((harmonic % 4) - 1) * pi / 2;
				mul = if(harmonic == 1, 0, if(harmonic % 2 == 0, 0, 8 / (pi ** 2) / (harmonic ** 2)));
				((harmonic * sample * 2 * pi / samples) - phase).sin * mul;
			},
			\saw, {
				phase = 0;
				mul = 2 / pi / harmonic;
				((harmonic * sample * 2 * pi / samples) - phase).sin * mul;
			},
			\sawNoFund, {
				phase = 0;
				mul = if(harmonic == 1, 0, 2 / pi / harmonic);
				((harmonic * sample * 2 * pi / samples) - phase).sin * mul;
			},
			\sqr, {
				phase = 0;
				mul = if(harmonic % 2 == 0, 0, 4 / pi / harmonic);
				((harmonic * sample * 2 * pi / samples) - phase).sin * mul;
			},
			\sqrNoFund, {
				phase = 0;
				mul = if(harmonic == 1, 0, if(harmonic % 2 == 0, 0, 4 / pi / harmonic));
				((harmonic * sample * 2 * pi / samples) - phase).sin * mul;
			},
			\primes, {
				phase = 0;
					mul = if(harmonic.isPrime, 1, 0) / (harmonic ** 2);
				((harmonic * sample * 2 * pi / samples) - phase).sin * mul;
			},
		);
	});
	forBy(highestBufferFillHypothetical + 1, highestBufferTouch, 1, {
		arg touchedBuffer;
		if(touchedBuffer >= 0, {if(touchedBuffer < buffers, {
			terrain.put(touchedBuffer, terrain[touchedBuffer] + (currentHarmonic *
			(((((((touchedBuffer * bufferRange / (buffers - 1)) + bufferFundamentalFirst)
			+ (12 * log2(harmonic))) - cutoffStart) * pi / cutoffRange).cos) + 1) / 2));
		})});
	});
	if(highestBufferFillHypothetical >= 0, {
	    terrain.put(highestBufferFillCurrent, terrain[highestBufferFillCurrent] + currentHarmonic);
	});
	for(0, buffers - 1, {arg buffer; for(0, samples - 1, {arg sample;
		bufferArray.put((buffer * samples) + sample, terrain[buffer][sample]);
	})});
});
"added all harmonics; loading plot".postln;
bufferArray.plot;
format("loaded plot; writing buffer to %", path).postln;
Buffer.loadCollection(s, bufferArray).write(path, headerFormat: "WAV", sampleFormat: "float");
};

genTerrain.value(
	formula: \sqr,
	directory: "~",
    samples: 16384,
	buffers: 128,
	bufferFundamentalFirst: 0,
	bufferFundamentalLast: 127,
	cutoffStartHz: 17500,
	cutoffEndHz: 20750
);

That code should just run if you evaluate file. It calculates one harmonic at a time, and adds them to 128 “buffers” (rows in a 2D sound file), one for each MIDI note. Apologies for the lack of commenting and readability.

I’ll try to steal from yours when I next revisit the problem :​)


#6

I redid my one. This is a bit more efficient than my previous one. My commenting is still bad so any questions will be happily answered (:

Here is the file:

////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// set some variables (the presets should be ideal, but to test this code quickly try reducing the number of buffers to 512)

(
~directory = "~/Music/SuperCollider/anti alias/rendered";
~samples = 16384;
~buffers = 1024;
~perOctave = 80;
~cutoffStart = 22500; // in Hz
~cutoffEnd = 45000;

~rollOff = log2(~cutoffEnd / ~cutoffStart);
~unreachedInterval = ~buffers / ~perOctave;
~reachedInterval = (~buffers - 1) / ~perOctave;
~lowNoteMultiple = 0.5 ** ~reachedInterval;
~lowNote = ~cutoffEnd * ~lowNoteMultiple;
~numHarms = roundUp(1 / ~lowNoteMultiple) - 1;
format("
roll-off: % octaves
lowest note available: % Hz
harmonics: %", ~rollOff, ~lowNote, ~numHarms);
)

////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// define a function

(
~genTerrain = { arg name, mulPhaseArray;
    var bufferFillPrev = 0; var bufferFillCur = 0; var bufferFillMin = 0;
    var terrainArray = Array.fill(~buffers, Array.fill(~samples, 0.0));
    var fileArray = Array.fill(~buffers * ~samples, 0.0);
    var currentHarm = Array.fill(~samples, 0.0);
    var path = format("%/%_%x%_%.wav", ~directory, name, ~samples, ~buffers, ~perOctave).standardizePath;
	for(1, ~numHarms, { arg harm;
        if(mulPhaseArray[0][harm - 1] != 0, {
            format("%: harmonic % of %: generating", name, harm, ~numHarms).postln;
            currentHarm = Array.fill(~samples, { arg sample;
                sin((sample / ~samples * harm * 2 * pi) - mulPhaseArray[1][harm - 1]) * mulPhaseArray[0][harm - 1] });
            bufferFillPrev = bufferFillCur;
            bufferFillCur = min(roundUp((log2(harm) + ~rollOff) * ~perOctave), ~buffers - 1);
            if(bufferFillPrev < bufferFillCur, { if(bufferFillPrev == (bufferFillCur - 1),
                format("%: harmonic % of %: initialising buffer %", name, harm, ~numHarms, bufferFillCur),
                format("%: harmonic % of %: initialising buffers % to %",
                    name, harm, ~numHarms, bufferFillPrev + 1, bufferFillCur)).postln });
            forBy(bufferFillPrev + 1, bufferFillCur, 1, { arg buffer; terrainArray.put(buffer, terrainArray[bufferFillPrev]) });
            bufferFillMin = min(trunc(log2(harm) * ~perOctave) + 1, ~buffers - 1);
            if(bufferFillMin == bufferFillCur,
                format("%: harmonic % of %: adding to buffer %", name, harm, ~numHarms, bufferFillCur),
                format("%: harmonic % of %: adding to buffers % to %", name, harm, ~numHarms, bufferFillMin, bufferFillCur)).postln;
            for(bufferFillMin, bufferFillCur, { arg buffer;
                terrainArray.put(buffer, terrainArray[buffer] + (currentHarm * (
                    (1 - cos(min(((buffer / ~perOctave) - log2(harm)) / ~rollOff, 1) * pi)) / 2
                ))) })
        }, {format("%: harmonic % of %: skipping silence", name, harm, ~numHarms).postln }) });
    forBy(bufferFillCur + 1, ~buffers - 1, 1, { arg buffer;
        format("%: initialising buffer %", name, buffer).postln;
        terrainArray.put(buffer, terrainArray[bufferFillCur]) });
    format("%: rendering to %", name, path).postln;
    for(0, ~buffers - 1, { arg buffer; for(0, ~samples - 1, { arg sample;
		fileArray.put((buffer * ~samples) + sample, terrainArray[buffer][sample]) }) });
    Buffer.loadCollection(s, fileArray).write(path, headerFormat: "WAV", sampleFormat: "float");
    "finished"
};
"
~genTerrain definition updated"
)

////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// generate some waves (this could take a long time with the defaults)

(
~genTerrain.value(
    "tri" // name of the wave
        , [Array.fill(~numHarms, {arg index; var harm = index + 1;
    (harm % 2) * (8 / (pi ** 2)) / (harm ** 2) // formula for amplitude
        }), Array.fill(~numHarms, {arg index; var harm = index + 1;
    ((harm % 4) - 1) * (pi / 2) // formula for phase
})]);

~genTerrain.value(
    "sawNoFund"
        , [Array.fill(~numHarms, {arg index; var harm = index + 1;
    if(harm == 1, 0, (2 / pi) / harm)
        }), Array.fill(~numHarms, {arg index; var harm = index + 1;
    0
})]);
)

////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// load a wave into buffer 0

(
var wave = "tri";
Buffer.read(s, format("%/%_%x%_%.wav", ~directory, wave, ~samples, ~buffers, ~perOctave).standardizePath, bufnum: 0)
)

////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// play the wave

(
{
    var master = -30; // volume in decibels for 440 Hz
    var gradient = -1; // decibel gradient by octaves, such that 880 Hz is the same decibels as 440 Hz plus gradient
    var freq = MouseX.kr(~lowNote, ~cutoffEnd, 1);
    var cutoff = MouseY.kr(~lowNote, ~cutoffEnd, 1);
    var indexX = LFSaw.ar(freq).range(0, 1);
    var indexY = K2A.ar((max(min((log2(cutoff / freq)), ~reachedInterval), 0)) / ~unreachedInterval);
    var amp = (master + (gradient * log2(freq / 440))).dbamp;
    var out = WaveTerrain.ar(0, indexX, indexY, ~samples, ~buffers, amp);
    [out, out]
}.play
)

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