hey, im really looking forward to our little pulsar night next wednesday
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
});
};
)