Oversampling oscillators

Hi Thor,

Did you try the prebuilt binary? It’s on the right side of the github page. Click on releases. You will need to dequarantine once it is in the extensions folder: ‘xattr -cr <the_oversampling_oscillators_directory>’

Sam

Thanks, I missed the prebuilt. I got it working, now time to try some stuff, looking forward!

I wrote an oversampled version of LoopBuf (with factors 2, 4, 8, and 16, no idea if that’s the standard practice) a while ago (I named it LoopPlay for now), with some other small added features: a) different interpolations including regular cubic and experimental Hermite and sync methods to see how it goes, also the idea of adaptative methods b)some extra crossfading options (I remember someone saying it was necessary, but I honestly would not miss it) c) plays nice with multichannel buffers. Eventually, I will publish it; I need the time to have one more look at it when possible. Maybe people could help me test it — or someone smarter than me will maybe find bugs and problems. It fits what I wanted, at least.

I’ll be glad if there is still interest from others)) Or check if others wrote something similar.

4 Likes

hi @smoge, sounds really good, thanks for sharing & looking forward to try it out and report if i find some unusual behaviour!

Thats really cool, figured out to create 1-D tables from a folder of single-cycles using FluidBufCompose for OscOS. Thats way cooler to index into different single-cycles of one buffer then crossfading BufRds either for a Demand sequence of indices or a continuous LFO for modulating the indices :slight_smile:

1 Like

Thank you for these great oscillators! I was wondering if anyone has implemented a DSF (Discrete Summation Formula) oscillator in this context? I’ve been enjoying the one in the EaganMatrix synth and it would be a useful one to include in this collection!

There is a straightforward summary of the complex number math here, I haven’t yet tried to implement this to make a UGen but am up to try if no one else has done it…

Moppel’s limited synths - The Math behind DSF Synthesis

1 Like

He Marc. This looks super fun. I’ll look into it! Always interested in new techniques.

FYI - Based on @dietcv 's excellent supercollider example of sinc interpolation, I updated OscOS and OscOS3 in this library to use sinc interpolation. To be clear, I could not have done this without dietcv’s example. In fact, I just exported their sinc window and use it directly in the code, so thanks to them for that! I think it sounds better, especially when loading pre-existing wavetables.

Sam

5 Likes

awesome <3, would it be possible to bypass the oversampling completely in your implementation? currently when you set oversampling to 0 you still get the delay from the AA Filter.

Im not sure if im doing something wrong, but my SC implementation is way better anti-aliased then the current version of OscOS (actually there is no anti-aliasing at all):

// prepare buffers
(
var getSinc = { |numRipples|
    var x = Array.interpolation(4096, -1.0, 1.0);
    sincPi(x * 0.5pi * numRipples);
};
var sinc = getSinc.(8);
var kaiser = Signal.kaiserWindow(4096);
var windowedSinc = sinc * kaiser;
~sincBuf = Buffer.loadCollection(s, windowedSinc);
)

(
~sndBuf = Buffer.alloc(s, 2048, 1, { |buf|
    buf.sine1Msg((1..512).reciprocal, asWavetable: false)
});
)

// run example!
(
var rampToSlope = { |phase|
    var history = Delay1.ar(phase);
    var delta = (phase - history);
    delta.wrap(-0.5, 0.5);
};

var sincInterpolated = { |phase, sndBuf, sincBuf, sampleSpacing, numSamples = 8|

    var sampleIndex, fracPart, intPart;
    var samples, windows;
    var halfSamples = numSamples.div(2);

    // Calculate the base sample position
    sampleIndex = phase * BufFrames.kr(sndBuf) / sampleSpacing;
    fracPart = sampleIndex.wrap(0, 1);  // fractional part for interpolation
    intPart = sampleIndex - fracPart;   // integer part for base position

    // Get sample values at integer offsets
    samples = (halfSamples.neg..(halfSamples - 1)).collect{ |offset|
        var readPos = ((intPart + offset) * sampleSpacing).wrap(0, BufFrames.kr(sndBuf));
        BufRd.ar(1, sndBuf, readPos, interpolation: 1);
    };

    // Get window values from sinc function
    windows = (1..numSamples).collect{ |i|
        var sincPos = (i / numSamples) - (fracPart / numSamples) * BufFrames.kr(sincBuf);
        BufRd.ar(1, sincBuf, sincPos, interpolation: 4);
    };

    (samples * windows).sum;
};

var mipmapInterpolate = { |phase, sndBuf, sincBuf|

    var slope, samplesPerFrame, octave, layer;
    var spacing1, spacing2, sig1, sig2;

	// Calculate mipmap parameters
    slope = rampToSlope.(phase);
    samplesPerFrame = slope.abs * BufFrames.kr(sndBuf);
    octave = max(0, log2(samplesPerFrame));
    layer = octave.ceil;

	// Calculate spacings for adjacent mipmap levels
    spacing1 = 2 ** layer;
    spacing2 = 2 ** (layer + 1);

	// Get and crossfade interpolated signals
    sig1 = sincInterpolated.(phase, sndBuf, sincBuf, spacing1);
    sig2 = sincInterpolated.(phase, sndBuf, sincBuf, spacing2);
    LinXFade2.ar(sig1, sig2, octave.wrap(0, 1) * 2 - 1);
};

{
    var rate, phase, sig;

	rate = SinOsc.ar(0.1, 1.5pi).linlin(-1, 1, 100, 8000);
    phase = Phasor.ar(DC.ar(0), rate * SampleDur.ir);
    sig = mipmapInterpolate.(phase, ~sndBuf, ~sincBuf);

    sig = LeakDC.ar(sig);
    sig!2 * 0.1;
}.play;
)

// compare! 

(
{
    var rate, phase, sig;

	rate = SinOsc.ar(0.1, 1.5pi).linlin(-1, 1, 100, 8000);
    phase = Phasor.ar(DC.ar(0), rate * SampleDur.ir);
    sig = OscOS.ar(~sndBuf, phase, 1, 0, 0);

    sig = LeakDC.ar(sig);
    sig!2 * 0.1;
}.play;
)

// check the freqscope!
s.freqscope;
1 Like

It just works differently. I would do it this way. (In fact, I would always use OscOS3 over OscOS as well.)

(
u = (16..0).collect{|i| 
	Signal.sineFill(2048, 1.0/(1..(2**i)));
};

~sndBuf = Buffer.loadCollection(s, u.flop.flatten, u.size);
)

(
{
	var mouse = MouseY.kr;
    var freq = SinOsc.ar(0.1, 1.5pi).linlin(-1, 1, 100, 8000);
    var buf_loc = freq.abs.curvelin(43,SampleRate.ir/4,0,~sndBuf.numChannels, 8).poll;
	OscOS3.ar(~sndBuf, -1, freq, 0, 0, 1, 0, ~sndBuf.numChannels, buf_loc/~sndBuf.numChannels, 2, 0, 1).dup*0.1
}.scope;
)

Sam

1 Like

thanks :slight_smile: I have to admit i dont really understand this attempt.

Im fine with 1D wavetables and want to drive my Oscs with a Phasor instead of frequency, thats the reason im using OscOS.

diet. Thanks for your continued testing of this. I seemed to have introduced a bug into OscOS that is now fixed (new release is posted). The bug had to do with your request that the oscillator override the oversampling machinery when oversampling index is 0. It now does this. It just uses the ramp directly without oversampling it.

The tricky part about this oscillator over OscOS3 is that the ramp is provided outside the oscillator. When using oversampling, you then have to oversample the ramp. But traditional oversampling will smooth out the jump of the ramp when it goes from 1 to 0 and create a distortion. I have to do some backflips to ensure that the ramp has a 1 sample vertical edge when it jumps from 1 to 0 or vice versa. In doing so, I had introduced a distortion. But it is now fixed.

This should now sound like butter:

(
	u = (16..0).collect{|i| 
		Signal.sineFill(2048, 1.0/(1..(2**i)));
	};
	
	~sndBuf = Buffer.loadCollection(s, u.flat);
)

(
	{
		var freq = SinOsc.ar(0.1, 1.5pi).linlin(-1, 1, 10, 16000);

		//buf_loc changes the buffer used based on the current octave of the oscillator. essentially, each octave the oscillator goes up, a buffer with half the number of partials is chosen. 
		var buf_loc = freq.abs.curvelin(43,SampleRate.ir/4,0,u.size, 8).poll;
		var phase = LFSaw.ar(freq, 0, 0.5, 0.5);
		OscOS.ar(~sndBuf, phase, 17, buf_loc/u.size, 0).dup*0.1;
	}.scope;
)

Just FYI - the key reason to use OscOS3 instead of OscOS has to do with this ramp issue. The ramp of OscOS3 is internal to the oscillator, so the internal ramp is actually running at the oversampled sampling rate rather than being oversampled from a phasor input. This makes it much more accurate. But I guess if you aren’t using oversampling, then this is a non-issue.

Sam

1 Like

thanks alot for the explanation and the further work on OscOS :slight_smile:

Instead of having to prepare one wavetable for each octave, couldnt these computed in realtime like in the SC example?
Where for every layer of the mipmap, we read only every 2 ** (numLayer) sample and then crossfade between the two nearest layers according to the fractional step between layers?

I’m sure this can be done. It actually seems quite easy. I’ll try and implement it! That would be much easier on the user for sure

Sam

awesome! I guess everything you would need is in the mipmapInterpolate function.

Could you elaborate where the exact problem is with the phasor input and the oversampling compared to an internal phasor and oversampling?

I really dont know, but would deriving the slope via rampToSlope of the incoming phasor and accumulating a new ramp help here?

It isn’t really a problem. It is fixed now. This part of the code wasn’t the actual issue. But I was so focused on correcting the ramp reset that I had forgotten to interpolate the ramp otherwise. So if the oversampling index was 2 (ratio of 4), the previous ramp value was 0.5 and our next value was 0.6, I was failing to interpolate, so instead of getting 0.525, 0.55, 0.75, 0.6, all of the values were 0.5. So the ramp was blocky and this added distortion.

Sam

1 Like

okay, i was searching on the whole web for ramp signals and oversampling haha.

Thanks alot again for figuring out the distortion with OscOS. Without knowing it any better, I thought that the oversampling and the delay which is caused from the AA filter is causing the aliasing in this sub-sample accurate approach. But good news thats not the case! There is a little bit of something on the freqscope if you disable oversampling in the OscOS compared to sin or BufRd but thats not something to worry about. So oversampling is fine with sub-sample accurate granulation! But having the mipmap dynamically would really be nice to benefit from the table interpolation of different waveforms which would all be band-limited, instead of having to use them to set up the dynamic mipmap itself and be limited to one specific waveform.


(
var rampToSlope = { |phase|
	var history = Delay1.ar(phase);
	var delta = (phase - history);
	delta.wrap(-0.5, 0.5);
};

var rampToTrig = { |phase|
	var history = Delay1.ar(phase);
	var delta = (phase - history);
	var sum = (phase + history);
	var trig = (delta / sum).abs > 0.5;
	Trig1.ar(trig, SampleDur.ir);
};

var getSubSampleOffset = { |phase, slope, trig|
	var sampleCount = phase - (slope < 0) / slope;
	Latch.ar(sampleCount, trig);
};

var accumulatorSubSample = { |trig, subSampleOffset|
	var hasTriggered = PulseCount.ar(trig) > 0;
	var accum = Duty.ar(SampleDur.ir, trig, Dseries(0, 1)) * hasTriggered;
	accum + subSampleOffset;
};

~sndBuf = Buffer.loadCollection(s, Signal.sineFill(4096, [1]));

{
	var stepPhase, stepTrigger, stepSlope;
	var subSampleOffset, accumulator;
	var windowSlope, windowPhase, grainWindow;
	var grainSlope, grainPhase, carrier, sig;

	stepPhase = Phasor.ar(DC.ar(0), \triggerFreq.kr(1043) * SampleDur.ir);
	//stepPhase = VariableRamp.ar(\triggerFreq.kr(1043));
	stepTrigger = rampToTrig.(stepPhase);
	stepSlope = rampToSlope.(stepPhase);

	subSampleOffset = getSubSampleOffset.(stepPhase, stepSlope, stepTrigger);
	accumulator = accumulatorSubSample.(stepTrigger, subSampleOffset);
	//accumulator = accumulatorSubSample.(stepTrigger, 0);

	windowSlope = Latch.ar(stepSlope, stepTrigger);
	windowPhase = (windowSlope * accumulator).clip(0, 1);
	grainWindow = 1 - cos(windowPhase * 2pi) * 0.5;

	grainSlope = \grainFreq.kr(2000) * SampleDur.ir;
	grainPhase = (grainSlope * accumulator).wrap(0, 1);

	//carrier = sin(grainPhase * 2pi);
	//carrier = BufRd.ar(1, ~sndBuf, grainPhase * BufFrames.kr(~sndBuf), 1, 4);
	carrier = OscOS.ar(~sndBuf, grainPhase, 1, 0, 4);

	sig = carrier * grainWindow;

	sig = LeakDC.ar(sig);
	sig!2 * 0.1;

}.play;
)

s.freqscope;

still hell of a journey compared to ordinary Impulse and GrainBuf:

(
~sndBuf = Buffer.loadCollection(s, Signal.sineFill(4096, [1]));

{
	var triggerFreq, trig, sig;

	triggerFreq = \triggerFreq.kr(1043);
	trig = Impulse.ar(triggerFreq);

	sig = GrainBuf.ar(
		numChannels: 1,
		trigger: trig,
		dur: 1 / triggerFreq,
		sndbuf: ~sndBuf,
		rate: \grainFreq.kr(2000) * SampleDur.ir * BufFrames.kr(~sndBuf),
		interp: 4
	);

	sig = LeakDC.ar(sig);
	sig!2 * 0.1;

}.play;
)

s.freqscope;
1 Like

I’m very close to having this working. A couple wrinkles to iron out.

2 Likes

@dietcv, try OscOS in this build.

There is an octave harmonic that is still there (and is folding over) that I don’t understand, but otherwise it is working great. An oversampling index of 1 removes all aliasing. I know that won’t work for @dietcv, but probably is fine for everyone else.

This also does 3D wavetables, so no more manual mipmap building is necessary for alias free wavetable interpolation at all frequencies.

Sinc interpolating mipmap is voodoo magic. It took me a hot minute to figure out what even was going on. Thanks for bringing it to my attention.

The code is hairy as hell, but if someone wants to dive in and try to find where the foldover is coming from, I’m glad to fix it. Or let me know if there are more bugs that I didn’t discover yet.

Sam

1 Like