diff --git a/packages/core/clockworker.js b/packages/core/clockworker.js index 77a45362e..c616b89dc 100644 --- a/packages/core/clockworker.js +++ b/packages/core/clockworker.js @@ -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); diff --git a/packages/core/cyclist.mjs b/packages/core/cyclist.mjs index 59e410c53..54a047505 100644 --- a/packages/core/cyclist.mjs +++ b/packages/core/cyclist.mjs @@ -14,7 +14,7 @@ export class Cyclist { onToggle, onError, getTime, - latency = 0.1, + latency = 0.03, setInterval, clearInterval, beforeStart, diff --git a/packages/core/logger.mjs b/packages/core/logger.mjs index 6727c2422..7d46b5fe8 100644 --- a/packages/core/logger.mjs +++ b/packages/core/logger.mjs @@ -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, { diff --git a/packages/core/neocyclist.mjs b/packages/core/neocyclist.mjs index 3e412074d..742a1f31a 100644 --- a/packages/core/neocyclist.mjs +++ b/packages/core/neocyclist.mjs @@ -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 +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 . */ @@ -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; diff --git a/website/public/encoder_disc.svg b/website/public/encoder_disc.svg new file mode 100644 index 000000000..c4317947d --- /dev/null +++ b/website/public/encoder_disc.svg @@ -0,0 +1,155 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/website/src/components/Tap.jsx b/website/src/components/Tap.jsx new file mode 100644 index 000000000..4fa9b5336 --- /dev/null +++ b/website/src/components/Tap.jsx @@ -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 ( +
+
+
{cps.toFixed(2)} cps
+ +
+
+ ); +} diff --git a/website/src/components/Vinyl.jsx b/website/src/components/Vinyl.jsx new file mode 100644 index 000000000..a0a651d37 --- /dev/null +++ b/website/src/components/Vinyl.jsx @@ -0,0 +1,59 @@ +/* +Vinyl.jsx - +Copyright (C) 2025 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 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 ( +
+
+ +
+
+ ); +} diff --git a/website/src/pages/cycler.astro b/website/src/pages/cycler.astro new file mode 100644 index 000000000..eb3d5ed2a --- /dev/null +++ b/website/src/pages/cycler.astro @@ -0,0 +1,17 @@ +--- +import HeadCommon from '../components/HeadCommon.astro'; +import { Vinyl } from '../components/Vinyl.jsx'; +import Tap from '../components/Tap.jsx'; +--- + + + + + Strudel Cycler + + +
Hello there.
+ + + +