From 2f8641cb285d9164d7e6ea97fc900c62135c4ef5 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Thu, 2 Jul 2026 05:38:19 -0700 Subject: [PATCH 01/10] 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 --- simulator/e2e/tests/mood-grade.spec.ts | 46 ++++++++++++++++++++++++++ simulator/static/app.js | 11 ++++-- 2 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 simulator/e2e/tests/mood-grade.spec.ts diff --git a/simulator/e2e/tests/mood-grade.spec.ts b/simulator/e2e/tests/mood-grade.spec.ts new file mode 100644 index 0000000..1f97027 --- /dev/null +++ b/simulator/e2e/tests/mood-grade.spec.ts @@ -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); +}); diff --git a/simulator/static/app.js b/simulator/static/app.js index 18004dc..1a0ce84 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -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. @@ -983,7 +985,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; -- 2.39.5 From 88ebb075781d8376747d0b68220220f2bdcdd562 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Thu, 2 Jul 2026 05:40:05 -0700 Subject: [PATCH 02/10] 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 --- simulator/e2e/tests/panel-layout.spec.ts | 25 ++++++++++++++++++++++++ simulator/static/style.css | 7 +++++-- 2 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 simulator/e2e/tests/panel-layout.spec.ts diff --git a/simulator/e2e/tests/panel-layout.spec.ts b/simulator/e2e/tests/panel-layout.spec.ts new file mode 100644 index 0000000..8d6674a --- /dev/null +++ b/simulator/e2e/tests/panel-layout.spec.ts @@ -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); +}); diff --git a/simulator/static/style.css b/simulator/static/style.css index 2bba2f2..7bc2081 100644 --- a/simulator/static/style.css +++ b/simulator/static/style.css @@ -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 (0–10): 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; } -- 2.39.5 From 9d42ed7fc4c55d6ef47359e41cf8431cad9eef3d Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Thu, 2 Jul 2026 05:44:41 -0700 Subject: [PATCH 03/10] 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 --- simulator/e2e/tests/auto-advance.spec.ts | 52 ++++++++++++++++++++++++ simulator/static/app.js | 31 +++++++++++++- simulator/static/i18n.js | 1 + simulator/static/index.html | 5 +++ 4 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 simulator/e2e/tests/auto-advance.spec.ts diff --git a/simulator/e2e/tests/auto-advance.spec.ts b/simulator/e2e/tests/auto-advance.spec.ts new file mode 100644 index 0000000..88d5205 --- /dev/null +++ b/simulator/e2e/tests/auto-advance.spec.ts @@ -0,0 +1,52 @@ +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("toggling Auto-advance off keeps the altitude parked", async ({ page }) => { + await boot(page, 1000); + await page.evaluate(() => { + const c = document.querySelector("#auto-advance") as HTMLInputElement; + c.checked = false; + c.dispatchEvent(new Event("change", { bubbles: true })); + }); + const start = await idx(page); + await page.waitForTimeout(2600); // well past two 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 +}); diff --git a/simulator/static/app.js b/simulator/static/app.js index 1a0ce84..4902f1c 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -848,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(() => { @@ -857,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 @@ -1064,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) { @@ -1090,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. @@ -1120,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 --- @@ -1582,6 +1606,7 @@ function beginExperience() { updateAudioLevelLabel(); applyAudio(); applyPlaySpeed(); + scheduleAutoAdvance(); // start the idle-drift countdown now that we're running } // Welcome screen ---------------------------------------------------------------- @@ -1646,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). diff --git a/simulator/static/i18n.js b/simulator/static/i18n.js index b9d62ad..c7e1ab6 100644 --- a/simulator/static/i18n.js +++ b/simulator/static/i18n.js @@ -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 d’expérience", ja: "体験つまみ" }, "knobs.think": { en: "Think", es: "Pensar", fr: "Penser", ja: "考える" }, "knobs.feel": { en: "Feel", es: "Sentir", fr: "Ressentir", ja: "感じる" }, diff --git a/simulator/static/index.html b/simulator/static/index.html index c835324..b0b6179 100644 --- a/simulator/static/index.html +++ b/simulator/static/index.html @@ -73,6 +73,11 @@ aria-valuemin="0" aria-valuenow="0" aria-valuetext="cosmos"> +
-- 2.39.5 From 08cc7c635590f093cf0fb8f80c05c7cf0e5c9c6d Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Thu, 2 Jul 2026 05:47:19 -0700 Subject: [PATCH 04/10] docs(audio): binaural stress-relief candidate pool + local review page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 1 + docs/binaural-candidate-pool.md | 68 ++++++++++++++++++++++++ simulator/e2e/tests/auto-advance.spec.ts | 19 ++++--- 3 files changed, 80 insertions(+), 8 deletions(-) create mode 100644 docs/binaural-candidate-pool.md diff --git a/.gitignore b/.gitignore index d515d75..74dbe87 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/docs/binaural-candidate-pool.md b/docs/binaural-candidate-pool.md new file mode 100644 index 0000000..5f69ea1 --- /dev/null +++ b/docs/binaural-candidate-pool.md @@ -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 (#1–7) needs headphones.** The beat *is* the L/R frequency + difference, so on speakers it collapses to a steady carrier tone (no entrainment). + **#8–11** are ambient sub-bass / pulsing drones that keep the calm feel on speakers. +- **Band coverage:** heavy on **delta (0.5–1.5 Hz)** + one clean **theta (4 Hz, #2)**. + **No CC0 alpha (8–12 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 #8–11 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//_-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]]). diff --git a/simulator/e2e/tests/auto-advance.spec.ts b/simulator/e2e/tests/auto-advance.spec.ts index 88d5205..fb5e4cf 100644 --- a/simulator/e2e/tests/auto-advance.spec.ts +++ b/simulator/e2e/tests/auto-advance.spec.ts @@ -24,15 +24,18 @@ test("auto-advances one altitude (descending) after the idle interval", async ({ expect(after).toBe((start + 1) % n); // descended exactly one, wraps }); -test("toggling Auto-advance off keeps the altitude parked", async ({ page }) => { - await boot(page, 1000); - await page.evaluate(() => { - const c = document.querySelector("#auto-advance") as HTMLInputElement; - c.checked = false; - c.dispatchEvent(new Event("change", { bubbles: true })); - }); +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(2600); // well past two intervals + await page.waitForTimeout(2200); // well past two 800ms intervals expect(await idx(page)).toBe(start); // never moved }); -- 2.39.5 From 028d02a0333b5be3f499fcf7bd67442aa0435027 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Thu, 2 Jul 2026 06:43:01 -0700 Subject: [PATCH 05/10] docs(audio): add Round-2 musical/evolving binaural candidates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 11 more candidates for the 'not boring' end: MANTICE Sacred Drone series (bassimat, CC0 — evolving composed ambient, binaural-capable via the generator but not certified per render), HoliznaCC0 long-form ambient music (FMA, CC0, no binaural), and shagrugge (ccMixter, CC BY, binaural-filtered vocals). Notes the CC BY-SA 'Hang Binaural' opt-in (best musical+confirmed-binaural fit, outside the CC0/CC-BY rule) and the NC drops. review_binaural.html now groups all 22 into Musical / True-binaural / Ambient sections with honest binaural-status badges. Co-Authored-By: Claude Opus 4.8 --- docs/binaural-candidate-pool.md | 37 +++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs/binaural-candidate-pool.md b/docs/binaural-candidate-pool.md index 5f69ea1..950465a 100644 --- a/docs/binaural-candidate-pool.md +++ b/docs/binaural-candidate-pool.md @@ -45,6 +45,39 @@ HQ preview so you can audition + tick keepers. Preview MP3s (for the review page) follow `https://cdn.freesound.org/previews//_-hq.mp3` — exact URLs are embedded in `review_binaural.html`. +## Round 2 — musical / evolving (the "not boring" set, 2026-07-02) + +Operator found the pure-tone set (#1–7) too plain. **Reality:** truly *musical* tracks +with a **confirmed** binaural layer are almost all NC (see rejects). Best legitimate +options — pick for how they *sound*; treat binaural as a bonus: + +| id | title | artist | source | license | dur | binaural? | +|---|---|---|---|---|---|---| +| `fs856223` | Theta Gateway Sacred Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/856223/ | CC0 | 5:00 | capable (theta band; not certified on render) | +| `fs856221` | Raga of Stillness Sacred Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/856221/ | CC0 | 5:00 | capable (Δ/θ/α/β optional) | +| `fs856220` | Ice Cathedral Sacred Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/856220/ | CC0 | 5:00 | capable | +| `fs856224` | Vowel of the Deep Sacred Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/856224/ | CC0 | 5:00 | capable | +| `fs856222` | Stellar Vowel Sacred Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/856222/ | CC0 | 5:00 | capable | +| `fs856329` | Harmonic Chamber High-Frequency Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/856329/ | CC0 | 5:00 | capable | +| `fs854438` | Immersive Ethereal Generative Ambient (Floating Harmonics) | bassimat | https://freesound.org/people/bassimat/sounds/854438/ | CC0 | 11:33 | no (richest single piece) | +| `fma_cosmic` | Cosmic Waves | HoliznaCC0 | https://freemusicarchive.org/music/holiznacc0/space-sleep-meditation/cosmic-waves/ | CC0 | 33:04 | no (long-form melodic pads) | +| `fma_dream` | DreamScape | HoliznaCC0 | https://freemusicarchive.org/music/holiznacc0/space-sleep-meditation/dreamscape/ | CC0 | 21:59 | no | +| `fma_rain` | Rain / Sleep / Meditation | HoliznaCC0 | https://freemusicarchive.org/music/holiznacc0/space-sleep-meditation/rain-sleep-meditation/ | CC0 | 14:35 | no (rain + pads) | +| `ccm_shagrugge` | How to Get Rid of a Dream (real imagination mix) | shagrugge | http://dig.ccmixter.org/files/shagrugge/12629 | CC BY 3.0 | ~ | yes-ish (binaural-filtered vocals; experimental) | + +- The MANTICE **Sacred Drone** series is the composed extension of Round-1 #8–10 (same + generator; the generator *does* binaural entrainment across Δ/θ/α/β, but the individual + freesound renders don't certify the beat is on — bassimat could confirm/render on request). +- **HoliznaCC0** (FMA, CC0) = the strongest pure-*music* options; no binaural layer. +- FMA `files.freemusicarchive.org/...` URLs verified live (206) — safe to self-host. + The **ccMixter** shagrugge URL is **hotlink-protected** (403) — download in a browser and self-host. + +### Opt-in (outside the CC0/CC-BY rule — needs operator OK) + +- **"Hang Binaural" — Mindseye** — https://freemusicarchive.org/music/MindsEye/single/hang-binaural + — **CC BY-SA 4.0**. The single best musical+*confirmed*-binaural fit (hang-drum music + + real binaural beats, 3:36, stereo), but ShareAlike copyleft. Add only if SA is acceptable. + ## Rejected / could not verify - **archive.org "Binaural Beats – Alpha/Beta/Theta/Delta"** & **"Isochronic Tones & @@ -59,6 +92,10 @@ Preview MP3s (for the review page) follow `https://cdn.freesound.org/previews/ Date: Thu, 2 Jul 2026 14:20:31 -0700 Subject: [PATCH 06/10] docs(audio): pivot Music direction to mellow chordal synth Operator dropped binaural. New direction: mellow, soothing synth with real chord movement. docs/music-candidate-pool.md = 12 ranked + spares, all CC0 (HoliznaCC0 lo-fi) or CC BY (Kevin MacLeod calming synth), verified live. Local review gallery simulator/static/review_music.html (gitignored). Binaural doc parked with a pointer. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 1 + docs/binaural-candidate-pool.md | 5 +++ docs/music-candidate-pool.md | 72 +++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 docs/music-candidate-pool.md diff --git a/.gitignore b/.gitignore index 74dbe87..2060ead 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ simulator/sample_media/**/*.m4a simulator/static/review.html simulator/static/review_audio.html simulator/static/review_binaural.html +simulator/static/review_music.html # Editor annotation/comment-thread metadata (not project content) .threads/ diff --git a/docs/binaural-candidate-pool.md b/docs/binaural-candidate-pool.md index 950465a..6e7e1bd 100644 --- a/docs/binaural-candidate-pool.md +++ b/docs/binaural-candidate-pool.md @@ -1,5 +1,10 @@ # Binaural / entrainment candidate pool — stress-relief "Music" tracks (2026-07-02) +> **PARKED (2026-07-02):** the operator dropped the binaural direction in favor of +> **mellow chordal synth music** — see `docs/music-candidate-pool.md`. This file is kept +> as the record of the binaural exploration (and its NC-licensing dead-ends). + + 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." diff --git a/docs/music-candidate-pool.md b/docs/music-candidate-pool.md new file mode 100644 index 0000000..888293e --- /dev/null +++ b/docs/music-candidate-pool.md @@ -0,0 +1,72 @@ +# Music candidate pool — mellow chordal synth (2026-07-02) + +The chosen direction for the **Music** audio option: **mellow, soothing synth music with +real chord movement** ("many chords" — progressions that evolve, not static drones or +binaural tones). Supersedes the binaural exploration ([[binaural-candidate-pool]] — parked). +We'll use **several** tracks and **crossfade between them** (not loop one). + +All **CC0 or CC-BY** (verified by fetching each page + a live HTTP 206 check on every audio +URL). Local review gallery (gitignored, like the other review pages): +`simulator/static/review_music.html` → open `/review_music.html` with the sim server running. + +**Honesty flag:** licenses + URLs are verified; the "harmony" notes are inferred from +genre/reputation, not a by-ear check — audition before final selection. + +## Two license families + +- **HoliznaCC0 = CC0** — zero attribution burden (simplest for the credits page). +- **Kevin MacLeod = CC BY 4.0** — must credit, e.g. *"Chill Wave by Kevin MacLeod — + incompetech.com — Licensed under Creative Commons: By Attribution 4.0."* The credits page + already exists, so this is fine. +- **Self-host everything** on R2 for the install — the `files.freemusicarchive.org` and + `incompetech.com` URLs are live/hot-linkable now but shouldn't be depended on in prod. + +## Candidates (ranked by chord movement) + +### Lo-fi / dreamy — HoliznaCC0 (CC0) + +| id | title | source page | mp3 (verified 206) | dur | harmony | +|---|---|---|---|---|---| +| `tokyo` | Tokyo Sunset | https://freemusicarchive.org/music/holiznacc0/public-domain-lofi/tokyo-sunset-lofi-peaceful-soft/ | .../Xnd9Hr5AVzB68IlWcImKtXPlwCePD2G2m8ZFSVj4.mp3 | 2:18 | jazzy extended-chord progression | +| `reverie` | Dreamy Reverie | https://freemusicarchive.org/music/holiznacc0/public-domain-lofi/dreamy-reverie-lofi-nostalgic-chill/ | .../Bo2XWazV4kHpnPksjJGRRLr16EmOE44NZnMITZaB.mp3 | 2:23 | moving 7th/9th chords | +| `complic` | Complicated Feelings | https://freemusicarchive.org/music/holiznacc0/public-domain-lofi/complicated-feelings-lofi-dreamy-relaxed/ | .../jEFkzWNxxtz76vjPoUOjZALHvdK3HkVMQAnGpgTn.mp3 | 2:39 | bittersweet changes | +| `drift` | Peaceful Drift | https://freemusicarchive.org/music/holiznacc0/public-domain-lofi/peaceful-drift-lofi-nostalgic-calm/ | .../SQvtLguk6S1VSthv0oXWycoB6ipUS0pt8jzAxxPq.mp3 | 2:08 | calm nostalgic progression | +| `currents`| Calm Currents | https://freemusicarchive.org/music/holiznacc0/public-domain-lofi/calm-currents-lofi-relax-calm/ | .../4rKapZUMNnNSPAOvpjlfSH6B5Ib8rgEWdvjnM7C6.mp3 | 2:27 | flowing chords | +| `moon` | Moon Unit | https://freemusicarchive.org/music/holiznacc0/public-domain-lofi/moon-unit-lofi-reflection-dreamy/ | .../CbNZO1QUuJq1f50RHzZ5kykNj1hdqT04UaWOYSNf.mp3 | 2:52 | reflective dreamy chords | +| `mist` | Into The Mist | https://freemusicarchive.org/music/holiznacc0/public-domain-lofi/into-the-mist-lofi-calm-relaxed/ | .../cI5iv9Xga0OOvGsS7mGZn9BlTshZdzAqLW7bmArA.mp3 | 2:09 | calm progression | +| `wave` | Wave Maker | https://freemusicarchive.org/music/holiznacc0/public-domain-lofi/wave-maker-lofi-dreamy-retro/ | .../SbPJTWMnBmT5yBsXP4jLfp6RsvWLhfOnbw2PxXol.mp3 | 3:05 | warm analog retro chords | + +*(mp3 host: `https://files.freemusicarchive.org/storage-freemusicarchive-org/tracks/`)* + +### Calming synth — Kevin MacLeod (CC BY 4.0 — credit required) + +| id | title | source page | mp3 (verified 206) | dur | harmony | +|---|---|---|---|---|---| +| `chillwave` | Chill Wave | https://archive.org/details/ChillWave_781 | https://incompetech.com/music/royalty-free/mp3-royaltyfree/Chill%20Wave.mp3 | 3:57 | clear repeating 4-chord cycle | +| `enchanted` | Enchanted Journey | https://freemusicarchive.org/music/Kevin_MacLeod/Calming | https://incompetech.com/music/royalty-free/mp3-royaltyfree/Enchanted%20Journey.mp3 | 4:29 | many changes, warm major | +| `dreamcult` | Dream Culture | https://freemusicarchive.org/music/Kevin_MacLeod/Calming | https://incompetech.com/music/royalty-free/mp3-royaltyfree/Dream%20Culture.mp3 | 3:34 | evolving chord sequence | +| `silverblue` | Silver Blue Light | https://freemusicarchive.org/music/Kevin_MacLeod/Calming | https://incompetech.com/music/royalty-free/mp3-royaltyfree/Silver%20Blue%20Light.mp3 | 5:50 | slow, deliberate changes | + +### Spares + +- **Clean Soul** — Kevin MacLeod — CC BY — 5:06 — `https://incompetech.com/music/royalty-free/mp3-royaltyfree/Clean%20Soul.mp3` +- **Old Age** — HoliznaCC0 — CC0 — 3:33 — https://freemusicarchive.org/music/holiznacc0/only-in-the-milky-way-part-1/old-age/ +- **Deja Vu** — HoliznaCC0 — CC0 — 2:04 — https://freemusicarchive.org/music/holiznacc0/only-in-the-milky-way-part-1/deja-vu/ +- More Kevin MacLeod (all CC BY 4.0, incompetech): Dreamer, Reawakening, Lightless Dawn, + Almost New, Angel Share. The whole **HoliznaCC0 "Public Domain Lofi"** album is 42 CC0 + tracks in this vibe — a ready-made rotation if wanted. + +## Rejected / not used + +- **Kevin MacLeod baroque/classical "public-domain" pieces** — the *composition* is PD but + incompetech states the *recording* is CC BY 4.0; harpsichord/orchestral, off-brief anyway. +- **NC finds** (great but non-commercial): Scott Holmes "Ambient Meditation" (CC BY-NC), + ccMixter chordal ambient (Jeris/Quarkstar/duckett — CC BY-NC). +- **"Hang Binaural" — Mindseye** — CC BY-SA (ShareAlike), excluded per the CC0/CC-BY rule. + +## Next + +Operator auditions `review_music.html`, ticks keepers (Copy picks). Then wire the **Music** +source to a multi-track set that crossfades between the kept tracks (replaces the single +ethereal loop; see [[audio-music-option]]). Kept CC-BY tracks get a credit line on +`credits.html`; all kept files self-hosted on R2. -- 2.39.5 From b0bd7843058e0dde71e22c915180c994f2c05dad Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Thu, 2 Jul 2026 15:32:23 -0700 Subject: [PATCH 07/10] docs(audio): drumless Round-4 KM candidates (like operator's 2 keepers) Operator kept Dream Culture + Silver Blue Light, rejected the rest (boring or too much snare). Round-4 = 12 Kevin MacLeod CC-BY tracks, drumless confirmed from his pieces.json instrument metadata, ranked by chord movement (best: Impact Lento, Dreams Become Real, Aretes, When The Wind Blows, Reawakening, Almost New). Lo-fi dropped. review_music.html rebuilt as the drumless cut. Co-Authored-By: Claude Opus 4.8 --- docs/music-candidate-pool.md | 50 +++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/docs/music-candidate-pool.md b/docs/music-candidate-pool.md index 888293e..cd44014 100644 --- a/docs/music-candidate-pool.md +++ b/docs/music-candidate-pool.md @@ -12,6 +12,14 @@ URL). Local review gallery (gitignored, like the other review pages): **Honesty flag:** licenses + URLs are verified; the "harmony" notes are inferred from genre/reputation, not a by-ear check — audition before final selection. +## Operator picks & refined brief (2026-07-02) + +Operator auditioned and **kept `dreamcult` (Dream Culture) + `silverblue` (Silver Blue Light)** +— both Kevin MacLeod, CC BY, drumless cinematic-calm synth. **Rejected everything else** as +"boring OR too much snare drum." So the refined target is **drumless, evolving-chord ambient +synth** in that exact lane — the HoliznaCC0 lo-fi (beat-driven) is OUT. A Round-4 search is +sourcing ~8–12 more like the two keepers (Kevin MacLeod's Calming/Ambient catalog first). + ## Two license families - **HoliznaCC0 = CC0** — zero attribution burden (simplest for the credits page). @@ -21,7 +29,47 @@ genre/reputation, not a by-ear check — audition before final selection. - **Self-host everything** on R2 for the install — the `files.freemusicarchive.org` and `incompetech.com` URLs are live/hot-linkable now but shouldn't be depended on in prod. -## Candidates (ranked by chord movement) +## Round 4 — drumless, chord-moving (the refined set, 2026-07-02) + +All **Kevin MacLeod, CC BY 4.0**, **drumless confirmed from his per-track `pieces.json` +instrument metadata** (tonal instruments only — no drum kit/percussion), verified live (206). +This is the authoritative candidate list; the lo-fi below (beat-driven) is dropped. + +*(mp3 host `https://incompetech.com/music/royalty-free/mp3-royaltyfree/.mp3`; source page +`https://incompetech.com/music/royalty-free/index.html?isrc=<code>`.)* + +**Best matches — metered, chords clearly move** (safest bets, like the two keepers): +| title | isrc | dur | instruments | harmony | +|---|---|---|---|---| +| Impact Lento | USUAN1100619 | 3:31 | Synths, Choir | "several striking chord progressions" | +| Dreams Become Real | USUAN1500027 | 6:50 | Piano, Synths | dreamy, layered, evolving (closest to Dream Culture) | +| Aretes | USUAN1100325 | 4:36 | Piano, Synths, Choir | warm major, clear motion | +| When The Wind Blows | USUAN1100717 | 4:26 | Guitar, Synths | lilting, calm (Silver-Blue-Light recipe) | +| Reawakening | USUAN1400017 | 3:34 | Piano, Cello | warm resolving progression | +| Almost New | USUAN1800008 | 3:23 | Piano, Basses | gentle moving harmony (documented) | + +**Long-form / floaty — drumless but slower-moving** (audition for "boring" risk): +| title | isrc | dur | instruments | note | +|---|---|---|---|---| +| Music for Manatees | USUAN1400009 | 17:36 | Piano, EP, Strings, Synth | slow consonant shifts | +| Clean Soul | USUAN1300033 | 5:07 | Electric Piano, Bass | spacious, sparser | +| Soaring | USUAN1600040 | 6:20 | Synths | evolving pads, mystical-calm | +| Dewdrop Fantasy | USUAN1700001 | 34:58 | Synths, Rain | slowly changing, lower density | +| Eastminster | USUAN1100719 | 6:45 | Plucked Strings, Synths | drone + koto, some tension — least chordal | +| Overheat | USUAN1100630 | 7:03 | Flute, Synth, Violin | trance-y minimalism | +| Concentration | — | 29:53 | Synths, EP | floaty, lower density | + +Reference: the kept **Dream Culture** is tagged "Piano, **Percussion**" (subtle beat); **Silver Blue +Light** is "Guitar, Synths" (drumless). The Round-4 picks are strictly tonal-only. A ~40-track +filtered pool of more drumless-calm-chordal KM tracks exists if a bigger rotation is wanted. + +**Rejected this round (percussion / not soothing):** Angel Share (Drums), Dreamer (Percussion, +bpm 100), Lightless Dawn (Percussion + unnerving), Anamalie (Percussion + dark), Chill Wave & +all HoliznaCC0 lo-fi (beat-driven). + +## Candidates — earlier mellow-synth round (lo-fi DROPPED, kept for record) + +Ranked by chord movement. ### Lo-fi / dreamy — HoliznaCC0 (CC0) -- 2.39.5 From 969236b3f78132960cc16aa43b0c46539de34c1a Mon Sep 17 00:00:00 2001 From: BenStullsBets <ben@benbetson.us> Date: Thu, 2 Jul 2026 16:00:59 -0700 Subject: [PATCH 08/10] feat(audio): shareable ?audio= deep-link presets the sound mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ?audio=none|soundtrack|music (and ?vol=0..10) presets the Audio source/volume on load, so a link opens straight into a chosen sound mode — e.g. share …/?audio=music with someone who'd prefer music, …/?audio=soundtrack for the ambience. Invalid values ignored (default Soundtrack stands). +5 e2e (url-params.spec.ts). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- simulator/e2e/tests/url-params.spec.ts | 47 ++++++++++++++++++++++++++ simulator/static/app.js | 23 +++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 simulator/e2e/tests/url-params.spec.ts diff --git a/simulator/e2e/tests/url-params.spec.ts b/simulator/e2e/tests/url-params.spec.ts new file mode 100644 index 0000000..d42fd36 --- /dev/null +++ b/simulator/e2e/tests/url-params.spec.ts @@ -0,0 +1,47 @@ +import { test, expect, Page } from "@playwright/test"; + +// Shareable deep-link: ?audio=none|soundtrack|music presets the Audio source on load +// (and ?vol=0..10 the master volume), so a link opens straight into a chosen sound mode. + +async function ready(page: Page) { + await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 }); +} +async function launch(page: Page) { + await page.locator("#welcome-launch").click(); + await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 }); +} +const srcVal = (page: Page) => page.locator("#audio-source").inputValue(); + +test("?audio=music preselects the Music source and survives launch", async ({ page }) => { + await page.goto("/?audio=music"); + await ready(page); + expect(await srcVal(page)).toBe("music"); // set at init, before launch + await launch(page); + expect(await srcVal(page)).toBe("music"); +}); + +test("?audio=soundtrack preselects Soundtrack", async ({ page }) => { + await page.goto("/?audio=soundtrack"); + await ready(page); + expect(await srcVal(page)).toBe("soundtrack"); +}); + +test("?audio=none preselects None and disables Volume", async ({ page }) => { + await page.goto("/?audio=none"); + await ready(page); + expect(await srcVal(page)).toBe("none"); + await expect(page.locator("#audio")).toBeDisabled(); +}); + +test("an invalid ?audio value is ignored (stays default Soundtrack)", async ({ page }) => { + await page.goto("/?audio=banana"); + await ready(page); + expect(await srcVal(page)).toBe("soundtrack"); +}); + +test("?vol sets the master volume for the shared link", async ({ page }) => { + await page.goto("/?audio=music&vol=7"); + await ready(page); + await launch(page); + expect(await page.locator("#audio").inputValue()).toBe("7"); +}); diff --git a/simulator/static/app.js b/simulator/static/app.js index 4902f1c..e949f6d 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -1410,6 +1410,27 @@ function syncVolumeDisabled() { if (slider) slider.disabled = audioSource() === "none"; } +// Shareable deep-link: ?audio=none|soundtrack|music presets the Audio source on load +// (and ?vol=0..10 the master volume), so a link opens straight into a chosen sound mode +// — e.g. share …/?audio=music with someone who'd prefer music over the soundtrack. The +// source is set on the (welcome-hidden) panel dropdown; the launch gesture then applies +// it. Invalid values are ignored (the default Soundtrack stands). +function applyUrlAudioParams() { + const p = new URLSearchParams(location.search); + const src = (p.get("audio") || "").toLowerCase(); + if (src === "none" || src === "soundtrack" || src === "music") { + const el = $("audio-source"); if (el) el.value = src; + } + const vol = p.get("vol"); + if (vol !== null && vol !== "" && Number.isFinite(+vol)) { + const v = String(Math.max(0, Math.min(10, Math.round(+vol)))); + // Seed BOTH the welcome mirror (carried into the panel on launch) and the panel + // slider, so the shared level survives entry. + const w = $("welcome-audio"); if (w) w.value = v; + const a = $("audio"); if (a) a.value = v; + } +} + // The element currently carrying the most signal — for the diagnostic readout, // which used to assume a single `aud`. With two crossfading elements either may be // the audible one, so report on whichever is louder (ties → aud). @@ -1682,7 +1703,9 @@ async function main() { // Audio source dropdown: reconcile SYNCHRONOUSLY in this change gesture so // switching to Soundtrack/Music unlocks + plays on Safari; None greys Volume. $("audio-source").addEventListener("change", () => { syncVolumeDisabled(); applyAudio(); debounced(); }); + applyUrlAudioParams(); // shareable ?audio=/?vol= deep-link presets syncVolumeDisabled(); + updateWelcomeAudioLabel(); // Video toggle (panel): the FIRST time video turns on without having entered via // Launch, begin the experience too — couple audio in. (Normally Launch entered // first, so videoEverOn is already true and this is a no-op beyond re-render.) -- 2.39.5 From 4f4f21a76728ef0b55fecb7abd9868dde1f2339e Mon Sep 17 00:00:00 2001 From: BenStullsBets <ben@benbetson.us> Date: Thu, 2 Jul 2026 16:22:28 -0700 Subject: [PATCH 09/10] feat(audio): Music becomes a crossfading playlist of 5 mellow synth tracks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single ethereal loop with the operator's chosen set — Music for Manatees, Dewdrop Fantasy, Eastminster, Overheat, Concentration (Kevin MacLeod, CC BY 4.0, drumless). The Music source now plays one track and every 5 min crossfades to a random OTHER track (pure HEFScrub.pickNextIndex, never an immediate repeat), altitude-independent, reusing the two audio elements; both primed in-gesture for Safari. Intervals injectable via window.__hefMusicRotateMs / __hefMusicXfadeMs. Media loudness-normalized + transcoded under the 25MB LFS limit (build_audio_media MUSIC), committed via LFS; ethereal loop retired. CC-BY attribution added to credits.html. Docs updated (music-candidate-pool = final set; audio-candidate-pool ethereal section superseded). +2 node unit (pickNextIndex), +2 e2e (music-playlist), audio-source spec updated. 40 unit + 49 e2e green; live probe confirms playback + rotation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- docs/audio-candidate-pool.md | 8 +- docs/music-candidate-pool.md | 21 ++++-- simulator/build_audio_media.py | 63 +++++++++++----- simulator/e2e/tests/audio-source.spec.ts | 19 +++-- simulator/e2e/tests/music-playlist.spec.ts | 57 +++++++++++++++ .../audio/music/concentration.mp3 | 3 + .../sample_media/audio/music/dewdrop.mp3 | 3 + .../sample_media/audio/music/eastminster.mp3 | 3 + .../audio/music/ethereal.loop.mp3 | 3 - .../sample_media/audio/music/ethereal.mp3 | 3 - .../sample_media/audio/music/manatees.mp3 | 3 + .../sample_media/audio/music/overheat.mp3 | 3 + simulator/static/app.js | 73 +++++++++++++++---- simulator/static/credits.html | 8 +- simulator/static/scrub.js | 12 ++- simulator/unit/audio.test.js | 20 +++++ 16 files changed, 246 insertions(+), 56 deletions(-) create mode 100644 simulator/e2e/tests/music-playlist.spec.ts create mode 100644 simulator/sample_media/audio/music/concentration.mp3 create mode 100644 simulator/sample_media/audio/music/dewdrop.mp3 create mode 100644 simulator/sample_media/audio/music/eastminster.mp3 delete mode 100644 simulator/sample_media/audio/music/ethereal.loop.mp3 delete mode 100644 simulator/sample_media/audio/music/ethereal.mp3 create mode 100644 simulator/sample_media/audio/music/manatees.mp3 create mode 100644 simulator/sample_media/audio/music/overheat.mp3 diff --git a/docs/audio-candidate-pool.md b/docs/audio-candidate-pool.md index 140c3b4..6073fa1 100644 --- a/docs/audio-candidate-pool.md +++ b/docs/audio-candidate-pool.md @@ -50,7 +50,13 @@ recordings (NASA/NOAA) keep the "machines reshaping perception" thesis literal. | 🏖 coast | `coast/waves` | Sea waves + tern calls | BigSoundBank #0267 — https://bigsoundbank.com/sea-waves-and-seagulls-s0267.html (`/UPLOAD/mp3/0267.mp3`) | 57s | CC0 | | 🕳 abyss | `abyss/whale` | NE Pacific blue-whale "AB call" | NOAA PMEL — https://www.pmel.noaa.gov/acoustics/multimedia/NETS_bluWhale.wav | 13s | PD (NOAA) | -## Music (the Audio-dropdown "Music" option, shipped 2026-07-01) +## Music (the Audio-dropdown "Music" option) + +> **SUPERSEDED 2026-07-02:** the single ethereal loop below was replaced by a +> **rotating crossfading playlist of mellow drumless synth** (Kevin MacLeod, CC BY) — +> see `docs/music-candidate-pool.md`. The ethereal-loop record is kept for history. + +### (history) ethereal loop — shipped 2026-07-01, then replaced **One** ethereal ambient loop, played the same at every altitude (altitude-independent — NOT the per-altitude idea the "cosmos/music" / "Frozen Star" rows above reserved; those diff --git a/docs/music-candidate-pool.md b/docs/music-candidate-pool.md index cd44014..fc42edc 100644 --- a/docs/music-candidate-pool.md +++ b/docs/music-candidate-pool.md @@ -12,13 +12,22 @@ URL). Local review gallery (gitignored, like the other review pages): **Honesty flag:** licenses + URLs are verified; the "harmony" notes are inferred from genre/reputation, not a by-ear check — audition before final selection. -## Operator picks & refined brief (2026-07-02) +## FINAL selection — WIRED 2026-07-02 -Operator auditioned and **kept `dreamcult` (Dream Culture) + `silverblue` (Silver Blue Light)** -— both Kevin MacLeod, CC BY, drumless cinematic-calm synth. **Rejected everything else** as -"boring OR too much snare drum." So the refined target is **drumless, evolving-chord ambient -synth** in that exact lane — the HoliznaCC0 lo-fi (beat-driven) is OUT. A Round-4 search is -sourcing ~8–12 more like the two keepers (Kevin MacLeod's Calming/Ambient catalog first). +After a Round-4 drumless pass, the operator's **final Music set = 5 Kevin MacLeod tracks** +(CC BY 4.0), now **shipped as a rotating crossfading playlist**: + +**Music for Manatees · Dewdrop Fantasy · Eastminster · Overheat · Concentration** + +Wiring: `app.js` `MUSIC_PLAYLIST` — the Music source plays one track and every +`MUSIC_ROTATE_MS` (5 min) crossfades to a random OTHER track (`pickNextIndex`, never an +immediate repeat), altitude-independent, on the two `#aud`/`#aud-b` elements. Files +loudness-normalized + transcoded under the 25MB LFS limit by `build_audio_media.py` +(`MUSIC`), committed via LFS; the single ethereal loop was **retired**. Credited on +`credits.html` (CC BY attribution). Earlier candidates below kept for record. + +*(History: the operator first kept Dream Culture + Silver Blue Light, then — after the +drumless Round-4 pass — chose the 5 long-form/floaty tracks above instead.)* ## Two license families diff --git a/simulator/build_audio_media.py b/simulator/build_audio_media.py index fafc287..0d8ba75 100644 --- a/simulator/build_audio_media.py +++ b/simulator/build_audio_media.py @@ -1,42 +1,62 @@ -"""Production pass for the 5 per-altitude soundtracks + the single Music loop -(audio spec §5.3; Music layer per docs/audio-candidate-pool.md). The audio -analogue of build_pool_manifest.py --media: read the sourced ambience clips -(re-downloadable per docs/audio-candidate-pool.md), and make each a seamless -loudness-normalized loop. +"""Production pass for the 5 per-altitude soundtracks + the Music playlist +(audio spec §5.3; Music per docs/music-candidate-pool.md). The audio analogue of +build_pool_manifest.py --media: read the sourced clips (re-downloadable per the +candidate-pool docs) and make each web-ready. -Media is gitignored; this script is the reproducible record. Outputs land under -simulator/sample_media/audio/<scale>/<name>.loop.mp3, served at /media/audio/... -Run: python simulator/build_audio_media.py +- Soundtracks: seamless loudness-normalized loops -> audio/<scale>/<name>.loop.mp3. +- Music: the mellow drumless synth playlist (Kevin MacLeod, CC BY 4.0) — NOT looped + (the client crossfades between tracks). Each is loudness-normalized and transcoded + to a bitrate that keeps it under the ~25MB git-LFS/nginx limit (ambient tolerates + it) -> audio/music/<name>.mp3. Put the downloaded original at audio/music/<name>.src.mp3 + (URLs below) first; this transcodes it. -(White-noise is deferred — the live Audio control is a simple on/off soundtrack -toggle. `tools.pipeline.audio_run.generate_white_noise` remains for when it's -wanted, but no noise bed is built here.) +Media is committed via git-LFS; this script is the reproducible record. +Run: python -m simulator.build_audio_media """ from __future__ import annotations +import subprocess from pathlib import Path from tools.pipeline.audio_run import process_soundtrack +from tools.pipeline.run import resolve_ffmpeg AUDIO = Path(__file__).parent / "sample_media" / "audio" # scale -> (sourced raw file, production output file), both under audio/<scale>/. # Sources per docs/audio-candidate-pool.md (the "✓ KEEP" picks). -# -# The `music` entry is the Audio-dropdown "Music" layer (docs/audio-candidate-pool.md -# § Music): ONE ethereal ambient loop played the same at every altitude — not a -# per-altitude soundtrack. It lands at audio/music/ethereal.loop.mp3, served at -# /media/audio/music/ethereal.loop.mp3 (client fallback: app.js MUSIC_FALLBACK). SOURCES: dict[str, tuple[str, str]] = { "cosmos": ("pillars.mp3", "pillars.loop.mp3"), "orbit": ("spaceamb.mp3", "spaceamb.loop.mp3"), "coast": ("waves.mp3", "waves.loop.mp3"), "reef": ("soundscape.mp3", "soundscape.loop.mp3"), "abyss": ("whale.wav", "whale.loop.mp3"), - "music": ("ethereal.mp3", "ethereal.loop.mp3"), } +# Music playlist (docs/music-candidate-pool.md § Round 4): name -> (incompetech source +# URL, target bitrate). Kevin MacLeod, CC BY 4.0 — credited on credits.html. Drumless, +# chord-moving ambient; the operator's picks. Longer tracks get a lower bitrate to stay +# under the LFS size limit. Client playlist: app.js MUSIC_PLAYLIST. +_KM = "https://incompetech.com/music/royalty-free/mp3-royaltyfree/" +MUSIC: dict[str, tuple[str, str]] = { + "manatees": (_KM + "Music%20for%20Manatees.mp3", "96k"), + "dewdrop": (_KM + "Dewdrop%20Fantasy.mp3", "80k"), + "eastminster": (_KM + "Eastminster.mp3", "128k"), + "overheat": (_KM + "Overheat.mp3", "128k"), + "concentration": (_KM + "Concentration.mp3", "80k"), +} + + +def _transcode(src: Path, dst: Path, bitrate: str, ff: str) -> Path: + """Loudness-normalize + transcode to mp3 at `bitrate` (no looping).""" + subprocess.run( + [ff, "-y", "-hide_banner", "-loglevel", "error", "-i", str(src), + "-af", "loudnorm", "-c:a", "libmp3lame", "-b:a", bitrate, "-ar", "44100", str(dst)], + check=True, + ) + return dst + def main() -> None: for scale, (raw, out) in SOURCES.items(): @@ -47,6 +67,15 @@ def main() -> None: dst = process_soundtrack(src, AUDIO / scale / out) print(f" produced {dst.relative_to(AUDIO.parent)}") + ff = resolve_ffmpeg() + for name, (url, bitrate) in MUSIC.items(): + src = AUDIO / "music" / f"{name}.src.mp3" + if not src.exists(): + print(f" SKIP music/{name}: source missing ({src}) — download {url}") + continue + dst = _transcode(src, AUDIO / "music" / f"{name}.mp3", bitrate, ff) + print(f" produced {dst.relative_to(AUDIO.parent)} @ {bitrate}") + if __name__ == "__main__": main() diff --git a/simulator/e2e/tests/audio-source.spec.ts b/simulator/e2e/tests/audio-source.spec.ts index 9df942b..631f188 100644 --- a/simulator/e2e/tests/audio-source.spec.ts +++ b/simulator/e2e/tests/audio-source.spec.ts @@ -2,7 +2,7 @@ import { test, expect, Page } from "@playwright/test"; // The Audio-source feature (docs/superpowers/specs/2026-07-01-audio-music-option-design.md): // the old 0–10 "Audio" slider becomes "Volume", and a new Audio <select> chooses the -// source — None / Soundtrack / Music. Music is ONE ethereal loop played the same at +// source — None / Soundtrack / Music. Music is a rotating playlist played the same at // every altitude (no per-altitude crossfade). async function enter(page: Page) { @@ -47,27 +47,30 @@ test.describe("Audio source dropdown + Volume rename", () => { await expect(page.locator("#audio")).toBeEnabled(); }); - test("Music plays one ethereal loop that persists across an altitude change", async ({ page }) => { + const MUSIC_RE = /audio\/music\/(manatees|dewdrop|eastminster|overheat|concentration)\.mp3/; + + test("Music plays a playlist track that persists across an altitude change", async ({ page }) => { await enter(page); await selectSource(page, "music"); let urls = await audUrls(page); - expect(urls.a).toContain("music/ethereal.loop.mp3"); - // Move the dial: Music is altitude-independent, so #aud stays on the music loop - // (it does NOT switch to a per-altitude soundtrack). + expect(urls.a).toMatch(MUSIC_RE); + // Move the dial: Music is altitude-independent, so #aud stays on a music track + // (it does NOT switch to a per-altitude soundtrack). Default rotate interval is + // 5 min, so it's the SAME track here. await wheelOnStage(page, 120); await wheelOnStage(page, 120); await page.waitForTimeout(300); urls = await audUrls(page); - expect(urls.a).toContain("music/ethereal.loop.mp3"); + expect(urls.a).toMatch(MUSIC_RE); }); - test("Soundtrack loads a per-altitude ambience, not the music loop", async ({ page }) => { + test("Soundtrack loads a per-altitude ambience, not a music track", async ({ page }) => { await enter(page); await selectSource(page, "soundtrack"); await page.waitForTimeout(200); const urls = await audUrls(page); const loaded = urls.a + "|" + urls.b; expect(loaded).toContain("/audio/"); - expect(loaded).not.toContain("music/ethereal.loop.mp3"); + expect(loaded).not.toContain("/audio/music/"); }); }); diff --git a/simulator/e2e/tests/music-playlist.spec.ts b/simulator/e2e/tests/music-playlist.spec.ts new file mode 100644 index 0000000..8dc5d95 --- /dev/null +++ b/simulator/e2e/tests/music-playlist.spec.ts @@ -0,0 +1,57 @@ +import { test, expect, Page } from "@playwright/test"; + +// Music is a rotating playlist that crossfades to a random OTHER track on a timer +// (docs/music-candidate-pool.md). The e2e injects short rotate/xfade intervals via +// window.__hefMusicRotateMs / __hefMusicXfadeMs before load. + +async function boot(page: Page, rotateMs: number, xfadeMs: number) { + await page.addInitScript(([r, x]) => { + (window as any).__hefMusicRotateMs = r; + (window as any).__hefMusicXfadeMs = x; + }, [rotateMs, xfadeMs]); + 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 }); +} + +// The track on the louder (active) music element. +async function activeTrack(page: Page): Promise<string> { + return await page.evaluate(() => { + const a = document.querySelector("#aud") as HTMLAudioElement; + const b = document.querySelector("#aud-b") as HTMLAudioElement; + const el = b.volume > a.volume ? b : a; + return el.dataset.url || ""; + }); +} + +test("Music crossfades to a DIFFERENT playlist track after the rotate interval", async ({ page }) => { + await boot(page, 1200, 300); + await page.selectOption("#audio-source", "music"); + await page.waitForTimeout(200); + const first = await activeTrack(page); + expect(first).toMatch(/audio\/music\/\w+\.mp3/); + // Wait past one rotation (1200ms) + the crossfade (300ms) + buffer. + await page.waitForFunction((f) => { + const a = document.querySelector("#aud") as HTMLAudioElement; + const b = document.querySelector("#aud-b") as HTMLAudioElement; + const el = b.volume > a.volume ? b : a; + return (el.dataset.url || "") !== f && (el.dataset.url || "").includes("/audio/music/"); + }, first, { timeout: 6000 }); + const second = await activeTrack(page); + expect(second).toMatch(/audio\/music\/\w+\.mp3/); + expect(second).not.toBe(first); // rotated to another track +}); + +test("Music keeps rotating (a second rotation lands on a track again)", async ({ page }) => { + await boot(page, 900, 250); + await page.selectOption("#audio-source", "music"); + const seen = new Set<string>(); + for (let i = 0; i < 3; i++) { + await page.waitForTimeout(1300); + seen.add(await activeTrack(page)); + } + // Over 3 rotations we should have heard more than one distinct track. + expect([...seen].every((u) => /audio\/music\/\w+\.mp3/.test(u))).toBe(true); + expect(seen.size).toBeGreaterThan(1); +}); diff --git a/simulator/sample_media/audio/music/concentration.mp3 b/simulator/sample_media/audio/music/concentration.mp3 new file mode 100644 index 0000000..1cebc12 --- /dev/null +++ b/simulator/sample_media/audio/music/concentration.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8c5fb794efbf0b8e6c59c6723e1da29b61ca2b5b48c882419909797d7ddb650d +size 17929636 diff --git a/simulator/sample_media/audio/music/dewdrop.mp3 b/simulator/sample_media/audio/music/dewdrop.mp3 new file mode 100644 index 0000000..80530de --- /dev/null +++ b/simulator/sample_media/audio/music/dewdrop.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b96e9051603066ae8b43213a231d309b9a0234f275d4a68fb0d9a54a92d48acb +size 20979975 diff --git a/simulator/sample_media/audio/music/eastminster.mp3 b/simulator/sample_media/audio/music/eastminster.mp3 new file mode 100644 index 0000000..29d7afd --- /dev/null +++ b/simulator/sample_media/audio/music/eastminster.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:86047b903f50275d6578b8e4051bbb75d2784cb39f3719f7e7b2b81eb58c83a2 +size 6482165 diff --git a/simulator/sample_media/audio/music/ethereal.loop.mp3 b/simulator/sample_media/audio/music/ethereal.loop.mp3 deleted file mode 100644 index 0c9fbc4..0000000 --- a/simulator/sample_media/audio/music/ethereal.loop.mp3 +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:dd4df4eac246061979d4959a95dbefa7405a57d45f6d8d1e06d1d98dad644a26 -size 3742100 diff --git a/simulator/sample_media/audio/music/ethereal.mp3 b/simulator/sample_media/audio/music/ethereal.mp3 deleted file mode 100644 index d76ac08..0000000 --- a/simulator/sample_media/audio/music/ethereal.mp3 +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3829adf9ca64784268b21a061f0b1cf49b2b14139949f8a2ea3f33ffd04c3949 -size 4923905 diff --git a/simulator/sample_media/audio/music/manatees.mp3 b/simulator/sample_media/audio/music/manatees.mp3 new file mode 100644 index 0000000..c7b6abe --- /dev/null +++ b/simulator/sample_media/audio/music/manatees.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:80547ef4a54a9879e1cf94f827b19da350feda1e55dd86876485dff620b5dfd1 +size 12673735 diff --git a/simulator/sample_media/audio/music/overheat.mp3 b/simulator/sample_media/audio/music/overheat.mp3 new file mode 100644 index 0000000..9115e48 --- /dev/null +++ b/simulator/sample_media/audio/music/overheat.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e0fa4796031f146b7f8bef55d613212c3a0f6b3de05bd48233e4229ed0a244e0 +size 7066594 diff --git a/simulator/static/app.js b/simulator/static/app.js index e949f6d..ea4f834 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -1520,15 +1520,28 @@ function scaleAudioUrl(index) { // The current resting altitude's soundtrack url (kept for the status readout). function soundtrackUrl() { return scaleAudioUrl(ringIndex); } -// The single continuous "Music" loop — one ethereal ambient track, played the -// same at every altitude. Fallback constant mirrors SCALE_AUDIO_FALLBACK so it -// works even against a server predating the manifest's music entry; routed through -// mediaBase() for R2 (static) / local /media (dev), like scaleAudioUrl. -const MUSIC_FALLBACK = "music/ethereal.loop.mp3"; -function musicUrl() { return mediaBase() + "audio/" + MUSIC_FALLBACK; } +// --- Music: a rotating playlist of mellow synth tracks that CROSSFADE between each +// other (not one looping track), altitude-independent — the same relaxing rotation at +// every altitude. Every MUSIC_ROTATE_MS it crossfades to a random OTHER track (never an +// immediate repeat), reusing the two audio elements. Intervals are injectable for tests. +const MUSIC_PLAYLIST = [ + "music/manatees.mp3", "music/dewdrop.mp3", "music/eastminster.mp3", + "music/overheat.mp3", "music/concentration.mp3", +]; +const MUSIC_ROTATE_MS = (typeof window !== "undefined" && window.__hefMusicRotateMs) || 5 * 60 * 1000; +const MUSIC_XFADE_MS = (typeof window !== "undefined" && window.__hefMusicXfadeMs) || 6000; +function musicTrackUrl(i) { + return mediaBase() + "audio/" + MUSIC_PLAYLIST[HEFScrub.wrapIndex(i, MUSIC_PLAYLIST.length)]; +} +let musicIdx = -1; // index of the track on the active music element (-1 = not started) +let musicEl = null; // the element currently carrying the active music track +let musicTimer = null; // rotation timer // The url the active source would play right now (for the diagnostic readout). -function currentAudioUrl() { return audioSource() === "music" ? musicUrl() : soundtrackUrl(); } +function currentAudioUrl() { + if (audioSource() === "music") return musicTrackUrl(musicIdx < 0 ? 0 : musicIdx); + return soundtrackUrl(); +} // Start (once) and set the volume of one element to a url. Safari needs the first // play() inside a user gesture; later programmatic plays reuse the unlocked element. @@ -1561,19 +1574,47 @@ function assignElements(urlLo, urlHi) { return { elLo: aud, elHi: audB }; } -// Both elements fade to silence (None, or Volume 0). +// Both elements fade to silence (None, or Volume 0). Also ends any music rotation. function silenceAll() { + stopMusic(); fadeVolume(aud, 0, FADE_MS, () => aud.pause()); fadeVolume(audB, 0, FADE_MS, () => audB.pause()); } -// Play the single continuous Music loop on `aud` at `vol`; fade the other out. No -// altitude crossfade — the same track plays the same at every altitude. -function playMusic(vol) { - ensurePlaying(aud, musicUrl(), vol); - fadeVolume(audB, 0, FADE_MS); +function scheduleMusicRotate() { + clearTimeout(musicTimer); + musicTimer = setTimeout(rotateMusic, MUSIC_ROTATE_MS); +} +// Crossfade the active music element out and a random OTHER track in on the other element. +function rotateMusic() { + if (audioSource() !== "music" || audioVol() === 0 || musicIdx < 0) { scheduleMusicRotate(); return; } + const next = HEFScrub.pickNextIndex(musicIdx, MUSIC_PLAYLIST.length, Math.random()); + const nextEl = musicEl === aud ? audB : aud; + ensurePlaying(nextEl, musicTrackUrl(next), 0); // incoming track, silent... + fadeVolume(nextEl, audioVol(), MUSIC_XFADE_MS); // ...fade it in, + fadeVolume(musicEl, 0, MUSIC_XFADE_MS, () => musicEl.pause()); // ...fade the old out. + musicEl = nextEl; musicIdx = next; + scheduleMusicRotate(); updateAudioStatus(); } +// Start the rotation (first call) or just re-apply the master volume to the active track. +// The FIRST call runs inside the audio gesture, so we prime BOTH elements here to unlock +// them on Safari (the timer-driven crossfade later plays the 2nd element outside a gesture). +function startMusic(vol) { + if (musicIdx < 0) { + musicIdx = Math.floor(Math.random() * MUSIC_PLAYLIST.length); + musicEl = aud; + ensurePlaying(aud, musicTrackUrl(musicIdx), vol); + ensurePlaying(audB, musicTrackUrl(musicIdx), 0); // prime + unlock the 2nd element (silent) + scheduleMusicRotate(); + } else { + cancelFade(musicEl); + musicEl.volume = HEFScrub.clamp01(vol); + } + updateAudioStatus(); +} +// Stop the rotation and reset (on leaving Music, or going silent). +function stopMusic() { clearTimeout(musicTimer); musicTimer = null; musicIdx = -1; musicEl = null; } // Mid-segment scrub. Soundtrack: crossfade scale `loIndex` (gain 1-frac) against // loIndex+1 (gain frac), scaled by the master volume. Music: hold the single loop. @@ -1581,7 +1622,8 @@ function playMusic(vol) { function blendAudio(loIndex, frac) { const plan = HEFScrub.audioPlan(audioSource(), audioVol(), frac); if (plan.mode === "silent") return; - if (plan.mode === "music") { playMusic(plan.vol); return; } + if (plan.mode === "music") { startMusic(plan.vol); return; } + stopMusic(); // leaving Music for the soundtrack crossfade const urlLo = scaleAudioUrl(loIndex), urlHi = scaleAudioUrl(loIndex + 1); const { elLo, elHi } = assignElements(urlLo, urlHi); ensurePlaying(elLo, urlLo, plan.lo); @@ -1595,7 +1637,8 @@ function blendAudio(loIndex, frac) { function restAudio(index) { const plan = HEFScrub.audioPlan(audioSource(), audioVol(), 0); if (plan.mode === "silent") { silenceAll(); return; } - if (plan.mode === "music") { playMusic(plan.vol); return; } + if (plan.mode === "music") { startMusic(plan.vol); return; } + stopMusic(); // soundtrack → ensure the music rotation is stopped const url = scaleAudioUrl(index); const active = (audB.dataset.url === url) ? audB : aud; const idle = active === audB ? aud : audB; diff --git a/simulator/static/credits.html b/simulator/static/credits.html index ec973c5..aae3582 100644 --- a/simulator/static/credits.html +++ b/simulator/static/credits.html @@ -48,14 +48,18 @@ <section> <h2>Sound</h2> <p class="note"> - Soundtracks are public domain or CC0 — credit is courtesy, not required. + Per-altitude soundtracks are public domain or CC0 — credit is courtesy. The + <em>Music</em> option is Creative Commons <strong>BY</strong> — attribution required, given below. </p> <ul class="audio-credits"> <li><strong>NASA / Chandra X-ray Observatory (CXC/SAO)</strong> — cosmos sonification (public domain).</li> <li><strong>NOAA</strong> SanctSound & PMEL hydrophone recordings — reef/abyss biological layers (public domain).</li> <li><strong>freesound.org</strong> contributors — Sonicfreak (station room-tone) and DCSFX (underwater bed) (CC0).</li> <li><strong>BigSoundBank</strong> — sea waves & gull calls (CC0).</li> - <li><strong>deadrobotmusic</strong> (freesound.org) — “Ambient F Sharp Minor Ethereal Choir Pad” (the <em>Music</em> option) (CC0).</li> + <li><strong>Kevin MacLeod</strong> (<a href="https://incompetech.com" rel="noopener">incompetech.com</a>) — the + <em>Music</em> option: “Music for Manatees”, “Dewdrop Fantasy”, “Eastminster”, “Overheat”, and + “Concentration”. Licensed under + <a href="https://creativecommons.org/licenses/by/4.0/" rel="license">Creative Commons: By Attribution 4.0</a>.</li> </ul> </section> </main> diff --git a/simulator/static/scrub.js b/simulator/static/scrub.js index 7ea930d..4dc7d83 100644 --- a/simulator/static/scrub.js +++ b/simulator/static/scrub.js @@ -44,6 +44,16 @@ return { mode: "blend", lo: g.from * level, hi: g.to * level }; } + // Pick the next track in a rotating playlist: a random index in [0,count) that is + // NEVER `current` (so a rotation always changes track), given r in [0,1). Maps r + // onto the (count-1) other slots. count<=1 stays on 0. Pure so the shuffle is + // node-testable; the caller passes Math.random(). + function pickNextIndex(current, count, r) { + if (count <= 1) return 0; + const k = Math.min(count - 2, Math.floor(clamp01(r) * (count - 1))); + return k >= current ? k + 1 : k; + } + // The base-loop frame a landing must continue from so the steady loop picks up // the EXACT frame the morph last showed — no jump. A morph spans src@0 (frac 0) // -> dst@loopTailS (frac 1), scrubbed bidirectionally. Descending (dir>0) lands @@ -68,5 +78,5 @@ return out; } - return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, audioPlan, loopLandFrame, integerCrossings }; + return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, audioPlan, pickNextIndex, loopLandFrame, integerCrossings }; }); diff --git a/simulator/unit/audio.test.js b/simulator/unit/audio.test.js index 10fa606..451c3bc 100644 --- a/simulator/unit/audio.test.js +++ b/simulator/unit/audio.test.js @@ -26,6 +26,26 @@ test("audioPlan: Music ignores altitude fraction (constant across the dial)", () assert.deepEqual(a, b); }); +test("pickNextIndex: never returns the current track; covers the rest", () => { + // r in [0,1) maps onto the (count-1) other indices, skipping `current`. + assert.equal(S.pickNextIndex(0, 5, 0), 1); + assert.equal(S.pickNextIndex(0, 5, 0.999), 4); + assert.equal(S.pickNextIndex(2, 5, 0), 0); + assert.equal(S.pickNextIndex(2, 5, 0.5), 3); + assert.equal(S.pickNextIndex(4, 5, 0.999), 3); + // sweep: for each current, every r lands somewhere valid and never on current + for (let cur = 0; cur < 5; cur++) { + for (let i = 0; i < 20; i++) { + const n = S.pickNextIndex(cur, 5, i / 20); + assert.ok(n >= 0 && n < 5 && n !== cur, `cur=${cur} r=${i / 20} -> ${n}`); + } + } +}); + +test("pickNextIndex: single-track playlist stays on 0", () => { + assert.equal(S.pickNextIndex(0, 1, 0.7), 0); +}); + test("audioPlan: Soundtrack blends lo/hi by fraction, scaled by level", () => { assert.deepEqual(S.audioPlan("soundtrack", 1, 0), { mode: "blend", lo: 1, hi: 0 }); assert.deepEqual(S.audioPlan("soundtrack", 1, 1), { mode: "blend", lo: 0, hi: 1 }); -- 2.39.5 From da3581afea010aba736b0a96fa727501b99b4948 Mon Sep 17 00:00:00 2001 From: BenStullsBets <ben@benbetson.us> Date: Thu, 2 Jul 2026 16:56:17 -0700 Subject: [PATCH 10/10] feat(audio): add the Audio source dropdown to the welcome screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The opening screen only had Volume + Video speed — no way to pick the sound mode before launching. Add a welcome Audio dropdown (None/Soundtrack/Music) mirroring the panel; applyWelcomeToPanel carries the choice into the session on launch, None greys the welcome Volume, and ?audio= presets it on the welcome screen too. +4 e2e (welcome-audio.spec.ts). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- simulator/e2e/tests/welcome-audio.spec.ts | 43 +++++++++++++++++++++++ simulator/static/app.js | 12 +++++++ simulator/static/index.html | 7 ++++ 3 files changed, 62 insertions(+) create mode 100644 simulator/e2e/tests/welcome-audio.spec.ts diff --git a/simulator/e2e/tests/welcome-audio.spec.ts b/simulator/e2e/tests/welcome-audio.spec.ts new file mode 100644 index 0000000..4a157cf --- /dev/null +++ b/simulator/e2e/tests/welcome-audio.spec.ts @@ -0,0 +1,43 @@ +import { test, expect, Page } from "@playwright/test"; + +// The welcome (opening) screen mirrors the panel controls. It must offer the Audio +// SOURCE choice (None/Soundtrack/Music) too — so a visitor can pick their sound mode +// before launching — carried into the session on entry. + +async function ready(page: Page) { + await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 }); +} + +test("the welcome screen has an Audio source dropdown with 3 options", async ({ page }) => { + await page.goto("/"); + await ready(page); + await expect(page.locator("#welcome")).toBeVisible(); + const opts = page.locator("#welcome-audio-source option"); + await expect(opts).toHaveCount(3); + await expect(opts).toHaveText(["None", "Soundtrack", "Music"]); +}); + +test("choosing Music on the welcome screen carries into the session", async ({ page }) => { + await page.goto("/"); + await ready(page); + await page.selectOption("#welcome-audio-source", "music"); + await page.locator("#welcome-launch").click(); + await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 }); + expect(await page.locator("#audio-source").inputValue()).toBe("music"); + await page.waitForTimeout(300); + const url = await page.evaluate(() => (document.querySelector("#aud") as HTMLAudioElement).dataset.url || ""); + expect(url).toMatch(/audio\/music\/\w+\.mp3/); +}); + +test("welcome None greys out the welcome Volume slider", async ({ page }) => { + await page.goto("/"); + await ready(page); + await page.selectOption("#welcome-audio-source", "none"); + await expect(page.locator("#welcome-audio")).toBeDisabled(); +}); + +test("?audio=music preselects Music on the welcome screen too", async ({ page }) => { + await page.goto("/?audio=music"); + await ready(page); + expect(await page.locator("#welcome-audio-source").inputValue()).toBe("music"); +}); diff --git a/simulator/static/app.js b/simulator/static/app.js index ea4f834..71729e2 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -1420,6 +1420,7 @@ function applyUrlAudioParams() { const src = (p.get("audio") || "").toLowerCase(); if (src === "none" || src === "soundtrack" || src === "music") { const el = $("audio-source"); if (el) el.value = src; + const w = $("welcome-audio-source"); if (w) w.value = src; // reflect on the opening screen too } const vol = p.get("vol"); if (vol !== null && vol !== "" && Number.isFinite(+vol)) { @@ -1681,11 +1682,18 @@ function applyWelcomeToPanel() { $("visual").checked = true; // the welcome screen assumes Video on (no toggle) const wa = $("welcome-audio"); if (wa) { $("audio").value = wa.value; updateAudioLevelLabel(); } + const ws = $("welcome-audio-source"); // carry the chosen source into the panel dropdown + if (ws) { $("audio-source").value = ws.value; syncVolumeDisabled(); } } function updateWelcomeAudioLabel() { const el = $("welcome-audio-val"); if (el) el.textContent = String(+($("welcome-audio") || {}).value || 0); } +// Volume is meaningless on None — grey the welcome slider too, mirroring the panel. +function syncWelcomeVolumeDisabled() { + const src = $("welcome-audio-source"), slider = $("welcome-audio"); + if (src && slider) slider.disabled = src.value === "none"; +} // Remove the welcome overlay (fade out) — the panel reveals via `body:has(#welcome)`. function enterSimulation() { @@ -1718,6 +1726,9 @@ function initWelcome() { updateWelcomeAudioLabel(); const wa = $("welcome-audio"); if (wa) wa.addEventListener("input", updateWelcomeAudioLabel); + const ws = $("welcome-audio-source"); + if (ws) ws.addEventListener("change", syncWelcomeVolumeDisabled); + syncWelcomeVolumeDisabled(); const launch = $("welcome-launch"); if (launch) launch.addEventListener("click", onLaunch); } @@ -1748,6 +1759,7 @@ async function main() { $("audio-source").addEventListener("change", () => { syncVolumeDisabled(); applyAudio(); debounced(); }); applyUrlAudioParams(); // shareable ?audio=/?vol= deep-link presets syncVolumeDisabled(); + syncWelcomeVolumeDisabled(); // reflect a URL-set source on the welcome slider too updateWelcomeAudioLabel(); // Video toggle (panel): the FIRST time video turns on without having entered via // Launch, begin the experience too — couple audio in. (Normally Launch entered diff --git a/simulator/static/index.html b/simulator/static/index.html index b0b6179..9b7eec7 100644 --- a/simulator/static/index.html +++ b/simulator/static/index.html @@ -26,6 +26,13 @@ list="play-speed-ticks" aria-describedby="welcome-play-speed-val" /> <span id="welcome-play-speed-val">1.00×</span> </label> + <label class="audio-source"><span data-i18n="output.audio">Audio</span> + <select id="welcome-audio-source" aria-label="Audio source"> + <option value="none" data-i18n="output.audio.none">None</option> + <option value="soundtrack" data-i18n="output.audio.soundtrack" selected>Soundtrack</option> + <option value="music" data-i18n="output.audio.music">Music</option> + </select> + </label> <label class="audio-level"><span data-i18n="output.volume">Volume</span> <input type="range" id="welcome-audio" min="0" max="10" value="2" step="1" /> <span id="welcome-audio-val">2</span>/10 -- 2.39.5