Compare commits

...

4 Commits

Author SHA1 Message Date
BenStullsBets 08cc7c6355 docs(audio): binaural stress-relief candidate pool + local review page
11 CC0 stereo binaural/entrainment candidates (verified on freesound) for
evolving the Music option into a crossfading stress-relief set. Durable record
in docs/binaural-candidate-pool.md; interactive audition gallery at
simulator/static/review_binaural.html (gitignored local-only, like the other
review galleries — streams the CC0 previews, tick-to-keep + copy picks).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 05:51:18 -07:00
BenStullsBets 9d42ed7fc4 feat(altitude): auto-advance one altitude per minute when idle
After the dial sits idle for 60s the altitude drifts one step deeper (descend;
wraps past the deepest back to the top), like a slow kiosk tour. Any manual
interaction (drag/wheel/arrow-key/knob) restarts the idle countdown; a mid-morph
or mid-drag beat is skipped and retried. A default-on 'Auto-advance' toggle in
the Altitude panel disables it. Interval is injectable for tests via
window.__hefAutoAdvanceMs. i18n for 4 langs. +3 e2e (auto-advance.spec.ts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 05:44:41 -07:00
BenStullsBets 88ebb07578 fix(ui): stop the 'Video speed' label wrapping in the Output panel
Widen the panel (280->300px), keep control labels on one line (white-space:
nowrap), and let the range shrink (min-width:0) instead of pushing the label
to a second row. +1 e2e (panel-layout.spec.ts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 05:40:05 -07:00
BenStullsBets 2f8641cb28 fix(mood): keep the Dark/Light grade applied through altitude morphs
The mood grade was dropped while a morph played (busy): the WebGL canvas set
filter 'none' and the fallback set #vid's filter 'none' on morph load, so the
transition showed ungraded and the grade snapped back only on landing. Now the
grade rides through the morph on both paths (the painterly DREAM stays off mid-
morph, as before, to keep it crisp). +1 e2e (mood-grade.spec.ts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 05:38:19 -07:00
9 changed files with 243 additions and 7 deletions
+1
View File
@@ -23,6 +23,7 @@ simulator/sample_media/**/*.m4a
# Local-only candidate review galleries (re-buildable; not shipped content).
simulator/static/review.html
simulator/static/review_audio.html
simulator/static/review_binaural.html
# Editor annotation/comment-thread metadata (not project content)
.threads/
+68
View File
@@ -0,0 +1,68 @@
# Binaural / entrainment candidate pool — stress-relief "Music" tracks (2026-07-02)
Candidates for evolving the **Music** audio option into a stress-relief set of
**binaural-beat / brainwave-entrainment** tracks that **crossfade between each
other** (not a single loop). Operator brief: "binaural beats for stress relief."
**All 11 below are CC0** (public domain — no attribution legally required; we credit
anyway as courtesy), **all stereo**, all sourced from freesound.org and license-
verified by fetching each page. Review gallery (gitignored, local-only, like
`review_audio.html`): `simulator/static/review_binaural.html` → open
`/review_binaural.html` with the sim server running. It streams each track's public
HQ preview so you can audition + tick keepers.
## Caveats (operator, read before picking)
- **True binaural (#17) needs headphones.** The beat *is* the L/R frequency
difference, so on speakers it collapses to a steady carrier tone (no entrainment).
**#811** are ambient sub-bass / pulsing drones that keep the calm feel on speakers.
- **Band coverage:** heavy on **delta (0.51.5 Hz)** + one clean **theta (4 Hz, #2)**.
**No CC0 alpha (812 Hz)** track surfaced — widen to CC-BY (FMA/ccMixter) if wanted.
- **No clean CC0/CC-BY isochronic or monaural** (the speaker-correct entrainment type)
was found — every hit was NC/SA or unlicensed (see rejects). For speaker playback,
lean on #811 for the "beat" feel.
- **Previews vs originals:** the `cdn.freesound.org/…-hq.mp3` URLs are public streamable
previews (verified live, HTTP 206). The **originals** (WAV/AIF/full-MP3) need a free
freesound login; CC0 permits self-hosting, so for keepers download the originals and
host on R2 — do not hot-link the CDN in production.
## Candidates
| # | id | title | artist | source page | license | dur | type | beat / carrier |
|---|---|---|---|---|---|---|---|---|
| 1 | `fs75309` | 60-64-delta | iantm | https://freesound.org/people/iantm/sounds/75309/ | CC0 | 0:30 | true-binaural | 4 Hz · 60/64 Hz |
| 2 | `fs457654` | Binaural Beats (Theta Waves 4 Hz) | Fabrizio84 | https://freesound.org/people/Fabrizio84/sounds/457654/ | CC0 | 30:00 | true-binaural | 4 Hz theta |
| 3 | `fs803742` | 1.23456789 Hz binaural beat (69L/70.23R) | itchytooth | https://freesound.org/people/itchytooth/sounds/803742/ | CC0 | 60:00 | true-binaural | 1.23 Hz delta · 69/70.23 Hz |
| 4 | `fs414271` | Binaural Delta 440/440.5 Hz | pbabin | https://freesound.org/people/pbabin/sounds/414271/ | CC0 | 10:00 | true-binaural | 0.5 Hz · 440/440.5 Hz |
| 5 | `fs414272` | Binaural Delta 500/501.5 Hz | pbabin | https://freesound.org/people/pbabin/sounds/414272/ | CC0 | 10:00 | true-binaural | 1.5 Hz · 500/501.5 Hz |
| 6 | `fs414273` | Binaural Delta 52/52.5 Hz | pbabin | https://freesound.org/people/pbabin/sounds/414273/ | CC0 | 10:00 | true-binaural | 0.5 Hz · 52/52.5 Hz |
| 7 | `fs414274` | Binaural Delta 60/61.5 Hz | pbabin | https://freesound.org/people/pbabin/sounds/414274/ | CC0 | 10:00 | true-binaural | 1.5 Hz · 60/61.5 Hz |
| 8 | `fs856225` | Delta Pulse Subharmonic Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/856225/ | CC0 | 5:00 | ambient+beats (speaker-ok) | delta sub-bass (procedural) |
| 9 | `fs854874` | Void Monolith Subharmonic Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/854874/ | CC0 | 5:00 | ambient (speaker-ok) | dark sub-drone |
| 10 | `fs860122` | Expanding & Vibrating Stereo Pulsating Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/860122/ | CC0 | 2:00 | ambient+beats (speaker-ok) | slow stereo pulsation |
| 11 | `fs493524` | Pulse Breath *(lower pref)* | phylobates | https://freesound.org/people/phylobates/sounds/493524/ | CC0 | 1:40 | ambient binaural | octaves of G / G♭ |
Preview MP3s (for the review page) follow `https://cdn.freesound.org/previews/<dir>/<id>_<uploader>-hq.mp3`
— exact URLs are embedded in `review_binaural.html`.
## Rejected / could not verify
- **archive.org "Binaural Beats Alpha/Beta/Theta/Delta"** & **"Isochronic Tones &
Binaural Beats Combined"** (loopool / Jean-Paul Garnier) — **CC BY-NC-SA 3.0** (NC).
The most promising isochronic set — rejected for the NC clause.
- **archive.org "Om Alpha Brainwave Entrainment Loop"** (Entraino / KS Chauhan;
freesound chauhans/100370) — **CC BY-NC 3.0**. Rejected (NC).
- **archive.org "1hr 30 Min Theta Binaural Beat (7 Hz)"** (Chari G) — no license shown.
Rejected (unverifiable).
- **freesound "deadthetawaves.wav"** (NoiseCollector/43295) — license fine (**CC BY 3.0**)
but "evil sounding loop of the living dead" — rejected for *fit*, not license. Back
pocket only if an unsettling deep-scale track is ever wanted.
- **TunePocket / dialedggsound / miraclefrequencies / audio.com "Theta 7Hz"** —
"royalty-free"/commercial pages, murky redistribution, no per-track CC license.
## Next
Operator auditions `review_binaural.html`, ticks keepers (Copy picks). Then we wire the
playback: the **Music** source becomes a multi-track set that crossfades between the kept
tracks (architecture decision — replace vs. join the ethereal loop — deferred to that
step; see [[audio-music-option]]).
+55
View File
@@ -0,0 +1,55 @@
import { test, expect, Page } from "@playwright/test";
// Auto-advance: after the dial sits idle for the interval, the altitude drifts one
// step deeper (descend), wrapping past the deepest. A default-on toggle disables it.
// The e2e injects a short interval via window.__hefAutoAdvanceMs before load.
async function boot(page: Page, intervalMs: number) {
await page.addInitScript((ms) => { (window as any).__hefAutoAdvanceMs = ms; }, intervalMs);
await page.goto("/");
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
await page.locator("#welcome-launch").click();
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
}
const idx = (page: Page) => page.evaluate(() => (window as any).__hefState().ringIndex as number);
test("auto-advances one altitude (descending) after the idle interval", async ({ page }) => {
await boot(page, 1200);
const n = await page.evaluate(() => (window as any).__hefState && (document.querySelectorAll(".dial-label").length));
const start = await idx(page);
// Wait past the interval + the morph animation for the landing.
await page.waitForFunction((s) => (window as any).__hefState().ringIndex !== s, start, { timeout: 8000 });
const after = await idx(page);
expect(after).toBe((start + 1) % n); // descended exactly one, wraps
});
test("Auto-advance off keeps the altitude parked", async ({ page }) => {
// Disable BEFORE launch so the idle timer never arms (deterministic — no race with
// the first interval firing during boot).
await page.addInitScript(() => { (window as any).__hefAutoAdvanceMs = 800; });
await page.goto("/");
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
await page.evaluate(() => { (document.querySelector("#auto-advance") as HTMLInputElement).checked = false; });
await page.locator("#welcome-launch").click();
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
const start = await idx(page);
await page.waitForTimeout(2200); // well past two 800ms intervals
expect(await idx(page)).toBe(start); // never moved
});
test("the idle countdown resets on interaction (no advance right after a manual turn)", async ({ page }) => {
await boot(page, 1500);
// Immediately before the first interval elapses, interact (keyboard step). That step
// itself advances by 1; the reset then means no AUTO advance fires for another
// full interval — so shortly after, we should still be at exactly that +1.
await page.waitForTimeout(900);
const before = await idx(page);
await page.locator("#dial").focus();
await page.locator("#dial").press("ArrowDown"); // manual descend + reset
await page.waitForFunction((b) => (window as any).__hefState().ringIndex !== b, before, { timeout: 4000 });
const manual = await idx(page);
await page.waitForTimeout(900); // < the 1500ms interval
expect(await idx(page)).toBe(manual); // countdown reset → no extra auto step yet
});
+46
View File
@@ -0,0 +1,46 @@
import { test, expect, Page } from "@playwright/test";
// Bug: the Dark/Light Mood grade was dropped DURING an altitude morph — the morph
// showed ungraded ("normal") and the grade snapped back only on landing. Both render
// paths were at fault: the WebGL canvas set filter "none" while `busy`, and the
// fallback set #vid's filter "none" when a morph loaded. The grade must ride through.
async function boot(page: Page) {
await page.goto("/");
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
await page.locator("#welcome-launch").click();
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
await expect(page.locator("#scale-name")).toBeVisible();
}
test("Dark Mood grade stays applied to the morph while scrubbing", async ({ page }) => {
await boot(page);
// Drive Mood hard dark; wait for the debounced render to build the grade.
await page.evaluate(() => {
const m = document.querySelector("#mood") as HTMLInputElement;
m.value = "-4";
m.dispatchEvent(new Event("input", { bubbles: true }));
});
await page.waitForTimeout(300);
// Begin a drag → a morph plays (busy = true). Sample the VISIBLE morph surface:
// the paint canvas (WebGL path) or #vid (fallback). #vid-loop is hidden mid-morph,
// so we deliberately exclude it.
const box = (await page.locator("#dial").boundingBox())!;
const cx = box.x + box.width / 2, cy = box.y + box.height / 2;
await page.mouse.move(cx, cy - 30);
await page.mouse.down();
await page.mouse.move(cx + 18, cy - 24, { steps: 8 });
await page.waitForFunction(() => (document.querySelector("#vid") as HTMLVideoElement).currentTime > 0, null, { timeout: 5000 });
const filters = await page.evaluate(() => [
(document.querySelector("#paint") as HTMLElement).style.filter,
(document.querySelector("#vid") as HTMLElement).style.filter,
]);
await page.mouse.up();
// Dark grade is brightness(<1)… — at least one visible surface must carry it.
// Before the fix both were "none" here.
const graded = filters.some((f) => /brightness\(/.test(f) && !/brightness\(1(\.0+)?\)/.test(f));
expect(graded, `visible morph surface must carry the dark grade; saw ${JSON.stringify(filters)}`).toBe(true);
});
+25
View File
@@ -0,0 +1,25 @@
import { test, expect, Page } from "@playwright/test";
// Guard: the "Video speed" control label must stay on ONE line in the Output panel
// (it was wrapping to two rows at the old panel width).
async function enter(page: Page) {
await page.goto("/");
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
await page.locator("#welcome-launch").click();
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
}
test('the "Video speed" label does not wrap to a second line', async ({ page }) => {
await enter(page);
const info = await page.evaluate(() => {
const span = document.querySelector('.panel .play-speed span[data-i18n="speed.label"]') as HTMLElement;
const cs = getComputedStyle(span);
let lh = parseFloat(cs.lineHeight);
if (Number.isNaN(lh)) lh = parseFloat(cs.fontSize) * 1.3; // "normal"
return { text: span.textContent, height: span.getBoundingClientRect().height, lineHeight: lh };
});
expect(info.text).toBe("Video speed");
// A wrapped label is ~2× a single line; allow slack but stay well under two lines.
expect(info.height).toBeLessThan(info.lineHeight * 1.6);
});
+37 -5
View File
@@ -451,11 +451,13 @@ function paintLoop() {
if (paint.width !== w || paint.height !== h) { paint.width = w; paint.height = h; }
gl.viewport(0, 0, w, h);
try { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, src); } catch (_) {}
// No painterly restyle during a ring transition (busy) — show it raw.
// No painterly DREAM during a ring transition (busy) — show the morph crisp.
// The Dark/Light mood GRADE, however, must ride through the transition too
// (else the morph shows ungraded and the grade snaps back only on landing).
gl.uniform1f(uAmt, busy ? 0 : dreamIntensity);
gl.uniform2f(uTexel, 1 / w, 1 / h);
gl.drawArrays(gl.TRIANGLES, 0, 3);
paint.style.filter = busy ? "none" : gradeFilter;
paint.style.filter = gradeFilter;
}
// Animate per-frame: re-place keyframed tracks AND re-evaluate time-windowed
// labels (a label enters/leaves frame as the loop plays) against playback time.
@@ -846,6 +848,7 @@ function autoScrub(targetPos, perAltMs = PER_ALTITUDE_MS) {
let wheelAccum = 0, wheelTimer = null;
function onWheel(e) {
e.preventDefault();
bumpAutoAdvance();
wheelAccum += e.deltaY;
clearTimeout(wheelTimer);
wheelTimer = setTimeout(() => {
@@ -855,6 +858,27 @@ function onWheel(e) {
}, 90);
}
// --- Auto-advance: after the dial sits idle for AUTO_ADVANCE_MS, drift one altitude
// deeper (descend; past the deepest it wraps back to the top), a slow kiosk tour. Any
// manual interaction restarts the idle countdown; a default-on toggle disables it.
// (E2E injects a short interval via window.__hefAutoAdvanceMs before load.)
const AUTO_ADVANCE_MS = (typeof window !== "undefined" && window.__hefAutoAdvanceMs) || 60000;
let autoAdvanceTimer = null;
function autoAdvanceOn() { const el = $("auto-advance"); return el ? el.checked : true; }
function scheduleAutoAdvance() {
clearTimeout(autoAdvanceTimer);
autoAdvanceTimer = null;
if (!autoAdvanceOn() || !videoEverOn) return; // only while the experience is running
autoAdvanceTimer = setTimeout(onAutoAdvance, AUTO_ADVANCE_MS);
}
function onAutoAdvance() {
// Skip a beat (but keep the cadence) if the visitor is mid-drag or a morph is playing.
if (autoAdvanceOn() && videoEverOn && !dialDrag && !busy) autoScrub(Math.round(pos) + 1);
scheduleAutoAdvance();
}
// Restart the idle countdown on any manual interaction (drag/wheel/key/knob).
function bumpAutoAdvance() { if (videoEverOn) scheduleAutoAdvance(); }
// --- Altitude knob: the endless rotary encoder, drawn as a turnable dial ---
// cosmos (highest) sits at the top; turning CLOCKWISE descends through the scales
// (cosmos → orbit → coast → reef → abyss) and past the deepest WRAPS back to the
@@ -983,7 +1007,10 @@ function rebuildSegment(lo, enteredFrom) {
overlay.style.opacity = "0";
affectLayer.style.opacity = "0";
tint.style.opacity = "0";
vid.style.filter = "none";
// WebGL path: the graded canvas sits on top, so #vid stays raw (no double-grade).
// Fallback path: #vid IS the visible surface, so it must carry the mood grade
// through the morph (setting "none" here was the "morph ignores Mood" bug).
vid.style.filter = paintOK ? "none" : gradeFilter;
vid.loop = false;
if (vid.dataset.morph !== file) {
vid.dataset.morph = file;
@@ -1059,6 +1086,7 @@ function setPos(next) {
function onDialDown(e) {
if (!ring || ring.scales.length < 2) return;
e.preventDefault();
bumpAutoAdvance();
dialDrag = { lastAng: dialAngle(e), accum: 0, moved: 0, target: e.target, restPos: pos };
}
function onDialMove(e) {
@@ -1085,6 +1113,7 @@ function onDialUp(e) {
// short way to the CLOSEST one and settle there (frac 0 locks). The result is the
// same as a tap/label-click: a grab-and-release always lands on a discrete altitude.
autoScrub(Math.round(pos));
bumpAutoAdvance();
}
// Click a label → auto-scrub the SHORTEST signed way around the ring to that scale.
@@ -1115,7 +1144,7 @@ function onDialKey(e) {
case "End": jumpToScale(ring.scales.length - 1); break;
default: handled = false;
}
if (handled) e.preventDefault();
if (handled) { e.preventDefault(); bumpAutoAdvance(); }
}
// --- Dev Mode: pool picker + live analysis, all under one toggle ---
@@ -1577,6 +1606,7 @@ function beginExperience() {
updateAudioLevelLabel();
applyAudio();
applyPlaySpeed();
scheduleAutoAdvance(); // start the idle-drift countdown now that we're running
}
// Welcome screen ----------------------------------------------------------------
@@ -1641,7 +1671,9 @@ async function main() {
initWelcome(); // wire the welcome-screen controls + Launch button
renderScaleReadout();
// Sliders stream on "input"; the toggles fire on "change".
for (const id of ["left", "right", "mood"]) $(id).addEventListener("input", debounced);
for (const id of ["left", "right", "mood"]) $(id).addEventListener("input", () => { bumpAutoAdvance(); debounced(); });
// Auto-advance toggle: (re)start or cancel the idle-drift countdown on change.
$("auto-advance").addEventListener("change", scheduleAutoAdvance);
// Audio level dial: set the master soundtrack gain. Reconcile SYNCHRONOUSLY in this
// input gesture so the first raise from 0 unlocks + plays on Safari (which blocks
// play() outside a user gesture).
+1
View File
@@ -29,6 +29,7 @@
"output.audio.music": { en: "Music", es: "Música", fr: "Musique", ja: "音楽" },
"output.volume": { en: "Volume", es: "Volumen", fr: "Volume", ja: "音量" },
"altitude.legend":{ en: "Altitude", es: "Altitud", fr: "Altitude", ja: "高度" },
"altitude.autoadvance": { en: "Auto-advance", es: "Avance automático", fr: "Avance automatique", ja: "自動で移動" },
"knobs.legend": { en: "Experience knobs", es: "Mandos de experiencia", fr: "Boutons dexpérience", ja: "体験つまみ" },
"knobs.think": { en: "Think", es: "Pensar", fr: "Penser", ja: "考える" },
"knobs.feel": { en: "Feel", es: "Sentir", fr: "Ressentir", ja: "感じる" },
+5
View File
@@ -73,6 +73,11 @@
aria-valuemin="0" aria-valuenow="0" aria-valuetext="cosmos"></svg>
</div>
<span id="scale-name" class="scale-name"></span>
<label class="dev-switch" for="auto-advance">
<input type="checkbox" id="auto-advance" checked />
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
<span class="dev-switch-label" data-i18n="altitude.autoadvance">Auto-advance</span>
</label>
</fieldset>
<fieldset>
+5 -2
View File
@@ -97,7 +97,7 @@ main { display: flex; gap: 1rem; padding: 1rem; flex-wrap: wrap; align-items: fl
.audio-status { margin-top: 0.5rem; font: 11px/1.4 monospace; color: #9ab; }
.audio-status.playing { color: #4e9; }
.audio-status.blocked { color: #f97; }
.panel { flex: 0 0 280px; display: flex; flex-direction: column; gap: 0.8rem;
.panel { flex: 0 0 300px; display: flex; flex-direction: column; gap: 0.8rem;
max-height: calc(100vh - 2rem); overflow-y: auto;
position: sticky; top: 1rem; }
/* The control panel stays hidden behind the welcome screen and appears only once
@@ -151,7 +151,10 @@ input[type=range], select { width: 100%; }
/* Audio level dial (010): label · slider · value on one row, matching the switch look. */
.audio-level { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.45rem;
color: #9af; font-weight: 600; letter-spacing: 0.3px; }
.audio-level input[type=range] { flex: 1; width: auto; }
/* Keep the control label on one line ("Video speed" was wrapping); let the range
shrink (min-width:0) instead of pushing the label to a second row. */
.audio-level > span:first-child { white-space: nowrap; }
.audio-level input[type=range] { flex: 1; width: auto; min-width: 0; }
#audio-level-val { font-variant-numeric: tabular-nums; min-width: 1.2em; text-align: right; }
/* Playback speed (2×…2×) reuses the audio-level row look; wider value field for the sign + decimals. */
#play-speed-val, #welcome-play-speed-val { font-variant-numeric: tabular-nums; min-width: 3.4em; text-align: right; }