Using Tidal to control modular synths with CV

Oh that's an interesting way to stack pitch and gate. The added gate logic makes sense, would you like to PR that?

I wonder if there's a way to shorten the legato value by 0.005 in the synth def itself, which would mean you could write a bit more cleaner tidal code... and it might have more consistent results in general?

Running 2 or more cv outputs on one channel will sum all the cv values. I think this theoretically is what it should do? Though I have only run into this by accident myself.

I think the problem generally with shortening the envelope via legato or by messing with fadeTime is that the target we're shooting for is infinitesimally small. We're trying to perfectly line up these envelopes so that they don't overlap or underlap, which both cause audible clicks.

I think superdirt, and tidal generally, wasn't designed with DC voltage in mind. the patterns create discrete envelopes, one per note, but dc voltages should be continuous and slide between values. That means somehow smoothing the output across multiple notes.

I’ve been using these synthdefs only for clocking so far, but am very soon to use them for modulation, so these latest replies were of great interest and I’ve been doing a little SC and SuperDirt reading today to learn more.

(To use this for pitch/mod/etc., I’d need it to hold pitch and some types of modulation indefinitely after setting their value, would eliminate the summing of values, and optimally would remove all slewing.)

[NB: I’m returning months later to strike out text which I subsequently learned is not just irrelevant, it is incorrect and would make things worse. superdirt-voltage must use OffsetOut because of how it bypasses superdirt’s output handling.]

I’m too far from done reading to suggest any changes yet, but did bump into this link (via the SuperDirt source code) which might provide an incremental improvement via use of Out instead of OffsetOut as in the current superdirt-voltage source.

~~https://club.tidalcycles.org/t/not-using-offsetout/1368~~

1 Like

Generally, you can have completely continuous synths with superdirt, but you need to write up the functions that set them in ~dirt.soundLibrary.addSynth(...). Check the hacks folder, there should be examples, you can make an Ndef for instance, that runs continually.

Also, there is continuous control of a single, very long event (not sure where in tidalcycles this is documented).

Sorry that I haven't followed this whole thread.

3 Likes

These both sound like great improvements, I haven't heard of Ndef until now. It's cool to see more activity on this!

Thanks @julian, I'm getting closer using your advice.

I set up an Ndef in SC that outputs a continuous level:

Ndef(\cvDef, { | freq = 440, lag=0.01 |
	var lagFreq = Lag.kr(freq, lagTime: lag);
	LinLin.ar( log2(lagFreq/440), -1, 9, 0, 1);
}).play(0);

The params to LinLin are calibrated for my external oscillator's 1v/oct range. Outputting 0-1 from this Ndef results in a 0-10v signal through my ES-9 audio interface.

Then I'm able to control it through this synth:

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

And in tidal:

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

Result:

Screen Shot 2023-01-18 at 7.24.12 PM

It's nice also that I can get true portamento that can also be patterned, via the Lag ugen.

The part I'm hung up on is routing the Ndef's output buffer to the dirt synth somehow so it's not just always playing. However, this is just a nice-to-have since the CV will be gated by a signal on a separate channel. I may look into writing a module to emit Ndef(\cvDef).start and .stop commands.

I tried using the playInside functionality but couldn't get it to work. See How to add a synth with `dirt.soundLibrary.addSynth` and playInside.

2 Likes

You want the pitch to hold, otherwise you’ll get pitch jumps during release phases of envelopes, so you’re probably closer to done than you think.

1 Like

Yes, pretty close.

I packaged it up into a pair of cv and gate synths for each orbit. I posted the latest code in a new thread so I don’t take over this one with talk of Ndefs.

But now I have a new problem. Jitter!

See: Sending continuous CV and Gate signals to a modular synth - #3 by colevscode

1 Like

Hi! Great discussion happening here :slight_smile: I haven’t tried any of these solutions, ‘cause the results i get with a MIDI module (Sol) plus a Slew Limiter (Contour 1) are being enough for me to control my modular with Tidal. The legato option works pretty well in this setup, most of the time i declare a clock on an orbit and output notes on another:

d1 $ stack [
    s "sol" $ n "~!7 1" 
        # midichan "3" -- reset
        # legato 0.5,
    s "sol" $ n "1!8" 
        # midichan "4" -- main clock
        # legato 0.5 
]
d2 $ note "c a f e" # s "sol" # midichan "0 1"

Hope you find this useful. I’ve already hearted this thread to keep a pin for myself and try the continuous voltages from SC too!

I always ran into weird timing issues (extra notes / jitter) using midi, but perhaps a dedicated midi device would fix that. I've only tried it with the midi converter on the 0-coast.

the only thing i’ve experienced with my setup were some clicks, kind of like a gate happening twice, but this was solved by adjusting the amp envelope on the synth and using a short legato. The Sol module is also very versatile, running CircuitPython one can customize it to one’s needs. But yeah, MIDI is not always the best way to go, having SC outputting signals or Pure Data sending audio (which i love) may be in other cases preferable.

I’ve been away from the forum a while and am just catching up, but to quickly add some datapoints for the moment:

I too use MIDI to CV conversion where I can (I only use direct CV out for things where I want sample-accurate timing and/or to output specific voltages.)

For MIDI to CV/gate, DROID with an X7 expander provides truly excellent output. The ability to patch flexibly within the DROID means I can add tracking calibration, octave shifts, multi-VCO detune in Hz, vibrato, envelopes, etc.

For CCs, and also for gate/velocity conversion, I really like the micro-MIDI modules from Flame. Between 12 and 16 outputs in just 5HP and easy to configure, too.

All of my MIDI gear hangs off of a Mio XL connected via ethernet. Really nice piece of gear.

The direct CV output at the heart of this thread is RME Digiface -> Expert Sleepers ES-3.

1 Like

Hey,

we're trying to use superdirt-voltage but the SynthDef(\nGate) declared in voltage.scd throws an error:

SynthDef nGate build failed
ERROR: OffsetOut  input at index  1 ( a MulAdd ) is not audio rate

It appears that the argument n is not audio rate.
We can make it work if we enclose it within DC.ar(n).

(
SynthDef(\nGate, {
	| out,
	channel = 0, 
	n, 
	portamento = 0 |
	// var sig = LinLin.ar(n, -1, 9, 0, 1); <<< original
	// vs.
	var sig = LinLin.ar(DC.ar(n), -1, 9, 0, 1); <<< hack
	
	OffsetOut.ar(channel, [sig]);
}).add
);

Is this the way to fix it? We're kind of confused that nobody has reported this yet. Does it work for everybody but us?

1 Like

I use superdirt-voltage regularly (and now have some tweaks I need to share back one of these days) but haven’t updated it in a while. In looking at github, it seems that both nPitch and nGate are recent additions. They’ve likely not seen much use yet, maybe not even any use given the error you’ve noticed.

does that mean that superdirt-voltage can be used without it?

Absolutely! You don’t need to use nGate or nPitch. Check out the other synthdefs. I personally haven’t had use for the pitch one, as I do MIDI->CV through DROID for pitch, but I clock a pile of modules using the gate synthdef and have recently also started using the voltage one to control some specific modulation targets. I would expect that all the synthdefs are working aside from either or both of the two (nPitch and/or nGate) added just last month.

1 Like

I added some of those newer synth defs based on the discussion in this thread. They do respond better for 'gapless' pitch sequences -- I'm using an ES-8, but please PR if you you can improve them generally. I might move them to the bottom in an 'advanced' section, because in many cases the original ones are much much simpler to use.

I've been meaning to share my superdirt-voltage tweaks for ages now but keep quite not getting to it. Thanks for your latest reply, as it was a good reminder so here we go:

I find superdirt-voltage to be less invasive and prescriptive than Tidal-CV, but tried out the latter anyway and found that while it can hold voltage its timing is only control rate-accurate (primarily because of how it sends messages.)

Taking a different approach, we can't quite achieve sample-accurate timing, because we need to run SC with system clock synchronization (rather than audio hardware synchronization,) but we can get extremely close through the use of audio-rate busses, which appear to be the recommended way to communicate this kind of information in SC.

(In fact, we can get close enough to sample accuracy that it reveals what appear to be timing issues within Tidal itself. As of 1.9.x, certain ranges of CPS result in tiny jitter. I have yet to diagnose this in detail, but it is observable when spending far too much time with an oscilloscope and reference oscillator. :wink: )

So, for my own uses, though this could surely be cleaned up for more general use, this achieves significantly-more-accurately-timed held voltages, while retaining the light touch of superdirt-voltage:

(Ignore my personal adjustments to n. The audio-rate busses, their usage near the end of the voltage synthdef, and the "gatedmonitor" setup are what matters.)

s.waitForBoot {
	var channels = 16;

	~superdirtVoltageBus1 = Bus.audio(s, channels);
	~superdirtVoltageBus2 = Bus.audio(s, channels);

	(
		SynthDef (\voltage, { | out,
			channel,
			n,
			rate = 1,
			delta,
			begin,
			end,
			portamento = 0 |
			var slew, env, phase;
			// n = n * 5; // Original SuperDirtVoltage code, presumably to suit a particular audio interface.
			n = n / 10; // Allow direct use of volts in Tidal, for ES-3 (which maps +/-1 to +/-10V)
			slew = (portamento);
			rate = rate;
			env = Env ([n, n + slew], [delta / rate]);
			phase = Line.ar (begin, end, delta / rate);
			// OffsetOut.ar (channel, IEnvGen.ar (env, phase)); // Original SuperDirtVoltage code
			OffsetOut.ar (~superdirtVoltageBus1.index + channel, IEnvGen.ar (env, phase));
			OffsetOut.ar (~superdirtVoltageBus2.index + channel, DC.ar(1));
		}).add;
	);

	SynthDef(\gatedmonitor, { | channel |
		Out.ar(channel, Gate.ar(In.ar(~superdirtVoltageBus1.index + channel), In.ar(~superdirtVoltageBus2.index + channel)));
	}).add;

	s.sync;

	channels.do { | channel | Synth.new(\gatedmonitor, [\channel, channel], addAction:\addToTail); }
};

In this way, the second bus (for each dedicated output channel) is used for for track-and-hold, via the Gate.

The only thing to bear in mind during usage is to keep legato below 0.99, or perhaps 0.97 to be super safe (240bpm 32nd notes levels of super safe,) because of some timing aspects of the Tidal/SuperDirt combination. If you fail to do this, you may encounter blips from summed overlapping voltages.

FWIW, this should easily extend to use in superdirt-voltage's pitch SynthDef, though I leave that as an exercise to the reader because I haven't needed it (I handle pitch via MIDI -> CV.)

Also, I haven't made sure this works perfectly in the face of features like portamento.

In fact, I suspect that portamento may be bugged, at least for some values of portamento, and perhaps even without my changes in play. One would want to make sure that all slewing will be completed before the last usable voltage value is sent to the bus, so that the correct value is held when the gate goes low.

And while I'm nearby, I might as well also share some voltage output calibration support code I've put in place.

For my uses, I don't need calibration throughout, say, a slow ramp, but for my uses I do need to hit specific voltages very accurately (to control delay times and such.) With that in mind, I opted to build some control-rate output calibration over on the Tidal side.

This can actually be used for all sorts of interpolated-transfer-function type silliness, to remap values well beyond the needs of calibrating voltages, so perhaps others might find it useful too.

I first wrote some Map helpers to retrieve nearest values, and some code to help with lookup and interpolation of values. These exist within my fork of Tidal, but it is unpublished so I'm just going to paste in the relevant module contents:

module Sound.TidalMV.Utils where

import Prelude
import qualified Data.Map as Map
import qualified Data.Map.Internal as MapInternal

data LookupNearestResult k a
  = NoMatch
  | LTMatch k a
  | EQMatch a
  | GTMatch k a
  | RangeMatch k a k a
  deriving (Eq, Show)

lookupNearest :: Ord k => k -> Map.Map k a -> LookupNearestResult k a
lookupNearest k (MapInternal.Bin _size key value left right) = case compare k key of
  LT -> case left of
    bin@MapInternal.Bin {} -> case lookupNearest k bin of
      NoMatch -> GTMatch key value
      LTMatch ltk ltv -> RangeMatch ltk ltv key value
      eqMatch@EQMatch {} -> eqMatch
      gtMatch@GTMatch {} -> gtMatch
      rangeMatch@RangeMatch {} -> rangeMatch
    MapInternal.Tip -> GTMatch key value
  EQ -> EQMatch value
  GT -> case right of
    bin@MapInternal.Bin {} -> case lookupNearest k bin of
      NoMatch -> LTMatch key value
      ltMatch@LTMatch {} -> ltMatch
      eqMatch@EQMatch {} -> eqMatch
      GTMatch gtk gtv -> RangeMatch key value gtk gtv
      rangeMatch@RangeMatch {} -> rangeMatch
    MapInternal.Tip -> LTMatch key value
lookupNearest _ MapInternal.Tip = NoMatch

lerp :: (Fractional a, Ord a) => a -> Map.Map a a -> Maybe a
lerp k m = case lookupNearest k m of
  NoMatch -> Nothing
  LTMatch ltk0 ltv0 ->
    if Map.size m == 1 then
      Just ltv0
      else do
        let (ltk1, ltv1) = Map.elemAt 1 m
        Just $ go k ltk0 ltv0 ltk1 ltv1
  EQMatch eqv -> Just eqv
  GTMatch gtk1 gtv1 -> do
    let mapSize = Map.size m
    if mapSize == 1 then
      Just gtv1
      else do
        let (gtk0, gtv0) = Map.elemAt (mapSize - 2) m
        Just $ go k gtk0 gtv0 gtk1 gtv1
  RangeMatch ltk ltv gtk gtv -> Just $ go k ltk ltv gtk gtv
  where go x x0 y0 x1 y1 = y0 + ((x - x0) * ((y1 - y0) / (x1 - x0)))

This is then used by:

  • constructing a list of specified voltage and resulting actual voltage
  • swapping those into (for calibration purposes) a map from resulting actual voltages to voltages which must be specified to the hardware interface, and then
  • using a calibrate function to turn patterns of expected output voltages into whatever value the hardware interface needs to see, with interpolation and extrapolation where needed.
import qualified Data.Map.Strict as Map
import qualified Data.Tuple as Tuple

leftES3Ch4List =
  [ ((-10), (-10.136))
  , ((-9.8387), (-10))
  , ((-9), (-9.147))
  , ((-8.8585), (-9))
  , ((-8), (-8.126))
  , ((-7.879), (-8))

... etc.

  , (8, 8.209)
  , (8.773, 9)
  , (9, 9.230)
  , (9.7525, 10)
  , (10, 10.240)
  ]

leftES3Ch4Map = Map.fromList $ Tuple.swap <$> leftES3Ch4List

calibrate :: (Fractional a, Ord a) => Map.Map a a -> Pattern a -> Pattern a
calibrate m = filterJust . fmap (flip lerp m)

With this all in place I can pattern voltages I expect to see at the physical output and the various code above will do its best to make it happen, ala

(...) calibrate leftES3Ch4Map "-2 0 2 3"

Which I generally wrap up into other helpers for controlling outputs destined for certain modules, so that by the time I'm done it looks more like:

ptapographictime "-1.724 2.06"

(Which I'm soon going to wrap up even more so that I can get replace the ugly numbers with nice strings, via yet more value mapping code.)

This does, of course, require that you go through a calibration process where you send uncalibrated voltages and then measure them on the other side, but (modulo temperature drift, power supply changes, etc.) that is generally a one-time process and you can provide as few or as many calibration values as you like.

1 Like