Advanced Synthesis - Oscillator Sync

So the idea is to start a series of posts reimplementing the ideas discussed in the Advanced Programming Techniques for Modular Synthesis. Each post will focus on a chapter, or part of a chapter.

A big motivation for me doing this is that I want to learn stuff. There’s a good chance that things I post will be wrong, or not the best approach. I just see that as part of the learning process. Feel free to correct, or improve anything I post. Or just take these ideas and do something cool. That’s kind of the point.

In this thread I’m going to start with oscillator sync. This might be the worst place to start as SuperCollider doesn’t really do a good job of supporting sync. None of the standard oscillators allow synching, so you have to hack it. There is a SyncSaw Ugen in extras, but it aliases. There are solutions to at least some of these problems though.

Here are a couple of links on what Oscillator Sync are as background:

And now here are the Nord Modular Links:

8 Likes

you have no idea how here for this I am.

I’ll be following closely, probably with plenty of questions

Hi,

there are two old threads about this.

https://www.listarc.bham.ac.uk/lists/sc-users-2015/msg47046.html

https://www.listarc.bham.ac.uk/lists/sc-users-2012/msg20757.html

You can do it with these specific UGens which have phase input + Phasor (also in variants), besides Osc or BufRd are possible for arbitrary waveforms. Personally I prefer BufRd to Osc to avoid the wavetable format.

All this is really hard sync with aliasing in general therefore

Example with BufRd

(
// use chebyshev polynomial as waveform
a = Signal.chebyFill(512, 0!5 ++ 1);
b = Buffer.loadCollection(s, a);
a.plot
)

s.freqscope

(
x = { |oscFreq = 100, trigFreq = 172|
    var trig = Impulse.ar(trigFreq);
    BufRd.ar(1, b, Phasor.ar(trig, oscFreq * 512 / SampleRate.ir, 0, 511), 1, 4) * 0.03
}.play
)

// change fundamental

x.set(\trigFreq, 145)

x.set(\trigFreq, 270)

x.set(\trigFreq, 60)


// change spectrum

x.set(\oscFreq, 205)

x.set(\oscFreq, 75)

x.release


// you can reduce artefacts with Lag e.g., a bit handwoven but might be practical

s.freqscope

// change lagtime with MouseX

(
y = { |oscFreq = 100, trigFreq = 109|
    var trig = Impulse.ar(trigFreq);
    Lag.ar(
        BufRd.ar(1, b, Phasor.ar(trig, oscFreq * 512 / SampleRate.ir, 0, 511), 1, 4),
        MouseX.kr(0.00003, 0.0003).poll(2, label: \lag)
    ) * 0.03
}.play
)

y.release

It might also be possible to modify Filter / VarLag params in parallel to the phasor reset, but I haven’t played around with that, it could maybe also make the filter instable in extreme cases.

James McCartney mentions the MinBLEPs technique, which uses sinc impulses. This is an elaborated procedure though.

Regards

Daniel

4 Likes

So let’s start with the easiest way to do this. In extras there’s a SyncSaw ugen that handles synching for us. Unfortunately it’s not anti-aliased, so let’s get a sense of what that means. First of all let’s fire up a frequency scope:

FreqScope.new();

Then play the synth below and switch between buses 0 and 1 on the frequency scope:

{[LFSaw.ar(1000), Saw.ar(1000)]}.play

So with the aliasing version on bus 0, and the anti-aliased version on bus 1 you should be able to see (and maybe hear) the difference. The aliasing is pretty bad. We can improve things a little:

{[LFSaw.ar(1000), HPF.ar(LFSaw.ar(1000), 900), Saw.ar(1000)]}.play

Bus 1 is better, but we still have a lot of noise between the fundamentals. So this is far from perfect. Let’s not worry about that for the moment. Evaluate the following function:

(
/* fFreq = Fundamental Frequency
   sFreq = Saw Frequency
*/
f = {|fFreq, sFreq, cycles=5|
  var x = {[
    SyncSaw.ar(fFreq, sFreq),
    LFSaw.ar(fFreq).range(-0.5, 0.5),
  ]};
  x.plot(cycles/fFreq);
}
)

This function will plot the output of the syncsaw on the top graph, with a reference saw wave below. This will give you a sense of t

fFreq is the frequency of the sync wave and sFreq is the frequency of our output saw wave. So our saw wave is being reset fFreq times per second, but the saw wave’s frequency is sFreq. Too much theory, let’s draw stuff:

f.(100, 100);

So this just a saw wave. Our sync oscillator is resetting our saw wave when our saw wave reaches a phase of 0. Let’s try some more values:

f.(100, 200);

If you ignore the second wave which is some kind of weird artefact, you can see that we have doubled the frequency.

f.(100, 300);

and so on. Let’s do something more interesting:

f.(100, 150);

(again ignore the second wave). So this time our periodic frequency is unchange, but our wave is split into two waves. The first saw wave which has the frequency of our saw wave, the second wave which has a smaller frequency with a range of -1 to 0.

f.(100, 250);

and let’s look at the frequency components:

{SyncSaw.ar(100, 200)}.play

So our fundamental here has been shifted (as we’d expect) from the saw wave (remember to do cmd-period/ctrl-period each time):

{Saw.ar(100)}.play

Playing around a bit more:

  f.(100, 120)
  {SyncSaw.ar(100, 200)}.play

So essentially when we double the saw frequency, we double the fundamental of the overall wave, which makes sense.

Whereas the faction changes the timbre, with fractions closer to the next number shifting the energy into the next harmonic:

  f.(100, 120)
  {SyncSaw.ar(100, 120)}.play
  
  f.(100, 280)
  {SyncSaw.ar(100, 280)}.play

So whole number ratios shift us up an octave, fractional values change the timbre (though inevitably higher fractions will also shift the perceived tone). I suspect, though I haven’t checked, that the more imperfect the ratio between the fundamental and audio wave frequencies, the wilder the timbre. Feel free to check.

How about we cross the streams:

  f.(300, 227.3)

So there’s nothing stopping us doing this, it’s just fairly uninteresting. We’re just reducing the amplitude and DC biasing our signal. So when doing hard sync always make the /fundamental/ lower than the audio wave. And when working out the affect remember:

  • fundamental defines the fundamental note.
  • The ratio of the audio wave frequency to the audio wave frequency defines the note we hear and the timbre.

So if we have a fundamental of 100hz and a saw wave of 243hz. the output wave will have a frequency of 200 hz, and and the 43 hz will affect our timbre.

That’s part one. Feel free to tell me what’s wrong and I’ll correct it. Part two will come whenever I find time and will start looking at some audio applications and ways to implement it in supercollider.

Daniel. Thanks for that! Those were certainly the approaches I was going to investigate but actual working code is great! Nathan also sent me some stuff on Slack that I’m digesting. Ultimately if this works I’d like to package these up into something more formal/document like. But let’s see how this goes for the moment.

I don’t think miniBleps would be possible directly in SuperCollider. Would need a UGen I think. I do have an idea for creating a anti-aliased syncable oscillator though. Think it’s doable and would be a neat thing.

Yes, probably.

That would definitely be nice!

Good stuff!
I got a few G2 patches that would be interesting to see come alive in SuperCollider
might be a good challenge

btw, that Nick Collins tutorial on server side sequencing you referred me to earlier had a surprisingly pleasing example of hard sync that uses an envelope generator:

(
{
	var mouse_y, mouse_x;
	mouse_y = MouseY.kr (0, 1) * [ 0, 128, 256, 128 ] / SampleRate.ir;
	mouse_x = MouseX.kr (10, 300, 'exponential');
	EnvGen.ar( Env([ 0, 0, 1, -1, 0 ], mouse_y), Impulse.ar (mouse_x));
}.play
)

minBLEP has been mostly superseded by polyBLEP. it’s tricky, but i think you could do polyBLEP using just existing UGens.

Nathaniel Virgo put up an anti-aliased (ish) SyncSaw on SCcode which I’ll post up here when I’ve cleaned it up (think it would be better as a pseudo ugen, with a few other fixes). He uses a buffer to implement delay lines which he uses to integrate a Saw wave. It sounds good, which I guess is the important test for these things.

The SoS article linked by OP can also be found here:

Something like this or I managed to misunderstand it?

SynthDef(\hardSync, { arg freq=440, syncFreqRatio=2, amp=0.5, gate=1;
    var masterOsc, slaveOsc, env, sync, filterFreq;
    filterFreq = freq * 0.9;
    masterOsc = Pulse.ar(freq, 0.5);
    slaveOsc = Saw.ar(freq * syncFreqRatio);
    sync = slaveOsc * (masterOsc < 0);
    sync = HPF.ar(sync, filterFreq);
    env = EnvGen.ar(Env.adsr(0.01, 0.2, 0.7, 0.3), gate, doneAction: 2) * amp;
    Out.ar(0, sync * env);
}).add;
1 Like

I think I arrived really late to this post but I coded an oscillator sync using Playbuf, an in depth explanation of the process is here:

How to create a Hard Sync Oscillator with SuperCollider. | by daniel luque | Apr, 2024 | Medium.

It basically creates any waveform with Signal, and it resets the Playbuf with a Pulse wave, it is aliased but im working on a post on how to fix it with an FFT filter.

This was made with this technique:

// Creation of the Sawtooth wave and loading it to the buffer
s.boot;

(
var numFrames = 2048;
var sig = Signal.newClear(numFrames);
sig = sig.collect({ |sample, index|
    2.0 * (index / numFrames) - 1.0;
});
b = Buffer.alloc(s,numFrames);
b.loadCollection(sig);
)

// Playing the oscillator sync with the saw wave slave and the pulse wave master

(
{
	var baseFreq, playbackRate, slave, slaveFreq, master, modFreq;
	modFreq = 100;
	slaveFreq = SinOsc.ar(0.2, -pi/2).range(modFreq,modFreq*16);
	master = LFPulse.ar(modFreq);
	baseFreq = s.sampleRate / (b.numFrames);
	playbackRate = (slaveFreq) / baseFreq;
	slave = PlayBuf.ar(1, b, playbackRate, trigger: master , loop: 1)!2 *0.1;
	}.play;
)```
1 Like

You can’t fix aliasing once its in a signal. You have to prevent it from getting into the signal in the first place. That requires either oversampling (which you can’t do without building a custom ugen in C++), or using a method that won’t cause much anti-aliasing.

There is a new set of extensions in SuperCollider which supposedly allow you to do hardsync without much aliasing:

I’d start there. If interested there’s a discussion somewhere on this forum.

2 Likes

Thanks for the comment, I already tried that library, but I don’t know if I did something wrong but I got really noticeable aliasing when doing the sync with this oscillators, Maybe it was me, I will try them again and see if it was an error on my side.

I’ve not used the library myself, so I’m not sure (it’s on the list). Hard sync for arbitrary wave forms is a really hard problem to solve, particularly if those source wave forms alias themselves. But that library will give you a better result than anything you code by hand, just because it’s over sampling.

Something I suspect a lot of commercial synths do is analyze arbitrary wave forms using an FFT, and then build non-aliased versions of them for different octave/note ranges (using FFT brickwalling prior to reconstruction). That’s also the easiest (and lowest DSP) way to get anti-aliased oscillators generally, though there are better approaches.

The author is active on this forum I think, so you could probably ask them, or raise an issue on the github. Going to be easiest if you can share an example of what you’re trying to achieve.

1 Like

Yes, I would like to reproduce this wavetable behavior that you described, I found a library that implements it but Im not able to make it work.

Basically you create a buffer with several versions of the waveform, the first being the unfiltered one and the las the most filtered one, and you play this versions depending on the desired frequency, so for the low frequencies you play the first and the higher frequencies you play the last. Im trying to figure out how to do it, cause Playbuf doesn’t have a way to read through the samples of the buffer like BufRd, but BufRd doesn’t have a trigger to reset the sample. In any case, just by doing a simple FFT brick wall high pass filtering when swiping across all the frequencies make the aliasing disappear, but also it makes the sound thiner. So I guess is one thing for another.

There’s a wavetable oscillator in oversampling oscillators that supports this. I’d start there. It oversamples, so it will give better results than James’ code.

You’ll have to write the code for selecting a wavetable based upon notes yourself, and do the filtering of wavetables in advance. I think Eli Fieldsteel has a youtube tutorial on this. But your approach of doing an FFT and a PV brickwall filter is the right approach here. Basically you pick a fundamental note (basically the lowest note that doesn’t seem to anti-alias, that you would also want to use), and then from your FFT version you just generate a series of waveforms that are brickwalled at progressively lower frequencies that represent what nyquist would be with each new note.

So for example let’s say you work out that C2 is good enough for your root frequency, and you want to go up in minor 3rds, then you would work out Nyquist if the same waveform was played at Eb2 - and use that for your brickwall, then do the sam for Gb2 and so forth. And stop at whatever point you’re no longer playing notes. The amount you increase by is a matter of taste. A lot of synths use octaves, while apparently some have been known to use intervals (which seems like overkill to me). The smaller the interval, the more buffer space you’ll need. :person_shrugging: If you’re oversampling I think you could probably get away with octaves.

If you want to be really fancy you could experiment with your original waveform as well. You could use interpolation (offline in something like Audacity) to increase the length of your wave sample. That might help. Or even try resampling it to a higher sample rate, then brickwalling it. That would probably help if there are any discontinuities in your original wave form. I’ve not tried either of these, so I’m not sure what the best approach is, but I imagine commercial wavetable synths do something like this with user supplied wave forms.

1 Like

For sync behavior, Oversampling Oscillators will work, though it wasn’t designed to do this. I think the next version could explicitly have this, but it would be a breaking change. However, you can do it with Sweep:

(
{
    var sin1 = SinOsc.ar(MouseX.kr(50,400));

    SinOscOS.ar(0, Sweep.ar(sin1,100)%1*2, 3, 0.1)
}.play;
)

(
    {
        var saw1 = LFSaw.ar(MouseX.kr(50,400));
    
        [saw1*0.1, SawOS.ar(0, (Sweep.ar(saw1+0.99,100)*2)%2, 3, 0.1)]
    }.scope;
)

For the increasingly filtered variable wavetable:

//using a wavetable to get a low pass filtered oscillator OR a bandlimited oscillator

//low pass filter--------------------------------------
(
    //a linkwitz riley low pass filter with that allows almost vertical slope
    ~linkwitzRileyLP2 = {arg size_of_sig, hiBin, order;
        var temp;
        temp = (0..(size_of_sig/2));
        temp = temp.collect{|item| 1/(1+((item/(hiBin)**order)))};
        temp.as(Signal)
    }
)

//build the 512 wavetable buffer where each buffer has a progressively lower cutoff
(
var make_lpf = {|sig, cutoff, order|
    var size = sig.size;
    var real, rfftsize, imag, cosTable, frqAmpPhs, complex, complex2, new_mags, irfft;
    var lp;

    real = sig;

    lp = ~linkwitzRileyLP2.(size, cutoff, order);

    rfftsize = size/2 + 1;
    cosTable = Signal.rfftCosTable(rfftsize);

    // Perform fft
    complex = rfft(real, cosTable);

    new_mags = Array.fill(rfftsize,{|i| complex.magnitude[i]*lp[i]});

    complex2 = Polar(new_mags, complex.phase.asArray).asComplex;

    irfft = complex2.real.as(Signal).irfft(complex2.imag.as(Signal), cosTable);

    irfft
};
var size = 1024;

var sig = Env([0,1,-1,0],[1,0,1]).asSignal(size);

~num_buffers = size/2;
~full_sig = Signal.newClear(0);

~num_buffers.do{|i|
    i.postln;
    ~full_sig = ~full_sig.addAll(make_lpf.(sig, (i)*((size/2)/~num_buffers)+1, 512))
};

~full_sig.plot;
~low_pass_buf = Buffer.loadCollection(s, ~full_sig.asSignal);

)

//play back the wavetable using MouseX
(
    {
        var sig,phase = LFSaw.ar(90).range(0,1);
    
        sig = OscOS.ar(~low_pass_buf,(phase),~num_buffers,MouseX.kr(0,0.999).lincurve(0,1,0,1,8), 2);
    // 
        sig.dup*0.2;
    }.scope
)

//play back a progressively more filtered wavetable based on the frequency
(
    {
        var sig;
    
        var mouse = MouseX.kr(0,0.999);

        var freq = mouse.linexp(0,1,23,20000).poll;

        var phase = LFSaw.ar(freq).range(0,1);

        var bufloc = mouse.lincurve(0,1,1,0,-8);

        (bufloc*~num_buffers).poll;

        sig = OscOS.ar(~low_pass_buf,(phase),~num_buffers,bufloc, 4);
    // 
        sig.dup*0.2;
    }.scope
)

I am working on a next release of the OversamplingOscillators that has a Serum/Vital style wavetable builder that does this for you. It takes any duration wavetable of wavetables and creates a N octave progressively more lowpassed version and then allows you to play it back as you please. Give me a week or 2 on that. The filtering works (see the image below - this is a wavetable from Vital going through this process) and it is just one line of code (for the user) so none of the nonsense from above. I just need to make the 3D wavetable player, which shouldn’t be too hard :sweat_smile:

Sam

3 Likes

I am working on a next release of the OversamplingOscillators that has a Serum/Vital style wavetable builder that does this for you.

Well that is exciting!

It also sounds like you’re also supporting the ability to have multiple ‘anti-aliased’ wavetables, so you could sweep between Saw->Square->Another Wave. Which would be fantastic.