From be13248bdd9462e1e3fc14561f67ffd914a91ae5 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Tue, 30 Jun 2026 07:25:24 -0700 Subject: [PATCH] feat(static): client-side alteration engine (port of player.alteration/audio) Co-Authored-By: Claude Opus 4.8 (1M context) --- simulator/static/alteration.js | 52 ++++++++++++++++++++++++++++++ simulator/unit/alteration.test.js | 51 +++++++++++++++++++++++++++++ tests/test_alteration_js_parity.py | 21 ++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 simulator/static/alteration.js create mode 100644 simulator/unit/alteration.test.js create mode 100644 tests/test_alteration_js_parity.py diff --git a/simulator/static/alteration.js b/simulator/static/alteration.js new file mode 100644 index 0000000..a49dc59 --- /dev/null +++ b/simulator/static/alteration.js @@ -0,0 +1,52 @@ +// Client-side port of player/alteration.py + player/audio.py (the alteration +// engine). Replaces the live POST /api/alteration in the static build — pure math, +// unity calibration (the client never sends a calibration, so DEFAULT_CALIBRATION +// = all gains 1.0 always applies). UMD so the browser gets `HEFAlteration` and +// `node --test` can require() it. Keep IN SYNC with the Python — the numeric +// contract is guarded by tests/test_alteration_js_parity.py. +(function (root, factory) { + const api = factory(); + if (typeof module !== "undefined" && module.exports) module.exports = api; + else root.HEFAlteration = api; +})(typeof self !== "undefined" ? self : this, function () { + "use strict"; + const KNOB_MAX = 4; // player/alteration.py:25 + const clamp = (x, lo, hi) => Math.max(lo, Math.min(hi, x)); + + // Mirror of player/alteration.py::plan_alteration with DEFAULT_CALIBRATION (unity). + function plan(c) { + const tone = clamp((c.light - c.dark) / KNOB_MAX, -1, 1); + const overlayIntensity = clamp(c.left / KNOB_MAX, 0, 1); + const dreamIntensity = clamp(c.right / KNOB_MAX, 0, 1); + const affectIntensity = clamp(c.right / KNOB_MAX, 0, 1); // affect uses overlay_gain too + const is_identity = tone === 0 && c.left === 0 && c.right === 0; + return { + grade: { tone }, + overlay: { level: c.left, intensity: overlayIntensity }, + affect: { strength: c.right, intensity: affectIntensity }, + dream: { strength: c.right, intensity: dreamIntensity }, + is_identity, + }; + } + + // Mirror of player/audio.py::resolve_audio. `scaleAudio` is the current scale's + // `audio` filename (relative to audio/); off → silence. + function renderAudio(source, scaleAudio, mediaBase) { + if (source === "off") return { source: "off", url: null, altitude_coupled: false }; + const url = scaleAudio ? mediaBase + "audio/" + scaleAudio : null; + return { source: "soundtrack", url, altitude_coupled: true }; + } + + // The full /api/alteration response shape, computed locally. + function alteration(controls, scaleAudio, mediaBase) { + return { + plan: plan(controls), + render: { + video: { shown: controls.visual === "on" }, // player/audio.py::resolve_visual + audio: renderAudio(controls.audio, scaleAudio, mediaBase), + }, + }; + } + + return { plan, renderAudio, alteration, KNOB_MAX }; +}); diff --git a/simulator/unit/alteration.test.js b/simulator/unit/alteration.test.js new file mode 100644 index 0000000..08cfacc --- /dev/null +++ b/simulator/unit/alteration.test.js @@ -0,0 +1,51 @@ +"use strict"; +const test = require("node:test"); +const assert = require("node:assert/strict"); +const A = require("../static/alteration.js"); + +const c = (o) => ({ visual: "on", audio: "off", left: 0, right: 0, dark: 0, light: 0, ...o }); + +test("identity at zero knobs", () => { + const p = A.plan(c({})); + assert.equal(p.grade.tone, 0); + assert.equal(p.overlay.level, 0); + assert.equal(p.dream.strength, 0); + assert.equal(p.is_identity, true); +}); + +test("mood tone is signed (light - dark)/KNOB_MAX, clamped", () => { + assert.equal(A.plan(c({ light: 4 })).grade.tone, 1); // full light + assert.equal(A.plan(c({ dark: 4 })).grade.tone, -1); // full dark + assert.equal(A.plan(c({ light: 2 })).grade.tone, 0.5); +}); + +test("left drives overlay level + intensity; right drives dream + affect", () => { + const p = A.plan(c({ left: 2, right: 4 })); + assert.equal(p.overlay.level, 2); + assert.equal(p.overlay.intensity, 0.5); + assert.equal(p.dream.strength, 4); + assert.equal(p.dream.intensity, 1); + assert.equal(p.affect.strength, 4); + assert.equal(p.affect.intensity, 1); + assert.equal(p.is_identity, false); +}); + +test("renderAudio: off → null, no coupling", () => { + assert.deepEqual(A.renderAudio("off", "cosmos.mp3", "https://x/"), + { source: "off", url: null, altitude_coupled: false }); +}); + +test("renderAudio: soundtrack → mediaBase audio url, coupled", () => { + assert.deepEqual(A.renderAudio("soundtrack", "cosmos.mp3", "https://x/"), + { source: "soundtrack", url: "https://x/audio/cosmos.mp3", altitude_coupled: true }); + // no scale audio → null url but still coupled + assert.deepEqual(A.renderAudio("soundtrack", "", "https://x/"), + { source: "soundtrack", url: null, altitude_coupled: true }); +}); + +test("alteration() composes plan + render with video.shown from visual", () => { + const r = A.alteration(c({ visual: "off", audio: "soundtrack", left: 1 }), "reef.mp3", "https://x/"); + assert.equal(r.render.video.shown, false); + assert.equal(r.render.audio.url, "https://x/audio/reef.mp3"); + assert.equal(r.plan.overlay.level, 1); +}); diff --git a/tests/test_alteration_js_parity.py b/tests/test_alteration_js_parity.py new file mode 100644 index 0000000..0067aa4 --- /dev/null +++ b/tests/test_alteration_js_parity.py @@ -0,0 +1,21 @@ +"""The static build reimplements plan_alteration/resolve_audio in JS +(simulator/static/alteration.js). This pins the numeric contract so the Python +and JS cannot silently diverge — if you change the engine, change both (the +mirrored JS cases live in simulator/unit/alteration.test.js).""" +from hef.selection import Coordinate +from player.alteration import plan_alteration, render_plan_to_dict +from player.audio import resolve_audio, resolve_visual + + +def test_engine_contract_matches_js_expectations(): + # mirrors the cases asserted in simulator/unit/alteration.test.js + p = render_plan_to_dict(plan_alteration(Coordinate(left=2, right=4, dark=0, light=0))) + assert p["overlay"] == {"level": 2, "intensity": 0.5} + assert p["dream"] == {"strength": 4, "intensity": 1.0} + assert p["affect"] == {"strength": 4, "intensity": 1.0} + assert render_plan_to_dict(plan_alteration(Coordinate(0, 0, 0, 4)))["grade"]["tone"] == 1.0 + assert render_plan_to_dict(plan_alteration(Coordinate(0, 0, 4, 0)))["grade"]["tone"] == -1.0 + assert render_plan_to_dict(plan_alteration(Coordinate(0, 0, 0, 0)))["is_identity"] is True + assert resolve_visual("off") is False + a = resolve_audio("soundtrack", scale_audio="cosmos.mp3") + assert a.url == "/media/audio/cosmos.mp3" and a.altitude_coupled is True