Sending continuous CV and Gate signals to a modular synth

I'm attempting to drive a modular synth with Tidal, using continuous CV (control voltage) and gate sequences. I'm motivated by the idea that modular synths are good at making weird sounds, but not great for sequencing. Most sequencer modules are little computers with bad user interfaces. I want to use my modular synth for synthesis, and my computer running Tidal as a sequencer.

The main challenge I've encountered so far is that by default, all synths in the Tidal environment use an envelope. Envelopes create a beginning and end for each note, but I want to send continuous varying control voltages to my modular synth. The result of applying an envelope to a CV is that, when multiple notes overlap, or if there are gaps, you hear clicks and pops from the synth.

Screen Shot 2023-01-15 at 9.05.49 PM

See this post for a more detailed discussion of the problem.

I'm making a lot of progress and hitting some interesting roadblocks. I'll use this thread to document my journey. I hope it's helpful to others.

3 Likes

I made a big breakthrough last week and was able to generate continuous CV using an Ndef. Ndefs or Node Proxy defs are basically the background processes of the supercollider environment. They let you send audio or control signals to an inaudible bus, and patch them together, using one Ndef to control the inputs of another. It's a bit like patching together modules in a modular synth.

All I'm trying to do though is create a continuous voltage, and then change that voltage with a pattern of notes in tidal.

I was able to create an Ndef that plays continuously once supercollider boots, and then change the frequency every time a note is played in tidal. Here's a glimpse of the output:

Screen Shot 2023-01-18 at 7.24.12 PM

I'm using the Lag ugen to smooth out the signal as it changes. The lag time above is 0.2 seconds.

You can see the code here: Using Tidal to control modular synths with CV - #45 by colevscode

1 Like

This week I fought an epic battle that boiled down to not understanding the difference between Symbols and strings. Main take-away is that if you define an Ndef with a string for a name, you'll never be able to access it again, since each string has a unique address.

In the end though I was able to export a bunch of CV and gate synths to tidal. Here's the code I added to startup.scd:

(
	~dirt.orbits.do({ | orbit, i |
		var chans, cvtag, gatetag;
		chans = ~dirt.numChannels;

		// CV synths

		cvtag=('cv_np'++i).asSymbol;

		Ndef(cvtag, { DC.ar }); // force the NP to audio rate
		Ndef(cvtag, { | freq = 440, lag=0.01 |
			var lagFreq = Lag.kr(freq, lagTime: lag);
			LinLin.ar( log2(lagFreq/440), -1, 9, 0, 1);
		});

		~dirt.soundLibrary.addSynth(('cv'++i).asSymbol, (play: {
			Ndef(cvtag).wakeUp; // make sure the Ndef runs
			Ndef(cvtag).set(\freq, ~freq);
		}));

		~dirt.soundLibrary.addSynth(('cv'++i++'_lag').asSymbol, (play: {
			Ndef(cvtag).wakeUp; // make sure the Ndef runs
			Ndef(cvtag).set(\lag, ~n);
		}));

		~dirt.soundLibrary.addSynth(('cv'++i++'_on').asSymbol, (play: {
			Ndef(cvtag).play(i*chans);
		}));

		~dirt.soundLibrary.addSynth(('cv'++i++'_off').asSymbol, (play: {
			Ndef(cvtag).stop;
		}));

		// gate synths

		gatetag = ('gate_np' ++ i).asSymbol;

		Ndef(gatetag, { DC.ar }); // force the NP to audio rate
		Ndef(gatetag, { | level = 0, lag=0.01 |
			var lagLevel = Lag.kr(level, lagTime: lag);
			LinLin.ar( lagLevel, 0, 1, 0, 0.5);
		});

		~dirt.soundLibrary.addSynth(('gate'++i).asSymbol, (play: {
			Ndef(gatetag).wakeUp; // make sure the Ndef runs
			Ndef(gatetag).set(\level, ~n);
		}));

		~dirt.soundLibrary.addSynth(('gate'++i++'_lag').asSymbol, (play: {
			Ndef(gatetag).wakeUp; // make sure the Ndef runs
			Ndef(gatetag).set(\lag, ~n);
		}));

		~dirt.soundLibrary.addSynth(('gate'++i++'_on').asSymbol, (play: {
			Ndef(gatetag).play(i*chans+1);
		}));

		~dirt.soundLibrary.addSynth(('gate' ++i++'_off').asSymbol, (play: {
			Ndef(gatetag).stop;
		}));

	});
)

This can be controlled in Tidal like so:

-- turn on cv0, this starts a continuous DC output on channel 0
once $ s "cv0_on"

-- turn on gate0
once $ s "gate0_on"

-- simple cv pattern
d1 $ n "a6 a5 a6 a5" # s "cv0" 

-- separate gate pattern   
d2 $ n "[1 0]*4" # s "gate0"

-- you can also pattern portamento
d3 $ n "0.01 0.5" # s "cv0_lag"

Here's the result. The red line is cv0 and the green is gate0:

Screen Shot 2023-01-24 at 3.37.48 PM

There is a pretty big problem, however. There's some jitter in the start times of each note. You can see it more clearly when you overlay a simple beat pattern from tidal on top of the CV signal.

d4 $ "bd bd" # channel 2

result with cv0 in blue (no lag) and the bass drum pattern in green:

Screen Shot 2023-01-24 at 3.43.25 PM

I think this may have something to do with sample offsets in tidal / superdirt. I haven't started investigating it yet, but for now, these CV synths aren't usable.

5 Likes

Today I tried moving the Ndef synth logic into a SynthDef that outputs with OffsetOut. The thinking was that if the freq parameter is control rate, then perhaps I need to use OffsetOut for sample-accurate timing. I wrapped the synth in an Ndef so I can still control the frequency through a dirtSynth from Tidal.

Unfortunately, the results were no different. Here's the new synth setup:

(
	SynthDef(\test_cv, { | out, freq = 440, lag=0.01 |
		var n = Lag.ar(log2(K2A.ar(freq)/440), lag);
		var sig = LinLin.ar(n, -1, 9, 0, 1);
		OffsetOut.ar(out, [sig]);
	}).add;
	Ndef(\cv_np).source = \test_cv;
	Ndef(\cv_np).play(0);
	
	~dirt.soundLibrary.addSynth(\cv, (play: {
		Ndef(\cv_np).wakeUp; // make sure the Ndef runs
		Ndef(\cv_np).set(\freq, ~freq);
	}));
)

Running through Tidal with:

d1 $ "hh hh hh hh" # channel 1 # gain 1.5

d2 $ n "a6 a5 a6 a5" # s "cv"

Overlaying the two signals you can see they're not lining up. Listening to the output, you can hear it, the high hat drops before the change in tone, and it's jittery.

Screen Shot 2023-01-26 at 10.55.19 AM

As a sanity check I also compared the cv signal to a clock on my modular synth that I manually set to the same frequency as tidal. Here also, the timing isn't consistent.

Screen Shot 2023-01-26 at 11.25.33 AM

I'm running out of ideas. Just for fun I ran ~offset.postln from the dirtSynth and the results are 0 every time. So if there is an issue with the sample offset, I don't know where it is or how to fix it.

The other thought I have is perhaps the Ndef.set message isn't precise or introduces latency. This one:

Ndef(\cv_np).set(\freq, ~freq);

However, I'm not aware of another way to change a parameter of a running synth.

If I don't come up with any ideas soon, I guess i'll go back to using the envelope method with a very tight legato value, and deal with the occasional click by applying a slew limiter inside my modular. It's a bit frustrating though, since I'll need one per cv, which could be quite expensive.

1 Like

hmm... i wonder if the .wakeUp method call is necessary, given that you already started the Ndef beforehand?

another thing (which i can't try out myself though, since i have neither an oscilloscope nor a CV interface) would be to add an OSCFunc that schedules the .set calls.
@mrreason showed this approach to me in late 2020 when i was trying to eliminate timing jitter from MIDI events sent to vst plugins hosted in tidal.

the only thing you're guaranteed when you add a function to the superdirt sound library is that SC will start executing the function on time, not how long it will take to actually do it, hence the jitter.

basically all things you can do in SClang take some time, and the only way to make sure they happen at the exact time you want them to is to nicely ask the scheduler in advance to do them at that specific point in the future. this is what tidal does when it sends OSC bundles to superdirt, and i think this is also what happens when you wrap things in Pdefs (though i'm not really familiar with that side of SC).

the OSC bundles tidal sends contain a "time" value, and the OSCFunc below (copied from my startup file) takes this value and uses it to compute the time in the future it needs to schedule an arbitrary function (in this case sending an event of type \vst_midi to a VSTPluginController instance). i'm pretty certain you could use the same logic to set the values in your ndef.


		(
			s.sync;

			~oscfunc = OSCFunc({ |msg, time, tidalAddr|
				var dict = (), timeDelta; // Create dictionary
				dict.putPairs(msg[1..]);
				if (dict.at(\n).isNil, {dict[\n] = 0});

				timeDelta = time - Main.elapsedTime;

				//it took me a while of playing around to find this value, it might be different for you.
				timeDelta = timeDelta - 0.3;

				/// adding a midi play function
				if ( ~instruments[dict[\s]].isNil == false ,{
					thisThread.clock.sched(timeDelta, {(\type: \vst_midi,\vst: ~instruments[dict[\s]].generator,
						\midicmd: \noteOn,
						\chan: 0,
						\amp: dict[\amp],
						\midinote: dict[\n] + 60,
						\dur: dict[\delta],
					).play;
					});

					if ( dict.at(\param).notNil == true && dict.at(\paramval).notNil == true && dict.at(\paramtarget).notNil == true ,{
						thisThread.clock.sched(timeDelta, {~instruments[dict[\paramtarget]].generator.set(dict[\param].asInteger,dict[\paramval]);
					})
				});

				});


			}, '/dirt/play', NetAddr("127.0.0.1"), recvPort: 57120 /*3337*/);

		);

@colevscode as @Robin_Hase mentioned I had a similar issue that I solved with TidalVST. The minimal working example looks like this:

var latency = ~lag + (~latency ? 0);

thisThread.clock.sched(latency, {
	instruments[vstName].midi.noteOn(0, note, velocity);
});

thisThread.clock.sched(sustain + latency, {
	instruments[vstName].midi.noteOff(0, note, velocity);
});

I guess instruments[vstName] should be you Ndef and instead of midi.noteOn and midi.noteOff you could then use your gate on and off. Didn't proved that but in theory this should work.

2 Likes

@mrreason @Robin_Hase Absolutely brilliant. Thanks so much.

By scheduling the Ndef.set commands using those latency parameters, I was able to line up the timing and remove the jitter. Here's the new addSynth command:

(
~dirt.soundLibrary.addSynth(\cv, (play: {
	var latency = ~lag + (~latency ? 0);
	var freq = ~freq;

	Ndef(\cv_np).wakeUp; // make sure the Ndef runs
	
	thisThread.clock.sched(latency - 0.025, {
		Ndef(\cv_np).set(\freq, freq);
	});
})
);
)

And here are the results:

Screen Shot 2023-01-27 at 2.54.48 PM

I tried with and without wakeUp but didn't see a difference.

I had to play with a few other things to get them lined up. I increased s.latency to 0.05 just to make sure I had enough time for the messages to get to the server. Also I slightly reduced the latency by a fixed -0.025 seconds because the CV was starting a little bit late. Not sure why but this correction did the trick. Next I'll have to figure out how the ~lag parameter is set in Tidal so I can remove this hard coded offset.

I'll do some additional testing but this gives me a lot of hope. Thanks again!

7 Likes

OK tested everything and it's working. Here's the latest version of the code block to add to startup.scd:

(
	~minCv = -1;
	~maxCv = 9;
	~triggerHold = 0.05;
	~latencyCorrection = -0.035;

	~dirt.orbits.do({ | orbit, i |
		var chans, cvtag, gatetag;
		chans = ~dirt.numChannels;

		// CV synths

		cvtag=('cv_np'++i).asSymbol;

		Ndef(cvtag, { | freq = 440, lag=0.01, level |
			var sig = if (
				level > 0,
				level,
				LinLin.kr( log2(freq/440), ~minCv, ~maxCv, 0, 1)
			);
			Lag.ar(K2A.ar(sig), lagTime: lag);
		});

		~dirt.soundLibrary.addSynth(('cv'++i).asSymbol, (play: {
			var latency = (~latency ? 0) + (~latencyCorrection ? 0);
			var n = ~n;
			Ndef(cvtag).wakeUp; // make sure the Ndef runs
			thisThread.clock.sched(latency, {
				Ndef(cvtag).set(\level, n);
			});
		}));

		~dirt.soundLibrary.addSynth(('cv'++i++'_pitch').asSymbol, (play: {
			var latency = (~latency ? 0) + (~latencyCorrection ? 0);
			var freq = ~freq;
			Ndef(cvtag).wakeUp; // make sure the Ndef runs
			thisThread.clock.sched(latency, {
				Ndef(cvtag).set(\level, 0, \freq, freq);
			});
		}));

		~dirt.soundLibrary.addSynth(('cv'++i++'_lag').asSymbol, (play: {
			var latency = (~latency ? 0) + (~latencyCorrection ? 0);
			var n = ~n;
			Ndef(cvtag).wakeUp; // make sure the Ndef runs
			thisThread.clock.sched(latency, {
				Ndef(cvtag).set(\lag, n);
			});
		}));

		~dirt.soundLibrary.addSynth(('cv'++i++'_on').asSymbol, (play: {
			('cv'++i++'_on').postln;
			Ndef(cvtag).play(i*chans);
		}));

		~dirt.soundLibrary.addSynth(('cv'++i++'_off').asSymbol, (play: {
			('cv'++i++'_off').postln;
			Ndef(cvtag).stop;
		}));

		// gate synths

		gatetag = ('gate_np' ++ i).asSymbol;

		Ndef(gatetag, { | level = 0, lag=0.01 |
			Lag.ar(K2A.ar(level), lagTime: lag);
		});

		~dirt.soundLibrary.addSynth(('gate'++i).asSymbol, (play: {
			var latency = (~latency ? 0) + (~latencyCorrection ? 0);
			var n = ~n;
			Ndef(gatetag).wakeUp; // make sure the Ndef runs
			thisThread.clock.sched(latency, {
				Ndef(gatetag).set(\level, n);
			});
		}));

		~dirt.soundLibrary.addSynth(('gate'++i++'_lag').asSymbol, (play: {
			var latency = (~latency ? 0) + (~latencyCorrection ? 0);
			var n = ~n;
			Ndef(gatetag).wakeUp; // make sure the Ndef runs
			thisThread.clock.sched(latency, {
				Ndef(gatetag).set(\lag, n);
			});
		}));

		~dirt.soundLibrary.addSynth(('gate'++i++'_trig').asSymbol, (play: {
			var latency = (~latency ? 0) + (~latencyCorrection ? 0);
			var triggerTime = latency + (~triggerHold ? 0.05);
			Ndef(gatetag).wakeUp; // make sure the Ndef runs
			thisThread.clock.sched(latency, {
				Ndef(gatetag).set(\level, 1.0);
			});
			thisThread.clock.sched(triggerTime, {
				Ndef(gatetag).set(\level, -0.01);
			});
		}));

		~dirt.soundLibrary.addSynth(('gate'++i++'_on').asSymbol, (play: {
			('gate'++i++'_on').postln;
			Ndef(gatetag).play(i*chans+1);
		}));

		~dirt.soundLibrary.addSynth(('gate'++i++'_off').asSymbol, (play: {
			('gate'++i++'_off').postln;
			Ndef(gatetag).stop;
		}));

	});
)

The ~minCv and ~maxCv values are used to tune the cv_pitch values for 1v/oct output.

There's a trigger synth for the gate signal that will send a short cv pulse (of ~triggerHold seconds).

Finally there's a ~latencyCorrection value that should be experimentally set to ensure the timing is correct for all these synth events.

Here's a demo of how it's used:

-- turn on cv1, this starts a continuous DC output on channel 1
once $ s "cv1_on"

-- turn on gate1
once $ s "gate1_on"

d1 $ stack [
  n "a6 a5 a6 a5" # s "cv1_pitch", 
  n "0.01 0.4" # s "cv1_lag",
  n "1 1 1 1" # s "gate1_trig"
]

And here's the output:

Screen Shot 2023-01-29 at 5.00.40 PM

I'll chuck all this into a github repo and post to the opening thread soon.

2 Likes

Darn I was hoping I could update the initial post to include a link to the github repo. I guess I have to just bury it down here.

3 Likes

Here's a clip from a recent jam session. Gate and CV signals (red) are coming from tidal and going to my synth, the drum track (yellow) is coming from tidal.

And here's tidal:

-- modular synth
d1 $ stack [
  m "a2*5 g2*1 bs2*12" 32 8 # s "cv1_pitch", 
  n "0.01!2 0.8!2 0.02!4" # s "cv1_slew",
  n "[1 0]!10 1 <1 0> [1 0]!4" # s "gate1"
]

-- drum track
d2 $ stack [
  "<hc:1*2 hc:2*2> hc:1*2!3" # cut 1 # gain 0.7, 
  "<bd [bd bd]> ~ sn:2 ~ "
] # channel 0

I'm working on packaging this library up as a quark so it's easier to install.

2 Likes

OK, it's now a quark. You can install by evaluating this line in sc:

Quarks.install("https://github.com/colevscode/tidal-cv.git");

Then add this line to your supercollider startup.scd file after loading superdirt.

~tidalcv = TidalCV(~dirt, minOct: -1, maxOct: 9, triggerHold: 0.05, latencyCorrection: -0.025);

Full docs are in the repo:

5 Likes

great stuff! someone should tell richard devine about this ASAP :smile:

really though, would you mind if i crosspost this on llllllll.co ? i think there's a lot of people over there that could be excited about this, given that it's a community with a lot of modular synth users who aren't shy about using computer keyboards as musical instruments.

1 Like

Feel free to cross post. I hope it doesn’t start a flame war about sequencer user interfaces. :grimacing: