Wavetable Synthesis with VOsc and Saw

Howdy!
Is it possible to read from a collection of six wavetables using Saw.ar (or LFSaw.ar) to drive the buffer index?
In my mind, this would go from [0, 1, 2, 3, 4, 5, 0, 1, 2, 3, 4, 5, 0 …]
When I try this, I get an undesirable click at the end of the loop … presumably when the Saw reaches the end of its period. My synth looks like

(
{
	var sig;
	sig = VOsc.ar(LFSaw.ar(0.5).range(0, 5), 120, 0.0, 0.3);
	[sig, sig]
}.play;
)

I’ve designed the wavetables so that they begin and end at 0 but perhaps VOsc simply can’t interpolate from 5 to 0? I’ve also tried adding a 7th wavetable that’s a duplicate of the first, but that didn’t work either. Here’s how I’m filling them (based on a tutorial by Eli Fieldsteel):

~buf = Buffer.allocConsecutive(6, s, 8192);

(
6.do({
	arg i;
	~env = Env(
		[0] ++ Array.rand( 6, -1.0, 1.0).normalize(-1.0, 1.0)++ [0],
		Array.rand(8, 0.1, 0.25),
		(Array.rand(8, 4, 12) * Array.fill(8, { [-1, 1].choose }))
	);
   //~env.plot;
	~env = ~env.discretize(4096);
	~wt = ~env.asWavetable;
	~buf[i].sendCollection(~wt);
})
)

Thanks++ for any tips/advice! I’ll just stick with using LFTri.ar for the time being.

LFSaw.ar(0.5).range(0, 4.999)

hjh

I’ve never used VOsc before… but is it documented somewhere that you’re not allowed to go all the way up to the final buffer index?

and FWIW I still get a click when I make the first and last buffers the same

~buf = Buffer.allocConsecutive(6, s, 8192);

(
5.do({
	arg i;
	~env = Env(
		[0] ++ Array.rand( 6, -1.0, 1.0).normalize(-1.0, 1.0)++ [0],
		Array.rand(8, 0.1, 0.25),
		(Array.rand(8, 4, 12) * Array.fill(8, { [-1, 1].choose }))
	);
	~env = ~env.discretize(4096);
	~wt = ~env.asWavetable;
	~buf[i].sendCollection(~wt);
  if (i == 0) { ~buf[5].sendCollection(~wt) };
})
)

and switch between them using e.g.

(
x = {
	var sig;
	sig = VOsc.ar(\pos.kr(0) + ~buf[0].bufnum, 120, 0.0, 0.3);
	[sig, sig]
}.play;
)

x.set(\pos, 4.9999)
x.set(\pos, 0)

Just messing around with this, here’s a way I found to get click-free circular playback, not the most efficient (reading from all buffers at once and using SelectX, also making first and last buffer the same):

~buf = Buffer.allocConsecutive(7, s, 4096);

(
6.do { |i|
	var env = Env(
		[0] ++ Array.rand( 6, -1.0, 1.0).normalize(-1.0, 1.0)++ [0],
		Array.rand(8, 0.1, 0.25),
		(Array.rand(8, 4, 12) * Array.fill(8, { [-1, 1].choose }))
	);
	env = env.discretize(4096);
	~buf[i].sendCollection(env);
  if (i == 0) { ~buf[6].sendCollection(env) };
}
)

(
x = {
  var pos = LFSaw.ar(0.5).range(0, 6);
  var phase = LFSaw.ar(120).range(0, 4096);
  var sigs = 7.collect { |i| BufRd.ar(1, i + ~buf[0].bufnum, phase) };
  var sig = SelectX.ar(pos, sigs);
  sig!2
}.play;
)
1 Like

I’m not sure what the documentation does or doesn’t cover, but, VOsc interpolates between consecutive wavetables. Interpolation requires at least 2 wavetables – so the interpolation implies that 1 wavetable is not sufficient.

Then, if you do push up to the last wavetable index, then it will interpolate between lastBuffer and lastBuffer + 1, the second of which doesn’t exist.

So it kinda makes sense that the number of buffers must be at least 2, and that the buffer index must be firstBuf <= bufnum < lastBuf (upper bound is not inclusive).

hjh

Thank you @jamshark70 and @Eric_Sluyter for your thoughts on this.

@jamshark70 re: your last post, this is why I also tried to add an extra buffer at the end of consecutive block that is a duplicate of the first one–and which I don’t read until the end. I also tried mirroring the first two blocks.

Here’s what I ended up with:

// using 9 buffers to mirror edges
// [6] 0 1 2 3 4 5 6 [0]
// bufnums 0 1 2 3 4 5 6 7 8

(
Buffer.freeAll;
~buf = Buffer.allocConsecutive(9, s, 8192);
)

// fill 7

(
7.do({
	arg i;
	~env = Env(
		[0] ++ Array.rand( 6, -1.0, 1.0).normalize(-1.0, 1.0).sort ++ [0],
		Array.rand(8, 0.1, 0.25).sort.reverse,
		(Array.rand(8, 4, 12) * Array.fill(8, { [-1, 1].choose }))
	);
   //~env.plot;
	~env = ~env.discretize(4096);
	~wt = ~env.asWavetable;
	~buf[i+1].sendCollection(~wt);
});

// mirror @ edges

// copy from 1 to 8
~buf[1].copyData(~buf[8]);
// copy from 7 to 0
~buf[7].copyData(~buf[0]);
)

// loop from buffer 0.5 to 7.5 (try debugging with the saw in the left channel)

(
{
	var sig, saw;
	saw = LFSaw.ar(0.5);
	sig = VOsc.ar(saw.range(0.5, 7.5), 120, 0.0, 0.3);
	[sig, sig]
}.play;
)

// loop from buffers 0 to 7 

(
{
	var sig, saw;
	saw = LFSaw.ar(0.5);
	sig = VOsc.ar(saw.range(0, 7), 120, 0.0, 0.3);
	[sig, sig]
}.play;
)


This approach definitely minimized the click but it’s still audible for ~3 samples. I took a look at the waveform and it seems to be happening a few frames after the cycle of LFSaw.ar completes. The audio in the top (L) channel is the output of LFSaw.ar (-1 to 1) before it is remapped.

Thanks again,
Jeremy

This may not be a suitable solution, since it changes the wavetable order to a mirrored sequence, but using LFTri at half the speed instead of LFSaw will stop the discontinuity:

// loop from buffers 0 to 7 
(
{
	var sig, tri;
	// saw = LFSaw.ar(0.5);
	tri = LFTri.ar(0.25);
	sig = VOsc.ar(tri.range(0, 7), 120, 0.0, 0.3);
	[sig, sig]
}.play;
)

Best,
Paul

1 Like

@TXMod thank you, I’ve been using LFTri in other SynthDefs … I was just hoping to get the interpolation moving in a single direction.

AFAICS, bufpos is control rate.

A large change in bufpos will be interpolated over the duration of one control block (hence the brief glitchiness).

Also, if bufpos has a large jump mid-control-block, VOsc will pick up the change slightly late (also seen in your plot).

That means, currently there’s no way to do it with VOsc. I’ve got another idea but no time just now.

hjh

Thanks for letting me know that I’m barking up the wrong tree. I still think there could be some workarounds using two VOscs and duplicate buffers … using cross-fading to avoid the clipping at the probematic moment. I’ll share here if I’m able to pull it off.

Out of curiosity, did you try my approach with the BufRds? Does it produce the desired result or is there some VOsc interpolation magic that is different from a simple crossfade?

Here is a solution using OscOS3 from OversamplingOscillators. I think you could adapt it for VOsc (but maybe just use this):

(
~six = 6.collect({
	arg i;
	~env = Env(
		[0] ++ Array.rand( 6, -1.0, 1.0).normalize(-1.0, 1.0)++ [0],
		Array.rand(8, 0.1, 0.25),
		(Array.rand(8, 4, 12) * Array.fill(8, { [-1, 1].choose }))
	);
  //~env.plot;
	~env = ~env.discretize(4096);
  ~env
}).flatten;
b = Buffer.loadCollection(s, ~six, 1)
)

(
{
  var freq = 0.25;
  var buf_divs = 6;
  var crosstime = 1/buf_divs;
  var crosstime_div2 = crosstime/2;
  var lookup = LFSaw.ar(freq).range(0,2+crosstime);
  var lookup1 = lookup.wrap(crosstime.neg,2).clip(0,1-(crosstime_div2)).linlin(0,1-(crosstime_div2),0,1);
  var lookup2 = LagUD.ar(lookup.clip(1+crosstime_div2,2)-1, 0, 1/freq/buf_divs);

  var slew = freq*buf_divs*2;
  var env1 = Slew.ar((lookup.wrap(0,2))<(1-crosstime_div2), slew, slew);
  var env2 = Slew.ar((lookup.wrap(0,2))>(1-crosstime_div2), slew, slew);

  ((OscOS3.ar(b, -1, 50, 0, 0, 6, lookup1, 1, 0, 1, 0, 2)*env1)+(OscOS3.ar(b, -1, 50, 0, 0, 6, lookup2, 1, 0, 1, 0, 2)*env2)).dup


}.scope
)

Sam

1 Like

Hey @Eric_Sluyter, I did try out your approach and it’s a lot cleaner than mine. I’m still hearing some jumps in between phases and especially at the end. It was more obvious when I cycled between 2 different wavetable patterns

(
6.do { |i|
	var env;
	if( i % 2 == 0, {
		thisThread.randSeed = 1925;
	},{
		thisThread.randSeed = 1926;
	});
	
	env = Env(
		[0] ++ Array.rand( 6, -1.0, 1.0).normalize(-1.0, 1.0)++ [0],
		Array.rand(8, 0.1, 0.25),
		(Array.rand(8, 4, 12) * Array.fill(8, { [-1, 1].choose }))
	);
	env = env.discretize(4096);
	~buf[i].sendCollection(env);
  if (i == 0) { ~buf[6].sendCollection(env) };
}
)

I have been doing some wrestling with VOsc myself. As a last resort if all of the great answers you already got failed You: try s.options.blockSize_(1);s.reboot and pray it works.

I will let myself out now.
Lukiss.

Since bufpos can’t modulate at audio rate, but BufRd’s phase input can, here’s a quite different design that seems to get the job done:

~numBufs = 6;
~wtSize = 4096;
~buf = Buffer.alloc(s, ~wtSize * ~numBufs);

(
~numBufs.do({ |i|
	~env = Env(
		[0] ++ Array.rand( 6, -1.0, 1.0).normalize(-1.0, 1.0)++ [0],
		Array.rand(8, 0.1, 0.25),
		(Array.rand(8, 4, 12) * Array.fill(8, { [-1, 1].choose }))
	).discretize(4096);
	~buf.sendCollection(~env, startFrame: i * ~wtSize);
})
)

(
a = { |freq = 220|
	var wtPos = Phasor.ar(0, 0.1 * SampleDur.ir, 0, 1) * ~numBufs;
	var wavePhase = Phasor.ar(0, freq * SampleDur.ir, 0, 1) * ~wtSize;
	
	var even = wtPos round: 2;
	var odd = (wtPos + 1) round: 2 - 1;
	var frac = wtPos.fold(0, 1);
	
	var pair = BufRd.ar(1, ~buf,
		phase: [even, odd] % ~numBufs * ~wtSize + wavePhase,
		interpolation: 2
	);

	// linear interpolation, just written out to skip the "frac * 2 - 1" that LinXFade2 would need
	var sig = (pair[1] - pair[0]) * frac + pair[0];

	(sig * 0.1).dup
}.play;
)

a.free;

hjh

1 Like

@jamshark70 & @Sam_Pluta: both of these approaches both sound great … I’m super grateful for your sharing some tips and demonstrating alternative options for VOsc.

Sam, your lookup approach to crossfading approaches was close to what I was trying to pull off on my own but couldn’t figure it out. I hope to find some time to figure it out with VOsc sometime.