draw scheduler in vanilla js

This commit is contained in:
Felix Roos 2023-05-05 10:52:21 +02:00
parent 95719654f3
commit 1f4c2f8c5a
6 changed files with 117 additions and 53 deletions

View File

@ -0,0 +1,91 @@
const round = (x) => Math.round(x * 1000) / 1000;
export class Framer {
constructor(onFrame, onError) {
this.onFrame = onFrame;
this.onError = onError;
}
start() {
const self = this;
let frame = requestAnimationFrame(function updateHighlights(time) {
try {
self.onFrame(time);
} catch (err) {
self.onError(err);
}
frame = requestAnimationFrame(updateHighlights);
});
self.cancel = () => {
cancelAnimationFrame(frame);
};
}
stop() {
if (this.cancel) {
this.cancel();
}
}
}
export class Drawer {
constructor(onDraw, drawTime) {
let [lookbehind, lookahead] = drawTime; // e.g. [-2, 2]
lookbehind = Math.abs(lookbehind);
this.visibleHaps = [];
this.lastFrame = null;
this.drawTime = drawTime;
this.framer = new Framer(
() => {
if (!this.scheduler) {
console.warn('Drawer: no scheduler');
return;
}
// calculate current frame time (think right side of screen for pianoroll)
const phase = this.scheduler.now() + lookahead;
// first frame just captures the phase
if (this.lastFrame === null) {
this.lastFrame = phase;
return;
}
// query haps from last frame till now. take last 100ms max
const haps = this.scheduler.pattern.queryArc(Math.max(this.lastFrame, phase - 1 / 10), phase);
this.lastFrame = phase;
this.visibleHaps = (this.visibleHaps || [])
// filter out haps that are too far in the past (think left edge of screen for pianoroll)
.filter((h) => h.whole.end >= phase - lookbehind - lookahead)
// add new haps with onset (think right edge bars scrolling in)
.concat(haps.filter((h) => h.hasOnset()));
const time = phase - lookahead;
onDraw(this.visibleHaps, time, this);
},
(err) => {
console.warn('draw error', err);
},
);
}
check() {
if (!this.scheduler) {
throw new Error('no scheduler set..');
}
}
invalidate() {
this.check();
const t = this.scheduler.now();
let [_, lookahead] = this.drawTime;
// remove all future haps
this.visibleHaps = this.visibleHaps.filter((h) => h.whole.begin < t);
// query future haps
const futureHaps = this.scheduler.pattern.queryArc(Math.max(t, 0), t + lookahead + 0.1); // +0.1 = workaround for weird holes in query..
// append future haps
this.visibleHaps = this.visibleHaps.concat(futureHaps);
}
start(scheduler) {
this.scheduler = scheduler;
this.invalidate();
this.framer.start();
}
stop() {
if (this.framer) {
this.framer.stop();
}
}
}

View File

@ -1,44 +0,0 @@
const round = (x) => Math.round(x * 1000) / 1000;
// this class can be used to create a code highlighter
// it is encapsulated from the editor via the onUpdate callback
// the scheduler is expected to be an instance of Cyclist
export class Highlighter {
constructor(onUpdate) {
this.onUpdate = onUpdate;
}
start(scheduler) {
let highlights = [];
let lastEnd = 0;
this.stop();
const self = this;
let frame = requestAnimationFrame(function updateHighlights() {
try {
const time = scheduler.now();
// force min framerate of 10 fps => fixes crash on tab refocus, where lastEnd could be far away
// see https://github.com/tidalcycles/strudel/issues/108
const begin = Math.max(lastEnd ?? time, time - 1 / 10, -0.01); // negative time seems buggy
const span = [round(begin), round(time + 1 / 60)];
lastEnd = span[1];
highlights = highlights.filter((hap) => hap.whole.end > time); // keep only highlights that are still active
const haps = scheduler.pattern
.queryArc(...span)
.filter((hap) => hap.hasOnset());
highlights = highlights.concat(haps); // add potential new onsets
self.onUpdate(highlights); // highlight all still active + new active haps
} catch (err) {
self.onUpdate([]);
}
frame = requestAnimationFrame(updateHighlights);
});
self.cancel = () => {
cancelAnimationFrame(frame);
};
}
stop() {
if (this.cancel) {
this.cancel();
this.onUpdate([]);
}
}
}

View File

@ -15,6 +15,7 @@
<div id="editor"></div>
<div id="output"></div>
</div>
<canvas id="roll"></canvas>
</main>
<script type="module" src="./main.js"></script>
</body>

View File

@ -2,10 +2,13 @@
import { initEditor, highlightHaps, flash } from './codemirror';
import { initStrudel } from './strudel';
import { Highlighter } from './highlighter';
import { Drawer } from './drawer';
import { bumpStreet } from './tunes';
import { pianoroll, getDrawOptions } from '@strudel.cycles/core';
let code = bumpStreet;
const repl = initStrudel();
const roll = document.getElementById('roll');
const view = initEditor({
initialCode: code,
@ -22,20 +25,27 @@ async function onEvaluate() {
if (!scheduler.started) {
scheduler.stop();
await evaluate(code);
highlighter.start(scheduler);
drawer.start(scheduler);
} else {
await evaluate(code);
drawer.invalidate(); // this is a bit mystic
}
}
async function onStop() {
const { scheduler } = await repl;
scheduler.stop();
highlighter.stop();
drawer.stop();
}
let highlighter = new Highlighter((haps) => highlightHaps(view, haps));
const ctx = roll.getContext('2d');
let drawer = new Drawer(
(haps, time, { drawTime }) => {
const currentFrame = haps.filter((hap) => time >= hap.whole.begin && time <= hap.whole.end);
highlightHaps(view, currentFrame);
pianoroll({ ctx, time, haps, ...getDrawOptions(drawTime, { fold: 1 }) });
},
[-2, 2],
);
document.getElementById('play').addEventListener('click', () => onEvaluate());
document.getElementById('stop').addEventListener('click', async () => onStop());

View File

@ -22,3 +22,7 @@ main {
.container {
flex-grow: 1;
}
#roll {
height: 300px;
}

View File

@ -156,7 +156,7 @@ export function pianoroll({
return this;
}
function getOptions(drawTime, options = {}) {
export function getDrawOptions(drawTime, options = {}) {
let [lookbehind, lookahead] = drawTime;
lookbehind = Math.abs(lookbehind);
const cycles = lookahead + lookbehind;
@ -165,11 +165,13 @@ function getOptions(drawTime, options = {}) {
}
Pattern.prototype.punchcard = function (options) {
return this.onPaint((ctx, time, haps, drawTime) => pianoroll({ ctx, time, haps, ...getOptions(drawTime, options) }));
return this.onPaint((ctx, time, haps, drawTime) =>
pianoroll({ ctx, time, haps, ...getDrawOptions(drawTime, options) }),
);
};
Pattern.prototype.pianoroll = function (options) {
return this.onPaint((ctx, time, haps, drawTime) =>
pianoroll({ ctx, time, haps, ...getOptions(drawTime, { fold: 0, ...options }) }),
pianoroll({ ctx, time, haps, ...getDrawOptions(drawTime, { fold: 0, ...options }) }),
);
};