Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Gain Modulation with calculated modulation per event #1095

Open
wants to merge 18 commits into
base: main
Choose a base branch
from
55 changes: 54 additions & 1 deletion packages/core/controls.mjs
Original file line number Diff line number Diff line change
@@ -430,6 +430,60 @@ export const { crush } = registerControl('crush');
*/
export const { coarse } = registerControl('coarse');

/**
* modulate the amplitude of a sound with a continuous waveform
*
* @name am
* @synonyms tremelo
* @param {number | Pattern} speed modulation speed in cycles
* @example
* s("triangle").am("2").amshape("<tri saw ramp square>").amdepth(.5)
*
*/
export const { am, tremolo } = registerControl(['am', 'amdepth', 'amskew', 'amphase'], 'tremolo');

/**
* depth of amplitude modulation
*
* @name amdepth
* @param {number | Pattern} depth
* @example
* s("triangle").am(1).amdepth("1")
*
*/
export const { amdepth } = registerControl('amdepth');
/**
* alter the shape of the modulation waveform
*
* @name amskew
* @param {number | Pattern} amount between 0 & 1, the shape of the waveform
* @example
* note("{f a c e}%16").am(4).amskew("<.5 0 1>")
*
*/
export const { amskew } = registerControl('amskew');

/**
* alter the phase of the modulation waveform
*
* @name amphase
* @param {number | Pattern} offset the offset in cycles of the modulation
* @example
* note("{f a c e}%16").am(4).amphase("<0 .25 .66>")
*
*/
export const { amphase } = registerControl('amphase');

/**
* shape of amplitude modulation
*
* @name amshape
* @param {number | Pattern} shape tri | square | sine | saw | ramp
* @example
* note("{f g c d}%16").am(4).amshape("ramp").s("sawtooth")
*
*/
export const { amshape } = registerControl('amshape');
/**
* Allows you to set the output channels on the interface
*
@@ -1529,7 +1583,6 @@ export const { zmod } = registerControl('zmod');
// like crush but scaled differently
export const { zcrush } = registerControl('zcrush');
export const { zdelay } = registerControl('zdelay');
export const { tremolo } = registerControl('tremolo');
export const { zzfx } = registerControl('zzfx');

/**
86 changes: 43 additions & 43 deletions packages/core/cyclist.mjs
Original file line number Diff line number Diff line change
@@ -8,61 +8,51 @@ import createClock from './zyklus.mjs';
import { logger } from './logger.mjs';

export class Cyclist {
constructor({ interval, onTrigger, onToggle, onError, getTime, latency = 0.1, setInterval, clearInterval }) {
constructor({ interval = 0.05, onTrigger, onToggle, onError, getTime, latency = 0.1, setInterval, clearInterval }) {
this.started = false;
this.cps = 0.5;
this.num_ticks_since_cps_change = 0;
this.lastTick = 0; // absolute time when last tick (clock callback) happened
this.lastBegin = 0; // query begin of last tick
this.lastEnd = 0; // query end of last tick
this.time_at_last_tick_message = 0;
this.cycle = 0;
this.getTime = getTime; // get absolute time
this.num_cycles_at_cps_change = 0;
this.seconds_at_cps_change; // clock phase when cps was changed
this.num_ticks_since_cps_change = 0;
this.onToggle = onToggle;
this.latency = latency; // fixed trigger time offset

this.interval = interval;

this.clock = createClock(
getTime,
// called slightly before each cycle
(phase, duration, _, t) => {
if (this.num_ticks_since_cps_change === 0) {
this.num_cycles_at_cps_change = this.lastEnd;
this.seconds_at_cps_change = phase;
(phase, duration, _, time) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the idea behind these changes? I can't quite figure out the intention by just looking at the code o_O

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cycle calculation has to be adjusted slightly to compensate for when the event is actually played so that the phase of the modulation can be lined up. I already had made the cycle calculation on the neocyclist, so I just brought that logic over as well as the setCycle function. I could not figure out how to get the cycle offset adjustment to calculate properly with current cyclist implementation. Maybe you might know a better way to get this value?

if (this.started === false) {
return;
}
this.num_ticks_since_cps_change++;
const seconds_since_cps_change = this.num_ticks_since_cps_change * duration;
const num_cycles_since_cps_change = seconds_since_cps_change * this.cps;
const num_seconds_since_cps_change = this.num_ticks_since_cps_change * duration;
const tickdeadline = phase - time;
const lastTick = time + tickdeadline;
const num_cycles_since_cps_change = num_seconds_since_cps_change * this.cps;
const begin = this.num_cycles_at_cps_change + num_cycles_since_cps_change;
const secondsSinceLastTick = time - lastTick - duration;
const eventLength = duration * this.cps;
const end = begin + eventLength;
this.cycle = begin + secondsSinceLastTick * this.cps;

try {
const begin = this.lastEnd;
this.lastBegin = begin;
const end = this.num_cycles_at_cps_change + num_cycles_since_cps_change;
this.lastEnd = end;
this.lastTick = phase;
//account for latency and tick duration when using cycle calculations for audio downstream
const cycle_gap = (this.latency - duration) * this.cps;

if (phase < t) {
// avoid querying haps that are in the past anyway
console.log(`skip query: too late`);
return;
const haps = this.pattern.queryArc(begin, end, { _cps: this.cps });
haps.forEach((hap) => {
if (hap.hasOnset()) {
let targetTime = (hap.whole.begin - this.num_cycles_at_cps_change) / this.cps;
targetTime = targetTime + this.latency + tickdeadline + time - num_seconds_since_cps_change;
const duration = hap.duration / this.cps;
onTrigger?.(hap, tickdeadline, duration, this.cps, targetTime, this.cycle - cycle_gap);
}

// query the pattern for events
const haps = this.pattern.queryArc(begin, end, { _cps: this.cps });

haps.forEach((hap) => {
if (hap.hasOnset()) {
const targetTime =
(hap.whole.begin - this.num_cycles_at_cps_change) / this.cps + this.seconds_at_cps_change + latency;
const duration = hap.duration / this.cps;
// the following line is dumb and only here for backwards compatibility
// see https://github.com/tidalcycles/strudel/pull/1004
const deadline = targetTime - phase;
onTrigger?.(hap, deadline, duration, this.cps, targetTime);
}
});
} catch (e) {
logger(`[cyclist] error: ${e.message}`);
onError?.(e);
}
});
this.time_at_last_tick_message = time;
this.num_ticks_since_cps_change++;
},
interval, // duration of each cycle
0.1,
@@ -75,16 +65,24 @@ export class Cyclist {
if (!this.started) {
return 0;
}
const secondsSinceLastTick = this.getTime() - this.lastTick - this.clock.duration;
return this.lastBegin + secondsSinceLastTick * this.cps; // + this.clock.minLatency;
const gap = (this.getTime() - this.time_at_last_tick_message) * this.cps;
return this.cycle + gap;
}

setCycle = (cycle) => {
this.num_ticks_since_cps_change = 0;
this.num_cycles_at_cps_change = cycle;
};
setStarted(v) {
this.started = v;

this.setCycle(0);
this.onToggle?.(v);
}
start() {
this.num_ticks_since_cps_change = 0;
this.num_cycles_at_cps_change = 0;

if (!this.pattern) {
throw new Error('Scheduler: no pattern set! call .setPattern first.');
}
@@ -113,6 +111,8 @@ export class Cyclist {
if (this.cps === cps) {
return;
}
const num_seconds_since_cps_change = this.num_ticks_since_cps_change * this.interval;
this.num_cycles_at_cps_change = this.num_cycles_at_cps_change + num_seconds_since_cps_change * cps;
this.cps = cps;
this.num_ticks_since_cps_change = 0;
}
5 changes: 1 addition & 4 deletions packages/core/neocyclist.mjs
Original file line number Diff line number Diff line change
@@ -10,11 +10,8 @@ export class NeoCyclist {
constructor({ onTrigger, onToggle, getTime }) {
this.started = false;
this.cps = 0.5;
this.lastTick = 0; // absolute time when last tick (clock callback) happened
this.getTime = getTime; // get absolute time
this.time_at_last_tick_message = 0;

this.num_cycles_at_cps_change = 0;
this.onToggle = onToggle;
this.latency = 0.1; // fixed trigger time offset
this.cycle = 0;
@@ -86,7 +83,7 @@ export class NeoCyclist {
this.latency +
this.worker_time_dif;
const duration = hap.duration / this.cps;
onTrigger?.(hap, 0, duration, this.cps, targetTime);
onTrigger?.(hap, 0, duration, this.cps, targetTime, this.cycle);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could cycle potentially be deduced from the hap itself? something like hap.whole.begin.sam() ? not sure

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was the original plan, but the events don't line up perfectly unless you get the adjusted cycle that accounts for latency etc. Honestly could use some help here, maybe there is a better way to do this.

}
});
};
6 changes: 3 additions & 3 deletions packages/core/repl.mjs
Original file line number Diff line number Diff line change
@@ -178,15 +178,15 @@ export function repl({

export const getTrigger =
({ getTime, defaultOutput }) =>
async (hap, deadline, duration, cps, t) => {
async (hap, deadline, duration, cps, t, cycle = 0) => {
// TODO: get rid of deadline after https://github.com/tidalcycles/strudel/pull/1004
try {
if (!hap.context.onTrigger || !hap.context.dominantTrigger) {
await defaultOutput(hap, deadline, duration, cps, t);
await defaultOutput(hap, deadline, duration, cps, t, cycle);
}
if (hap.context.onTrigger) {
// call signature of output / onTrigger is different...
await hap.context.onTrigger(getTime() + deadline, hap, getTime(), cps, t);
await hap.context.onTrigger(getTime() + deadline, hap, getTime(), cps, t, cycle);
}
} catch (err) {
logger(`[cyclist] error: ${err.message}`, 'error');
21 changes: 20 additions & 1 deletion packages/superdough/superdough.mjs
Original file line number Diff line number Diff line change
@@ -260,7 +260,7 @@ export function resetGlobalEffects() {
analysersData = {};
}

export const superdough = async (value, t, hapDuration) => {
export const superdough = async (value, t, hapDuration, cps, cycle) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could there be a default value for cycle to make am (and potential future friends) be relative to seconds? so am(4) would be 4Hz. thinking about using superdough without strudel, where cycle is a non existing concept

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah you could just pass in the time in seconds since clock start and it should work that way. I agree having both options would be nice for different effects

const ac = getAudioContext();
if (typeof value !== 'object') {
throw new Error(
@@ -281,6 +281,10 @@ export const superdough = async (value, t, hapDuration) => {
}
// destructure
let {
am,
amdepth = 1,
amskew = 0.5,
amphase = 0,
s = 'triangle',
bank,
source,
@@ -322,8 +326,10 @@ export const superdough = async (value, t, hapDuration) => {
phasercenter,
//
coarse,

crush,
shape,

shapevol = 1,
distort,
distortvol = 1,
@@ -470,6 +476,19 @@ export const superdough = async (value, t, hapDuration) => {
crush !== undefined && chain.push(getWorklet(ac, 'crush-processor', { crush }));
shape !== undefined && chain.push(getWorklet(ac, 'shape-processor', { shape, postgain: shapevol }));
distort !== undefined && chain.push(getWorklet(ac, 'distort-processor', { distort, postgain: distortvol }));
am !== undefined &&
chain.push(
getWorklet(ac, 'am-processor', {
speed: am,
depth: amdepth,
skew: amskew,
phaseoffset: amphase,
// shape: amshape,

cps,
cycle,
}),
);

compressorThreshold !== undefined &&
chain.push(
160 changes: 130 additions & 30 deletions packages/superdough/worklets.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,134 @@
// coarse, crush, and shape processors adapted from dktr0's webdirt: https://github.com/dktr0/WebDirt/blob/5ce3d698362c54d6e1b68acc47eb2955ac62c793/dist/AudioWorklets.js
// LICENSE GNU General Public License v3.0 see https://github.com/dktr0/WebDirt/blob/main/LICENSE

import { clamp, _mod } from './util.mjs';
// const clamp = (num, min, max) => Math.min(Math.max(num, min), max);
const blockSize = 128;
// adjust waveshape to remove frequencies above nyquist to prevent aliasing
// referenced from https://www.kvraudio.com/forum/viewtopic.php?t=375517
function polyBlep(phase, dt) {
// 0 <= phase < 1
if (phase < dt) {
phase /= dt;
// 2 * (phase - phase^2/2 - 0.5)
return phase + phase - phase * phase - 1;
}

// -1 < phase < 0
else if (phase > 1 - dt) {
phase = (phase - 1) / dt;
// 2 * (phase^2/2 + phase + 0.5)
return phase * phase + phase + phase + 1;
}

// 0 otherwise
else {
return 0;
}
}

const waveshapes = {
sine(phase) {
return Math.sin(Math.PI * 2 * phase);
},
custom(phase, values = [0, 1]) {
const numParts = values.length - 1;
const currPart = Math.floor(phase * numParts);

const partLength = 1 / numParts;
const startVal = clamp(values[currPart], 0, 1);
const endVal = clamp(values[currPart + 1], 0, 1);
const y2 = endVal;
const y1 = startVal;
const x1 = 0;
const x2 = partLength;
const slope = (y2 - y1) / (x2 - x1);
return slope * (phase - partLength * currPart) + startVal;
},
sawblep(phase, dt) {
const v = 2 * phase - 1;
return v - polyBlep(phase, dt);
},
ramp(phase) {
return phase;
},
saw(phase) {
return 1 - phase;
},
tri(phase, skew = 0.5) {
const x = 1 - skew;
if (phase >= skew) {
return 1 / x - phase / x;
}
return phase / skew;
},
square(phase, skew = 0.5) {
if (phase >= skew) {
return 0;
}
return 1;
},
};

class AMProcessor extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [
{ name: 'cps', defaultValue: 0.5 },
{ name: 'speed', defaultValue: 0.5 },
{ name: 'cycle', defaultValue: 0 },
{ name: 'skew', defaultValue: 0.5 },
{ name: 'depth', defaultValue: 1 },
{ name: 'phaseoffset', defaultValue: 0 },
];
}

constructor() {
super();
this.phase;
this.started = false;
}

incrementPhase(dt) {
this.phase += dt;
if (this.phase > 1.0) {
this.phase = this.phase - 1;
}
}

process(inputs, outputs, parameters) {
const input = inputs[0];
const output = outputs[0];
const hasInput = !(input[0] === undefined);
if (this.started && !hasInput) {
return false;
}
this.started = hasInput;

const speed = parameters['speed'][0];
const cps = parameters['cps'][0];
const cycle = parameters['cycle'][0];
const depth = parameters['depth'][0];
const skew = parameters['skew'][0];
const phaseoffset = parameters['phaseoffset'][0];

const frequency = speed * cps;
if (this.phase == null) {
const secondsPassed = cycle / cps;
this.phase = _mod(secondsPassed * frequency + phaseoffset, 1);
}
// eslint-disable-next-line no-undef
const dt = frequency / sampleRate;
for (let n = 0; n < blockSize; n++) {
for (let i = 0; i < input.length; i++) {
const modval = clamp(waveshapes.tri(this.phase, skew) * depth + (1 - depth), 0, 1);
output[i][n] = input[i][n] * modval;
}
this.incrementPhase(dt);
}
return true;
}
}
registerProcessor('am-processor', AMProcessor);

class CoarseProcessor extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [{ name: 'coarse', defaultValue: 1 }];
@@ -142,34 +269,7 @@ class DistortProcessor extends AudioWorkletProcessor {
}
registerProcessor('distort-processor', DistortProcessor);

// adjust waveshape to remove frequencies above nyquist to prevent aliasing
// referenced from https://www.kvraudio.com/forum/viewtopic.php?t=375517
const polyBlep = (phase, dt) => {
// 0 <= phase < 1
if (phase < dt) {
phase /= dt;
// 2 * (phase - phase^2/2 - 0.5)
return phase + phase - phase * phase - 1;
}

// -1 < phase < 0
else if (phase > 1 - dt) {
phase = (phase - 1) / dt;
// 2 * (phase^2/2 + phase + 0.5)
return phase * phase + phase + phase + 1;
}

// 0 otherwise
else {
return 0;
}
};

const saw = (phase, dt) => {
const v = 2 * phase - 1;
return v - polyBlep(phase, dt);
};

// SUPERSAW
function lerp(a, b, n) {
return n * (b - a) + a;
}
@@ -269,7 +369,7 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor {

for (let i = 0; i < output[0].length; i++) {
this.phase[n] = this.phase[n] ?? Math.random();
const v = saw(this.phase[n], dt);
const v = waveshapes.sawblep(this.phase[n], dt);

output[0][i] = output[0][i] + v * gainL;
output[1][i] = output[1][i] + v * gainR;
7 changes: 4 additions & 3 deletions packages/webaudio/webaudio.mjs
Original file line number Diff line number Diff line change
@@ -17,9 +17,9 @@ const hap2value = (hap) => {

export const webaudioOutputTrigger = (t, hap, ct, cps) => superdough(hap2value(hap), t - ct, hap.duration / cps, cps);
// uses more precise, absolute t if available, see https://github.com/tidalcycles/strudel/pull/1004
export const webaudioOutput = (hap, deadline, hapDuration, cps, t) =>
superdough(hap2value(hap), t ? `=${t}` : deadline, hapDuration);

export const webaudioOutput = (hap, deadline, hapDuration, cps, t, cycle) => {
return superdough(hap2value(hap), t ? `=${t}` : deadline, hapDuration, cps, cycle);
};
Pattern.prototype.webaudio = function () {
return this.onTrigger(webaudioOutputTrigger);
};
@@ -30,6 +30,7 @@ export function webaudioScheduler(options = {}) {
defaultOutput: webaudioOutput,
...options,
};

const { defaultOutput, getTime } = options;
return new strudel.Cyclist({
...options,
375 changes: 225 additions & 150 deletions test/__snapshots__/examples.test.mjs.snap

Large diffs are not rendered by default.