feat(sim): scrub-driven altitude transitions #28
@@ -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 };
|
||||
});
|
||||
@@ -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).
|
||||
@@ -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 }]);
|
||||
});
|
||||
Reference in New Issue
Block a user