diff --git a/simulator/static/scrub.js b/simulator/static/scrub.js new file mode 100644 index 0000000..f7f19cb --- /dev/null +++ b/simulator/static/scrub.js @@ -0,0 +1,45 @@ +// Pure altitude-scrub math — no DOM. A continuous `pos` (float) is the knob: +// integers are altitudes/detents, fractions are mid-morph blend. UMD so the +// browser gets a `HEFScrub` global and `node --test` can require() it. +(function (root, factory) { + const api = factory(); + if (typeof module !== "undefined" && module.exports) module.exports = api; + else root.HEFScrub = api; +})(typeof self !== "undefined" ? self : this, function () { + "use strict"; + + const clamp01 = (x) => Math.max(0, Math.min(1, x)); + const wrapIndex = (i, n) => ((i % n) + n) % n; + + function segmentOf(pos) { + const lo = Math.floor(pos); + return { lo, frac: pos - lo }; + } + + function accumToPos(restPos, accumDeg, stepDeg) { + return restPos + accumDeg / stepDeg; + } + + function fracToTime(frac, duration) { + return clamp01(frac) * (duration || 0); + } + + function crossfadeGains(frac) { + const f = clamp01(frac); + return { from: 1 - f, to: f }; + } + + // Integers strictly crossed moving prevPos -> pos, in travel order. Landing + // exactly on an integer counts as crossing it (it commits that altitude). + function integerCrossings(prevPos, pos) { + const out = []; + if (pos > prevPos) { + for (let k = Math.floor(prevPos) + 1; k <= pos + 1e-9; k++) out.push({ index: k, dir: 1 }); + } else if (pos < prevPos) { + for (let k = Math.ceil(prevPos) - 1; k >= pos - 1e-9; k--) out.push({ index: k, dir: -1 }); + } + return out; + } + + return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, integerCrossings }; +}); diff --git a/simulator/unit/README.md b/simulator/unit/README.md new file mode 100644 index 0000000..e7ce2bf --- /dev/null +++ b/simulator/unit/README.md @@ -0,0 +1,8 @@ +# Simulator unit tests + +Pure-logic JS unit tests (no browser), run with Node's built-in test runner: + + node --test simulator/unit/ + +`scrub.test.js` covers `simulator/static/scrub.js` — the pure altitude-scrub math +(position→segment, frac→currentTime, audio gains, integer-crossing detection). diff --git a/simulator/unit/scrub.test.js b/simulator/unit/scrub.test.js new file mode 100644 index 0000000..039120d --- /dev/null +++ b/simulator/unit/scrub.test.js @@ -0,0 +1,60 @@ +"use strict"; +const test = require("node:test"); +const assert = require("node:assert/strict"); +const S = require("../static/scrub.js"); + +test("clamp01 bounds to [0,1]", () => { + assert.equal(S.clamp01(-0.2), 0); + assert.equal(S.clamp01(0.4), 0.4); + assert.equal(S.clamp01(1.5), 1); +}); + +test("wrapIndex wraps both directions", () => { + assert.equal(S.wrapIndex(0, 5), 0); + assert.equal(S.wrapIndex(5, 5), 0); + assert.equal(S.wrapIndex(-1, 5), 4); + assert.equal(S.wrapIndex(7, 5), 2); +}); + +test("segmentOf splits floor and fraction", () => { + assert.deepEqual(S.segmentOf(2), { lo: 2, frac: 0 }); + const s = S.segmentOf(2.4); + assert.equal(s.lo, 2); + assert.ok(Math.abs(s.frac - 0.4) < 1e-9); +}); + +test("accumToPos maps knob degrees to position", () => { + assert.equal(S.accumToPos(2, 0, 72), 2); + assert.equal(S.accumToPos(2, 72, 72), 3); + assert.equal(S.accumToPos(2, -36, 72), 1.5); +}); + +test("fracToTime clamps and scales by duration", () => { + assert.equal(S.fracToTime(0, 1.5), 0); + assert.equal(S.fracToTime(0.5, 1.5), 0.75); + assert.equal(S.fracToTime(1.2, 1.5), 1.5); + assert.equal(S.fracToTime(0.5, 0), 0); +}); + +test("crossfadeGains splits energy by fraction", () => { + assert.deepEqual(S.crossfadeGains(0), { from: 1, to: 0 }); + assert.deepEqual(S.crossfadeGains(1), { from: 0, to: 1 }); + assert.deepEqual(S.crossfadeGains(0.25), { from: 0.75, to: 0.25 }); +}); + +test("integerCrossings: none while inside a segment", () => { + assert.deepEqual(S.integerCrossings(2.1, 2.9), []); + assert.deepEqual(S.integerCrossings(2.0, 2.5), []); + assert.deepEqual(S.integerCrossings(3.0, 2.5), []); +}); + +test("integerCrossings: ascending and descending single crossings", () => { + assert.deepEqual(S.integerCrossings(2.4, 3.0), [{ index: 3, dir: 1 }]); + assert.deepEqual(S.integerCrossings(2.0, 3.0), [{ index: 3, dir: 1 }]); + assert.deepEqual(S.integerCrossings(3.4, 3.0), [{ index: 3, dir: -1 }]); +}); + +test("integerCrossings: multiple crossings in travel order", () => { + assert.deepEqual(S.integerCrossings(2.4, 4.1), [{ index: 3, dir: 1 }, { index: 4, dir: 1 }]); + assert.deepEqual(S.integerCrossings(3.2, 1.8), [{ index: 3, dir: -1 }, { index: 2, dir: -1 }]); +});