NOTAM Meetups: Spring 2025

New year, new me(etup dates)!! As always, facilitated by our friends at Notam, the online meetings will take place approximately once a month on Zoom at 7pm CET (Oslo Time):

SuperCollider meetup:
Meeting ID: 974 3258 0111
Link: Launch Meeting - Zoom

At these meetups, SuperCollider users (usually 2 per meetup) present a project, class library, instrument, or artistic practice featuring our favourite audio programming environment. These presentations are informal, vary in their format, and are intended to showcase the diversity and flexibility of expression our beloved SC permits.

If you’re interested in presenting a project/workflow/tool/whatever at one of the Meetups, send me a DM and I’ll find a slot for you - absolutely everyone is welcome to share!

All community events at Notam fall under the NOTAM Code of Conduct to make them as inclusive as possible. Please follow read the full Code of Conduct before joining an event. If you have accessibility related requests or questions about the meetup, please send me a message and I’ll do what I can to address them!

In the week before each meetup I’ll return here to present info about the forthcoming presenters, so be sure to follow this thread via the :bell: on the right!

The meetup dates for spring 2025 will be:

2025-04-02T17:00:00Z
2025-04-30T17:00:00Z
2025-05-28T17:00:00Z
2025-06-18T17:00:00Z

Looking forward to see you soon!!

(EDIT: dates changed on 04.05.25)

7 Likes

march 05 instead of feb 05? :slight_smile:

Thank you, changed now! :slight_smile:

1 Like

Just thought I’d send a quick note to inform that I’ve rescheduled tomorrow’s planned meetup; I was having some trouble finding presenters, as I think a lot of users are preparing for the upcoming symposium…so I decided to move the meetup to June 18!

Looking forward to see some of you at the symposium, and we’ll meet again online on April 2nd!

1 Like

Hello again - I hope everyone is well! Dropping in to remind you about our first Meetup of the spring, happening on 2025-04-02T17:00:00Z! Our first presenter is @Sergio_Luque - looking forward to see many of you there!

Sergio Luque is a composer of instrumental and electroacoustic music, and a researcher in computer music. He lives in Madrid, where he directs the Master’s program in Electroacoustic Composition at the Katarina Gurska Higher Education Center and curates the VANG new music festival at the Cybele Palace. He is also a guest lecturer at the Royal Conservatoire in The Hague.

He holds a PhD in Musical Composition from the University of Birmingham. His research focused on extending Iannis Xenakis’s stochastic synthesis, developing methods for algorithmic composition, and implementing the BEASTmulch software—a tool designed for presenting electroacoustic music in multichannel systems. He earned a Master’s degree with distinction in Sonology from the Royal Conservatoire in The Hague and a Master’s in Composition from the Rotterdam Conservatoire.

/=========/

SuperCollider has been integral to my work for almost 25 years, and I have been teaching it since 2004. I use it to compose instrumental and electroacoustic music with the help of stochastic and deterministic algorithms. In this presentation, I will talk about my experience with SuperCollider, how I use it in my work, and share sound examples.

In my instrumental music, I explore chords with intervallic relationships at both ends of the consonance/dissonance continuum, combinations of pitches with internal consonances and interferences. These chords, their connections, and their unfolding over time are created using algorithmic methods in SuperCollider, guided by an intuitive, ear-driven process.

For my electroacoustic pieces, I employ non-standard synthesis techniques, primarily based on my extensions to Xenakis’s stochastic algorithms, which I have been developing for over 20 years in SuperCollider and C.

5 Likes

And our second presenter on Wednesday will be @skmecs!

Gil Fuser is an artist exploring the intersection of art and technology, crafting open-ended meanings. His work blends sensors, cameras, everyday objects, discarded materials, plants, fungi, and computers running open-source and custom software. He creates generative and live-coded music and visuals for solo and collaborative performances, along with interactive objects, instruments, and installations.

From 2014 to 2017, he studied Generative Art / Computational Art at UdK (Berlin University of the Arts) in Germany. Based in São Paulo, he collaborates with artists worldwide and engages with local and online communities focused on livecoding and generative art, including Algorave Brasil and Roda de Código.

/=========/

I’ll share how SuperCollider has been my primary tool and medium for the past ten years. I’ll discuss fostering the art collective and study group, Roda de Código, in SĆ£o Paulo, focusing on livecoding and generative art, and how to introduce SuperCollider in that context. We’ll explore projects such as the Band of Plants, which translates plant stimuli into sound, and the Algomagical Box of Follies, designed to recombine rhythms inspired by Brazilian folk festivities. Additionally, I’ll discuss the Maritrola, a gadget merging functionalities of a mixer, turntable, and music selector for children experiment scratching. We’ll also delve into Active Listening, an audio-visual instrument developed for deaf students to facilitate musical engagement through visual feedback and gestural interactions. Furthermore, I’ll present Liebesbrunnen, an audiovisual instrument that shows how love looks and sounds inside of us. Most of those works can be found in gilfuser.net

3 Likes

Another meetup is already around the corner! First up next week will be @dietcv!

My name is Jan-Lars, I’m 35 years old and live in Cologne, Germany. Beside my dayjob as a civil engineer for water economy, i have been studying electronic composition at Folkwang University in Essen for a while (from 2021 - 2023) and use Supercollider for about 5 years now. I’m researching synthesis attempts which have a lot of possibilities for timbral transformation and macro controls with the ability to continuously transform sound objects to create expressive musical gestures. After attending a FluCoMa workshop at Stanford University in 2023 I’m using the MLP Regressor for preset interpolation and have worked on a GUI which is based on NodeProxyGui2.

A lot of times formal structures are imbedded in the musical material itself or in the tools which are being used to shape the material. Im studying musical material and their possibilities for timbral transformation and then adjusting or extending the synthesis attempts I’m working with. One synthesis attempt I’m researching for some years now is Pulsar Synthesis.

In my presentation we will explore phasor based scheduling for sub-sample accurate granulation in Supercollider (a technique ive learned from the book Generating Sound & Organizing Time). I will show two sequencing attempts (one exclusively on the server and the other as a hybrid approach between language and server). Both of these attempts are using continuous, linear ramps between 0 and 1, from which you can derive all sorts of useful information like slopes, triggers, durations and sub-sample offsets. These ramps can be subdivided by non-integer ratios and can be modulated without truncation or distortion of your grain windows. We will look at the basic building blocks for granular synthesis, so you could build your own custom granular synthesis instrument in Supercollider with possibilities for extended modulation and anti-aliasing.

Some of the building blocks we are using can be found in these two libraries i have put together:

We are additionally using the VariableRamp and OscOS Ugens from the Oversampling Oscillators by @Sam_Pluta:

7 Likes

It’s gonna be a pulsar synthesis fest on Wednesday; our second presenter is @marcin_pietruszewski!

Media Archaeology and SuperCollider

Marcin Pietruszewski, composer and researcher, presents his work on the New Pulsar Generator (nuPG) at an upcoming online SuperCollider user meet-up. The presentation focuses on nuPG as a custom synthesis environment based on pulsar synthesis, developed through research into media archaeology, media specificity, and composition. Rather than treating nuPG as a neutral tool, Pietruszewski frames it as a conceptual instrument that exposes the temporal and material conditions of digital sound production.Drawing from media-archaeological perspectives, the presentation investigates how nuPG reimagines Curtis Roads’ pulsar synthesis model, emphasizing micro-temporal structures and the compositional use of overlapping pulse trains. Pietruszewski explores how nuPG operates as both a software instrument and a site of aesthetic inquiry, foregrounding the relationship between synthesis techniques and the historical contexts of their implementation.The session includes audio examples, code demonstrations, and discussion on the design philosophy behind nuPG. It offers insight into the intersection of compositional practice and software development, showing how the specificities of media shape musical thinking. By positioning the composer as both theorist and tool-maker, Pietruszewski challenges conventional approaches to synthesis and highlights the critical role of software in shaping contemporary sonic art.

BIO

Marcin Pietruszewski (1984, Poland) is a composer and researcher. He is engaged in sound synthesis and composition with computers, exploring specific formal developments in the tradition of electroacoustic music and contemporary sound art. He works across composition, pluriphonic installations, and radio productions. Recurring interests of his practice include synthetic sound, algorithmic systems, and the integration of scientific formalisms as compositional materials. Works exhibited at Remote Viewing (Philadelphia, 2019) and the Institute of Contemporary Arts (London, 2017). Commissions by Sonic Acts (2024), CTM Festival (2021), ZKM Karlsruhe (2018) and Deutschlandradio Kultur (2016). Collaborations include a sound installation, ā€˜Auditory Scene Resynthesis as Cochlear Wavepackets’ (HKW, Berlin, 2021) with Jan St. Werner; NORMIFICATION with Florian Hecker. A recent sound installation - ā€˜Love Numbers’ - with Anthea Caddy, premiered at Venice Music Biennale 2023. Marcin has experience as an educator, designing and delivering courses focused on digital instrument design, digital signal processing, sound theory, and practice. He has taught at The Reid School of Music (Edinburgh College of Art, UK) and Design Informatics (The University of Edinburgh, UK). He also writes on issues related to computer music histories, aesthetics, and technology. His texts have been published by Hatje Cantz and ZKM among others. Currently, Marcin holds the position of Fellow in Artistic Research in Acoustic Ecologies and Sound Studies, Department of Media Art and Design, Bauhaus University in Weimar, Germany. Marcin lives and works in Berlin

LINKS:

www.marcinpietruszewski.com

3 Likes

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
	});
};
)
3 Likes

phasor based sub-sample accurate granulation:

// Granular Synthesis is a type of Amplitude Modulational Synthesis.
// Think about applying a window function like an envelope to a carrier on a micro time scale.

// There are three types of granular synthesis:
// 1.) carrier is a single cycle waveform (e.g. pulsar, glisson, trainlet synthesis etc.)
// 2.) carrier is an audio file (e.g. buffer granulation)
// 3.) carrier is an arbitrary input signal, needs a circular buffer (e.g. grain delay)

// 1.1.) stateless window functions

// - stateless functions can be modulated at audio rate
// - the output of a stateless function only depends on the instantaneous input of the ramp signal
// - the ramp signal has to be linear and between 0 and 1

// the most basic granular window - the hanning window
// It is symmetrical and has a continious slope -
// perfect for overlapping grains without introducing amplitude modulational artifacts.

(
var triangle = { |phase, skew|
	var warpedPhase = Select.ar(BinaryOpUGen('>', phase, skew), [
		phase / skew,
		1 - ((phase - skew) / (1 - skew))
	]);
	Select.ar(BinaryOpUGen('==', skew, 0), [warpedPhase, 1 - phase]);
};

var unitHanning = { |phase|
	1 - cos(phase * pi) * 0.5;
};

var unitGaussian = { |phase, index|
	var cosine = cos(phase * 0.5pi) * index;
	exp(cosine * cosine.neg);
};

{
	var skew = \skew.kr(0.5);
	var phase = Phasor.ar(DC.ar(0), 50 * SampleDur.ir);
	var warpedPhase = triangle.(phase, skew);
	var hanning = unitHanning.(warpedPhase, skew);
	var gaussian = unitGaussian.(warpedPhase, \index.kr(2)) * hanning;
	[phase, warpedPhase];
	//[phase, warpedPhase, hanning];
	//[phase, warpedPhase, gaussian];
}.plot(0.021);
)

// 1.2.) sub-sample accurate granulation

// 1.2.1.) granulation starts with hard sync

// - the carrier waveform needs a phase reset
// - the carrier gets reset by the trigger derived from the scheduling phasor (hard sync)
// - to smooth out the phase reset a window function is applied to the carrier (windowed sync)

(
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 triangle = { |phase, skew|
	var warpedPhase = Select.ar(BinaryOpUGen('>', phase, skew), [
		phase / skew,
		1 - ((phase - skew) / (1 - skew))
	]);
	Select.ar(BinaryOpUGen('==', skew, 0), [warpedPhase, 1 - phase]);
};

var unitHanning = { |phase|
	1 - cos(phase * pi) * 0.5;
};

var hanningWindow = { |phase, skew|
	var warpedPhase = triangle.(phase, skew);
	unitHanning.(warpedPhase);
};

{
	var eventPhase, eventSlope, eventTrigger;
	var subSampleOffset, accumulator;
	var windowSlope, windowPhase, grainWindow;
	var grainFreq, grainSlope, grainPhase;
	var carrier, grain;

	eventPhase = VariableRamp.ar(\tFreq.kr(100));
	eventSlope = rampToSlope.(eventPhase);

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

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

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

	grainFreq = \freq.kr(440);
	grainSlope = grainFreq * SampleDur.ir;
	grainPhase = (grainSlope * accumulator).wrap(0, 1);

	carrier = sin(grainPhase * 2pi);

	grain = carrier * grainWindow;

	[windowPhase.wrap(0, 1), eventTrigger, grainPhase, carrier, grain];
}.plot(0.021);
)

// 1.2.2.) compare hard sync with no hard sync

(
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 triangle = { |phase, skew|
	var warpedPhase = Select.ar(BinaryOpUGen('>', phase, skew), [
		phase / skew,
		1 - ((phase - skew) / (1 - skew))
	]);
	Select.ar(BinaryOpUGen('==', skew, 0), [warpedPhase, 1 - phase]);
};

var unitHanning = { |phase|
	1 - cos(phase * pi) * 0.5;
};

var hanningWindow = { |phase, skew|
	var warpedPhase = triangle.(phase, skew);
	unitHanning.(warpedPhase);
};

{
	var eventPhase, eventSlope, eventTrigger;
	var subSampleOffset, accumulator;
	var windowSlope, windowPhase, grainWindow;
	var grainFreq, grainSlope, grainPhase;
	var carrier, grain;

	eventPhase = VariableRamp.ar(\tFreq.kr(100));
	eventSlope = rampToSlope.(eventPhase);

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

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

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

	grainFreq = \freq.kr(440);
	grainSlope = grainFreq * SampleDur.ir;
	grainPhase = (grainSlope * accumulator).wrap(0, 1);

	carrier = sin(grainPhase * 2pi);
	//carrier = SinOsc.ar(grainFreq);

	grain = carrier * grainWindow;

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

}.play;
)

s.freqscope;

// 1.2.3.)  grain duration depending on grain frequency

// - the grain duration can depent on the grain frequency instead of the trigger frequency
// - high grain frequencies then lead to short grain durations and vice versa
// - This creates a cheap physical modelling effect!

// We can still add an overlap factor to adjust the grain duration to compensate for that effect

// There are two design problems with this approach:
// 1.) There is no fixed maximum overlap value
// The maximum overlap possible depents on the ratio between the grain frequency and the trigger frequency.

// 2.) You barely get overlapping grains
// The grain frequency is often multiple times higher then the trigger frequency,
// so the grain durations are really short and the grains dont overlap.
// This is not very versatile and mostly interesting for ratchets.

// 1.2.3.1.) the plot

(
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 triangle = { |phase, skew|
	var warpedPhase = Select.ar(BinaryOpUGen('>', phase, skew), [
		phase / skew,
		1 - ((phase - skew) / (1 - skew))
	]);
	Select.ar(BinaryOpUGen('==', skew, 0), [warpedPhase, 1 - phase]);
};

var unitHanning = { |phase|
	1 - cos(phase * pi) * 0.5;
};

var hanningWindow = { |phase, skew|
	var warpedPhase = triangle.(phase, skew);
	unitHanning.(warpedPhase);
};

{
	var eventPhase, eventSlope, eventTrigger;
	var subSampleOffset, accumulator, overlap, maxOverlap;
	var windowSlope, windowPhase, grainWindow;
	var grainFreq, grainSlope, grainPhase;
	var carrier, grain;

	eventPhase = VariableRamp.ar(\tFreq.kr(50));
	eventSlope = rampToSlope.(eventPhase);

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

	grainFreq = \freq.ar(400);
	grainSlope = grainFreq * SampleDur.ir;

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

	overlap = \overlap.kr(4);
	maxOverlap = min(overlap, Latch.ar(grainSlope / eventSlope, eventTrigger));

	windowSlope = Latch.ar(grainSlope, eventTrigger) / max(0.001, maxOverlap);
	windowPhase = (windowSlope * accumulator).clip(0, 1);
	grainWindow = hanningWindow.(windowPhase, \skew.kr(0.5));

	grainPhase = (grainSlope * accumulator).wrap(0, 1);
	carrier = sin(grainPhase * 2pi);

	grain = carrier * grainWindow;

	[windowPhase.wrap(0, 1), grainWindow, carrier, grain];
}.plot(0.04);
)

// 1.2.3.2) listen to pulsar ratchets

(
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;
	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 multiChannelAccumulator = { |triggers, subSampleOffsets|
	triggers.collect{ |localTrig, i|
		accumulatorSubSample.(localTrig, subSampleOffsets[i]);
	};
};

var triangle = { |phase, skew|
	var warpedPhase = Select.ar(BinaryOpUGen('>', phase, skew), [
		phase / skew,
		1 - ((phase - skew) / (1 - skew))
	]);
	Select.ar(BinaryOpUGen('==', skew, 0), [warpedPhase, 1 - phase]);
};

var unitHanning = { |phase|
	1 - cos(phase * pi) * 0.5;
};

var hanningWindow = { |phase, skew|
	var warpedPhase = triangle.(phase, skew);
	unitHanning.(warpedPhase);
};

var easingToLinear = { |x, shape, easingFunc|
	var mix = shape * 2;
	easingFunc * (1 - mix) + (x * mix);
};

var linearToEasing = { |x, shape, easingFunc|
	var mix = (shape - 0.5) * 2;
	x * (1 - mix) + (easingFunc * mix);
};

var lerpEasing = { |x, shape, easingFuncA, easingFuncB|
	Select.ar(BinaryOpUGen('>', shape, 0.5), [
		easingToLinear.(x, shape, easingFuncA),
		linearToEasing.(x, shape, easingFuncB)
	]);
};

var pseudoExponentialIn = { |x, coef = 13|
	(2 ** (coef * x) - 1) / (2 ** coef - 1)
};

var pseudoExponentialOut = { |x|
	1 - pseudoExponentialIn.(1 - x);
};

var exponentialLerp = { |x, shape|
	var easeOut = pseudoExponentialOut.(x);
	var easeIn = pseudoExponentialIn.(x);
	lerpEasing.(x, shape, easeOut, easeIn);
};

{

	var numChannels = 5;

	var flux, tFreqMod, tFreq;
	var eventPhase, eventTrigger, eventSlope;
	var triggers, subSampleOffsets, accumulator;
	var overlap, maxOverlap;
	var windowSlopes, windowPhases, grainWindows;
	var grainFreq, grainSlope, grainPhases;
	var carriers, grains, sig;

	flux = LFDNoise3.ar(\fluxMF.kr(1));
	flux = 2 ** (flux * \fluxMD.kr(0.5));

	tFreqMod = SinOsc.ar(1, 1.5pi).lincurve(-1, 1, 0.1, 3.0, 2) * SinOsc.ar(0.5, 0.75pi).linlin(-1, 1, 0.1, 1.0);
	tFreq = \tFreq.kr(300) * tFreqMod * flux;

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

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

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

	// distribute triggers round-robin across the channels
	triggers = multiChannelTrigger.(numChannels, eventTrigger);

	// calculate sub-sample offset per multichannel trigger
	subSampleOffsets = getSubSampleOffset.(eventPhase, eventSlope, triggers);

	// create a multichannel accumulator with sub-sample accuracy
	accumulator = multiChannelAccumulator.(triggers, subSampleOffsets);

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

	grainFreq = \freq.ar(1200);
	grainFreq = grainFreq * flux;
	grainSlope = grainFreq * SampleDur.ir;

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

	overlap = \overlap.kr(4);
	maxOverlap = min(overlap, numChannels * Latch.ar(grainSlope / eventSlope, triggers));

	windowSlopes = Latch.ar(grainSlope, triggers) / max(0.001, maxOverlap);
	windowPhases = (windowSlopes * accumulator).clip(0, 1);
	grainWindows = hanningWindow.(windowPhases, \skew.kr(0.01));

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

	grainPhases = exponentialLerp.(windowPhases, \shape.kr(0.03));
	grainPhases = (grainPhases * maxOverlap).wrap(0, 1);

	carriers = sin(grainPhases * 2pi);
	grains = carriers * grainWindows;

	grains = PanAz.ar(2, grains, \pan.kr(0));
	sig = grains.sum;

	sig!2 * 0.1;

}.play;
)

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

// 1.3.) multichannel expansion / polyphony (the round-robin method)

// 1.3.1.) handle grain durations longer then the trigger period

// if the current grain duration is longer then the trigger period the ramp doesnt reach 1
// and therefore doesnt read through the entire window function
// we have to setup multichannel expansion / polyphony
// e.g. to play a three note chord on the piano you need three fingers,
// to play three grains simultaneously you need three channels!

(
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 triangle = { |phase, skew|
	var warpedPhase = Select.ar(BinaryOpUGen('>', phase, skew), [
		phase / skew,
		1 - ((phase - skew) / (1 - skew))
	]);
	Select.ar(BinaryOpUGen('==', skew, 0), [warpedPhase, 1 - phase]);
};

var unitHanning = { |phase|
	1 - cos(phase * pi) * 0.5;
};

var hanningWindow = { |phase, skew|
	var warpedPhase = triangle.(phase, skew);
	unitHanning.(warpedPhase);
};

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

	eventPhase = VariableRamp.ar(\tFreq.kr(50));
	eventSlope = rampToSlope.(eventPhase);

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

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

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

	[windowPhase.wrap(0, 1), grainWindow];
}.plot(0.041);
)

// 1.3.2.) overlap grains when the grain duration is longer then the trigger period

// create a multichannel trigger based on PulseDivider for overlapping grains (round-robin method)
// the round-robin method increments a counter by 1 when receiving a trigger
// and distributes the next event to next channel and wraps around at the last channel

// - the next grain can already start before the current one has finished without truncation
// - the maximum polyphony / overlap is specified by numChannels (fixed with SynthDef evaluation)

(
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 multiChannelTrigger = { |numChannels, trig|
	var count = Demand.ar(trig, DC.ar(0), Dseries(0, 1));
	numChannels.collect{ |chan|
		trig * BinaryOpUGen('==', (count + (numChannels - 1 - chan) + 1) % numChannels, 0);
	};
};
*/
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 multiChannelAccumulator = { |triggers, subSampleOffsets|
	triggers.collect{ |localTrig, i|
		accumulatorSubSample.(localTrig, subSampleOffsets[i]);
	};
};

var triangle = { |phase, skew|
	var warpedPhase = Select.ar(BinaryOpUGen('>', phase, skew), [
		phase / skew,
		1 - ((phase - skew) / (1 - skew))
	]);
	Select.ar(BinaryOpUGen('==', skew, 0), [warpedPhase, 1 - phase]);
};

var unitHanning = { |phase|
	1 - cos(phase * pi) * 0.5;
};

var hanningWindow = { |phase, skew|
	var warpedPhase = triangle.(phase, skew);
	unitHanning.(warpedPhase);
};

{
	var numChannels = 5;

	var tFreqMod, modDepth, tFreq;
	var eventPhase, eventTrigger, eventSlope;
	var triggers, subSampleOffsets, accumulator, overlap, maxOverlap;
	var windowSlopes, windowPhases, grainWindows;

	tFreq = \tFreq.kr(500);

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

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

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

	// distribute triggers round-robin across the channels
	triggers = multiChannelTrigger.(numChannels, eventTrigger);

	// calculate sub-sample offset per multichannel trigger
	subSampleOffsets = getSubSampleOffset.(eventPhase, eventSlope, triggers);

	// create a multichannel accumulator with sub-sample accuracy
	accumulator = multiChannelAccumulator.(triggers, subSampleOffsets);

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

	overlap = \overlap.ar(1);
	maxOverlap = min(overlap, numChannels); // maximum polyphony

	windowSlopes = Latch.ar(eventSlope, triggers) / max(0.001, Latch.ar(maxOverlap, triggers));
	windowPhases = (windowSlopes * accumulator).clip(0, 1);
	grainWindows = hanningWindow.(windowPhases, \skew.kr(0.5));

}.plot(0.021);
)

// 1.3.3.) overlapping grains with durations of unequal lengths

// the round-robin method doesnt know about the state of its channels (busy or not)
// if the grains are of unequal lenghts e.g modulation of trigger frequency,
// we cant know about the maximum possible overlap per channel in advance

// We can additionally multiply maxOverlap with 2 ** modDepth.neg
// This makes sure grains of unequal length (modulation of trigger frequency) doesnt cause any truncation
// The tradeoff however is that the maximum possible overlap is small for high modulational indices.

(
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;
	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 multiChannelAccumulator = { |triggers, subSampleOffsets|
	triggers.collect{ |localTrig, i|
		accumulatorSubSample.(localTrig, subSampleOffsets[i]);
	};
};

var triangle = { |phase, skew|
	var warpedPhase = Select.ar(BinaryOpUGen('>', phase, skew), [
		phase / skew,
		1 - ((phase - skew) / (1 - skew))
	]);
	Select.ar(BinaryOpUGen('==', skew, 0), [warpedPhase, 1 - phase]);
};

var unitHanning = { |phase|
	1 - cos(phase * pi) * 0.5;
};

var hanningWindow = { |phase, skew|
	var warpedPhase = triangle.(phase, skew);
	unitHanning.(warpedPhase);
};

{
	var numChannels = 5;

	var tFreqMod, modDepth, tFreq;
	var eventPhase, eventTrigger, eventSlope;
	var triggers, subSampleOffsets, accumulator, overlap, maxOverlap;
	var windowSlopes, windowPhases, grainWindows;

	modDepth = \modMD.kr(1);
	tFreqMod = 2 ** (SinOsc.ar(\modMF.kr(50)) * modDepth);
	tFreq = \tFreq.kr(500) * tFreqMod;

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

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

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

	// distribute triggers round-robin across the channels
	triggers = multiChannelTrigger.(numChannels, eventTrigger);

	// calculate sub-sample offset per multichannel trigger
	subSampleOffsets = getSubSampleOffset.(eventPhase, eventSlope, triggers);

	// create a multichannel accumulator with sub-sample accuracy
	accumulator = multiChannelAccumulator.(triggers, subSampleOffsets);

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

	overlap = \overlap.ar(1);
	maxOverlap = min(overlap, 2 ** modDepth.neg * numChannels); // maxOverlap tradeoff

	windowSlopes = Latch.ar(eventSlope, triggers) / max(0.001, Latch.ar(maxOverlap, triggers));
	windowPhases = (windowSlopes * accumulator).clip(0, 1);
	grainWindows = hanningWindow.(windowPhases, \skew.kr(0.5));

}.plot(0.021);
)

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

// 1.4.) extending the setup

// - exchange carrier or window for BufRd, OscOS or stateless window functions etc.
// - add sequencing and modulation
// - add Frequency Modulation, Phase Modulation, Phase Shaping etc.
// - add Panning
// - add multichannel fx

(
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;
	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 multiChannelAccumulator = { |triggers, subSampleOffsets|
	triggers.collect{ |localTrig, i|
		accumulatorSubSample.(localTrig, subSampleOffsets[i]);
	};
};

var triangle = { |phase, skew|
	var warpedPhase = Select.ar(BinaryOpUGen('>', phase, skew), [
		phase / skew,
		1 - ((phase - skew) / (1 - skew))
	]);
	Select.ar(BinaryOpUGen('==', skew, 0), [warpedPhase, 1 - phase]);
};

var unitHanning = { |phase|
	1 - cos(phase * pi) * 0.5;
};

var hanningWindow = { |phase, skew|
	var warpedPhase = triangle.(phase, skew);
	unitHanning.(warpedPhase);
};

var multiChannelDwhite = { |triggers|
	var demand = Dwhite(-1.0, 1.0);
	triggers.collect{ |localTrig|
		Demand.ar(localTrig, DC.ar(0), demand)
	};
};

{
	var numChannels = 5;

	var tFreqMod, modDepth, tFreq;
	var eventPhase, eventTrigger, eventSlope;
	var triggers, subSampleOffsets, accumulator, overlap, maxOverlap;
	var windowSlopes, windowPhases, grainWindows;
	var grainFreqMod, grainFreqs, grainSlopes, grainPhases, sigs;

	modDepth = \modMD.kr(0);
	tFreqMod = 2 ** (SinOsc.ar(\modMF.kr(50)) * modDepth);
	tFreq = \tFreq.kr(200) * tFreqMod;

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

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

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

	// distribute triggers round-robin across the channels
	triggers = multiChannelTrigger.(numChannels, eventTrigger);

	// calculate sub-sample offset per multichannel trigger
	subSampleOffsets = getSubSampleOffset.(eventPhase, eventSlope, triggers);

	// create a multichannel accumulator with sub-sample accuracy
	accumulator = multiChannelAccumulator.(triggers, subSampleOffsets);

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

	overlap = \overlap.ar(3);
	maxOverlap = min(overlap, 2 ** modDepth.neg * numChannels);

	windowSlopes = Latch.ar(eventSlope, triggers) / max(0.001, Latch.ar(maxOverlap, triggers));
	windowPhases = (windowSlopes * accumulator).clip(0, 1);
	grainWindows = hanningWindow.(windowPhases, \skew.kr(0.5));

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

	grainFreqMod = multiChannelDwhite.(triggers);
	grainFreqs = \freq.kr(800) * (2 ** (grainFreqMod * \freqMD.kr(2)));
	grainSlopes = grainFreqs * SampleDur.ir;
	grainPhases = (grainSlopes * accumulator).wrap(0, 1);

	sigs = sin(grainPhases * 2pi);
	sigs * grainWindows;

}.plot(0.041);
)

(
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;
	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 multiChannelAccumulator = { |triggers, subSampleOffsets|
	triggers.collect{ |localTrig, i|
		accumulatorSubSample.(localTrig, subSampleOffsets[i]);
	};
};

var triangle = { |phase, skew|
	var warpedPhase = Select.ar(BinaryOpUGen('>', phase, skew), [
		phase / skew,
		1 - ((phase - skew) / (1 - skew))
	]);
	Select.ar(BinaryOpUGen('==', skew, 0), [warpedPhase, 1 - phase]);
};

var unitHanning = { |phase|
	1 - cos(phase * pi) * 0.5;
};

var hanningWindow = { |phase, skew|
	var warpedPhase = triangle.(phase, skew);
	unitHanning.(warpedPhase);
};

var multiChannelDwhite = { |triggers|
	var demand = Dwhite(-1.0, 1.0);
	triggers.collect{ |localTrig|
		Demand.ar(localTrig, DC.ar(0), demand)
	};
};

{
	var numChannels = 5;

	var tFreqMod, modDepth, tFreq;
	var eventPhase, eventTrigger, eventSlope;
	var triggers, subSampleOffsets, accumulator, overlap, maxOverlap;
	var windowSlopes, windowPhases, grainWindows;
	var grainFreqMod, grainFreqs, grainSlopes, grainPhases;
	var sigs, sig;

	modDepth = \modMD.kr(2);
	tFreqMod = 2 ** (LFDNoise3.ar(\modMF.kr(0.3)) * modDepth);
	tFreq = \tFreq.kr(10) * tFreqMod;

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

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

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

	// distribute triggers round-robin across the channels
	triggers = multiChannelTrigger.(numChannels, eventTrigger);

	// calculate sub-sample offset per multichannel trigger
	subSampleOffsets = getSubSampleOffset.(eventPhase, eventSlope, triggers);

	// create a multichannel accumulator with sub-sample accuracy
	accumulator = multiChannelAccumulator.(triggers, subSampleOffsets);

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

	overlap = \overlap.ar(4);
	maxOverlap = min(overlap, 2 ** modDepth.neg * numChannels);

	windowSlopes = Latch.ar(eventSlope, triggers) / max(0.001, Latch.ar(maxOverlap, triggers));
	windowPhases = (windowSlopes * accumulator).clip(0, 1);
	grainWindows = hanningWindow.(windowPhases, LFDNoise3.ar(0.3).linlin(-1, 1, 0.01, 0.99));

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

	grainFreqMod = multiChannelDwhite.(triggers);
	grainFreqs = \freq.kr(440) * (2 ** (grainFreqMod * \freqMD.kr(2)));
	grainSlopes = grainFreqs * SampleDur.ir;
	grainPhases = (grainSlopes * accumulator).wrap(0, 1);

	sigs = sin(grainPhases * 2pi);
	sigs = sigs * grainWindows;

	sigs = PanAz.ar(2, sigs, \pan.kr(0));
	sig = sigs.sum;

	sig!2 * 0.1;

}.play;
)


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

// 1.5.) phase shaping vs. FM

(
var eventData = { |rate|

	var eventPhase, eventSlope, eventTrigger;

	eventPhase = VariableRamp.ar(rate);
	eventSlope = ~grainFunctions.helperFunctions[\rampToSlope].(eventPhase);

	eventPhase = Delay1.ar(eventPhase);
	eventTrigger = ~grainFunctions.helperFunctions[\rampToTrig].(eventPhase);

	(
		phase: eventPhase,
		slope: eventSlope,
		trigger: eventTrigger
	);

};

{
	var tFreq, events;
	var subSampleOffset, accumulator;
	var windowSlope, windowPhase, grainWindow;
	var grainFreq, grainFreqMD, grainSlope, grainPhaseA, grainPhaseB;
	var freqWindow, grainFreqA, grainFreqB;

	tFreq = 100;
	events = eventData.(tFreq);

	subSampleOffset = ~grainFunctions.helperFunctions[\subSampleOffset].(
		events[\phase],
		events[\slope],
		events[\trigger]
	);

	accumulator = ~grainFunctions.helperFunctions[\accumSubSample].(
		trig: events[\trigger],
		subSampleOffset: subSampleOffset
	);

	windowSlope = events[\slope] / max(0.001, \overlap.kr(1.0));
	windowPhase = (windowSlope * accumulator).clip(0, 1);
	grainWindow = windowPhase < 1;

	grainFreq = \freq.kr(400);

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

	freqWindow = ~unitShapers.windowFunctions[\exponential].(
		windowPhase,
		\pitchSkew.kr(0),
		\pitchShape.kr(1)
	);

	grainFreqA = grainFreq * (1 + (freqWindow * \grainFreqMD.kr(14)));
	grainSlope = grainFreqA * SampleDur.ir;

	grainPhaseA = ~grainFunctions.helperFunctions[\rampSubSample].(
		trig: events[\trigger],
		slope: grainSlope,
		subSampleOffset: subSampleOffset
	);
	grainPhaseA = grainPhaseA.wrap(0, 1);

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

	grainPhaseB = ~unitShapers.lerpingFunctions[\exponential].(
		windowPhase,
		\phaseShape.kr(0)
	);
	grainPhaseB = grainPhaseB * (grainFreq / max(0.001, windowSlope * SampleRate.ir));
	grainPhaseB = grainPhaseB.wrap(0, 1);

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

	//[grainPhaseA, grainPhaseB];
	[sin(grainPhaseA * 2pi) * grainWindow, sin(grainPhaseB * 2pi) * grainWindow];

}.plot(0.021);
)
4 Likes

Will this be recorded by any chance? I don’t think I can make it on Wednesday

Unfortunately Notam has decided against recording the meetups - they found that audiences become a little more shy/reluctant to engage and they don’t really have the infrastructure to store and manage all the recordings (they have facilitated around 4 online meetups a month since 2020 or so), also there’s the whole GDPR thing, etc. etc.

Howweeeevvveeeerr - I certainly won’t stop you from sending a message to the presenters to ask for links to relevant research, repositories, or resources (accidental alliteration!) if you think you’ll have to miss the meetup. :slight_smile:

That makes total sense. Thank you for organizing these meetings!

1 Like

Meetup starting in 15 minutes! :slight_smile:

hey, thanks everyone for attending :slight_smile: Unfortunately we have been running out of time, so we couldnt have a look at all the possibilities for extended modulation and listen to some of its crazy sounds.
Here are just a few of them, make sure to have the latest version of the two libraries. Ive just updated them.

These techniques unlock further possibilities for timbral transformation of pulsar synthesis and arent available in any of the exisiting commercial or non-commercial devices. I would encourage you to experiment with those :).

I will write an additional thread with my current pulsar SynthDef, which uses some of these things. Still work in progress :slight_smile:



// 1.5.) FM vs. PM

// 1.5.1.) phase shaping vs. FM with frequency window

(
var eventData = { |rate|

	var eventPhase, eventSlope, eventTrigger;

	eventPhase = VariableRamp.ar(rate);
	eventSlope = ~grainFunctions.helperFunctions[\rampToSlope].(eventPhase);

	eventPhase = Delay1.ar(eventPhase);
	eventTrigger = ~grainFunctions.helperFunctions[\rampToTrig].(eventPhase);

	(
		phase: eventPhase,
		slope: eventSlope,
		trigger: eventTrigger
	);

};

{
	var tFreq, events;
	var subSampleOffset, accumulator;
	var windowSlope, windowPhase, grainWindow;
	var grainFreq, grainFreqMD, grainSlope, grainPhaseA, grainPhaseB;
	var freqWindow, grainFreqA, grainFreqB;
	var sigA, sigB;

	tFreq = 100;
	events = eventData.(tFreq);

	subSampleOffset = ~grainFunctions.helperFunctions[\subSampleOffset].(
		events[\phase],
		events[\slope],
		events[\trigger]
	);

	accumulator = ~grainFunctions.helperFunctions[\accumSubSample].(
		trig: events[\trigger],
		subSampleOffset: subSampleOffset
	);

	windowSlope = events[\slope] / max(0.001, \overlap.kr(1));
	windowPhase = (windowSlope * accumulator).clip(0, 1);
	grainWindow = windowPhase < 1;

	grainFreq = \freq.kr(400);

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

	// FM with "frequency window"

	freqWindow = ~unitShapers.windowFunctions[\exponential].(
		windowPhase,
		\pitchSkew.kr(0),
		\pitchShape.kr(1)
	);

	grainFreqA = grainFreq + (grainFreq * freqWindow * \grainFreqMD.kr(14));
	grainSlope = grainFreqA * SampleDur.ir;

	grainPhaseA = ~grainFunctions.helperFunctions[\rampSubSample].(
		trig: events[\trigger],
		slope: grainSlope,
		subSampleOffset: subSampleOffset
	);
	grainPhaseA = grainPhaseA.wrap(0, 1);

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

	// Phase shaping

	grainPhaseB = ~unitShapers.lerpingFunctions[\exponential].(
		windowPhase,
		\phaseShape.kr(0)
	);
	grainPhaseB = grainPhaseB * (grainFreq / max(0.001, windowSlope * SampleRate.ir));
	grainPhaseB = grainPhaseB.wrap(0, 1);

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

	sigA = sin(grainPhaseA * 2pi) * grainWindow;
	sigB = sin(grainPhaseB * 2pi) * grainWindow;

	//[grainPhaseA, grainPhaseB];
	[sigA, sigB];

}.plot(0.021);
)

// 1.5.2.) PM vs. FM with a tracking OnePole Filter

(
var eventData = { |rate|

	var eventPhase, eventSlope, eventTrigger;

	eventPhase = VariableRamp.ar(rate);
	eventSlope = ~grainFunctions.helperFunctions[\rampToSlope].(eventPhase);

	eventPhase = Delay1.ar(eventPhase);
	eventTrigger = ~grainFunctions.helperFunctions[\rampToTrig].(eventPhase);

	(
		phase: eventPhase,
		slope: eventSlope,
		trigger: eventTrigger
	);

};

{
	var tFreq, events;
	var subSampleOffset, accumulator;
	var windowSlope, windowPhase, grainWindow;
	var grainFreq, grainSlope, pmod, fmod;
	var grainPhasePM_A, grainPhasePM_B, grainPhaseFM;

	tFreq = \tFreq.kr(100);
	events = eventData.(tFreq);

	subSampleOffset = ~grainFunctions.helperFunctions[\subSampleOffset].(
		events[\phase],
		events[\slope],
		events[\trigger]
	);

	accumulator = ~grainFunctions.helperFunctions[\accumSubSample].(
		trig: events[\trigger],
		subSampleOffset: subSampleOffset
	);

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

	windowSlope = Latch.ar(events[\slope] / max(0.001, \overlap.kr(1)), events[\trigger]);
	windowPhase = (windowSlope * accumulator).clip(0, 1);

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

	grainFreq = \freq.ar(400);
	grainSlope = grainFreq * SampleDur.ir;

	// PM & FM with tracking OnePole filter
	pmod = sin(windowPhase * 2pi) * \pmIndex.kr(2);
	pmod = pmod / 2pi * Latch.ar(grainSlope / windowSlope, events[\trigger]);
	pmod = OnePole.ar(pmod, exp(-2pi * windowSlope));

	fmod = sin(windowPhase * 2pi) * \fmIndex.kr(2);
	fmod = fmod - OnePole.ar(fmod, exp(-2pi * windowSlope));

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

	// PM_A
	grainPhasePM_A = (accumulator * Latch.ar(grainSlope, events[\trigger])).wrap(0, 1);
	grainPhasePM_A = (grainPhasePM_A + pmod).wrap(0, 1);

	// PM_B
	grainPhasePM_B = (windowPhase * Latch.ar(grainSlope / windowSlope, events[\trigger])).wrap(0, 1);
	grainPhasePM_B = (grainPhasePM_B + pmod).wrap(0, 1);

	// FM
	grainPhaseFM = ~grainFunctions.helperFunctions[\rampSubSample].(
		trig: events[\trigger],
		slope: grainFreq + (grainFreq * fmod) * SampleDur.ir,
		subSampleOffset: subSampleOffset
	);
	grainPhaseFM = grainPhaseFM.wrap(0, 1);
	grainPhaseFM = Phasor.ar(DC.ar(0), grainFreq + (grainFreq * fmod) * SampleDur.ir);

	[
		grainPhasePM_A,
		grainPhasePM_B,
		grainPhaseFM
	];

}.plot(0.021);
)

// 1.5.3.) cascading transfer functions - "phase shaping" and "phase increment distortion"

(
var eventData = { |rate|

	var eventPhase, eventSlope, eventTrigger;

	eventPhase = VariableRamp.ar(rate);
	eventSlope = ~grainFunctions.helperFunctions[\rampToSlope].(eventPhase);

	eventPhase = Delay1.ar(eventPhase);
	eventTrigger = ~grainFunctions.helperFunctions[\rampToTrig].(eventPhase);

	(
		phase: eventPhase,
		slope: eventSlope,
		trigger: eventTrigger
	);

};

{
    var tFreq, events;
    var subSampleOffset, accumulator;
    var windowSlope, windowPhase, grainWindow;
    var grainFreq, grainSlope, grainPhase;

    tFreq = \tFreq.kr(100);
    events = eventData.(tFreq);

	subSampleOffset = ~grainFunctions.helperFunctions[\subSampleOffset].(
		events[\phase],
		events[\slope],
		events[\trigger]
	);

	accumulator = ~grainFunctions.helperFunctions[\accumSubSample].(
		trig: events[\trigger],
		subSampleOffset: subSampleOffset
	);

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

    windowSlope = Latch.ar(events[\slope] / max(0.001, \overlap.kr(1)), events[\trigger]);
    windowPhase = (windowSlope * accumulator).clip(0, 1);

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

    grainFreq = \freq.kr(200);
    grainSlope = grainFreq * SampleDur.ir;

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

    grainPhase = ~unitShapers.lerpingFunctions[\exponential].(windowPhase, \shapeA.kr(0.5));
    grainPhase = (grainPhase * Latch.ar(grainSlope / windowSlope, events[\trigger])).wrap(0, 1);

	grainPhase = grainPhase + (sin(windowPhase * pi) / pi * Latch.ar(grainSlope / windowSlope, events[\trigger]) * \index.kr(1));

	grainPhase = ~unitShapers.lerpingFunctions[\quinticSeat].(grainPhase, \shapeB.kr(0.25), \height.kr(0.5));
	//grainPhase = ~unitShapers.lerpingFunctions[\sigmoidToSeat].(grainPhase, \shapeC.kr(1));

	sin(grainPhase * 2pi);

}.plot(0.021);
)
5 Likes

Thanks @dietcv !

The examples sound amazing! Would it be possible for you to share the SynthDefs used to generate these kinds of sounds?

Thank you in advance!

JosƩ

I’d love to see that too

i would encourage you to play around with the different bits and pieces you find in the two guides and especially FM/PM etc. (find some examples in the latest post). Im not using anything more then that :slight_smile: The overall idea with these things is to have lots of possibilities for experimentation and share discoveries instead of just one model which fits everybody`s needs.

4 Likes

Thanks @dietcv !

Maybe just a little example to start working on? I see a lot of information and plot examples, but not so much related to sound… Using .play I can hear something, but the sounds are quite static. Anyway, I’ll keep exploring to get a better understanding and try modulating the parameters :slight_smile:

Thanks a lot for sharing!

hey a starting point would be to add simple PM (there are different ways of implementing that, the one here is the least interesting, find more information in the post above), maybe some amplitude modulation and load wavetables instead of just single cycle waveforms into OscOS and modulate the tableIndex. You can also modulate the window params, the overlap, etc… I have included the ControlSpecs, so you can see some sensible ranges.


(
var eventData = { |rate|
	
	var eventPhase, eventSlope, eventTrigger;
	
	eventPhase = VariableRamp.ar(rate);
	eventSlope = ~grainFunctions.helperFunctions[\rampToSlope].(eventPhase);
	
	eventPhase = Delay1.ar(eventPhase);
	eventTrigger = ~grainFunctions.helperFunctions[\rampToTrig].(eventPhase);
	
	(
		phase: eventPhase,
		slope: eventSlope,
		trigger: eventTrigger
	);
	
};

SynthDef(\simplePulsarPM, { |sndBuf, modBuf|
	
	var numChannels = 5;
	
	var tFreqMD, tFreqMod, tFreq, events;
	var triggers, subSampleOffsets, accumulator, overlap, maxOverlap;
	var windowSlopes, windowPhases, grainWindows;
	var grainFreqMF, grainFreqMD, grainFreqMod, grainFreq, grainFreqs, grainSlopes, grainPhases;
	var modSlopes, modPhases, pmIndex, pmFltRatio, pmods, tableIndexMF, tableIndexMod, tableIndex;
	var grainOscs, grains, sig;
	
	tFreqMD = \tFreqMD.kr(0, spec: ControlSpec(0, 2));
	tFreqMod = SinOsc.ar(\tFreqMF.kr(0.3, spec: ControlSpec(0.01, 2)));
	
	tFreq = \tFreq.kr(100, spec: ControlSpec(1, 500, \exp));
	tFreq = tFreq * (2 ** (tFreqMod * tFreqMD));
	
	events = eventData.(tFreq);
	
	///////////////////////////////////////////////////////////////////
	
	// distribute triggers round-robin across the channels
	triggers = ~grainFunctions.multiChannel[\trigger].(numChannels, events[\trigger]);

	// calculate sub-sample offset per multichannel trigger
	subSampleOffsets = ~grainFunctions.helperFunctions[\subSampleOffset].(
		events[\phase],
		events[\slope],
		triggers
	);

	// create a multichannel accumulator with sub-sample accuracy
	accumulator = ~grainFunctions.multiChannel[\accumSubSample].(triggers, subSampleOffsets);

	///////////////////////////////////////////////////////////////////
	
	overlap = \overlap.ar(1, spec: ControlSpec(0.125, numChannels));
	maxOverlap = min(overlap, 2 ** tFreqMD.neg * numChannels);
	
	windowSlopes = Latch.ar(events[\slope] / max(0.001, maxOverlap), triggers);
	windowPhases = (windowSlopes * accumulator).clip(0, 1);
	
	// Create grain windows
	grainWindows = ~unitShapers.windowFunctions[\gaussian].(
		windowPhases,
		\windowSkew.kr(0.5, spec: ControlSpec(0, 1)),
		\windowIndex.kr(0, spec: ControlSpec(0, 5))
	);
	
	///////////////////////////////////////////////////////////////////
	
	// Calculate grain frequencies
	grainFreqMF = \freqMF.kr(0.3, spec: ControlSpec(0.01, 1));
	grainFreqMD = \freqMD.kr(0, spec: ControlSpec(0, 1));
	grainFreqMod = { |phase|
		SinOsc.ar(grainFreqMF, phase * pi)
	};
	
	grainFreq = \freq.kr(440, spec: ControlSpec(20, 2000));
	grainFreq = grainFreq * (2 ** (grainFreqMod.(0.5) * grainFreqMD));
	
	grainFreqs = Latch.ar(grainFreq, triggers);
	grainSlopes = grainFreqs * SampleDur.ir;
	
	///////////////////////////////////////////////////////////////////
	
	// Calculate mod slopes and mod phases for PM
	pmIndex = \pmIndex.kr(0, spec: ControlSpec(0, 2));
	pmFltRatio = \pmFltRatio.kr(1, spec: ControlSpec(1, 5));
	
	modSlopes = grainSlopes * \pmRatio.kr(1, spec: ControlSpec(1, 5));
	modPhases = (modSlopes * accumulator).wrap(0, 1);
	
	pmods = OscOS.ar(
		bufnum: modBuf,
		phase: modPhases,
		buf_divs: BufFrames.kr(modBuf) / 2048,
		buf_loc: 0,
		oversample: 0
	);
	
	pmods = pmods / 2pi * pmIndex;
	pmods = ~unitShapers.onePoleFilters[\lpf].(pmods, modSlopes * pmFltRatio);
	
	///////////////////////////////////////////////////////////////////
	
	// Create table index modulation
	tableIndexMF = \tableIndexMF.kr(0.1, spec: ControlSpec(0.1, 1));
	tableIndexMod = { |phase|
		SinOsc.ar(tableIndexMF, phase * pi)
	};
	
	tableIndex = ~unitShapers.helperFunctions[\modScaleBipolar].(
		modulator: tableIndexMod.(0.5),
		value: \tableIndex.kr(0, spec: ControlSpec(0, 1)),
		amount: \tableIndexMD.kr(1, spec: ControlSpec(0, 1)),
		direction: \up
	);
	
	// Calculate grain phases and add Phase Modulation
	grainPhases = (grainSlopes * accumulator + pmods).wrap(0, 1);
	
	// Generate grains
	grainOscs = OscOS.ar(
		bufnum: sndBuf,
		phase: grainPhases,
		buf_divs: BufFrames.kr(sndBuf) / 2048,
		buf_loc: tableIndex,
		oversample: 2
	);
	
	///////////////////////////////////////////////////////////////////
	
	grains = grainOscs * grainWindows; 
	
	grains = PanAz.ar(2, grains, \pan.kr(0));
	sig = grains.sum;
	
	// Apply amplitude modulation
	sig = sig * ~unitShapers.helperFunctions[\modScaleBipolar].(
		modulator: SinOsc.ar(\ampMF.kr(5, spec: ControlSpec(0.01, 10))),
		value: \amp.kr(-25, spec: ControlSpec(-35, -5)).dbamp,
		amount: \ampMD.kr(0, spec: ControlSpec(0, 1)),
		direction: \down
	);
	
	// Envelope and output
	sig = sig * Env.asr(0.001, 1, 0.001).ar(Done.freeSelf, \gate.kr(1));
	
	sig = LeakDC.ar(sig);
	sig = Limiter.ar(sig);
	Out.ar(\out.kr(0), sig);
}).add;
)


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

(
Synth(\simplePulsarPM, [
	
	\tFreqMD, 0.3,
	\tFreqMD, 2,
	\tFreq, 10,
	
	\windowSkew, 0.01,
	\windowIndex, 1,
	
	\overlap, 0.5,
	
	\freqMF, 0.3,
	\freqMD, 1,
	\freq, 440,
	
	\pmRatio, 1.5,
	\pmIndex, 1,
	\pmFltRatio, 2,
	
	\sndBuf, ~sndBuf,
	\modBuf, ~modBuf,
	
	\tableIndexMF, 0.3,
	\tableIndexMD, 0,
	\tableIndex, 0,
	
	\ampMF, 5,
	\ampMD, 0,
	\amp, -15,
	
	\out, 0,
	
]);
)
1 Like