diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index 66dee4f1c..12bcc3565 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -448,13 +448,23 @@ 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 + * @param {number | Pattern} speed modulation speed in HZ * @example * s("triangle").am("2").amshape("").amdepth(.5) * */ -export const { am, tremolo } = registerControl(['am', 'amdepth', 'amskew', 'amphase'], 'tremolo'); +export const { am, } = registerControl(['am', 'amdepth', 'amskew', 'amphase'],); + +/** + * modulate the amplitude of a sound with a continuous waveform + * + * @name amsync + * @param {number | Pattern} cycles modulation speed in cycles + * @example + * s("triangle").am("2").amshape("").amdepth(.5) + * + */ +export const { amsync } = registerControl(['amsync', 'amdepth', 'amskew', 'amphase']); /** * depth of amplitude modulation diff --git a/packages/core/cyclist copy.mjs b/packages/core/cyclist copy.mjs new file mode 100644 index 000000000..fec819548 --- /dev/null +++ b/packages/core/cyclist copy.mjs @@ -0,0 +1,140 @@ +/* +cyclist.mjs - event scheduler for a single strudel instance. for multi-instance scheduler, see - see +Copyright (C) 2022 Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ + +import createClock from './zyklus.mjs'; +import { logger } from './logger.mjs'; + +export class Cyclist { + constructor({ + interval, + onTrigger, + onToggle, + onError, + getTime, + latency = 0.1, + setInterval, + clearInterval, + beforeStart, + }) { + this.started = false; + this.beforeStart = beforeStart; + 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.getTime = getTime; // get absolute time + this.num_cycles_at_cps_change = 0; + this.seconds_at_cps_change; // clock phase when cps was changed + this.onToggle = onToggle; + this.latency = latency; // fixed trigger time offset + 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; + } + 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; + + 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; + + if (phase < t) { + // avoid querying haps that are in the past anyway + console.log(`skip query: too late`); + return; + } + + // 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://codeberg.org/uzu/strudel/pulls/1004 + const deadline = targetTime - phase; + // this onTrigger has another signature + onTrigger?.(hap, deadline, duration, this.cps, targetTime); + if (hap.value.cps !== undefined && this.cps != hap.value.cps) { + this.cps = hap.value.cps; + this.num_ticks_since_cps_change = 0; + } + } + }); + } catch (e) { + logger(`[cyclist] error: ${e.message}`); + onError?.(e); + } + }, + interval, // duration of each cycle + 0.1, + 0.1, + setInterval, + clearInterval, + ); + } + now() { + if (!this.started) { + return 0; + } + const secondsSinceLastTick = this.getTime() - this.lastTick - this.clock.duration; + return this.lastBegin + secondsSinceLastTick * this.cps; // + this.clock.minLatency; + } + setStarted(v) { + this.started = v; + this.onToggle?.(v); + } + async start() { + await this.beforeStart?.(); + 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.'); + } + logger('[cyclist] start'); + this.clock.start(); + this.setStarted(true); + } + pause() { + logger('[cyclist] pause'); + this.clock.pause(); + this.setStarted(false); + } + stop() { + logger('[cyclist] stop'); + this.clock.stop(); + this.lastEnd = 0; + this.setStarted(false); + } + async setPattern(pat, autostart = false) { + this.pattern = pat; + if (autostart && !this.started) { + await this.start(); + } + } + setCps(cps = 0.5) { + if (this.cps === cps) { + return; + } + this.cps = cps; + this.num_ticks_since_cps_change = 0; + } + log(begin, end, haps) { + const onsets = haps.filter((h) => h.hasOnset()); + console.log(`${begin.toFixed(4)} - ${end.toFixed(4)} ${Array(onsets.length).fill('I').join('')}`); + } +} diff --git a/packages/core/cyclist.mjs b/packages/core/cyclist.mjs index fec819548..43e1f508e 100644 --- a/packages/core/cyclist.mjs +++ b/packages/core/cyclist.mjs @@ -1,6 +1,6 @@ /* -cyclist.mjs - event scheduler for a single strudel instance. for multi-instance scheduler, see - see -Copyright (C) 2022 Strudel contributors - see +cyclist.mjs - event scheduler for a single strudel instance. for multi-instance scheduler, see - see +Copyright (C) 2022 Strudel contributors - see This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ @@ -8,77 +8,50 @@ import createClock from './zyklus.mjs'; import { logger } from './logger.mjs'; export class Cyclist { - constructor({ - interval, - onTrigger, - onToggle, - onError, - getTime, - latency = 0.1, - setInterval, - clearInterval, - beforeStart, - }) { + constructor({ interval = 0.05, onTrigger, onToggle, onError, getTime, latency = 0.1, setInterval, clearInterval }) { this.started = false; - this.beforeStart = beforeStart; 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; + (lastTick, duration, _, time) => { + 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 = lastTick - time; + 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://codeberg.org/uzu/strudel/pulls/1004 - const deadline = targetTime - phase; - // this onTrigger has another signature - onTrigger?.(hap, deadline, duration, this.cps, targetTime); - if (hap.value.cps !== undefined && this.cps != hap.value.cps) { - this.cps = hap.value.cps; - this.num_ticks_since_cps_change = 0; - } - } - }); - } 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, @@ -91,17 +64,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); } - async start() { - await this.beforeStart?.(); + 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.'); } @@ -120,16 +100,18 @@ export class Cyclist { this.lastEnd = 0; this.setStarted(false); } - async setPattern(pat, autostart = false) { + setPattern(pat, autostart = false) { this.pattern = pat; if (autostart && !this.started) { - await this.start(); + this.start(); } } setCps(cps = 0.5) { 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; } @@ -137,4 +119,4 @@ export class Cyclist { const onsets = haps.filter((h) => h.hasOnset()); console.log(`${begin.toFixed(4)} - ${end.toFixed(4)} ${Array(onsets.length).fill('I').join('')}`); } -} +} \ No newline at end of file diff --git a/packages/core/neocyclist.mjs b/packages/core/neocyclist.mjs index 261f08acb..4e6d385d8 100644 --- a/packages/core/neocyclist.mjs +++ b/packages/core/neocyclist.mjs @@ -45,7 +45,7 @@ export class NeoCyclist { const timeUntilTrigger = cycleToSeconds(hap.whole.begin - this.cycle, this.cps); const targetTime = timeUntilTrigger + currentTime + this.latency; const duration = cycleToSeconds(hap.duration, this.cps); - onTrigger?.(hap, 0, duration, this.cps, targetTime); + onTrigger?.(hap, 0, duration, this.cps, targetTime, this.cycle); } }); }; diff --git a/packages/core/repl.mjs b/packages/core/repl.mjs index aeaa6418e..5e696c85f 100644 --- a/packages/core/repl.mjs +++ b/packages/core/repl.mjs @@ -244,16 +244,16 @@ export function repl({ export const getTrigger = ({ getTime, defaultOutput }) => - async (hap, deadline, duration, cps, t) => { + async (hap, deadline, duration, cps, t, cycle = 0) => { // ^ this signature is different from hap.context.onTrigger, as set by Pattern.onTrigger(onTrigger) // TODO: get rid of deadline after https://codeberg.org/uzu/strudel/pulls/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(hap, getTime(), cps, t); + await hap.context.onTrigger(hap, getTime(), cps, t, cycle); } } catch (err) { logger(`[cyclist] error: ${err.message}`, 'error'); diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index bbc7d8a1c..79c1ff117 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -45,6 +45,8 @@ export function setGainCurve(newGainCurveFunc) { gainCurveFunc = newGainCurveFunc; } + + function aliasBankMap(aliasMap) { // Make all bank keys lower case for case insensitivity for (const key in aliasMap) { @@ -325,12 +327,29 @@ function getDelay(orbit, delaytime, delayfeedback, t, channels) { return delays[orbit]; } -export function getLfo(audioContext, time, end, properties = {}) { +export function getLfo(audioContext, begin, end, properties = {}) { return getWorklet(audioContext, 'lfo-processor', { frequency: 1, depth: 1, skew: 0, phaseoffset: 0, + time: begin, + begin, + end, + shape: 1, + dcoffset: -0.5, + ...properties, + }); +} + +export function getSyncedLfo(audioContext, time, end, cps, cycle, properties = {}) { + const frequency = cycle/cps + + return getWorklet(audioContext, 'lfo-processor', { + frequency, + depth: 1, + skew: 0, + phaseoffset: 0, time, end, shape: 1, @@ -450,9 +469,10 @@ function mapChannelNumbers(channels) { return (Array.isArray(channels) ? channels : [channels]).map((ch) => ch - 1); } -export const superdough = async (value, t, hapDuration, cps = 0.5) => { +export const superdough = async (value, t, hapDuration, cps = 0.5, cycle) => { // new: t is always expected to be the absolute target onset time const ac = getAudioContext(); + let { stretch } = value; if (stretch != null) { //account for phase vocoder latency @@ -707,11 +727,12 @@ export const superdough = async (value, t, hapDuration, cps = 0.5) => { coarse !== undefined && chain.push(getWorklet(ac, 'coarse-processor', { coarse })); 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 })); + // distort !== undefined && chain.push(getWorklet(ac, 'distort-processor', { distort, postgain: distortvol })); // am !== undefined && // chain.push( // getWorklet(ac, 'am-processor', { // speed: am, + // begin: t, // depth: amdepth, // skew: amskew, // phaseoffset: amphase, @@ -724,12 +745,21 @@ export const superdough = async (value, t, hapDuration, cps = 0.5) => { if (am !== undefined) { const amGain = new GainNode(ac, { gain: 1 }); - const frequency = cycleToSeconds(am, cps) + const frequency = cps / am const phaseoffset = cycleToSeconds(amphase, cps) + + + const time = cycle / cps + + // console.info(cycle, time, frequency) + + + const lfo = getLfo(ac, t, t + hapDuration, { skew: amskew, - frequency: am * cps, - depth: amdepth, + frequency, + depth: amdepth, + time, // dcoffset: 0, shape: amshape, phaseoffset diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index ffa2165b4..366be8a5e 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -85,6 +85,7 @@ const waveShapeNames = Object.keys(waveshapes); class LFOProcessor extends AudioWorkletProcessor { static get parameterDescriptors() { return [ + { name: 'begin', defaultValue: 0 }, { name: 'time', defaultValue: 0 }, { name: 'end', defaultValue: 0 }, { name: 'frequency', defaultValue: 0.5 }, @@ -109,10 +110,14 @@ class LFOProcessor extends AudioWorkletProcessor { } process(inputs, outputs, parameters) { + const begin = parameters['begin'][0] // eslint-disable-next-line no-undef if (currentTime >= parameters.end[0]) { return false; } + if (currentTime <= begin) { + return true; + } const output = outputs[0]; const frequency = parameters['frequency'][0]; @@ -159,6 +164,8 @@ class CoarseProcessor extends AudioWorkletProcessor { const input = inputs[0]; const output = outputs[0]; + + const hasInput = !(input[0] === undefined); if (this.started && !hasInput) { return false; @@ -899,6 +906,7 @@ registerProcessor('byte-beat-processor', ByteBeatProcessor); class AMProcessor extends AudioWorkletProcessor { static get parameterDescriptors() { return [ + { name: 'begin', defaultValue: 0 }, { name: 'cps', defaultValue: 0.5 }, { name: 'speed', defaultValue: 0.5 }, { name: 'cycle', defaultValue: 0 }, @@ -925,10 +933,15 @@ class AMProcessor extends AudioWorkletProcessor { const input = inputs[0]; const output = outputs[0]; const hasInput = !(input[0] === undefined); + + if (this.started && !hasInput) { return false; } this.started = hasInput; + if (currentTime <= parameters.begin[0]) { + return true; + } const speed = parameters['speed'][0]; const cps = parameters['cps'][0]; @@ -937,7 +950,7 @@ class AMProcessor extends AudioWorkletProcessor { const skew = parameters['skew'][0]; const phaseoffset = parameters['phaseoffset'][0]; - const frequency = speed * cps; + const frequency = cps / speed if (this.phase == null) { const secondsPassed = cycle / cps; this.phase = _mod(secondsPassed * frequency + phaseoffset, 1); diff --git a/packages/webaudio/webaudio.mjs b/packages/webaudio/webaudio.mjs index 01ad9fb8b..78441ee55 100644 --- a/packages/webaudio/webaudio.mjs +++ b/packages/webaudio/webaudio.mjs @@ -17,8 +17,12 @@ const hap2value = (hap) => { // uses more precise, absolute t if available, see https://github.com/tidalcycles/strudel/pull/1004 // TODO: refactor output callbacks to eliminate deadline -export const webaudioOutput = (hap, deadline, hapDuration, cps, t) => { - return superdough(hap2value(hap), t, hapDuration, cps); +export const webaudioOutput = (hap, deadline, hapDuration, cps, t, cycle) => { + + return superdough(hap2value(hap), t, hapDuration, cps, + hap.whole.begin.valueOf() + // cycle +); }; export function webaudioRepl(options = {}) {