NOTAM Meetups: Spring 2025

hey, im really looking forward to our little pulsar night next wednesday :slight_smile:
In preparation i have written two guides, one for sub-sample accurate phasor based scheduling and the other which buillds on top of that for sub-sample accurate granulation. These include alot of details i have figured out over the last 2-3 years and are based on alot of threads i have written on that topic (In addition i will show some gen~ patches to illustrate some of the details).
I hope you find them useful:

sub-sample accurate phasor based scheduling:

// ramp based scheduling for sub-sample accurate events

// 1.1) scheduling phasor as a source of time (clock)

// 1.1.1.) continuous, linear ramps between 0 and 1 (no phase reset)

// At every moment in time you know:
// - how much time has elapsed after your last phasors wrap
// - how much time is left before the next phasors wrap
// -> big advantage over trigger based scheduling!

(
{
	Phasor.ar(DC.ar(0), 50 * SampleDur.ir);
}.plot(0.041);
)

///////////////////////////////////////////////////////////////////

// 1.2.) deriving triggers from scheduling phasor

// 1.2.1.) magnitude delta - compare difference with threshold

// - calculate the slope / delta (rate of change per sample)
// - use delta.abs > 0.5 to derive a trigger if change was large (phasors wrap)
// - optional: add initial trigger with Impulse.ar(0) or start phasor one sample earlier

(
var rampToTrig = { |phase|
	var history = Delay1.ar(phase);
	var delta = phase - history;
	delta.abs > 0.5;
};

{
	var initTrig, phase, trig;

	initTrig = Impulse.ar(0);

	phase = Phasor.ar(DC.ar(0), 1000 * SampleDur.ir);
	//phase = (Phasor.ar(DC.ar(0), 1000 * SampleDur.ir) - SampleDur.ir).wrap(0, 1);

	trig = rampToTrig.(phase);// + initTrig;

	[phase, trig];
}.plot(0.0021).plotMode_(\plines);
)

// 1.2.1.) proportional change - dividing difference by sum and compare with threshold

// delta too small to cause a trigger:
// - no initial trigger or trigger on pause / unpause
// - no trigger for reset of scheduling phasor during first half of its duty cycle

// calculate the delta and the sum
// calculate absolute proportial change by delta divided by sum
// compare with threshold, if bigger then threshold create trigger (significant change)
// triggers on false-to-true transitions only (extreme inputs, do not cause double triggers)

// first trigger is one sample late from the initial phasors wrap
// start scheduling phasor one sample earlier to align the first trigger with the initial phasors wrap
// not needed if we calculate the sub-sample offset and add it to the accumulated ramp

(
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 phase, trig;

	phase = Phasor.ar(DC.ar(0), 1000 * SampleDur.ir);
	//phase = (Phasor.ar(DC.ar(0), 1000 * SampleDur.ir) - SampleDur.ir).wrap(0, 1);

	trig = rampToTrig.(phase);

	[phase, trig];
}.plot(0.0021).plotMode_(\plines);
)

///////////////////////////////////////////////////////////////////

// 1.3.) deriving slopes from scheduling phasor

// - slope / delta is rate of change per sample (normalized frequency)
// - constant small positive value for upwward ramps (negative for downward ramps)
// - discontinuity in slope at the phasors wrap, recenter between -0.5 and 0.5
// - slope multiplied by samplerate is frequency in hz
// - frequency in hz divided by samplerate is slope

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

{
	var rate, phase, slope;

	rate = 1000;
	phase = Phasor.ar(DC.ar(0), rate * SampleDur.ir);

	slope = rampToSlope.(phase);

	[phase, slope];
	//[rate, slope * SampleRate.ir];
}.plot(0.0021).plotMode_(\plines);
)

///////////////////////////////////////////////////////////////////

// 1.4.) accumulate ramps from scheduling phasor

// 1.4.1.) accumumlator with Duty

// - calculate slope and triggers from scheduling phasor
// - count samples with Duty and reset it by the derived trigger
// - multiply by the slope

(
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 accum = { |trig|
	var hasTriggered = PulseCount.ar(trig) > 0;
	Duty.ar(SampleDur.ir, trig, Dseries(0, 1)) * hasTriggered;
};

{
	var phase, trig, slope;
	var accumulator, accumulatedRamp;

	phase = Phasor.ar(DC.ar(0), 1000 * SampleDur.ir);
	trig = rampToTrig.(phase);
	slope = rampToSlope.(phase);

	accumulator = accum.(trig);
	accumulatedRamp = slope * accumulator;

	[phase, trig, accumulatedRamp];
}.plot(0.0021).plotMode_(\plines);
)

// 1.4.2.) integrator with Sweep

// - calculate slope and triggers from scheduling phasor
// - integrate slope values with Sweep and reset it by the derived trigger

(
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 ramp = { |trig, slope|
	var hasTriggered = PulseCount.ar(trig) > 0;
	Sweep.ar(trig, slope * SampleRate.ir) * hasTriggered;
};

{
	var phase, trig, slope;
	var integratedRamp;

	phase = Phasor.ar(DC.ar(0), 1000 * SampleDur.ir);
	trig = rampToTrig.(phase);
	slope = rampToSlope.(phase);

	integratedRamp = ramp.(trig, slope);

	[phase, trig, integratedRamp];
}.plot(0.0021).plotMode_(\plines);
)

///////////////////////////////////////////////////////////////////////////

// 1.4.) ramp division (clock division)

// - possible with non-integer ratios
// - derived events can be sub-sample accurate

// 1.4.1.) multiply and wrap

// - multiply by a ratio
// - wrap it between 0 and 1

(
{
	var phase, subdividedRamp;

	phase = Phasor.ar(DC.ar(0), \rate.kr(100) * SampleDur.ir);
	subdividedRamp = (phase * \ratio.kr(4)).wrap(0, 1);

	[phase, subdividedRamp];
}.plot(0.021);
)

// 1.4.2.) accumulator with reset

// The triggered reset is the only way of keeping them 100% in sync.
// Thats what we are using for granulation!

// - calculate slope and a trigger from scheduling phasor
// - run an accumulator and reset it by the derived trigger
// - multiply by the slope and a ratio
// - wrap or clip it between 0 and 1 (or both)

(
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 phase, slope, trig;
	var accumulator, subdividedRamp;

	phase = Phasor.ar(DC.ar(0), \rate.kr(100) * SampleDur.ir);
	slope = rampToSlope.(phase);
	trig = rampToTrig.(phase);

	accumulator = Duty.ar(SampleDur.ir, trig, Dseries(0, 1));
	subdividedRamp = (slope * \ratio.kr(4) * accumulator).wrap(0, 1);

	[phase, subdividedRamp];
}.plot(0.021)
)

// 1.4.3.) accumulator without reset

// - only way to accumulate ramps which are slower then your scheduling phasor
// - no guarantee that these ramps will remain phase-synchronized with the scheduling phasor
// - even if they start synchronized, modulations to the ratio can cause them to drift

// In the GO book is one patch which syncs the derived ramps to the main ramp when the ratio changes by a significant amount by detecting a proportional change above a certain threshold (needs a single-sample feedback loop)

// - calculate slope from scheduling phasor
// - run an accumulator without reset
// - multiply by the slope and a ratio
// - wrap it between 0 and 1

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

{
	var phase, slope;
	var accumulator, subdividedRamp;

	phase = Phasor.ar(DC.ar(0), \rate.kr(100) * SampleDur.ir);
	slope = rampToSlope.(phase);

	accumulator = Duty.ar(SampleDur.ir, DC.ar(0), Dseries(0, 1));
	subdividedRamp = (slope * \ratio.kr(0.5) * accumulator).wrap(0, 1);

	[phase, subdividedRamp];
}.plot(0.021)
)

///////////////////////////////////////////////////////////////////

// 1.5.) calculate sub-sample offset

// - high trigger rates which are non-integer divisions of your samplerate cause aliasing
// - The scheduling phasor has a fractional value of non-zero at the moment it wraps from 1 to 0 (sub-sample offset)

// for each sample frame where the scheduling phasor wraps:
// - calculate sub-sample offset with a fractional sample counter (phasor divided by its own slope)
// - sample and hold of factional sample count with derived trigger
// - add factional sample count (sub-sample offset) to accumulator on phase reset

// 1.5.1.) accumulator with Duty

(
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 getSubSampleOffset = { |phase, slope, trig|
	var sampleCount = phase / slope;
	Latch.ar(sampleCount, trig);
};

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

{
	var eventPhase, eventTrigger, eventSlope;
	var subSampleOffset, accumumlator;
	var accumulatedRamp;

	eventPhase = (Phasor.ar(DC.ar(0), 1000 * SampleDur.ir) - SampleDur.ir).wrap(0, 1);
	eventSlope = rampToSlope.(eventPhase);
	eventTrigger = rampToTrig.(eventPhase);

	subSampleOffset = getSubSampleOffset.(eventPhase, eventSlope, eventTrigger);
	//accumumlator = accumSubSample.(eventTrigger, 0);
	accumumlator = accumSubSample.(eventTrigger, subSampleOffset);
	accumulatedRamp = eventSlope * accumumlator;

	[eventPhase, eventTrigger, accumulatedRamp];
}.plot(0.0011).plotMode_(\plines);
)

// 1.5.2.) integrator with Sweep

(
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 getSubSampleOffset = { |phase, slope, trig|
	var sampleCount = phase / slope;
	Latch.ar(sampleCount, trig);
};

var rampSubSample = { |trig, slope, subSampleOffset|
	var hasTriggered = PulseCount.ar(trig) > 0;
	var accum = Sweep.ar(trig, slope * SampleRate.ir) * hasTriggered;
	accum + (slope * subSampleOffset);
};

{
	var eventPhase, eventTrigger, eventSlope;
	var subSampleOffset, integratedRamp;

	eventPhase = (Phasor.ar(DC.ar(0), 1000 * SampleDur.ir) - SampleDur.ir).wrap(0, 1);
	eventSlope = rampToSlope.(eventPhase);
	eventTrigger = rampToTrig.(eventPhase);

	subSampleOffset = getSubSampleOffset.(eventPhase, eventSlope, eventTrigger);
	integratedRamp = rampSubSample.(eventTrigger, eventSlope, subSampleOffset);

	[eventPhase, eventTrigger, integratedRamp];
}.plot(0.0011).plotMode_(\plines);
)

// 1.5.3.) compare sub-sample accurate events with trigger based scheduling

// 1.5.3.1.) accumulator with Duty

(
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;
	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 triggerFreq, eventPhase, eventTrigger, eventSlope;
	var subSampleOffset, accumulator;
	var windowSlope, windowPhase, grainWindow;
	var grainSlope, grainPhase, carrier, sig;

	triggerFreq = \triggerFreq.kr(1043);
	//triggerFreq = s.sampleRate / 40;

	eventPhase = Phasor.ar(DC.ar(0), triggerFreq * SampleDur.ir);
	eventTrigger = rampToTrig.(eventPhase);
	eventSlope = rampToSlope.(eventPhase);

	subSampleOffset = getSubSampleOffset.(eventPhase, eventSlope, eventTrigger);
	accumulator = accumulatorSubSample.(eventTrigger, subSampleOffset);
	//accumulator = accumulatorSubSample.(eventTrigger, 0);

	windowSlope = Latch.ar(eventSlope, eventTrigger) / max(0.001, \overlap.kr(1));
	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 = BufRd.ar(1, ~sndBuf, grainPhase * BufFrames.kr(~sndBuf), 1, 4);

	sig = carrier * grainWindow;

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

}.play;
)

s.freqscope;

// 1.5.3.2.) integrator with Sweep

(
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;
	Latch.ar(sampleCount, trig);
};

var rampSubSample = { |trig, slope, subSampleOffset|
	var hasTriggered = PulseCount.ar(trig) > 0;
	var accum = Sweep.ar(trig, slope * SampleRate.ir) * hasTriggered;
	accum + (slope * subSampleOffset);
};

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

{
	var triggerFreq, eventPhase, eventTrigger, eventSlope;
	var subSampleOffset, accumulator;
	var windowSlope, windowPhase, grainWindow;
	var grainSlope, grainPhase, carrier, sig;

	triggerFreq = \triggerFreq.kr(1043);
	//triggerFreq = s.sampleRate / 40;

	eventPhase = Phasor.ar(DC.ar(0), triggerFreq * SampleDur.ir);
	eventTrigger = rampToTrig.(eventPhase);
	eventSlope = rampToSlope.(eventPhase);

	subSampleOffset = getSubSampleOffset.(eventPhase, eventSlope, eventTrigger);

	windowSlope = eventSlope / max(0.001, \overlap.kr(1));
	windowPhase = rampSubSample.(eventTrigger, windowSlope, subSampleOffset).clip(0, 1);
	grainWindow = 1 - cos(windowPhase * 2pi) * 0.5;

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

	carrier = BufRd.ar(1, ~sndBuf, grainPhase * BufFrames.kr(~sndBuf), 1, 4);

	sig = carrier * grainWindow;

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

}.play;
)

s.freqscope;

// 1.5.3.3.) trigger based scheduling with GrainBuf

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

{
	var triggerFreq, trig, sig;

	triggerFreq = \triggerFreq.kr(1043);
	//triggerFreq = s.sampleRate / 40;

	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.6.) modulation of the trigger frequency

// problem in two parts:
// - the scheduling phasor has to be linear even when beeing modulated
// - the round-robin method is not suitable for overlapping grains with durations of unequal lengths

// The rate changes have to be 100% in sync with the wrap of your scheduling phasor
// e.g. only apply a new rate value when a phase cycle completes.

// a rate change in the current cycle leads to a discontinuity in slope,
// which entirely messes up the distribution of our events.

// The phasors wrap should determine when its time to pick a new rate value for its next cycle
// and not the upstream modulation!
// e.g. sample and hold the phasors rate with a trigger derived from its wrap in a single sample feedback loop.

// 1.6.1.) modulating the trigger frequency of Phasor

// - the ramp signal of the scheduling phasor gets bended / curved / shaped / distorted
// - the derived slope is sampled and hold per derived trigger (picks up one slope in the beginning of the cycle)
// - the accumulated ramp is not reaching 1 and the stateless window function is truncated

(
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;
	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;
};

{
	var tFreqMod, tFreq;
	var eventPhase, eventTrigger, eventSlope;
	var subSampleOffset, accumulator;
	var windowSlope, windowPhase, grainWindow;

	tFreqMod = SinOsc.ar(10, 1.5pi);
	tFreq = \tFreq.kr(200) * (2 ** (tFreqMod * \modDepth.kr(2)));

	eventPhase = Phasor.ar(DC.ar(0), tFreq * SampleDur.ir);
	eventTrigger = rampToTrig.(eventPhase);
	eventSlope = rampToSlope.(eventPhase);

	subSampleOffset = getSubSampleOffset.(eventPhase, eventSlope, eventTrigger);
	accumulator = accumulatorSubSample.(eventTrigger, subSampleOffset);

	windowSlope = Latch.ar(eventSlope, eventTrigger) / max(0.001, \overlap.kr(1));
	windowPhase = (windowSlope * accumulator).clip(0, 1);
	grainWindow = 1 - cos(windowPhase * 2pi) * 0.5;

	[eventPhase, windowPhase, grainWindow];

}.plot(0.041);
)

// 1.6.2.) modulating the trigger frequency of VariableRamp from Oversampling Oscillators by Sam Pluta

// - sample and holds the slope value for each cycle
// - the ramp signal of the scheduling phasor is linear
// - the accumulated ramp is reaching 1 and the stateless window function is correct.

(
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;
	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;
};

{
	var tFreqMod, tFreq;
	var eventPhase, eventTrigger, eventSlope;
	var subSampleOffset, accumulator;
	var windowSlope, windowPhase, grainWindow;

	tFreqMod = SinOsc.ar(10, 1.5pi);
	tFreq = \tFreq.kr(200) * (2 ** (tFreqMod * \modDepth.kr(2)));

	eventPhase = VariableRamp.ar(tFreq);
	eventSlope = rampToSlope.(eventPhase);

	eventPhase = Delay1.ar(eventPhase); // we have to add Delay1, after the slope calculation
	eventTrigger = rampToTrig.(eventPhase);

	subSampleOffset = getSubSampleOffset.(eventPhase, eventSlope, eventTrigger);
	accumulator = accumulatorSubSample.(eventTrigger, subSampleOffset);

	windowSlope = Latch.ar(eventSlope, eventTrigger) / max(0.001, \overlap.kr(1));
	windowPhase = (windowSlope * accumulator).clip(0, 1);
	grainWindow = 1 - cos(windowPhase * 2pi) * 0.5;

	[eventPhase, windowPhase, grainWindow];

}.plot(0.041);
)

//  1.6.3.) without Delay1 after slope calculation and mod indices bigger 2

(
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 getSubSampleOffset = { |phase, slope, trig|
	var sampleCount = phase / 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;
};

x = {

	var rateMod, rate;
	var eventPhase, eventSlope, eventTrigger;
	var subSampleOffset, accumulator;
	var windowSlope, windowPhase;

	rateMod = 2 ** (SinOsc.ar(50) * \index.kr(2));
	rate = 200 * rateMod;

	eventPhase = VariableRamp.ar(rate);
	eventSlope = rampToSlope.(eventPhase);

	eventPhase = Delay1.ar(eventPhase);
	eventTrigger = rampToTrig.(eventPhase);

	subSampleOffset = getSubSampleOffset.(eventPhase, eventSlope, eventTrigger);
	accumulator = accumulatorSubSample.(eventTrigger, subSampleOffset);

	windowSlope = Latch.ar(eventSlope, eventTrigger) / max(0.001, \overlap.kr(1));
	windowPhase = (windowSlope * accumulator).clip(0, 1);
	windowPhase = windowPhase.wrap(0, 1);

	[eventPhase, windowPhase, eventTrigger];
};

~zoomIn.(x, 3);
)

(
~zoomIn = { |func, n|
	func.loadToFloatArray(0.021, action: { |array|
		var d, u;
		{
			d = array.as(Array).clump(n).flop; // split into n arrays
			u = ScaledUserViewContainer(nil, Rect(50, 400, 490, 400));
			u.maxZoom = 30; // set higher if you want more zoom range
			u.unscaledDrawFunc = { |view|
				d.do({ |item, i|
					var col = [Color.red, Color.blue, Color.gray][i];
					Pen.color = col;
					Pen.moveTo(0 @ item[0]);
					item.do({ |val, ind|
						var x = ind / item.size;
						var y = (1 - val) ;
						Pen.lineTo(view.translateScale(Point(x, y)));
					});
					Pen.stroke;
				});
			};
		}.defer // defer gui process
	});
};
)
4 Likes