Compare commits

..

16 Commits

Author SHA1 Message Date
Alex McLean 70c932a760 Merge branch 'cycler' into textbox 2025-10-14 12:39:26 +02:00
Alex McLean f5631a01ae fixes 2025-10-14 11:02:53 +01:00
Alex McLean 989b5dcc3a thingie to display current pattern value 2025-10-14 10:41:57 +01:00
alex 5de891f31d format 2025-10-14 08:57:25 +01:00
alex 325e348bbf Merge branch 'main' into cycler 2025-10-14 08:56:47 +01:00
alex fe117e23fd start of tap tempo 2025-10-14 08:56:11 +01:00
Alex McLean 69a82ddc5b low latency clock control experimentation 2025-10-08 12:18:32 +01:00
Alex McLean 4209506fd0 Merge branch 'main' into cycler 2025-10-01 13:27:49 +02:00
Alex McLean ff790c803d format 2025-10-01 12:01:19 +01:00
Alex McLean f0efaabe20 Merge branch 'main' into cycler 2025-10-01 11:58:19 +01:00
alex 5097ae8fc0 use encoder disc 2025-03-01 18:07:25 +00:00
alex 6b1e6cc7af try to make more reactish 2025-03-01 16:53:13 +00:00
alex 9c6eca9a59 it works 2025-03-01 15:55:10 +00:00
alex 02bb88bde4 rm 2025-03-01 10:57:39 +00:00
alex c16511dfbd towards spinning a disk matching cycles inside strudel 2025-03-01 10:55:37 +00:00
alex 5054756ca2 experiment 2025-02-28 17:20:28 +00:00
21 changed files with 417 additions and 155 deletions
+6
View File
@@ -140,3 +140,9 @@ registerWidget('_spectrum', (id, options = {}, pat) => {
const ctx = getCanvasWidget(id, options).getContext('2d');
return pat.spectrum({ ...options, ctx, id });
});
registerWidget('_textbox', (id, options = {}, pat) => {
options = { width: 500, height: 30, ...options };
const ctx = getCanvasWidget(id, options).getContext('2d');
return pat.textbox({ ...options, ctx, id });
});
+15 -3
View File
@@ -12,6 +12,8 @@ function getTime() {
let num_cycles_at_cps_change = 0;
let num_ticks_since_cps_change = 0;
let num_seconds_at_cps_change = 0;
let max_cycle = null;
let cps = 0.5;
// {id: {started: boolean}}
const clients = new Map();
@@ -39,6 +41,7 @@ const sendTick = (phase, duration, tick, time) => {
cps,
time,
cycle,
max_cycle,
});
num_ticks_since_cps_change++;
};
@@ -60,9 +63,10 @@ const stopClock = async (id) => {
const otherClientStarted = Array.from(clients.values()).some((c) => c.started);
//dont stop the clock if other instances are running...
if (!started || otherClientStarted) {
return;
}
// actually do stop it
// if (!started || otherClientStarted) {
// return;
// }
clock.stop();
setCycle(0);
@@ -74,6 +78,10 @@ const setCycle = (cycle) => {
num_cycles_at_cps_change = cycle;
};
const setMaxCycle = (cycle) => {
max_cycle = cycle;
};
const processMessage = (message) => {
const { type, payload } = message;
@@ -92,6 +100,10 @@ const processMessage = (message) => {
setCycle(payload.cycle);
break;
}
case 'setmaxcycle': {
setMaxCycle(payload.maxcycle);
break;
}
case 'toggle': {
if (payload.started) {
startClock(message.id);
-12
View File
@@ -396,18 +396,6 @@ export const { velocity } = registerControl('velocity');
*
*/
export const { gain } = registerControl('gain');
/**
* Applies a linear gain adjustment
*
* @name gainlinear
* @synonyms gainlin
* @param {number | Pattern} amount gainlin
* @example
* s("hh*8").gainlin(".4!2 1 .4!2 1 .4 1").fast(2)
*
*/
export const { gainlinear, gainlin } = registerControl('gainlinear', 'gainlin');
/**
* Gain applied after all effects have been processed.
*
+1 -1
View File
@@ -14,7 +14,7 @@ export class Cyclist {
onToggle,
onError,
getTime,
latency = 0.1,
latency = 0.03,
setInterval,
clearInterval,
beforeStart,
+1 -1
View File
@@ -18,7 +18,7 @@ export function logger(message, type, data = {}) {
}
lastMessage = message;
lastTime = t;
console.log(`%c${message}`, 'background-color: black;color:white;border-radius:15px');
console.log(`${t} %c${message}`, 'background-color: black;color:white;border-radius:15px');
if (typeof document !== 'undefined' && typeof CustomEvent !== 'undefined') {
document.dispatchEvent(
new CustomEvent(logKey, {
+6 -3
View File
@@ -1,6 +1,6 @@
/*
neocyclist.mjs - event scheduler like cyclist, except recieves clock pulses from clockworker in order to sync across multiple instances.
Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/core/neocyclist.mjs>
Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/packages/core/neocyclist.mjs>
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 <https://www.gnu.org/licenses/>.
*/
@@ -11,6 +11,7 @@ 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;
// the clock of the worker and the audio context clock can drift apart over time
@@ -19,16 +20,18 @@ export class NeoCyclist {
// in order to schedule events consistently.
this.collator = new ClockCollator({ getTargetClockTime: getTime });
this.onToggle = onToggle;
this.latency = 0.1; // fixed trigger time offset
this.latency = -0.1; // fixed trigger time offset
this.cycle = 0;
this.maxcycle = null;
this.id = Math.round(Date.now() * Math.random());
this.worker = new SharedWorker(new URL('./clockworker.js', import.meta.url));
this.worker.port.start();
this.channel = new BroadcastChannel('strudeltick');
const tickCallback = (payload) => {
const { cps, begin, end, cycle, time } = payload;
const { cps, begin, end, cycle, maxcycle, time } = payload;
this.cps = cps;
this.cycle = cycle;
this.maxcycle = maxcycle;
const currentTime = this.collator.calculateOffset(time) + time;
processHaps(begin, end, currentTime);
this.time_at_last_tick_message = currentTime;
+1
View File
@@ -4,3 +4,4 @@ export * from './draw.mjs';
export * from './pianoroll.mjs';
export * from './spiral.mjs';
export * from './pitchwheel.mjs';
export * from './textbox.mjs';
+50
View File
@@ -0,0 +1,50 @@
import { getTheme, getDrawContext } from './draw.mjs';
import { Pattern } from '@strudel/core';
export function textbox({ haps, ctx, id, margin = 10, fontsize = 24 } = {}) {
const w = ctx.canvas.width;
const h = ctx.canvas.height;
ctx.clearRect(0, 0, w, h);
const color = getTheme().foreground;
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.globalAlpha = 1;
ctx.textAlign = 'left';
haps.forEach((hap) => {
if (hap.hasOnset()) {
const hapColor = hap.value.color || color;
ctx.strokeStyle = hapColor;
ctx.fillStyle = hapColor;
const { velocity = 1, gain = 1 } = hap.value || {};
const alpha = velocity * gain;
ctx.globalAlpha = alpha;
ctx.font = `${fontsize}px sans-serif`;
ctx.fillText(hap.value, 0, fontsize);
}
});
return;
}
Pattern.prototype.textbox = function (options = {}) {
let { ctx = getDrawContext(), id = 1 } = options;
this.draw(
(haps, time) => {
textbox({
...options,
time,
ctx,
haps: haps.filter((hap) => hap.isActive(time)),
});
},
{
lookbehind: 0,
lookahead: 0,
id,
},
);
return this;
};
-1
View File
@@ -153,7 +153,6 @@ samples('github:tidalcycles/dirt-samples')
The format is `github:<user>/<repo>/<branch>`.
If `<repo>` and `<branch>` are not specified, they will default to `samples` and `main` respectively.
It expects a `strudel.json` file to be present at the root of the given repository, which declares the sample paths in the repo.
The format is also expected to be the same as explained above.
+5 -12
View File
@@ -121,20 +121,13 @@ function githubPath(base, subpath = '') {
if (!base.startsWith('github:')) {
throw new Error('expected "github:" at the start of pseudoUrl');
}
let path = base.slice('github:'.length);
let [_, path] = base.split('github:');
path = path.endsWith('/') ? path.slice(0, -1) : path;
let components = path.split('/');
let user = components[0];
let repo = components.length >= 2 ? components[1] : 'samples';
let branch = components.length >= 3 ? components[2] : 'main';
let other = components.slice(3);
if (subpath) {
other.push(subpath);
if (path.split('/').length === 2) {
// assume main as default branch if none set
path += '/main';
}
other = other.join('/');
return `https://raw.githubusercontent.com/${user}/${repo}/${branch}/${other}`;
return `https://raw.githubusercontent.com/${path}/${subpath}`;
}
export const processSampleMap = (sampleMap, fn, baseUrl = sampleMap._base || '') => {
+15 -16
View File
@@ -37,7 +37,7 @@ export function registerSound(key, onTrigger, data = {}) {
soundMap.setKey(key, { onTrigger, data });
}
let gainCurveFunc = (val) => Math.pow(val, 2);
let gainCurveFunc = (val) => val;
export function applyGainCurve(val) {
return gainCurveFunc(val);
@@ -141,9 +141,9 @@ export const getAudioDevices = async () => {
return devicesMap;
};
const defaultDefaultValues = {
let defaultDefaultValues = {
s: 'triangle',
gain: 1,
gain: 0.8,
postgain: 1,
density: '.03',
ftype: '12db',
@@ -166,6 +166,17 @@ const defaultDefaultValues = {
fft: 8,
};
const defaultDefaultDefaultValues = Object.freeze({ ...defaultDefaultValues });
export function setDefault(control, value) {
// const main = getControlName(control); // we cant do this because superdough is independent of strudel/core
defaultDefaultValues[control] = value;
}
export function resetDefaults() {
defaultDefaultValues = { ...defaultDefaultDefaultValues };
}
let defaultControls = new Map(Object.entries(defaultDefaultValues));
export function setDefaultValue(key, value) {
@@ -184,17 +195,9 @@ export function resetDefaultValues() {
}
export function setVersionDefaults(version) {
resetDefaultValues();
const v = Number(version);
if (v <= 1.0) {
if (version === '1.0') {
setDefaultValue('fanchor', 0.5);
}
if (v <= 1.2) {
setDefaultValue('gain', 0.8);
setGainCurve((v) => v);
} else {
setDefaultValue('gain', 1);
setGainCurve((v) => Math.pow(v, 2));
}
}
export const resetLoadedSounds = () => soundMap.set({});
@@ -403,7 +406,6 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
bank,
source,
gain = getDefaultValue('gain'),
gainlinear,
postgain = getDefaultValue('postgain'),
density = getDefaultValue('density'),
duckorbit,
@@ -501,9 +503,6 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
velocity = applyGainCurve(velocity);
tremolodepth = applyGainCurve(tremolodepth);
gain *= velocity; // velocity currently only multiplies with gain. it might do other things in the future
if (gainlinear != null) {
gain *= gainlinear;
}
const end = t + hapDuration;
const endWithRelease = end + release;
+2 -2
View File
@@ -231,12 +231,12 @@ export async function onTriggerSynth(t, value, onended, tables, cps, frameLen) {
begin: t,
end: envEnd,
frequency,
freqspread: value.detune,
detune: value.detune,
position: value.wt,
warp: value.warp,
warpMode: warpmode,
voices: Math.max(value.unison ?? 1, 1),
panspread: value.spread,
spread: value.spread,
phaserand: (value.wtphaserand ?? value.unison > 1) ? 1 : 0,
},
{ outputChannelCount: [2] },
+13 -17
View File
@@ -1050,15 +1050,14 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
return [
{ name: 'begin', defaultValue: 0, min: 0, max: Number.POSITIVE_INFINITY },
{ name: 'end', defaultValue: 0, min: 0, max: Number.POSITIVE_INFINITY },
{ name: 'frequency', defaultValue: 440, min: Number.EPSILON },
{ name: 'detune', defaultValue: 0 },
{ name: 'freqspread', defaultValue: 0.18, min: 0 },
{ name: 'position', defaultValue: 0, min: 0, max: 1 },
{ name: 'warp', defaultValue: 0, min: 0, max: 1 },
{ name: 'frequency', defaultValue: 220, minValue: 0.01, maxValue: 20000 },
{ name: 'detune', defaultValue: 0.18 },
{ name: 'position', defaultValue: 0, minValue: 0, maxValue: 1 },
{ name: 'warp', defaultValue: 0, minValue: 0, maxValue: 1 },
{ name: 'warpMode', defaultValue: 0 },
{ name: 'voices', defaultValue: 1, min: 1 },
{ name: 'panspread', defaultValue: 0.7, min: 0, max: 1 },
{ name: 'phaserand', defaultValue: 0, min: 0, max: 1 },
{ name: 'voices', defaultValue: 1, minValue: 1, maxValue: 32 },
{ name: 'spread', defaultValue: 0.7, minValue: 0, maxValue: 1 },
{ name: 'phaserand', defaultValue: 0, minValue: 0, maxValue: 1 },
];
}
@@ -1236,12 +1235,10 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
_sampleFrame(frame, phase) {
const len = frame.length;
const pos = phase * len;
let i = pos | 0;
if (i >= len) i = 0; // fast wrap
const i = pos | 0;
const frac = pos - i;
const a = frame[i];
let i1 = i + 1;
if (i1 >= len) i1 = 0;
const i1 = i + 1 < len ? i + 1 : 0; // fast wrap
const b = frame[i1];
return a + (b - a) * frac;
}
@@ -1271,7 +1268,6 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
}
for (let i = 0; i < outL.length; i++) {
const detune = pv(parameters.detune, i);
const freqspread = pv(parameters.freqspread, i);
const tablePos = clamp(pv(parameters.position, i), 0, 1);
const idx = tablePos * (this.numFrames - 1);
const fIdx = idx | 0;
@@ -1280,9 +1276,9 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
const warpMode = pv(parameters.warpMode, i);
const voices = pv(parameters.voices, i);
const phaseRand = clamp(pv(parameters.phaserand, i), 0, 1);
const panspread = voices > 1 ? clamp(pv(parameters.panspread, i), 0, 1) : 0;
const gain1 = Math.sqrt(0.5 - 0.5 * panspread);
const gain2 = Math.sqrt(0.5 + 0.5 * panspread);
const spread = voices > 1 ? clamp(pv(parameters.spread, i), 0, 1) : 0;
const gain1 = Math.sqrt(0.5 - 0.5 * spread);
const gain2 = Math.sqrt(0.5 + 0.5 * spread);
let f = pv(parameters.frequency, i);
f = applySemitoneDetuneToFrequency(f, detune / 100); // overall detune
const normalizer = 1 / Math.sqrt(voices);
@@ -1295,7 +1291,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
gainL = gain2;
gainR = gain1;
}
const fVoice = applySemitoneDetuneToFrequency(f, getUnisonDetune(voices, freqspread, n)); // voice detune
const fVoice = applySemitoneDetuneToFrequency(f, getUnisonDetune(voices, detune, n)); // voice detune
const dPhase = fVoice * this.invSR;
const level = this._chooseMip(dPhase);
const table = this.tables[level];
-69
View File
@@ -4527,75 +4527,6 @@ exports[`runs examples > example "gain" example index 0 1`] = `
]
`;
exports[`runs examples > example "gainlinear" example index 0 1`] = `
[
"[ 0/1 → 1/16 | s:hh gainlinear:0.4 ]",
"[ 1/16 → 1/8 | s:hh gainlinear:0.4 ]",
"[ 1/8 → 3/16 | s:hh gainlinear:1 ]",
"[ 3/16 → 1/4 | s:hh gainlinear:0.4 ]",
"[ 1/4 → 5/16 | s:hh gainlinear:0.4 ]",
"[ 5/16 → 3/8 | s:hh gainlinear:1 ]",
"[ 3/8 → 7/16 | s:hh gainlinear:0.4 ]",
"[ 7/16 → 1/2 | s:hh gainlinear:1 ]",
"[ 1/2 → 9/16 | s:hh gainlinear:0.4 ]",
"[ 9/16 → 5/8 | s:hh gainlinear:0.4 ]",
"[ 5/8 → 11/16 | s:hh gainlinear:1 ]",
"[ 11/16 → 3/4 | s:hh gainlinear:0.4 ]",
"[ 3/4 → 13/16 | s:hh gainlinear:0.4 ]",
"[ 13/16 → 7/8 | s:hh gainlinear:1 ]",
"[ 7/8 → 15/16 | s:hh gainlinear:0.4 ]",
"[ 15/16 → 1/1 | s:hh gainlinear:1 ]",
"[ 1/1 → 17/16 | s:hh gainlinear:0.4 ]",
"[ 17/16 → 9/8 | s:hh gainlinear:0.4 ]",
"[ 9/8 → 19/16 | s:hh gainlinear:1 ]",
"[ 19/16 → 5/4 | s:hh gainlinear:0.4 ]",
"[ 5/4 → 21/16 | s:hh gainlinear:0.4 ]",
"[ 21/16 → 11/8 | s:hh gainlinear:1 ]",
"[ 11/8 → 23/16 | s:hh gainlinear:0.4 ]",
"[ 23/16 → 3/2 | s:hh gainlinear:1 ]",
"[ 3/2 → 25/16 | s:hh gainlinear:0.4 ]",
"[ 25/16 → 13/8 | s:hh gainlinear:0.4 ]",
"[ 13/8 → 27/16 | s:hh gainlinear:1 ]",
"[ 27/16 → 7/4 | s:hh gainlinear:0.4 ]",
"[ 7/4 → 29/16 | s:hh gainlinear:0.4 ]",
"[ 29/16 → 15/8 | s:hh gainlinear:1 ]",
"[ 15/8 → 31/16 | s:hh gainlinear:0.4 ]",
"[ 31/16 → 2/1 | s:hh gainlinear:1 ]",
"[ 2/1 → 33/16 | s:hh gainlinear:0.4 ]",
"[ 33/16 → 17/8 | s:hh gainlinear:0.4 ]",
"[ 17/8 → 35/16 | s:hh gainlinear:1 ]",
"[ 35/16 → 9/4 | s:hh gainlinear:0.4 ]",
"[ 9/4 → 37/16 | s:hh gainlinear:0.4 ]",
"[ 37/16 → 19/8 | s:hh gainlinear:1 ]",
"[ 19/8 → 39/16 | s:hh gainlinear:0.4 ]",
"[ 39/16 → 5/2 | s:hh gainlinear:1 ]",
"[ 5/2 → 41/16 | s:hh gainlinear:0.4 ]",
"[ 41/16 → 21/8 | s:hh gainlinear:0.4 ]",
"[ 21/8 → 43/16 | s:hh gainlinear:1 ]",
"[ 43/16 → 11/4 | s:hh gainlinear:0.4 ]",
"[ 11/4 → 45/16 | s:hh gainlinear:0.4 ]",
"[ 45/16 → 23/8 | s:hh gainlinear:1 ]",
"[ 23/8 → 47/16 | s:hh gainlinear:0.4 ]",
"[ 47/16 → 3/1 | s:hh gainlinear:1 ]",
"[ 3/1 → 49/16 | s:hh gainlinear:0.4 ]",
"[ 49/16 → 25/8 | s:hh gainlinear:0.4 ]",
"[ 25/8 → 51/16 | s:hh gainlinear:1 ]",
"[ 51/16 → 13/4 | s:hh gainlinear:0.4 ]",
"[ 13/4 → 53/16 | s:hh gainlinear:0.4 ]",
"[ 53/16 → 27/8 | s:hh gainlinear:1 ]",
"[ 27/8 → 55/16 | s:hh gainlinear:0.4 ]",
"[ 55/16 → 7/2 | s:hh gainlinear:1 ]",
"[ 7/2 → 57/16 | s:hh gainlinear:0.4 ]",
"[ 57/16 → 29/8 | s:hh gainlinear:0.4 ]",
"[ 29/8 → 59/16 | s:hh gainlinear:1 ]",
"[ 59/16 → 15/4 | s:hh gainlinear:0.4 ]",
"[ 15/4 → 61/16 | s:hh gainlinear:0.4 ]",
"[ 61/16 → 31/8 | s:hh gainlinear:1 ]",
"[ 31/8 → 63/16 | s:hh gainlinear:0.4 ]",
"[ 63/16 → 4/1 | s:hh gainlinear:1 ]",
]
`;
exports[`runs examples > example "gap" example index 0 1`] = `[]`;
exports[`runs examples > example "grow" example index 0 1`] = `
+155
View File
@@ -0,0 +1,155 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Adobe Illustrator 12.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 51448) -->
<svg
version="1.0"
id="Layer_1"
width="284.46"
height="284.46"
viewBox="0 0 284.46 284.46"
overflow="visible"
enable-background="new 0 0 284.46 284.46"
xml:space="preserve"
sodipodi:version="0.32"
inkscape:version="1.4 (1:1.4+202410161351+e7c3feb100)"
sodipodi:docname="encoder_disc.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"><metadata
id="metadata2068"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs
id="defs2066">
</defs><sodipodi:namedview
inkscape:window-height="1131"
inkscape:window-width="1920"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
borderopacity="1.0"
bordercolor="#666666"
pagecolor="#ffffff"
id="base"
inkscape:zoom="1.0549412"
inkscape:cx="195.27154"
inkscape:cy="-101.90141"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:current-layer="Layer_1"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:window-maximized="1" />
<path
id="path1"
style="fill:#f9f9f9;stroke:none;stroke-width:1.23072"
d="M 142.22902,0.61574073 A 141.61462,141.61462 0 0 0 0.61574173,142.22901 141.61462,141.61462 0 0 0 142.22902,283.84425 141.61462,141.61462 0 0 0 283.84425,142.22901 141.61462,141.61462 0 0 0 142.22902,0.61574073 Z m 0,106.22069927 a 35.393924,35.393924 0 0 1 35.39453,35.39257 35.393924,35.393924 0 0 1 -35.39453,35.39455 35.393924,35.393924 0 0 1 -35.39258,-35.39455 35.393924,35.393924 0 0 1 35.39258,-35.39257 z" /><g
id="g2"><path
d="M 242.47,41.99 242.441,42.02 217.4,67.06 C 198.17,47.83 171.589,35.93 142.23,35.93 V 0.5 c 39.17,0 74.6,15.85 100.24,41.49 z"
id="path2015" /><path
d="m 142.23,0.5 v 35.43 c -29.37,0 -55.93,11.89 -75.17,31.13 L 42.02,42.02 41.99,41.99 C 67.63,16.35 103.06,0.5 142.23,0.5 Z"
id="path2017" /><path
d="m 142.23,35.93 v 35.43 c -19.59,0 -37.3,7.92 -50.12,20.75 L 67.06,67.06 C 86.3,47.82 112.86,35.93 142.23,35.93 Z"
id="path2019" /><path
d="M 67.06,67.06 92.11,92.11 C 79.28,104.93 71.36,122.64 71.36,142.23 H 35.93 c 0,-29.36 11.89,-55.94 31.13,-75.17 z"
id="path2021" /><path
d="M 92.11,192.35 67.06,217.4 C 47.82,198.171 35.93,171.59 35.93,142.23 h 35.43 c 0,19.589 7.92,37.3 20.75,50.12 z"
id="path2023" /><path
d="m 142.23,213.09 v 35.44 c -29.37,0 -55.93,-11.891 -75.17,-31.131 l 25.05,-25.05 c 12.82,12.821 30.53,20.741 50.12,20.741 z"
id="path2025" /><path
d="m 142.23,248.53 v 35.43 c -39.17,0 -74.6,-15.851 -100.24,-41.49 l 0.03,-0.03 25.04,-25.04 c 19.24,19.24 45.8,31.13 75.17,31.13 z"
id="path2027" /><path
d="m 142.23,283.96 v -35.43 c 29.36,0 55.94,-11.9 75.17,-31.131 l 25.04,25.04 0.029,0.03 C 216.83,268.109 181.4,283.96 142.23,283.96 Z"
id="path2029" /><path
d="m 177.66,142.229 h 35.43 c 0,19.59 -7.92,37.301 -20.74,50.12 L 167.3,167.3 c 6.4,-6.401 10.36,-15.25 10.36,-25.071 z"
id="path2031" /><path
d="m 167.3,167.3 25.05,25.05 c -12.819,12.82 -30.529,20.74 -50.12,20.74 v -35.43 c 9.8,0 18.66,-3.95 25.07,-10.36 z"
id="path2033" /><path
d="m 142.23,177.66 v 35.43 c -19.59,0 -37.3,-7.92 -50.12,-20.74 l 25.05,-25.04 c 6.42,6.41 15.28,10.35 25.07,10.35 z"
id="path2035" /><path
d="m 117.16,167.3 v 0.01 L 92.11,192.35 C 79.28,179.531 71.36,161.82 71.36,142.23 h 35.43 c 0,9.82 3.96,18.669 10.37,25.07 z"
id="path2037" /><path
fill="none"
stroke="#000000"
d="m 142.23,283.96 c -39.17,0 -74.6,-15.851 -100.24,-41.49 C 16.35,216.83 0.5,181.399 0.5,142.229 0.5,103.059 16.35,67.629 41.99,41.989 67.63,16.35 103.06,0.5 142.23,0.5 c 39.17,0 74.6,15.85 100.24,41.49 25.641,25.64 41.49,61.07 41.49,100.24 0,39.17 -15.85,74.601 -41.49,100.24 -25.64,25.639 -61.07,41.49 -100.24,41.49 z"
id="path2041" /><path
fill="none"
stroke="#000000"
d="m 248.53,142.229 c 0,-29.35 -11.891,-55.93 -31.13,-75.17 -19.23,-19.23 -45.811,-31.13 -75.17,-31.13 -29.37,0 -55.93,11.89 -75.17,31.13 -19.24,19.23 -31.13,45.81 -31.13,75.17 0,29.36 11.89,55.94 31.13,75.17 19.24,19.24 45.8,31.131 75.17,31.131 29.36,0 55.94,-11.9 75.17,-31.131 19.24,-19.239 31.13,-45.819 31.13,-75.17 z"
id="path2043" /><path
fill="none"
stroke="#000000"
d="m 142.23,213.09 c -19.59,0 -37.3,-7.92 -50.12,-20.74 -12.83,-12.819 -20.75,-30.53 -20.75,-50.12 0,-19.59 7.92,-37.3 20.75,-50.12 12.82,-12.83 30.53,-20.75 50.12,-20.75 19.59,0 37.3,7.92 50.12,20.75 12.82,12.82 20.74,30.53 20.74,50.12 0,19.59 -7.92,37.301 -20.74,50.12 -12.82,12.82 -30.53,20.74 -50.12,20.74 z"
id="path2045" /><path
fill="none"
stroke="#000000"
d="m 117.16,167.31 c 6.42,6.41 15.28,10.351 25.07,10.351 9.8,0 18.66,-3.95 25.07,-10.36 6.4,-6.4 10.36,-15.25 10.36,-25.07 0,-9.819 -3.95,-18.67 -10.36,-25.07 -6.399,-6.41 -15.27,-10.36 -25.07,-10.36 -9.8,0 -18.66,3.95 -25.08,10.35 -6.4,6.4 -10.36,15.26 -10.36,25.08 0,9.82 3.96,18.67 10.37,25.07"
id="path2047" /><path
id="polyline2049"
style="fill:none;stroke:#000000"
d="m 142.23,177.66 v 35.43 35.44 35.43"
sodipodi:nodetypes="cccc" /><path
id="polyline2051"
style="fill:none;stroke:#000000"
d="m 167.3,167.3 25.05,25.05 25.05,25.049 25.04,25.04"
sodipodi:nodetypes="cccc" /><path
id="polyline2053"
style="fill:none;stroke:#000000"
d="m 177.66,142.229 h 35.43 35.44 35.43"
sodipodi:nodetypes="cccc" /><path
id="polyline2055"
style="fill:none;stroke:#000000"
d="M 167.3,117.16 192.35,92.11 217.4,67.06 242.44,42.02"
sodipodi:nodetypes="cccc" /><path
id="polyline2057"
style="fill:none;stroke:#000000"
d="M 142.23,106.8 V 71.36 35.93 0.5"
sodipodi:nodetypes="cccc" /><path
id="polyline2059"
style="fill:none;stroke:#000000"
d="M 117.15,117.15 92.11,92.11 67.06,67.06 42.02,42.02"
sodipodi:nodetypes="cccc" /><path
id="polyline2061"
style="fill:none;stroke:#000000"
d="M 106.79,142.229 H 71.36 35.93 0.5"
sodipodi:nodetypes="cccc" /><path
id="polyline2063"
style="fill:none;stroke:#000000"
d="m 117.16,167.3 v 0.01 l -25.05,25.04 -25.05,25.049 -25.04,25.04"
sodipodi:nodetypes="ccccc" /></g>
</svg>

After

Width:  |  Height:  |  Size: 6.8 KiB

+68
View File
@@ -0,0 +1,68 @@
import React, { useState, useRef, useEffect } from 'react';
export default function Tap({ initialCps = 0.4, maxSamples = 3 }) {
const [timestamps, setTimestamps] = useState([]);
const [cps, setCps] = useState(initialCps);
function addTap(ts = Date.now()) {
setTimestamps((prev) => {
const next = [...prev, ts].slice(-(maxSamples + 1));
calcCps(next);
return next;
});
}
function calcCps(times) {
if (!times || times.length < 2) return;
const intervals = [];
for (let i = 1; i < times.length; i++) {
intervals.push(times[i] - times[i - 1]);
}
const avgMs = intervals.reduce((a, b) => a + b, 0) / intervals.length;
const newCps = 1000 / avgMs;
if (Number.isFinite(newCps) && newCps > 0 && newCps < 1000) {
setCps(newCps);
}
}
function handleTap(e) {
e && e.preventDefault();
addTap();
}
function handleReset(e) {
e && e.preventDefault();
reset();
}
useEffect(() => {
function onKey(e) {
if (e.code === 'Space') {
e.preventDefault();
addTap();
} else if (e.code === 'Backspace') {
e.preventDefault();
reset();
}
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
function reset() {
setTimestamps([]);
// setCps(initialCps);
}
return (
<div>
<div class="flex flex-col items-center p-7 font-medium text-white">
<div>{cps.toFixed(2)} cps</div>
<button class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" onClick={handleTap}>
Tap
</button>
</div>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
/*
Vinyl.jsx - <short description TODO>
Copyright (C) 2025 Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/repl/src/App.js>
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 <https://www.gnu.org/licenses/>.
*/
import React, { useState, useEffect } from 'react';
import { setInterval, clearInterval } from 'worker-timers';
import { NeoCyclist } from '@strudel/core/neocyclist.mjs';
import { getAudioContext } from '@strudel/webaudio';
import { saw } from '@strudel/core';
import encoder from '../../public/encoder_disc.svg';
console.log(encoder);
const schedulerOptions = {
onTrigger: (x) => {},
getTime: () => getAudioContext().currentTime,
onToggle: (started) => console.log('started: ', started),
setInterval,
clearInterval,
// beforeStart,
};
const cyclist = new NeoCyclist(schedulerOptions);
export function Vinyl() {
const [isActive, setIsActive] = useState(false);
const [cyclepos, setCyclepos] = useState(0);
const activate = () => setIsActive(!isActive);
if (isActive) {
if (!cyclist.started) {
cyclist.start();
// cyclist.setPattern(saw.segment(8));
}
} else {
cyclist.stop();
}
useEffect(() => {
const intervalId = setInterval(() => {
setCyclepos(cyclist.cycle);
}, 10);
return () => clearInterval(intervalId);
}, []);
const deg = (cyclepos % 1) * 360;
const style = {
fontSize: '20em',
transform: 'rotate(' + deg + 'deg)',
};
return (
<div onClick={activate} style={style}>
<center>
<img src={encoder.src} />
</center>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
---
import HeadCommon from '../components/HeadCommon.astro';
import { Vinyl } from '../components/Vinyl.jsx';
import Tap from '../components/Tap.jsx';
---
<html lang="en" class="m-0 dark">
<head>
<HeadCommon />
<title>Strudel Cycler</title>
</head>
<body class="h-app-height bg-background m-0">
<div>Hello there.</div>
<Vinyl client:only="react" />
<Tap client:only="react" />
</body>
</html>
-17
View File
@@ -60,23 +60,6 @@ Strudel makes heavy use of chained functions. Here is a more sophisticated examp
.room(0.5)`}
/>
## Write your own chained function
You can write your own chained function using `register`. Here's the above chain but registered as a reusable, chained function.
<MiniRepl
client:idle
tune={`const effectChain = register('effectChain', (pat) => pat
.s("sawtooth")
.cutoff(500)
//.delay(0.5)
.room(0.5)
)
note("a3 c#4 e4 a4").effectChain()`}
/>
Try adding `.rev()` after `effectChain()` to hear further effects added.
# Comments
The `//` in the example above is a line comment, resulting in the `delay` function being ignored.
+1 -1
View File
@@ -381,7 +381,7 @@ Sampler effects are functions that can be used to change the behaviour of sample
### scrub
<JsDoc client:idle name="Pattern.scrub" h={0} />
<JsDoc client:idle name="Pattern.scrub" h={0} />{' '}
### speed
+2
View File
@@ -13,6 +13,7 @@ import {
resetGlobalEffects,
resetLoadedSounds,
initAudioOnFirstClick,
resetDefaults,
} from '@strudel/webaudio';
import { setVersionDefaultsFrom } from './util.mjs';
import { StrudelMirror, defaultSettings } from '@strudel/codemirror';
@@ -181,6 +182,7 @@ export function useReplContext() {
const resetEditor = async () => {
(await getModule('@strudel/tonal'))?.resetVoicings();
resetDefaults();
resetGlobalEffects();
clearCanvas();
clearHydra();