I think synthesis should be done in a SynthDef.
SCs event paradigma leads to confusion for users when they want to implement a synthesis technique which needs audio rate for all their params (if one is used to use Pbind with EnvGen and SinOsc then this task is way more complicated then one would have assumed). There is also alot of confusion about language side events and ugen based triggers and having them in sync.
To get the best of both worlds, i think the best method is to use a combination of language and server side see here: Sub-sample accurate granulation with random periods - #24 by dietcv
I often times see people using EnvGen or oscillators without a phase reset for these kinds of things, which will not give you the desired results, not even speaking about implementing polyphony. Implementing these things on the server gives you a better understanding of how and why these synthesis techniques work and you have more possibilities for adjustment. Using lang-side approaches you are stuck with basic stuff imo.
Can you give an example of what you feel you canât do? I think separating synthesis (one synth per grain) and scheduling / parameterisation is very clear and flexible, though it is in a way a matter of taste.
For example continuous audio rate modulation of your grain window shapes with an LFO. At least not 1.) without using an external bus for your LFO and not 2.) without swapping EnvGen (not a stateless window) or IEnvGen (no modulation possible) for a custom stateless window function (i have shared alot of them in different threads). Implemented like this the output is also not subsample accurate and therefore gives you aliasing for higher trigger rates.
Okay. 1. doesnât seem too onerous? and could be modelled language side if needed? Re 2, presumably you can compensate for anything with SubsampleOffset.ir, or? Could you explain what you mean by a stateless window function? Thanks for satisfying my curiousity.
I have looked at the documentation of SubsampleOffset.ir
. I have to admit that i think the implementation which im using here to calculate the subSampleOffset
together with the explanation given here:
is easier to understand and feels less like a hack to me then using DelayC
.
Additonally at least how its implemented in the help file it does also not give the desired result.
Open the freqscope and try out the version from the helpfile:
(
SynthDef("Help_Subsample_Grain",
{ arg out=0, freq=440, sustain=0.5;
var env, offset, sig, sd;
sd = SampleDur.ir;
offset = (1 - SubsampleOffset.ir) * sd;
// free synth after delay:
Line.ar(1,0, sustain + offset, doneAction: Done.freeSelf);
env = EnvGen.ar(Env([0, 1, 0], [0.5, 0.5], \sin), timeScale: sustain);
sig = SinOsc.ar(freq, 0, env);
sig = DelayC.ar(sig, sd * 4, offset);
OffsetOut.ar(out, sig);
}, [\ir, \ir, \ir, \ir]).add;
)
(
Routine {
var sustain = 1 / 1013;
loop {
s.sendBundle(0.2, [9, \Help_Subsample_Grain, -1, 1, 1, \sustain, sustain, \freq, 2017]);
sustain.wait;
}
}.play;
)
vs.
(
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, trig|
var slope = rampToSlope.(phase);
var sampleCount = phase - (slope < 0) / slope;
Latch.ar(sampleCount, trig);
};
var accumulatorSubSample = { |trig, subSampleOffset|
var accum = Duty.ar(SampleDur.ir, trig, Dseries(0, 1));
accum + subSampleOffset;
};
var sndBuf = Buffer.loadCollection(s, Signal.sineFill(4096, [1,0], 0!2));
{
var tFreq, eventPhase, eventTrigger, eventSlope, subSampleOffset, accumulator;
var windowSlope, windowPhase, grainWindow, grainSlope, grainPhase, sig;
tFreq = 1013;
eventPhase = (Phasor.ar(DC.ar(0), tFreq * SampleDur.ir) - SampleDur.ir).wrap(0, 1);
eventTrigger = rampToTrig.(eventPhase);
eventSlope = rampToSlope.(eventPhase);
subSampleOffset = getSubSampleOffset.(eventPhase, eventTrigger);
accumulator = accumulatorSubSample.(eventTrigger, subSampleOffset);
windowSlope = Latch.ar(eventSlope, eventTrigger);
windowPhase = (windowSlope * accumulator).clip(0, 1);
grainWindow = IEnvGen.ar(Env([0, 1, 0], [0.5, 0.5], \sin), windowPhase);
grainSlope = 2017 * SampleDur.ir;
grainPhase = (grainSlope * accumulator).wrap(0, 1);
sig = BufRd.ar(1, sndBuf, grainPhase * BufFrames.kr(sndBuf), 1, 4);
sig = sig * grainWindow;
sig = LeakDC.ar(sig);
sig!2 * 0.1;
}.play;
)
and here once again your normal Impulse / GrainBuf combination:
(
var sndBuf = Buffer.loadCollection(s, Signal.sineFill(4096, [1,0], 0!2));
{
var tFreq = 1013;
var trig = Impulse.ar(tFreq);
var grainRate = 2017;
var sig = GrainBuf.ar(
numChannels: 1,
trigger: trig,
dur: 1 / tFreq,
sndbuf: sndBuf,
rate: grainRate * SampleDur.ir * BufFrames.kr(sndBuf),
interp: 4
);
sig = LeakDC.ar(sig);
sig!2 * 0.1;
}.play;
)
A stateless window is driven by a linear ramp between 0 and 1. Its output depends only on the instantaneous input of the ramp signal. When beeing modulated at audio rate:
- produces consistent, predictable outputs
- maintains perfect phase coherence
- output is stable and artifact-free even at high modulation rates
A segmented envelope like EnvGen is divided into discrete time segments. The output depends on which segment is active. When beeing modulated at audio rate:
- can introduce discontinuities at segment boundaries
- time-dependent behavior causes phase smearing
- can become unstable or glitchy at very high modulation rates due to internal state transitions
This is absolutely huge! @Sam_Pluta implemented the latched ramp scheduler in his oversampling oscillators <3
Using the VariableRamp
class you can modulate your scheduling phasor at audio rate and be sure to have always continuous, linear ramps between 0 and 1, which are correctly distributed round robin across the channels, while beeing modulated.
(
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 rampToSlope = { |phase|
var history = Delay1.ar(phase);
var delta = (phase - history);
delta.wrap(-0.5, 0.5);
};
var multiChannelTrigger = { |numChannels, trig|
numChannels.collect{ |chan|
PulseDivider.ar(trig, numChannels, numChannels - 1 - chan);
};
};
var getSubSampleOffset = { |phase, slope, trig|
var sampleCount = phase - (slope < 0) / slope;
Latch.ar(sampleCount, trig);
};
var accumulatorSubSample = { |trig, subSampleOffset|
var accum = Duty.ar(SampleDur.ir, trig, Dseries(0, 1));
accum + subSampleOffset;
};
var multiChannelAccumulator = { |triggers, subSampleOffsets|
triggers.collect{ |localTrig, i|
var hasTriggered = PulseCount.ar(localTrig) > 0;
var localAccum = accumulatorSubSample.(localTrig, subSampleOffsets[i]);
localAccum * hasTriggered;
};
};
{
var numChannels = 5;
var tFreqMod, modDepth, tFreq, stepPhase, stepTrigger, stepSlope, subSampleOffsets;
var triggers, accumulator, overlap, maxOverlap;
var windowSlopes, windowPhases;
var grainWindows, grainSlope, grainPhases;
var sigs, sig;
modDepth = \modMD.kr(1);
tFreqMod = 2 ** (SinOsc.ar(\modMF.kr(50)) * modDepth);
tFreq = \tFreq.kr(500) * tFreqMod;
stepPhase = VariableRamp.ar(tFreq);
stepSlope = rampToSlope.(stepPhase);
stepPhase = Delay1.ar(stepPhase);
stepTrigger = rampToTrig.(stepPhase);
// distribute triggers round-robin across the channels
triggers = multiChannelTrigger.(numChannels, stepTrigger);
// calculate sub-sample offset per multichannel trigger
subSampleOffsets = getSubSampleOffset.(stepPhase, stepSlope, triggers);
// create a multichannel accumulator with sub-sample accuracy
accumulator = multiChannelAccumulator.(triggers, subSampleOffsets);
overlap = \overlap.kr(1);
maxOverlap = min(overlap, 2 ** modDepth.neg * numChannels);
windowSlopes = Latch.ar(stepSlope, triggers) / max(0.001, maxOverlap);
windowPhases = (windowSlopes * accumulator).clip(0, 1);
windowPhases = windowPhases.wrap(0, 1);
}.plot(0.021);
)
I still dont know why you have to add the Delay1
after deriving the slope of your scheduling phasor to derive the triggers and calculate the sub-sample offset correctly though. Thats still a mistery to me.